apify-hermes-agent-plugin 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [0.1.0] - 2026-08-11
8
+
9
+ ### Added
10
+
11
+ - Initial release.
12
+ - `apify_discover` tool — search the Apify Store by keyword, or fetch an Actor's input schema and README by `actor_id`.
13
+ - `apify_start` tool — fire-and-forget batch Actor runs (up to 10 per call).
14
+ - `apify_collect` tool — poll run statuses and return completed dataset results.
15
+ - `hermes apify-setup` CLI command — prompt for (or accept via `--token`) the `APIFY_API_TOKEN` and enable the Apify toolset.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Apify
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ include CHANGELOG.md
2
+ prune tests
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: apify-hermes-agent-plugin
3
+ Version: 0.1.0
4
+ Summary: Hermes Agent plugin for Apify, the world's largest marketplace for AI tools — discover, run, and collect results from Actors, all from natural language
5
+ Author-email: "Apify Technologies s.r.o." <support@apify.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/apify/apify-hermes-agent-plugin
8
+ Project-URL: Source Code, https://github.com/apify/apify-hermes-agent-plugin
9
+ Project-URL: Issue Tracker, https://github.com/apify/apify-hermes-agent-plugin/issues
10
+ Project-URL: Changelog, https://github.com/apify/apify-hermes-agent-plugin/blob/main/CHANGELOG.md
11
+ Project-URL: Hermes Agent, https://hermes-agent.nousresearch.com
12
+ Keywords: apify,hermes,hermes-agent,actor,plugin,ai-tools,automation
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Topic :: Software Development :: Libraries
23
+ Requires-Python: >=3.11
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: hermes-agent>=0.15.1
27
+ Requires-Dist: apify-client<4,>=3.0.1
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest==9.0.2; extra == "dev"
30
+ Requires-Dist: pytest-asyncio==1.3.0; extra == "dev"
31
+ Requires-Dist: ruff<0.17,>=0.15.20; extra == "dev"
32
+ Requires-Dist: ty<0.1,>=0.0.65; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ # apify-hermes-agent-plugin
36
+
37
+ Hermes Agent plugin for [Apify](https://apify.com), the world's largest marketplace for AI tools — discover
38
+ Actors in the Apify Store, start runs, and collect structured results, all from natural language, via
39
+ [Hermes Agent](https://hermes-agent.nousresearch.com).
40
+
41
+ ## Install
42
+
43
+ pip install apify-hermes-agent-plugin
44
+ hermes plugins enable apify
45
+
46
+ Then run:
47
+
48
+ hermes apify-setup
49
+
50
+ This prompts for your `APIFY_API_TOKEN` (get one at https://apify.com/account/integrations),
51
+ saves it, and enables the Apify Actors toolset for the CLI. Pass `--token <token>` to skip the
52
+ prompt.
53
+
54
+ ## Tools
55
+
56
+ - `apify_discover` — search the Apify Store by keyword, or fetch an Actor's input schema + README by `actor_id`.
57
+ - `apify_start` — fire-and-forget batch Actor starts (up to 10 per call).
58
+ - `apify_collect` — poll run statuses and return completed dataset results.
59
+
60
+ ## Development
61
+
62
+ pip install -e ".[dev]"
63
+ pytest
@@ -0,0 +1,29 @@
1
+ # apify-hermes-agent-plugin
2
+
3
+ Hermes Agent plugin for [Apify](https://apify.com), the world's largest marketplace for AI tools — discover
4
+ Actors in the Apify Store, start runs, and collect structured results, all from natural language, via
5
+ [Hermes Agent](https://hermes-agent.nousresearch.com).
6
+
7
+ ## Install
8
+
9
+ pip install apify-hermes-agent-plugin
10
+ hermes plugins enable apify
11
+
12
+ Then run:
13
+
14
+ hermes apify-setup
15
+
16
+ This prompts for your `APIFY_API_TOKEN` (get one at https://apify.com/account/integrations),
17
+ saves it, and enables the Apify Actors toolset for the CLI. Pass `--token <token>` to skip the
18
+ prompt.
19
+
20
+ ## Tools
21
+
22
+ - `apify_discover` — search the Apify Store by keyword, or fetch an Actor's input schema + README by `actor_id`.
23
+ - `apify_start` — fire-and-forget batch Actor starts (up to 10 per call).
24
+ - `apify_collect` — poll run statuses and return completed dataset results.
25
+
26
+ ## Development
27
+
28
+ pip install -e ".[dev]"
29
+ pytest
@@ -0,0 +1,121 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "apify-hermes-agent-plugin"
7
+ version = "0.1.0"
8
+ description = "Hermes Agent plugin for Apify, the world's largest marketplace for AI tools — discover, run, and collect results from Actors, all from natural language"
9
+ authors = [{ name = "Apify Technologies s.r.o.", email = "support@apify.com" }]
10
+ readme = "README.md"
11
+ requires-python = ">=3.11"
12
+ license = { text = "MIT" }
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
23
+ "Topic :: Software Development :: Libraries",
24
+ ]
25
+ keywords = ["apify", "hermes", "hermes-agent", "actor", "plugin", "ai-tools", "automation"]
26
+ dependencies = [
27
+ "hermes-agent>=0.15.1",
28
+ "apify-client>=3.0.1,<4",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = ["pytest==9.0.2", "pytest-asyncio==1.3.0", "ruff>=0.15.20,<0.17", "ty>=0.0.65,<0.1"]
33
+
34
+ [project.urls]
35
+ "Homepage" = "https://github.com/apify/apify-hermes-agent-plugin"
36
+ "Source Code" = "https://github.com/apify/apify-hermes-agent-plugin"
37
+ "Issue Tracker" = "https://github.com/apify/apify-hermes-agent-plugin/issues"
38
+ "Changelog" = "https://github.com/apify/apify-hermes-agent-plugin/blob/main/CHANGELOG.md"
39
+ "Hermes Agent" = "https://hermes-agent.nousresearch.com"
40
+
41
+ [project.entry-points."hermes_agent.plugins"]
42
+ # NOTE: must point at the bare module, not "apify_hermes_agent_plugin:register".
43
+ # hermes-agent's loader (hermes_cli/plugins.py::_load_entrypoint_module) calls
44
+ # ep.load() and then does getattr(result, "register") — it expects a module.
45
+ # Per importlib.metadata semantics, "module:attr" entry points make ep.load()
46
+ # return the attr itself (the register function), not the module, so
47
+ # getattr(register_fn, "register") finds nothing and the plugin silently
48
+ # fails to load ("no register() function"). This diverges from hermes-agent's
49
+ # own plugin docs example (which shows "my_plugin:register") — see
50
+ # https://hermes-agent.nousresearch.com/docs/user-guide/features/plugins.
51
+ apify = "apify_hermes_agent_plugin"
52
+
53
+ [tool.setuptools.packages.find]
54
+ where = ["src"]
55
+
56
+ [tool.pytest.ini_options]
57
+ testpaths = ["tests"]
58
+ asyncio_mode = "strict"
59
+
60
+ [tool.ruff]
61
+ line-length = 120
62
+ include = ["src/**/*.py", "tests/**/*.py"]
63
+
64
+ [tool.ruff.format]
65
+ quote-style = "single"
66
+ indent-style = "space"
67
+
68
+ [tool.ruff.lint.flake8-quotes]
69
+ docstring-quotes = "double"
70
+ inline-quotes = "single"
71
+
72
+ [tool.ruff.lint]
73
+ select = ["ALL"]
74
+ ignore = [
75
+ "ANN401", # `Any` is disallowed — this plugin's whole surface is dynamically-typed SDK
76
+ # responses (apify-client returns Pydantic models OR dicts depending on call site)
77
+ # and JSON-schema-validated tool args; `Any` here is accurate, not lazy.
78
+ "BLE001", # Do not catch blind exception — every handler must turn arbitrary SDK/network
79
+ # failures into a JSON error object for the LLM, never let one crash the tool call.
80
+ "COM812", # Conflicts with the formatter.
81
+ "CPY001", # Missing copyright notice — this is an MIT-licensed package (see LICENSE at the
82
+ # repo root); per-file copyright headers aren't this project's convention.
83
+ "D100", # Missing docstring in public module.
84
+ "D104", # Missing docstring in public package.
85
+ "D107", # Missing docstring in `__init__`.
86
+ "D203", # One blank line required before class docstring.
87
+ "D213", # Multi-line docstring summary should start at the second line.
88
+ "PLC0415", # Import should be at top-level — deliberate: `tools.py`/`cli.py` re-import
89
+ # `tools.interrupt.is_interrupted` and hermes_cli internals inside each function
90
+ # so tests can `monkeypatch.setattr("tools.interrupt.is_interrupted", ...)` and
91
+ # have it take effect. A module-level import would bind the original function
92
+ # once at import time and monkeypatching the source module afterwards would not
93
+ # reach it — see tests/test_tools.py's `not_interrupted` fixture.
94
+ "PLR0913", # Too many arguments in function definition.
95
+ "PLW0603", # Using `global` — `src/apify_hermes_agent_plugin/client.py`'s two module-level
96
+ # variables are a deliberate single-client memoization cache; a wrapper class for
97
+ # two variables would be more ceremony than the pattern it replaces.
98
+ "EM101", # Exception must not use a string literal — validation errors are one-off, not reused.
99
+ "EM102", # Exception must not use an f-string literal — same reason.
100
+ "TRY003", # Avoid specifying long messages outside the exception class — same reason.
101
+ ]
102
+
103
+ [tool.ruff.lint.per-file-ignores]
104
+ "tests/*" = [
105
+ "ANN", # Type annotations on test helpers/mocks add noise, not safety.
106
+ "ARG001", # Mocks commonly do not use all arguments.
107
+ "ARG002", # Same — pytest fixtures (e.g. an autouse-style mock_client) are commonly
108
+ # accepted by a test method for their side effect and never referenced in the body.
109
+ "ARG005", # Same — monkeypatch stub lambdas commonly ignore their arguments.
110
+ "D", # Missing docstrings.
111
+ "INP001", # Implicit namespace package — tests/ deliberately has no __init__.py (a pytest
112
+ # rootdir/import-mode choice), so this rule will always fire here; it's a
113
+ # permanent ignore, not pending work.
114
+ "PLR2004", # Magic value used in comparison.
115
+ "S101", # Use of assert detected.
116
+ "S105", # Possible hardcoded password — these are fake test tokens.
117
+ "S106", # Possible hardcoded password assigned to argument — same reason.
118
+ ]
119
+ "src/apify_hermes_agent_plugin/cli.py" = [
120
+ "T201", # `print` found — this file *is* the CLI's terminal UI; printing is the point.
121
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,45 @@
1
+ """apify-hermes-agent-plugin — Apify Actor execution tools for Hermes Agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from apify_hermes_agent_plugin.cli import apify_setup_command, register_cli
8
+ from apify_hermes_agent_plugin.tools import (
9
+ _COLLECT_SCHEMA,
10
+ _DISCOVER_SCHEMA,
11
+ _START_SCHEMA,
12
+ _check_token,
13
+ collect_handler_str,
14
+ discover_handler_str,
15
+ start_handler_str,
16
+ )
17
+
18
+ _TOOLS = (
19
+ ('apify_discover', _DISCOVER_SCHEMA, discover_handler_str, '🔍', False),
20
+ ('apify_start', _START_SCHEMA, start_handler_str, '▶️', False),
21
+ ('apify_collect', _COLLECT_SCHEMA, collect_handler_str, '📦', True),
22
+ )
23
+
24
+
25
+ def register(ctx: Any) -> None:
26
+ """Register the three Apify Actor tools. Called once by the Hermes plugin loader."""
27
+ for name, schema, handler, emoji, is_async in _TOOLS:
28
+ ctx.register_tool(
29
+ name=name,
30
+ toolset='apify',
31
+ schema=schema,
32
+ handler=handler,
33
+ check_fn=_check_token,
34
+ requires_env=['APIFY_API_TOKEN'],
35
+ is_async=is_async,
36
+ emoji=emoji,
37
+ )
38
+
39
+ ctx.register_cli_command(
40
+ name='apify-setup',
41
+ help='Set your Apify API token',
42
+ setup_fn=register_cli,
43
+ handler_fn=apify_setup_command,
44
+ description=('Prompt for (or accept via --token) your APIFY_API_TOKEN and save it to ~/.hermes/.env.'),
45
+ )
@@ -0,0 +1,86 @@
1
+ """``hermes apify-setup`` — prompt for and save the Apify API token."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import sys
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ import argparse
11
+
12
+ _ENV_KEY = 'APIFY_API_TOKEN'
13
+
14
+
15
+ def register_cli(parser: argparse.ArgumentParser) -> None:
16
+ """Wire up ``hermes apify-setup`` argument parsing.
17
+
18
+ Called by the Hermes plugin loader with the parser for this subcommand.
19
+ """
20
+ parser.add_argument(
21
+ '--token',
22
+ default=None,
23
+ help=("Apify API token (skips the interactive prompt). If omitted, you'll be prompted securely."),
24
+ )
25
+
26
+
27
+ def apify_setup_command(args: argparse.Namespace) -> int:
28
+ """Prompt for (or accept via --token) the Apify API token and save it."""
29
+ token = getattr(args, 'token', None)
30
+ if token is not None:
31
+ token = token.strip()
32
+ else:
33
+ print('Set your Apify API token (get one at https://apify.com/account/integrations).')
34
+ token = getpass.getpass(f'{_ENV_KEY}: ').strip()
35
+
36
+ if not token:
37
+ print('No token provided — aborted.')
38
+ sys.exit(1)
39
+
40
+ from hermes_cli.config import get_env_path, save_env_value
41
+
42
+ save_env_value(_ENV_KEY, token)
43
+ print(f'Saved {_ENV_KEY} to {get_env_path()}')
44
+
45
+ try:
46
+ _enable_apify_toolset_for_cli()
47
+ print('Enabled the Apify Actors toolset for the CLI.')
48
+ except Exception as exc:
49
+ print(f'Could not auto-enable the Apify Actors toolset ({exc}).')
50
+ print('Run `hermes tools` and enable it manually.')
51
+
52
+ return 0
53
+
54
+
55
+ def _enable_apify_toolset_for_cli() -> None:
56
+ """Add "apify" to the set of toolsets enabled for the "cli" platform.
57
+
58
+ Reuses hermes-agent's own config read/merge/save logic (rather than
59
+ reimplementing it) since it has non-obvious invariants — reconciling
60
+ agent.disabled_toolsets, preserving MCP server entries, plugin-toolset
61
+ bookkeeping. These are private hermes_cli internals with no stability
62
+ guarantee; callers should treat failure here as non-fatal.
63
+
64
+ If the "cli" platform has no explicit toolset list yet, resolving and
65
+ saving it (via ``_get_platform_tools``) would expand the implicit
66
+ default composite (every default-on toolset, plus MCP servers) into a
67
+ frozen explicit snapshot — silently opting this user out of any future
68
+ default-on toolset that ships in a later hermes-agent release. Instead,
69
+ keep the composite referenced by name (the same "[hermes-cli, spotify]"
70
+ mixed-config shape ``_get_platform_tools`` already supports for a single
71
+ explicit opt-in alongside the defaults).
72
+ """
73
+ from hermes_cli.config import load_config
74
+ from hermes_cli.tools_config import PLATFORMS, _get_platform_tools, _save_platform_tools
75
+
76
+ config = load_config()
77
+ existing = (config.get('platform_toolsets') or {}).get('cli')
78
+
79
+ if isinstance(existing, list):
80
+ enabled = _get_platform_tools(config, 'cli')
81
+ else:
82
+ default_ts = PLATFORMS.get('cli', {}).get('default_toolset', 'hermes-cli')
83
+ enabled = {default_ts}
84
+
85
+ enabled.add('apify')
86
+ _save_platform_tools(config, 'cli', enabled)
@@ -0,0 +1,45 @@
1
+ """Apify SDK client — token validation and cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ from apify_client import ApifyClient
9
+
10
+ # Sent with every request so Apify can attribute traffic to this integration.
11
+ _HERMES_HEADERS = {'x-apify-integration-platform': 'hermes-agent'}
12
+
13
+ _CLIENT: Any | None = None
14
+ _CLIENT_CONFIG: Any | None = None
15
+
16
+
17
+ def check_apify_api_key() -> bool:
18
+ """Return True when APIFY_API_TOKEN is configured."""
19
+ return bool(os.getenv('APIFY_API_TOKEN', '').strip())
20
+
21
+
22
+ def get_apify_client() -> Any:
23
+ """Return a cached ApifyClient built from APIFY_API_TOKEN.
24
+
25
+ Raises ValueError when the token is not set.
26
+ """
27
+ global _CLIENT, _CLIENT_CONFIG
28
+ api_token = os.getenv('APIFY_API_TOKEN', '').strip()
29
+ if not api_token:
30
+ raise ValueError(
31
+ 'Apify tools are not configured. Set APIFY_API_TOKEN (get one at https://apify.com/account/integrations).'
32
+ )
33
+ client_config = ('direct', api_token)
34
+ if _CLIENT is not None and client_config == _CLIENT_CONFIG:
35
+ return _CLIENT
36
+ _CLIENT = ApifyClient(token=api_token, headers=_HERMES_HEADERS)
37
+ _CLIENT_CONFIG = client_config
38
+ return _CLIENT
39
+
40
+
41
+ def _reset_client_for_tests() -> None:
42
+ """Drop cached client so tests can re-instantiate cleanly."""
43
+ global _CLIENT, _CLIENT_CONFIG
44
+ _CLIENT = None
45
+ _CLIENT_CONFIG = None
@@ -0,0 +1,431 @@
1
+ """Apify Actor execution tools — discover, start, collect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from typing import Any
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ _TERMINAL_STATUSES = {'SUCCEEDED', 'FAILED', 'ABORTED', 'TIMED-OUT'}
13
+ _MAX_BATCH_RUNS = 10
14
+ _COLLECT_DEFAULT_LIMIT = 100
15
+ _MAX_CONTENT_CHARS = 50_000
16
+
17
+
18
+ def _attr(obj: Any, key: str, default: Any = None) -> Any:
19
+ """Get attribute or dict key from SDK response objects (apify_client returns either)."""
20
+ if isinstance(obj, dict):
21
+ return obj.get(key, default)
22
+ if obj is not None and hasattr(obj, key):
23
+ return getattr(obj, key)
24
+ return default
25
+
26
+
27
+ def _get_client() -> Any:
28
+ from apify_hermes_agent_plugin.client import get_apify_client
29
+
30
+ return get_apify_client()
31
+
32
+
33
+ def _check_token() -> bool:
34
+ from apify_hermes_agent_plugin.client import check_apify_api_key
35
+
36
+ return check_apify_api_key()
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Handlers
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ def _discover_handler(args: dict[str, Any]) -> dict[str, Any]:
45
+ from tools.interrupt import is_interrupted
46
+
47
+ if is_interrupted():
48
+ return {'error': 'Interrupted'}
49
+
50
+ query = (_attr(args, 'query') or '').strip() or None
51
+ actor_id = (_attr(args, 'actor_id') or '').strip() or None
52
+
53
+ if not query and not actor_id:
54
+ return {
55
+ 'error': (
56
+ "Provide exactly one of 'query' (to search the Apify Store) "
57
+ "or 'actor_id' (to fetch an Actor's input schema)."
58
+ )
59
+ }
60
+
61
+ client = _get_client()
62
+ if actor_id:
63
+ return _discover_actor(client, actor_id)
64
+ # This is a type-narrowing hint for `ty`, not a `-O`-surviving safety net — it's provably
65
+ # redundant given the `query`/`actor_id` guard above, so it's fine if stripped.
66
+ assert query is not None, 'guaranteed by the query/actor_id check above' # noqa: S101
67
+ return _discover_store(client, query)
68
+
69
+
70
+ def _discover_actor(client: Any, actor_id: str) -> dict[str, Any]:
71
+ """Fetch a single Actor's input schema and README by ID or username~actor-name."""
72
+ try:
73
+ actor_info = client.actor(actor_id).get()
74
+ if actor_info is None:
75
+ return {'error': (f"Actor '{actor_id}' not found. Check the ID format: username~actor-name.")}
76
+
77
+ input_schema: Any = None
78
+ readme: Any = None
79
+
80
+ build_detail = client.actor(actor_id).default_build().get()
81
+ if build_detail is not None:
82
+ # apify-client 3.x returns Pydantic models with snake_case attributes
83
+ # (e.g. Build.actor_definition, Build.input_schema) — the JSON's
84
+ # camelCase names only exist as (de)serialization aliases, not as
85
+ # real attributes, so _attr() must be called with the snake_case name.
86
+ actor_def = _attr(build_detail, 'actor_definition')
87
+ raw_schema = _attr(actor_def, 'input')
88
+ if raw_schema:
89
+ input_schema = json.dumps(raw_schema)
90
+ else:
91
+ fallback = _attr(build_detail, 'input_schema')
92
+ if fallback:
93
+ input_schema = str(fallback)
94
+
95
+ raw_readme = _attr(actor_def, 'readme') or _attr(build_detail, 'readme')
96
+ if raw_readme:
97
+ readme = str(raw_readme)[:3000]
98
+
99
+ username = _attr(actor_info, 'username', '')
100
+ name = _attr(actor_info, 'name', '')
101
+ title = _attr(actor_info, 'title', '') or name
102
+ except Exception as exc:
103
+ logger.warning('apify_discover schema fetch error for %s: %s', actor_id, exc)
104
+ return {'error': str(exc)}
105
+ else:
106
+ return {
107
+ 'actor_id': f'{username}~{name}',
108
+ 'name': name,
109
+ 'title': title,
110
+ 'username': username,
111
+ 'description': _attr(actor_info, 'description', ''),
112
+ 'input_schema': input_schema,
113
+ 'readme': readme,
114
+ 'tip': (f"Use apify_start with actor_id='{username}~{name}' and an input matching the input_schema above."),
115
+ }
116
+
117
+
118
+ def _discover_store(client: Any, query: str) -> dict[str, Any]:
119
+ """Search the Apify Store by keyword."""
120
+ try:
121
+ result = client.store().list(search=query, limit=10, sort_by='relevance')
122
+ items = _attr(result, 'items') or []
123
+ actors: list[dict[str, Any]] = []
124
+ for item in items:
125
+ stats = _attr(item, 'stats')
126
+ name = _attr(item, 'name', '')
127
+ username = _attr(item, 'username', '')
128
+ title = _attr(item, 'title') or name
129
+ desc = (_attr(item, 'description') or '')[:200]
130
+ # ActorStats.total_runs — see the snake_case note in _discover_actor().
131
+ run_count = _attr(stats, 'total_runs', 0) or 0
132
+ actors.append(
133
+ {
134
+ 'actor_id': f'{username}~{name}',
135
+ 'name': name,
136
+ 'title': title,
137
+ 'username': username,
138
+ 'description': desc,
139
+ 'run_count': run_count,
140
+ }
141
+ )
142
+ except Exception as exc:
143
+ logger.warning("apify_discover store search error for '%s': %s", query, exc)
144
+ return {'error': str(exc)}
145
+ else:
146
+ return {'actors': actors}
147
+
148
+
149
+ def _start_one_run(client: Any, spec: dict[str, Any]) -> dict[str, Any]:
150
+ actor_id = (spec.get('actor_id') or '').strip()
151
+ run_input = spec.get('input') or {}
152
+ label = spec.get('label')
153
+
154
+ if not actor_id:
155
+ return {'_ok': False, 'error': "Missing 'actor_id' in run spec."}
156
+
157
+ try:
158
+ run = client.actor(actor_id).start(run_input=run_input)
159
+ entry: dict[str, Any] = {
160
+ 'run_id': _attr(run, 'id'),
161
+ 'actor_id': actor_id,
162
+ 'dataset_id': _attr(run, 'default_dataset_id'),
163
+ 'status': _attr(run, 'status'),
164
+ }
165
+ if label:
166
+ entry['label'] = label
167
+ except Exception as exc:
168
+ logger.warning('apify_start error for %s: %s', actor_id, exc)
169
+ err: dict[str, Any] = {'actor_id': actor_id, 'error': str(exc)}
170
+ if label:
171
+ err['label'] = label
172
+ return {'_ok': False, **err}
173
+ else:
174
+ return {'_ok': True, **entry}
175
+
176
+
177
+ def _start_handler(args: dict[str, Any]) -> dict[str, Any]:
178
+ from tools.interrupt import is_interrupted
179
+
180
+ if is_interrupted():
181
+ return {'error': 'Interrupted'}
182
+
183
+ run_specs = args.get('runs') or []
184
+ if not run_specs:
185
+ return {'error': "Provide at least one run spec in 'runs'."}
186
+ if len(run_specs) > _MAX_BATCH_RUNS:
187
+ return {'error': f'Batch too large: {len(run_specs)} runs requested, maximum is {_MAX_BATCH_RUNS}.'}
188
+
189
+ client = _get_client()
190
+ started: list[dict[str, Any]] = []
191
+ errors: list[dict[str, Any]] = []
192
+
193
+ for spec in run_specs:
194
+ from tools.interrupt import is_interrupted
195
+
196
+ if is_interrupted():
197
+ break
198
+ outcome = _start_one_run(client, spec)
199
+ ok = outcome.pop('_ok')
200
+ (started if ok else errors).append(outcome)
201
+
202
+ result: dict[str, Any] = {'runs': started}
203
+ if errors:
204
+ result['errors'] = errors
205
+ return result
206
+
207
+
208
+ async def _check_run(client: Any, limit: int, ref: dict[str, Any]) -> dict[str, Any]:
209
+ run_id = ref.get('run_id', '')
210
+ actor_id = ref.get('actor_id', '')
211
+ dataset_id = ref.get('dataset_id', '')
212
+ label = ref.get('label')
213
+
214
+ base: dict[str, Any] = {
215
+ 'run_id': run_id,
216
+ 'actor_id': actor_id,
217
+ 'dataset_id': dataset_id,
218
+ }
219
+ if label:
220
+ base['label'] = label
221
+
222
+ try:
223
+ run_info = await asyncio.to_thread(client.run(run_id).get)
224
+
225
+ if run_info is None:
226
+ return {**base, '_type': 'error', 'error': 'Run not found.'}
227
+
228
+ status = _attr(run_info, 'status', 'UNKNOWN')
229
+ base['status'] = status
230
+
231
+ if status not in _TERMINAL_STATUSES:
232
+ return {**base, '_type': 'pending'}
233
+
234
+ if status != 'SUCCEEDED':
235
+ return {**base, '_type': 'error', 'error': f'Run ended with status: {status}'}
236
+
237
+ return await _collect_dataset(client, limit, run_id, dataset_id, base)
238
+
239
+ except Exception as exc:
240
+ logger.warning('apify_collect error for run %s: %s', run_id, exc)
241
+ return {**base, '_type': 'error', 'error': str(exc)}
242
+
243
+
244
+ async def _collect_dataset(
245
+ client: Any, limit: int, run_id: str, dataset_id: str, base: dict[str, Any]
246
+ ) -> dict[str, Any]:
247
+ dataset_result = await asyncio.to_thread(client.dataset(dataset_id).list_items, limit=limit)
248
+ items = list(_attr(dataset_result, 'items') or [])
249
+ may_have_more = len(items) == limit
250
+ if may_have_more:
251
+ logger.warning(
252
+ 'apify_collect run %s: fetched %d items (hit limit=%d) — '
253
+ 'dataset may have more; re-call with a higher limit if needed',
254
+ run_id,
255
+ len(items),
256
+ limit,
257
+ )
258
+ raw = json.dumps(items, indent=2, default=str)
259
+ if len(raw) > _MAX_CONTENT_CHARS:
260
+ raw = raw[:_MAX_CONTENT_CHARS] + '\n\n[…truncated]'
261
+ wrapped = '<<<EXTERNAL_UNTRUSTED_CONTENT>>>\n' + raw + '\n<<<END_EXTERNAL_UNTRUSTED_CONTENT>>>'
262
+ result: dict[str, Any] = {
263
+ **base,
264
+ '_type': 'completed',
265
+ 'result_count': len(items),
266
+ 'data': wrapped,
267
+ }
268
+ if may_have_more:
269
+ result['may_have_more'] = True
270
+ return result
271
+
272
+
273
+ async def _collect_handler(args: dict[str, Any]) -> dict[str, Any]:
274
+ from tools.interrupt import is_interrupted
275
+
276
+ if is_interrupted():
277
+ return {'error': 'Interrupted'}
278
+
279
+ run_refs = args.get('runs') or []
280
+ if not run_refs:
281
+ return {'error': "Provide 'runs' array (from apify_start)."}
282
+
283
+ limit = int(args.get('limit') or _COLLECT_DEFAULT_LIMIT)
284
+ client = _get_client()
285
+
286
+ raw_results = await asyncio.gather(*[_check_run(client, limit, ref) for ref in run_refs])
287
+
288
+ completed: list[dict[str, Any]] = []
289
+ pending: list[dict[str, Any]] = []
290
+ errors: list[dict[str, Any]] = []
291
+
292
+ for r in raw_results:
293
+ t = r.pop('_type', 'error')
294
+ if t == 'pending':
295
+ pending.append(r)
296
+ elif t == 'error':
297
+ errors.append(r)
298
+ else:
299
+ completed.append(r)
300
+
301
+ return {
302
+ 'all_done': len(pending) == 0,
303
+ 'completed': completed,
304
+ 'pending': pending,
305
+ 'errors': errors,
306
+ }
307
+
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # Schemas
311
+ # ---------------------------------------------------------------------------
312
+
313
+ _DISCOVER_SCHEMA: dict[str, Any] = {
314
+ 'name': 'apify_discover',
315
+ 'description': (
316
+ "Search the Apify Store for Actors by keyword, or fetch an Actor's "
317
+ "input schema and README. Provide 'query' to search, or 'actor_id' "
318
+ 'to inspect a specific Actor. Actor IDs accept either the unique ID '
319
+ 'or the username~actor-name format.'
320
+ ),
321
+ 'parameters': {
322
+ 'type': 'object',
323
+ 'properties': {
324
+ 'query': {
325
+ 'type': 'string',
326
+ 'description': "Keywords to search the Apify Store (e.g. 'instagram scraper').",
327
+ },
328
+ 'actor_id': {
329
+ 'type': 'string',
330
+ 'description': (
331
+ 'Actor ID to fetch its input schema and README — either the '
332
+ "unique ID or 'username~actor-name' (e.g. 'apify~google-search-scraper')."
333
+ ),
334
+ },
335
+ },
336
+ },
337
+ }
338
+
339
+ _START_SCHEMA: dict[str, Any] = {
340
+ 'name': 'apify_start',
341
+ 'description': (
342
+ 'Start one or more Apify Actor runs. Returns run references immediately '
343
+ f'(fire-and-forget). Pass the returned run refs to apify_collect to get results. '
344
+ f'Maximum {_MAX_BATCH_RUNS} runs per call.'
345
+ ),
346
+ 'parameters': {
347
+ 'type': 'object',
348
+ 'properties': {
349
+ 'runs': {
350
+ 'type': 'array',
351
+ 'description': f'List of Actor runs to start (max {_MAX_BATCH_RUNS}).',
352
+ 'maxItems': _MAX_BATCH_RUNS,
353
+ 'items': {
354
+ 'type': 'object',
355
+ 'properties': {
356
+ 'actor_id': {
357
+ 'type': 'string',
358
+ 'description': 'Actor ID (username~actor-name).',
359
+ },
360
+ 'input': {
361
+ 'type': 'object',
362
+ 'description': 'Actor input parameters.',
363
+ },
364
+ 'label': {
365
+ 'type': 'string',
366
+ 'description': 'Optional label to identify this run in results.',
367
+ },
368
+ },
369
+ 'required': ['actor_id'],
370
+ },
371
+ },
372
+ },
373
+ 'required': ['runs'],
374
+ },
375
+ }
376
+
377
+ _COLLECT_SCHEMA: dict[str, Any] = {
378
+ 'name': 'apify_collect',
379
+ 'description': (
380
+ 'Poll the status of Apify Actor runs started with apify_start. '
381
+ 'Returns completed results, still-running refs, and errors. '
382
+ 'Re-call with the same run refs until all_done is true.'
383
+ ),
384
+ 'parameters': {
385
+ 'type': 'object',
386
+ 'properties': {
387
+ 'runs': {
388
+ 'type': 'array',
389
+ 'description': 'Run references returned by apify_start.',
390
+ 'items': {
391
+ 'type': 'object',
392
+ 'properties': {
393
+ 'run_id': {'type': 'string'},
394
+ 'actor_id': {'type': 'string'},
395
+ 'dataset_id': {'type': 'string'},
396
+ 'label': {'type': 'string'},
397
+ },
398
+ 'required': ['run_id', 'actor_id', 'dataset_id'],
399
+ },
400
+ },
401
+ 'limit': {
402
+ 'type': 'integer',
403
+ 'description': (
404
+ f'Max dataset items to fetch per run (default {_COLLECT_DEFAULT_LIMIT}). '
405
+ 'Increase if may_have_more is true in a previous response.'
406
+ ),
407
+ },
408
+ },
409
+ 'required': ['runs'],
410
+ },
411
+ }
412
+
413
+
414
+ # ---------------------------------------------------------------------------
415
+ # String-returning wrappers consumed by register(ctx) in __init__.py
416
+ # ---------------------------------------------------------------------------
417
+
418
+
419
+ def discover_handler_str(args: dict[str, Any], **_kw: Any) -> str:
420
+ """String-returning wrapper around `_discover_handler`, for hermes-agent's tool dispatcher."""
421
+ return json.dumps(_discover_handler(args), default=str)
422
+
423
+
424
+ def start_handler_str(args: dict[str, Any], **_kw: Any) -> str:
425
+ """String-returning wrapper around `_start_handler`, for hermes-agent's tool dispatcher."""
426
+ return json.dumps(_start_handler(args), default=str)
427
+
428
+
429
+ async def collect_handler_str(args: dict[str, Any], **_kw: Any) -> str:
430
+ """String-returning wrapper around `_collect_handler`, for hermes-agent's tool dispatcher."""
431
+ return json.dumps(await _collect_handler(args), default=str)
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: apify-hermes-agent-plugin
3
+ Version: 0.1.0
4
+ Summary: Hermes Agent plugin for Apify, the world's largest marketplace for AI tools — discover, run, and collect results from Actors, all from natural language
5
+ Author-email: "Apify Technologies s.r.o." <support@apify.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/apify/apify-hermes-agent-plugin
8
+ Project-URL: Source Code, https://github.com/apify/apify-hermes-agent-plugin
9
+ Project-URL: Issue Tracker, https://github.com/apify/apify-hermes-agent-plugin/issues
10
+ Project-URL: Changelog, https://github.com/apify/apify-hermes-agent-plugin/blob/main/CHANGELOG.md
11
+ Project-URL: Hermes Agent, https://hermes-agent.nousresearch.com
12
+ Keywords: apify,hermes,hermes-agent,actor,plugin,ai-tools,automation
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Topic :: Software Development :: Libraries
23
+ Requires-Python: >=3.11
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: hermes-agent>=0.15.1
27
+ Requires-Dist: apify-client<4,>=3.0.1
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest==9.0.2; extra == "dev"
30
+ Requires-Dist: pytest-asyncio==1.3.0; extra == "dev"
31
+ Requires-Dist: ruff<0.17,>=0.15.20; extra == "dev"
32
+ Requires-Dist: ty<0.1,>=0.0.65; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ # apify-hermes-agent-plugin
36
+
37
+ Hermes Agent plugin for [Apify](https://apify.com), the world's largest marketplace for AI tools — discover
38
+ Actors in the Apify Store, start runs, and collect structured results, all from natural language, via
39
+ [Hermes Agent](https://hermes-agent.nousresearch.com).
40
+
41
+ ## Install
42
+
43
+ pip install apify-hermes-agent-plugin
44
+ hermes plugins enable apify
45
+
46
+ Then run:
47
+
48
+ hermes apify-setup
49
+
50
+ This prompts for your `APIFY_API_TOKEN` (get one at https://apify.com/account/integrations),
51
+ saves it, and enables the Apify Actors toolset for the CLI. Pass `--token <token>` to skip the
52
+ prompt.
53
+
54
+ ## Tools
55
+
56
+ - `apify_discover` — search the Apify Store by keyword, or fetch an Actor's input schema + README by `actor_id`.
57
+ - `apify_start` — fire-and-forget batch Actor starts (up to 10 per call).
58
+ - `apify_collect` — poll run statuses and return completed dataset results.
59
+
60
+ ## Development
61
+
62
+ pip install -e ".[dev]"
63
+ pytest
@@ -0,0 +1,15 @@
1
+ CHANGELOG.md
2
+ LICENSE
3
+ MANIFEST.in
4
+ README.md
5
+ pyproject.toml
6
+ src/apify_hermes_agent_plugin/__init__.py
7
+ src/apify_hermes_agent_plugin/cli.py
8
+ src/apify_hermes_agent_plugin/client.py
9
+ src/apify_hermes_agent_plugin/tools.py
10
+ src/apify_hermes_agent_plugin.egg-info/PKG-INFO
11
+ src/apify_hermes_agent_plugin.egg-info/SOURCES.txt
12
+ src/apify_hermes_agent_plugin.egg-info/dependency_links.txt
13
+ src/apify_hermes_agent_plugin.egg-info/entry_points.txt
14
+ src/apify_hermes_agent_plugin.egg-info/requires.txt
15
+ src/apify_hermes_agent_plugin.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [hermes_agent.plugins]
2
+ apify = apify_hermes_agent_plugin
@@ -0,0 +1,8 @@
1
+ hermes-agent>=0.15.1
2
+ apify-client<4,>=3.0.1
3
+
4
+ [dev]
5
+ pytest==9.0.2
6
+ pytest-asyncio==1.3.0
7
+ ruff<0.17,>=0.15.20
8
+ ty<0.1,>=0.0.65