refactor(apify): move Actor tools into bundled plugin

Re-shelve the three Apify tools from core into plugins/apify/, matching
the Spotify plugin pattern for optional third-party SaaS integrations.
tools/ is reserved for foundational capabilities; third-party service
integrations live in plugins/.

- plugins/apify/{__init__,tools,client}.py + plugin.yaml + README
  (kind: backend, auto-loads; registers via ctx.register_tool())
- remove apify_* from _HERMES_CORE_TOOLS in toolsets.py
  (TOOLSETS["apify"] entry kept, mirroring spotify)
- tests moved tests/tools/test_apify_tool.py -> tests/plugins/test_apify.py,
  import paths updated (34 tests pass)
- add JanHranicky to AUTHOR_MAP in scripts/release.py (CI gate)

The tools_config.py / config.py / lazy_deps.py / pyproject.toml setup +
config UX from the original commit is retained unchanged.

Co-authored-by: JanHranicky <jan.hranicky@seznam.cz>
This commit is contained in:
alt-glitch 2026-06-08 15:03:11 +05:30
parent 58e921a819
commit c97f0a6c82
8 changed files with 176 additions and 81 deletions

41
plugins/apify/README.md Normal file
View File

@ -0,0 +1,41 @@
# Apify Actor Tools
Bundled plugin that brings [Apify](https://apify.com/) Actors into Hermes. Apify
hosts 20,000+ ready-made Actors for web automation and data extraction
(Instagram, YouTube, Google Maps, LinkedIn, Amazon, and more). The agent can
discover the right Actor, inspect its input schema, run it, and collect
structured results.
## Tools
| Tool | What it does |
|------|--------------|
| `apify_discover` | Search the Apify Store by keyword, or fetch a specific Actor's input schema + README by `actor_id`. |
| `apify_start` | Fire-and-forget batch Actor starts (up to 10 per call). Returns run refs immediately so the agent keeps reasoning while Actors run. |
| `apify_collect` | Poll run statuses and return completed dataset results, wrapped in `EXTERNAL_UNTRUSTED_CONTENT` markers. Supports `limit` / `may_have_more` pagination. |
## Setup
1. Create an Apify account and get a token at
<https://apify.com/account/integrations>.
2. Run `hermes tools`, open **Apify Actors**, enable the toolset, and paste your
token. (The token is stored in `~/.hermes/.env` as `APIFY_API_TOKEN` and is
never sent to the model.)
The `apify` toolset is **off by default**. The three tools register on startup
but stay invisible to the model until `APIFY_API_TOKEN` is set (runtime
`check_fn` gate).
## Architecture
This is a bundled `kind: backend` plugin (auto-loads, no opt-in), modeled on the
Spotify plugin. Tool registration goes through the plugin API
(`ctx.register_tool()`), so the Apify tools never enter `_HERMES_CORE_TOOLS`. The
`apify-client` SDK is installed on demand via `tools.lazy_deps` (`search.apify`)
the first time an Actor runs.
| File | Purpose |
|------|---------|
| `__init__.py` | `register(ctx)` — wires the three tools via `ctx.register_tool()`. |
| `tools.py` | Handlers + JSON schemas. |
| `client.py` | Lazy `apify-client` import, token validation, module-level cache. |

72
plugins/apify/__init__.py Normal file
View File

@ -0,0 +1,72 @@
"""Apify Actor execution plugin — bundled, auto-loaded.
Registers three tools (``apify_discover``, ``apify_start``, ``apify_collect``)
into the ``apify`` toolset. Each tool is gated by ``_check_token()`` when the
user has not set ``APIFY_API_TOKEN`` the tools stay registered (so they appear
in ``hermes tools``) but the runtime check prevents dispatch.
Why a plugin instead of top-level ``tools/`` files?
- ``plugins/`` is where third-party service integrations live (see
``plugins/spotify/`` for the same pattern optional SaaS, token-gated,
default-off toolset). ``tools/`` is reserved for foundational capabilities
(terminal, read_file, web_search, etc.).
- Bundled + ``kind: backend`` auto-loads on startup just like the Spotify
plugin no user opt-in needed, no ``plugins.enabled`` config.
- Keeps the three Apify tools out of ``_HERMES_CORE_TOOLS`` in ``toolsets.py``;
the plugin loader registers them via ``ctx.register_tool()``.
The ``apify`` toolset is default-off (``_DEFAULT_OFF_TOOLSETS`` in
``hermes_cli/tools_config.py``) and the ``APIFY_API_TOKEN`` setup UX is wired
through ``hermes tools`` (``TOOL_CATEGORIES``) and ``OPTIONAL_ENV_VARS``.
"""
from __future__ import annotations
import json
from typing import Any, Dict
from plugins.apify.tools import (
_COLLECT_SCHEMA,
_DISCOVER_SCHEMA,
_START_SCHEMA,
_check_token,
_collect_handler,
_discover_handler,
_start_handler,
)
async def _collect_handler_str(args: Dict[str, Any], **_kw: Any) -> str:
return json.dumps(await _collect_handler(args), default=str)
def register(ctx) -> None:
"""Register the Apify Actor tools. Called once by the plugin loader."""
ctx.register_tool(
name="apify_discover",
toolset="apify",
schema=_DISCOVER_SCHEMA,
handler=lambda args, **kw: json.dumps(_discover_handler(args), default=str),
check_fn=_check_token,
requires_env=["APIFY_API_TOKEN"],
emoji="🔍",
)
ctx.register_tool(
name="apify_start",
toolset="apify",
schema=_START_SCHEMA,
handler=lambda args, **kw: json.dumps(_start_handler(args), default=str),
check_fn=_check_token,
requires_env=["APIFY_API_TOKEN"],
emoji="▶️",
)
ctx.register_tool(
name="apify_collect",
toolset="apify",
schema=_COLLECT_SCHEMA,
handler=_collect_handler_str,
check_fn=_check_token,
requires_env=["APIFY_API_TOKEN"],
is_async=True,
emoji="📦",
)

View File

@ -1,14 +1,18 @@
"""Shared Apify SDK client — lazy import, token validation, and cache.
Extracted so both the web-search provider (plugins/web/apify/provider.py) and
the Actor execution tools (tools/apify_tool.py) can import the client without
depending on each other.
Used by the Apify Actor execution tools (plugins/apify/tools.py). The
``apify-client`` SDK is installed on demand via ``tools.lazy_deps`` so the
dependency is only pulled when the user actually enables the plugin and runs
an Actor.
"""
from __future__ import annotations
import os
from typing import Any, Optional
# Sent with every request so Apify can attribute traffic to this integration.
_HERMES_HEADERS = {"x-apify-integration-platform": "hermes-agent"}
_CLIENT_CLS: Optional[type] = None
_CLIENT: Optional[Any] = None
_CLIENT_CONFIG: Optional[Any] = None
@ -49,7 +53,7 @@ def get_apify_client() -> Any:
client_config = ("direct", api_token)
if _CLIENT is not None and _CLIENT_CONFIG == client_config:
return _CLIENT
_CLIENT = _load_client_cls()(token=api_token)
_CLIENT = _load_client_cls()(token=api_token, headers=_HERMES_HEADERS)
_CLIENT_CONFIG = client_config
return _CLIENT

View File

@ -0,0 +1,9 @@
name: apify
version: 1.0.0
description: "Apify Actor execution — 3 tools (discover, start, collect) for running any of 20,000+ Actors from the Apify Store (web automation, data extraction, social media, maps, e-commerce). Gated on APIFY_API_TOKEN. Toolset is default-off; enable via `hermes tools` → Apify Actors."
author: JanHranicky
kind: backend
provides_tools:
- apify_discover
- apify_start
- apify_collect

View File

@ -1,4 +1,9 @@
"""Apify Actor execution tools — discover, start, collect."""
"""Apify Actor execution tools — discover, start, collect.
Handlers and schemas for the three Apify tools. Registration happens in
``plugins/apify/__init__.py`` via ``ctx.register_tool()`` (the plugin API),
not via direct ``registry.register()`` calls.
"""
from __future__ import annotations
import asyncio
@ -6,8 +11,6 @@ import json
import logging
from typing import Any, Dict, List
from tools.registry import registry
logger = logging.getLogger(__name__)
_TERMINAL_STATUSES = {"SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"}
@ -25,12 +28,12 @@ def _attr(obj: Any, key: str, default: Any = None) -> Any:
def _get_client() -> Any:
from tools.apify_client import get_apify_client
from plugins.apify.client import get_apify_client
return get_apify_client()
def _check_token() -> bool:
from tools.apify_client import check_apify_api_key
from plugins.apify.client import check_apify_api_key
return check_apify_api_key()
@ -385,36 +388,3 @@ _COLLECT_SCHEMA: Dict[str, Any] = {
"required": ["runs"],
},
}
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
registry.register(
name="apify_discover",
toolset="apify",
schema=_DISCOVER_SCHEMA,
handler=lambda args, **kw: _discover_handler(args),
check_fn=_check_token,
emoji="🔍",
)
registry.register(
name="apify_start",
toolset="apify",
schema=_START_SCHEMA,
handler=lambda args, **kw: _start_handler(args),
check_fn=_check_token,
emoji="▶️",
)
registry.register(
name="apify_collect",
toolset="apify",
schema=_COLLECT_SCHEMA,
handler=lambda args, **kw: _collect_handler(args),
check_fn=_check_token,
is_async=True,
emoji="📦",
)

View File

@ -132,6 +132,7 @@ AUTHOR_MAP = {
"tillfalko@gmail.com": "tillfalko",
"hi@fesalfayed.com": "fesalfayed",
"marek.les@seznam.cz": "maxcz79",
"jan.hranicky@seznam.cz": "JanHranicky",
# teknium (multiple emails)
"teknium1@gmail.com": "teknium1",
"kenyon1977@gmail.com": "kenyonxu",

View File

@ -1,4 +1,4 @@
"""Tests for tools/apify_tool.py — all mocked, no live Actor calls."""
"""Tests for plugins/apify/tools.py — all mocked, no live Actor calls."""
from __future__ import annotations
import json
@ -21,7 +21,7 @@ def mock_client(monkeypatch):
"""
client = MagicMock()
monkeypatch.setattr(
"tools.apify_tool._get_client",
"plugins.apify.tools._get_client",
lambda: client,
)
return client
@ -53,7 +53,7 @@ class TestDiscoverStoreSearch:
list_result.items = [actor_mock]
mock_client.store.return_value.list.return_value = list_result
from tools.apify_tool import _discover_handler
from plugins.apify.tools import _discover_handler
result = _discover_handler({"query": "instagram scraper"})
assert "actors" in result
@ -81,7 +81,7 @@ class TestDiscoverStoreSearch:
list_result.items = [actor_mock]
mock_client.store.return_value.list.return_value = list_result
from tools.apify_tool import _discover_handler
from plugins.apify.tools import _discover_handler
result = _discover_handler({"query": "test"})
assert len(result["actors"][0]["description"]) == 200
@ -89,7 +89,7 @@ class TestDiscoverStoreSearch:
def test_store_search_api_error_returns_error_dict(self, mock_client):
mock_client.store.return_value.list.side_effect = RuntimeError("API error")
from tools.apify_tool import _discover_handler
from plugins.apify.tools import _discover_handler
result = _discover_handler({"query": "test"})
assert "error" in result
@ -125,7 +125,7 @@ class TestDiscoverActorSchema:
schema = {"type": "object", "properties": {"query": {"type": "string"}}}
self._setup_build_mock(mock_client, input_schema=schema, readme="# README content")
from tools.apify_tool import _discover_handler
from plugins.apify.tools import _discover_handler
result = _discover_handler({"actor_id": "apify~google-search-scraper"})
assert result["actor_id"] == "apify~google-search-scraper"
@ -141,7 +141,7 @@ class TestDiscoverActorSchema:
def test_readme_truncated_to_3000_chars(self, mock_client):
self._setup_build_mock(mock_client, readme="R" * 4000)
from tools.apify_tool import _discover_handler
from plugins.apify.tools import _discover_handler
result = _discover_handler({"actor_id": "apify~google-search-scraper"})
assert len(result["readme"]) == 3000
@ -152,7 +152,7 @@ class TestDiscoverActorSchema:
build_detail.inputSchema = '{"type":"object"}'
build_detail.actorDefinition.input = None
from tools.apify_tool import _discover_handler
from plugins.apify.tools import _discover_handler
result = _discover_handler({"actor_id": "apify~google-search-scraper"})
assert result["input_schema"] == '{"type":"object"}'
@ -160,7 +160,7 @@ class TestDiscoverActorSchema:
def test_actor_not_found_returns_error(self, mock_client):
mock_client.actor.return_value.get.return_value = None
from tools.apify_tool import _discover_handler
from plugins.apify.tools import _discover_handler
result = _discover_handler({"actor_id": "apify~nonexistent"})
assert "error" in result
@ -173,20 +173,20 @@ class TestDiscoverActorSchema:
class TestDiscoverValidation:
def test_missing_both_params_returns_error(self, mock_client):
from tools.apify_tool import _discover_handler
from plugins.apify.tools import _discover_handler
result = _discover_handler({})
assert "error" in result
assert "query" in result["error"]
assert "actor_id" in result["error"]
def test_empty_string_params_treated_as_missing(self, mock_client):
from tools.apify_tool import _discover_handler
from plugins.apify.tools import _discover_handler
result = _discover_handler({"query": " ", "actor_id": ""})
assert "error" in result
def test_interrupted_returns_error(self, mock_client, monkeypatch):
monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: True)
from tools.apify_tool import _discover_handler
from plugins.apify.tools import _discover_handler
result = _discover_handler({"query": "test"})
assert result == {"error": "Interrupted"}
mock_client.store.assert_not_called()
@ -207,7 +207,7 @@ class TestStart:
def test_single_run_returns_run_ref(self, mock_client):
mock_client.actor.return_value.start.return_value = self._make_run_mock()
from tools.apify_tool import _start_handler
from plugins.apify.tools import _start_handler
result = _start_handler({
"runs": [{"actor_id": "apify~test", "input": {"key": "val"}}]
})
@ -226,7 +226,7 @@ class TestStart:
def test_label_included_when_provided(self, mock_client):
mock_client.actor.return_value.start.return_value = self._make_run_mock()
from tools.apify_tool import _start_handler
from plugins.apify.tools import _start_handler
result = _start_handler({
"runs": [{"actor_id": "apify~test", "input": {}, "label": "my-run"}]
})
@ -236,7 +236,7 @@ class TestStart:
def test_label_absent_when_not_provided(self, mock_client):
mock_client.actor.return_value.start.return_value = self._make_run_mock()
from tools.apify_tool import _start_handler
from plugins.apify.tools import _start_handler
result = _start_handler({
"runs": [{"actor_id": "apify~test", "input": {}}]
})
@ -248,7 +248,7 @@ class TestStart:
run2 = self._make_run_mock(run_id="r2", dataset_id="d2")
mock_client.actor.return_value.start.side_effect = [run1, run2]
from tools.apify_tool import _start_handler
from plugins.apify.tools import _start_handler
result = _start_handler({
"runs": [
{"actor_id": "apify~actor-a", "input": {}},
@ -263,7 +263,7 @@ class TestStart:
def test_per_run_api_error_goes_to_errors(self, mock_client):
mock_client.actor.return_value.start.side_effect = RuntimeError("not found")
from tools.apify_tool import _start_handler
from plugins.apify.tools import _start_handler
result = _start_handler({
"runs": [{"actor_id": "apify~bad-actor", "input": {}}]
})
@ -275,7 +275,7 @@ class TestStart:
def test_interrupted_returns_early(self, mock_client, monkeypatch):
monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: True)
from tools.apify_tool import _start_handler
from plugins.apify.tools import _start_handler
result = _start_handler({"runs": [{"actor_id": "apify~test", "input": {}}]})
assert result == {"error": "Interrupted"}
@ -296,7 +296,7 @@ class TestStart:
interrupted_after_first = iter([False, False, True])
monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: next(interrupted_after_first))
from tools.apify_tool import _start_handler
from plugins.apify.tools import _start_handler
result = _start_handler({
"runs": [
{"actor_id": "apify~actor-a", "input": {}},
@ -309,7 +309,7 @@ class TestStart:
assert len(result["runs"]) == 1
def test_batch_over_limit_returns_error(self, mock_client):
from tools.apify_tool import _start_handler, _MAX_BATCH_RUNS
from plugins.apify.tools import _start_handler, _MAX_BATCH_RUNS
oversized = [{"actor_id": f"apify~actor-{i}", "input": {}} for i in range(_MAX_BATCH_RUNS + 1)]
result = _start_handler({"runs": oversized})
assert "error" in result
@ -328,7 +328,7 @@ class TestCollectNonTerminal:
run_info.status = "RUNNING"
mock_client.run.return_value.get.return_value = run_info
from tools.apify_tool import _collect_handler
from plugins.apify.tools import _collect_handler
result = await _collect_handler({
"runs": [{"run_id": "r1", "actor_id": "apify~test", "dataset_id": "d1"}]
})
@ -346,7 +346,7 @@ class TestCollectNonTerminal:
run_info.status = "QUEUED"
mock_client.run.return_value.get.return_value = run_info
from tools.apify_tool import _collect_handler
from plugins.apify.tools import _collect_handler
result = await _collect_handler({
"runs": [{"run_id": "r1", "actor_id": "apify~test", "dataset_id": "d1"}]
})
@ -360,7 +360,7 @@ class TestCollectNonTerminal:
run_info.status = "FAILED"
mock_client.run.return_value.get.return_value = run_info
from tools.apify_tool import _collect_handler
from plugins.apify.tools import _collect_handler
result = await _collect_handler({
"runs": [{"run_id": "r1", "actor_id": "apify~test", "dataset_id": "d1"}]
})
@ -375,7 +375,7 @@ class TestCollectNonTerminal:
run_info.status = "ABORTED"
mock_client.run.return_value.get.return_value = run_info
from tools.apify_tool import _collect_handler
from plugins.apify.tools import _collect_handler
result = await _collect_handler({
"runs": [{"run_id": "r1", "actor_id": "apify~test", "dataset_id": "d1"}]
})
@ -386,7 +386,7 @@ class TestCollectNonTerminal:
async def test_run_not_found_goes_to_errors(self, mock_client):
mock_client.run.return_value.get.return_value = None
from tools.apify_tool import _collect_handler
from plugins.apify.tools import _collect_handler
result = await _collect_handler({
"runs": [{"run_id": "r1", "actor_id": "apify~test", "dataset_id": "d1"}]
})
@ -399,7 +399,7 @@ class TestCollectNonTerminal:
run_info.status = "RUNNING"
mock_client.run.return_value.get.return_value = run_info
from tools.apify_tool import _collect_handler
from plugins.apify.tools import _collect_handler
result = await _collect_handler({
"runs": [{"run_id": "r1", "actor_id": "apify~test", "dataset_id": "d1", "label": "instagram"}]
})
@ -423,7 +423,7 @@ class TestCollectSucceeded:
dataset_result.items = items
mock_client.dataset.return_value.list_items.return_value = dataset_result
from tools.apify_tool import _collect_handler
from plugins.apify.tools import _collect_handler
result = await _collect_handler({
"runs": [{"run_id": "r1", "actor_id": "apify~test", "dataset_id": "d1"}]
})
@ -436,7 +436,7 @@ class TestCollectSucceeded:
assert "<<<EXTERNAL_UNTRUSTED_CONTENT>>>" in c["data"]
assert "<<<END_EXTERNAL_UNTRUSTED_CONTENT>>>" in c["data"]
assert "Result 1" in c["data"]
from tools.apify_tool import _COLLECT_DEFAULT_LIMIT
from plugins.apify.tools import _COLLECT_DEFAULT_LIMIT
mock_client.dataset.return_value.list_items.assert_called_once_with(limit=_COLLECT_DEFAULT_LIMIT)
mock_client.dataset.assert_called_once_with("d1")
@ -452,7 +452,7 @@ class TestCollectSucceeded:
dataset_result.items = items
mock_client.dataset.return_value.list_items.return_value = dataset_result
from tools.apify_tool import _collect_handler
from plugins.apify.tools import _collect_handler
result = await _collect_handler({
"runs": [{"run_id": "r1", "actor_id": "apify~test", "dataset_id": "d1"}]
})
@ -472,7 +472,7 @@ class TestCollectSucceeded:
mock_client.run.return_value.get.return_value = run_info
mock_client.dataset.return_value.list_items.return_value = MagicMock(items=[])
from tools.apify_tool import _collect_handler
from plugins.apify.tools import _collect_handler
result = await _collect_handler({
"runs": [{"run_id": "r1", "actor_id": "apify~test", "dataset_id": "d1"}]
})
@ -482,7 +482,7 @@ class TestCollectSucceeded:
@pytest.mark.asyncio
async def test_may_have_more_set_when_result_count_equals_limit(self, mock_client):
from tools.apify_tool import _collect_handler, _COLLECT_DEFAULT_LIMIT
from plugins.apify.tools import _collect_handler, _COLLECT_DEFAULT_LIMIT
run_info = MagicMock()
run_info.status = "SUCCEEDED"
mock_client.run.return_value.get.return_value = run_info
@ -502,7 +502,7 @@ class TestCollectSucceeded:
mock_client.run.return_value.get.return_value = run_info
mock_client.dataset.return_value.list_items.return_value = MagicMock(items=[{"i": 0}])
from tools.apify_tool import _collect_handler
from plugins.apify.tools import _collect_handler
result = await _collect_handler({
"runs": [{"run_id": "r1", "actor_id": "apify~test", "dataset_id": "d1"}]
})
@ -516,7 +516,7 @@ class TestCollectSucceeded:
mock_client.run.return_value.get.return_value = run_info
mock_client.dataset.return_value.list_items.return_value = MagicMock(items=[])
from tools.apify_tool import _collect_handler
from plugins.apify.tools import _collect_handler
await _collect_handler({
"runs": [{"run_id": "r1", "actor_id": "apify~test", "dataset_id": "d1"}],
"limit": 500,
@ -548,7 +548,7 @@ class TestCollectMixed:
mock_client.run.side_effect = _run_get_side_effect
mock_client.dataset.return_value.list_items.return_value = MagicMock(items=[{"result": 1}])
from tools.apify_tool import _collect_handler
from plugins.apify.tools import _collect_handler
result = await _collect_handler({
"runs": [
{"run_id": "r1", "actor_id": "apify~a", "dataset_id": "d1"},
@ -566,7 +566,7 @@ class TestCollectMixed:
async def test_interrupted_returns_early(self, mock_client, monkeypatch):
monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: True)
from tools.apify_tool import _collect_handler
from plugins.apify.tools import _collect_handler
result = await _collect_handler({
"runs": [{"run_id": "r1", "actor_id": "apify~test", "dataset_id": "d1"}]
})
@ -578,7 +578,7 @@ class TestCollectMixed:
async def test_api_exception_goes_to_errors(self, mock_client):
mock_client.run.return_value.get.side_effect = RuntimeError("API error")
from tools.apify_tool import _collect_handler
from plugins.apify.tools import _collect_handler
result = await _collect_handler({
"runs": [{"run_id": "r1", "actor_id": "apify~test", "dataset_id": "d1"}]
})
@ -597,7 +597,7 @@ class TestCollectFullWorkflow:
run2 = MagicMock(id="r2", default_dataset_id="d2", status="QUEUED")
mock_client.actor.return_value.start.side_effect = [run1, run2]
from tools.apify_tool import _start_handler, _collect_handler
from plugins.apify.tools import _start_handler, _collect_handler
start_result = _start_handler({
"runs": [

View File

@ -68,8 +68,6 @@ _HERMES_CORE_TOOLS = [
"kanban_complete", "kanban_block", "kanban_heartbeat",
"kanban_comment", "kanban_create", "kanban_link",
"kanban_unblock",
# Apify Actor execution (gated on APIFY_API_TOKEN via check_fn)
"apify_discover", "apify_start", "apify_collect",
# Computer use (macOS, gated on cua-driver being installed via check_fn)
"computer_use",
]