@tiny-fish/hermes 0.1.1
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.
- package/LICENSE +38 -0
- package/README.md +102 -0
- package/__init__.py +10 -0
- package/package.json +33 -0
- package/plugin.yaml +13 -0
- package/pyproject.toml +66 -0
- package/tinyfish_hermes/__init__.py +113 -0
- package/tinyfish_hermes/browser_provider.py +133 -0
- package/tinyfish_hermes/config.py +164 -0
- package/tinyfish_hermes/credit_policy.py +128 -0
- package/tinyfish_hermes/normalize.py +110 -0
- package/tinyfish_hermes/provider.py +163 -0
- package/tinyfish_hermes/rest_client.py +276 -0
- package/tinyfish_hermes/routing_context.py +70 -0
- package/tinyfish_hermes/setup_cli.py +695 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tiny Fish, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
Third-party notices
|
|
14
|
+
|
|
15
|
+
The provider architecture is ported from hermes-plugin-tinyfish
|
|
16
|
+
(https://github.com/gabeosx/hermes-plugin-tinyfish), used under the MIT License:
|
|
17
|
+
|
|
18
|
+
MIT License
|
|
19
|
+
|
|
20
|
+
Copyright (c) 2026 gabeosx
|
|
21
|
+
|
|
22
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
23
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
24
|
+
in the Software without restriction, including without limitation the rights
|
|
25
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
26
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
27
|
+
furnished to do so, subject to the following conditions:
|
|
28
|
+
|
|
29
|
+
The above copyright notice and this permission notice shall be included in all
|
|
30
|
+
copies or substantial portions of the Software.
|
|
31
|
+
|
|
32
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
33
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
34
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
35
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
36
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
37
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
38
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# TinyFish for Hermes Agent
|
|
2
|
+
|
|
3
|
+
`tinyfish-hermes` is the first-party TinyFish plugin for [Hermes Agent](https://github.com/NousResearch/Hermes-Agent). It registers a `tinyfish` web provider that serves Hermes' web search and extract tools through the TinyFish Search and Fetch REST APIs, plus a `tinyfish` cloud browser provider for credit-gated remote browser sessions.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
The supported path is the TinyFish CLI, which installs the plugin from npm
|
|
8
|
+
(`@tiny-fish/hermes@latest`), seeds the API key, and points Hermes' web backends at it:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
tinyfish connect hermes
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
To install just the plugin straight from this repo (no key seeding, no backend wiring):
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
hermes plugins install tinyfish-io/tinyfish-web-agent-integrations/hermes --enable
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Authentication
|
|
21
|
+
|
|
22
|
+
The plugin authenticates with a TinyFish API key — create one at [agent.tinyfish.ai/api-keys](https://agent.tinyfish.ai/api-keys). Keys are resolved in order:
|
|
23
|
+
|
|
24
|
+
| Env var | Source |
|
|
25
|
+
| ------- | ------ |
|
|
26
|
+
| `TINYFISH_API_KEY` | Set it yourself (shell env, or `hermes tinyfish setup --api-key`) |
|
|
27
|
+
| `MCP_TINYFISH_API_KEY` | Seeded by the TinyFish npm CLI during `tinyfish connect hermes` |
|
|
28
|
+
|
|
29
|
+
## CLI
|
|
30
|
+
|
|
31
|
+
The plugin registers a `hermes tinyfish` command and an in-session `/tinyfish-status [live]` command:
|
|
32
|
+
|
|
33
|
+
| Subcommand | Purpose |
|
|
34
|
+
| ---------- | ------- |
|
|
35
|
+
| `setup [--yes] [--api-key KEY] [--no-web-backend] [--live]` | Route web tools to TinyFish and save the API key |
|
|
36
|
+
| `status [--json]` | Non-secret configuration status |
|
|
37
|
+
| `doctor [--json] [--live] [--live-paid]` | Diagnostics; `--live` runs one real search+fetch, `--live-paid` a browser session per credit policy |
|
|
38
|
+
| `credits {status,set,reset}` | Inspect or change `tinyfish.credit_policy.browser` |
|
|
39
|
+
| `browser {enable,disable,status}` | Toggle `browser.cloud_provider: tinyfish` |
|
|
40
|
+
| `usage [--json]` | Wallet balance, auto-reload, and billing rates |
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
`hermes tinyfish setup` routes Hermes' web tools to TinyFish; the equivalent Hermes configuration is:
|
|
45
|
+
|
|
46
|
+
```yaml
|
|
47
|
+
web:
|
|
48
|
+
search_backend: tinyfish
|
|
49
|
+
extract_backend: tinyfish
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Optional request tuning under a `tinyfish` section:
|
|
53
|
+
|
|
54
|
+
```yaml
|
|
55
|
+
tinyfish:
|
|
56
|
+
search:
|
|
57
|
+
location: US # also: language, recency_minutes, after_date,
|
|
58
|
+
domain_type: news # before_date, page, purpose
|
|
59
|
+
fetch:
|
|
60
|
+
format: markdown # default output format for extract
|
|
61
|
+
ttl: 300 # also: per_url_timeout_ms, links, image_links
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Browser sessions
|
|
65
|
+
|
|
66
|
+
Setting `browser.cloud_provider: tinyfish` routes Hermes' browser tools through TinyFish remote browser sessions. Each session consumes TinyFish credits, so sessions are policy-gated via `tinyfish.credit_policy.browser`:
|
|
67
|
+
|
|
68
|
+
| Policy | Behavior |
|
|
69
|
+
| ------ | -------- |
|
|
70
|
+
| `request` (default) | Every session goes through Hermes' approval gate |
|
|
71
|
+
| `allow` | Sessions start without per-session approval |
|
|
72
|
+
| `deny` | Browser tools are blocked while routed to TinyFish |
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
browser:
|
|
76
|
+
cloud_provider: tinyfish
|
|
77
|
+
tinyfish:
|
|
78
|
+
credit_policy:
|
|
79
|
+
browser: request
|
|
80
|
+
browser:
|
|
81
|
+
timeout_seconds: 300 # optional session timeout
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Tool-routing guidance
|
|
85
|
+
|
|
86
|
+
When the `tinyfish` MCP server is also configured in Hermes (`mcp_servers.tinyfish.url: https://agent.tinyfish.ai/mcp`), the plugin injects once-per-context guidance steering the model between the generic web tools and the native MCP tools. Disable with `tinyfish.routing_context: false`.
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
python -m pip install -e . -r requirements-dev.txt # dev deps are version-bounded in requirements-dev.txt
|
|
92
|
+
make lint
|
|
93
|
+
make test
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Credits
|
|
97
|
+
|
|
98
|
+
The provider architecture is ported from [gabeosx/hermes-plugin-tinyfish](https://github.com/gabeosx/hermes-plugin-tinyfish) (MIT), an independent community plugin. Both plugins register a web provider named `tinyfish`, so install only one of them.
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
package/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Directory-plugin entry point loaded by ``hermes plugins install``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from .tinyfish_hermes import __version__, register
|
|
7
|
+
except ImportError: # pip installs import the packaged module directly
|
|
8
|
+
from tinyfish_hermes import __version__, register
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__", "register"]
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tiny-fish/hermes",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "TinyFish web provider plugin for Hermes Agent, delivered via npm for the TinyFish CLI. Installed by `tinyfish connect hermes`; not a JavaScript library.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://docs.tinyfish.ai",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/tinyfish-io/tinyfish-web-agent-integrations.git",
|
|
10
|
+
"directory": "hermes"
|
|
11
|
+
},
|
|
12
|
+
"author": {
|
|
13
|
+
"name": "TinyFish",
|
|
14
|
+
"email": "support@tinyfish.io",
|
|
15
|
+
"url": "https://tinyfish.ai"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"hermes",
|
|
19
|
+
"tinyfish",
|
|
20
|
+
"web-search",
|
|
21
|
+
"web-fetch",
|
|
22
|
+
"ai-agent"
|
|
23
|
+
],
|
|
24
|
+
"files": [
|
|
25
|
+
"tinyfish_hermes/*.py",
|
|
26
|
+
"plugin.yaml",
|
|
27
|
+
"pyproject.toml",
|
|
28
|
+
"__init__.py"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/plugin.yaml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
name: tinyfish
|
|
2
|
+
version: 0.1.1
|
|
3
|
+
description: "First-party TinyFish provider plugin for Hermes Agent: Search and Fetch over the TinyFish REST APIs with API-key auth, plus credit-gated Browser sessions."
|
|
4
|
+
author: TinyFish
|
|
5
|
+
kind: backend
|
|
6
|
+
provides_web_providers:
|
|
7
|
+
- tinyfish
|
|
8
|
+
provides_browser_providers:
|
|
9
|
+
- tinyfish
|
|
10
|
+
requires_env:
|
|
11
|
+
- TINYFISH_API_KEY
|
|
12
|
+
homepage: "https://tinyfish.ai"
|
|
13
|
+
license: MIT
|
package/pyproject.toml
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tinyfish-hermes"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "TinyFish web provider plugin for Hermes Agent - Search and Fetch over the TinyFish REST APIs"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "TinyFish", email = "support@tinyfish.io" }]
|
|
13
|
+
keywords = ["hermes", "tinyfish", "web-search", "web-fetch", "ai-agent"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Software Development :: Libraries",
|
|
24
|
+
"Topic :: Internet :: WWW/HTTP :: Indexing/Search",
|
|
25
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"httpx>=0.24,<1.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://tinyfish.ai"
|
|
33
|
+
Documentation = "https://docs.tinyfish.ai"
|
|
34
|
+
Repository = "https://github.com/tinyfish-io/tinyfish-web-agent-integrations"
|
|
35
|
+
|
|
36
|
+
[project.entry-points."hermes_agent.plugins"]
|
|
37
|
+
tinyfish = "tinyfish_hermes"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["tinyfish_hermes"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
target-version = "py310"
|
|
44
|
+
line-length = 88
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = ["E", "F", "I", "T201", "W"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint.per-file-ignores]
|
|
50
|
+
# ROUTING_GUIDANCE is injected verbatim into LLM context, one bullet per line.
|
|
51
|
+
"tinyfish_hermes/routing_context.py" = ["E501"]
|
|
52
|
+
# The CLI's output medium is stdout.
|
|
53
|
+
"tinyfish_hermes/setup_cli.py" = ["T201"]
|
|
54
|
+
|
|
55
|
+
[tool.mypy]
|
|
56
|
+
python_version = "3.10"
|
|
57
|
+
# The directory-install shim (./__init__.py) would otherwise make mypy see the package twice.
|
|
58
|
+
explicit_package_bases = true
|
|
59
|
+
warn_return_any = true
|
|
60
|
+
warn_unused_configs = true
|
|
61
|
+
disallow_untyped_defs = true
|
|
62
|
+
ignore_missing_imports = true
|
|
63
|
+
|
|
64
|
+
[tool.pytest.ini_options]
|
|
65
|
+
testpaths = ["tests"]
|
|
66
|
+
addopts = "--strict-markers --strict-config --durations=5"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""First-party TinyFish web provider plugin for Hermes Agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import metadata
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_DISTRIBUTION_NAME = "tinyfish-hermes"
|
|
10
|
+
_PLUGIN_MANIFEST = Path(__file__).resolve().parents[1] / "plugin.yaml"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _version_from_plugin_manifest(path: Path | None = None) -> str | None:
|
|
14
|
+
path = _PLUGIN_MANIFEST if path is None else path
|
|
15
|
+
try:
|
|
16
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
17
|
+
except (OSError, UnicodeError):
|
|
18
|
+
return None
|
|
19
|
+
for line in lines:
|
|
20
|
+
if not line.startswith("version:"):
|
|
21
|
+
continue
|
|
22
|
+
value = line.partition(":")[2].split("#", 1)[0].strip().strip("'\"")
|
|
23
|
+
return value or None
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _version_from_metadata() -> str | None:
|
|
28
|
+
try:
|
|
29
|
+
return metadata.version(_DISTRIBUTION_NAME)
|
|
30
|
+
except Exception: # directory installs must not depend on package metadata
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _resolve_version() -> str:
|
|
35
|
+
# httpx headers must be ASCII; wheels omit plugin.yaml.
|
|
36
|
+
for candidate in (_version_from_plugin_manifest(), _version_from_metadata()):
|
|
37
|
+
if candidate and candidate.isascii():
|
|
38
|
+
return candidate
|
|
39
|
+
return "0+unknown"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__version__ = _resolve_version()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def register(ctx: Any) -> None:
|
|
46
|
+
"""Register TinyFish providers, lifecycle hooks, and CLI commands with Hermes."""
|
|
47
|
+
|
|
48
|
+
from .browser_provider import TinyFishBrowserProvider
|
|
49
|
+
from .credit_policy import pre_tool_call_policy
|
|
50
|
+
from .provider import TinyFishWebSearchProvider
|
|
51
|
+
from .routing_context import routing_context_hook
|
|
52
|
+
from .setup_cli import (
|
|
53
|
+
dispatch_tinyfish_cli,
|
|
54
|
+
setup_tinyfish_cli,
|
|
55
|
+
tinyfish_status_command,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
provider = TinyFishWebSearchProvider()
|
|
59
|
+
ctx.register_web_search_provider(provider)
|
|
60
|
+
if hasattr(ctx, "register_hook"):
|
|
61
|
+
# Only offer the browser provider where the pre_tool_call credit gate lands.
|
|
62
|
+
if hasattr(ctx, "register_browser_provider"):
|
|
63
|
+
ctx.register_browser_provider(TinyFishBrowserProvider())
|
|
64
|
+
ctx.register_hook("pre_tool_call", pre_tool_call_policy)
|
|
65
|
+
ctx.register_hook("pre_llm_call", routing_context_hook)
|
|
66
|
+
|
|
67
|
+
if hasattr(ctx, "register_cli_command"):
|
|
68
|
+
|
|
69
|
+
def _dispatch_cli(args: Any) -> int:
|
|
70
|
+
# Hermes' CLI main() propagates a nonzero int return to sys.exit.
|
|
71
|
+
return dispatch_tinyfish_cli(args, provider=provider)
|
|
72
|
+
|
|
73
|
+
ctx.register_cli_command(
|
|
74
|
+
name="tinyfish",
|
|
75
|
+
help="Configure and diagnose the TinyFish web provider",
|
|
76
|
+
setup_fn=setup_tinyfish_cli,
|
|
77
|
+
handler_fn=_dispatch_cli,
|
|
78
|
+
description=(
|
|
79
|
+
"Route Hermes web tools to TinyFish and manage the API key, "
|
|
80
|
+
"credit policies, and browser sessions."
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
if hasattr(ctx, "register_command"):
|
|
84
|
+
ctx.register_command(
|
|
85
|
+
name="tinyfish-status",
|
|
86
|
+
handler=lambda raw_args: tinyfish_status_command(
|
|
87
|
+
raw_args, provider=provider
|
|
88
|
+
),
|
|
89
|
+
description=(
|
|
90
|
+
"Show TinyFish provider status; pass 'live' to test Search and Fetch."
|
|
91
|
+
),
|
|
92
|
+
args_hint="[live]",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def __getattr__(name: str) -> Any:
|
|
97
|
+
if name == "TinyFishWebSearchProvider":
|
|
98
|
+
from .provider import TinyFishWebSearchProvider
|
|
99
|
+
|
|
100
|
+
return TinyFishWebSearchProvider
|
|
101
|
+
if name == "TinyFishBrowserProvider":
|
|
102
|
+
from .browser_provider import TinyFishBrowserProvider
|
|
103
|
+
|
|
104
|
+
return TinyFishBrowserProvider
|
|
105
|
+
raise AttributeError(name)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
__all__ = [
|
|
109
|
+
"TinyFishBrowserProvider",
|
|
110
|
+
"TinyFishWebSearchProvider",
|
|
111
|
+
"__version__",
|
|
112
|
+
"register",
|
|
113
|
+
]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Hermes BrowserProvider implementation for TinyFish Browser sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from agent.browser_provider import BrowserProvider as _HermesBrowserProvider
|
|
11
|
+
except Exception: # pragma: no cover - lets the package import outside Hermes
|
|
12
|
+
|
|
13
|
+
class _HermesBrowserProvider: # type: ignore[no-redef]
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
from . import rest_client
|
|
18
|
+
from .config import credit_policy, tinyfish_config
|
|
19
|
+
from .credit_policy import block_message
|
|
20
|
+
from .provider import API_KEY_URL, MISSING_KEY_ERROR, _api_key
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TinyFishBrowserProvider(_HermesBrowserProvider): # type: ignore[misc]
|
|
26
|
+
"""TinyFish cloud browser backend for Hermes browser tools."""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def name(self) -> str:
|
|
30
|
+
return "tinyfish"
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def display_name(self) -> str:
|
|
34
|
+
return "TinyFish"
|
|
35
|
+
|
|
36
|
+
def is_available(self) -> bool:
|
|
37
|
+
return bool(credit_policy("browser") in {"request", "allow"} and _api_key())
|
|
38
|
+
|
|
39
|
+
def create_session(self, task_id: str) -> dict[str, object]:
|
|
40
|
+
api_key = _api_key()
|
|
41
|
+
if not api_key:
|
|
42
|
+
# Hermes falls back to a local browser on raise; the hook is the real gate.
|
|
43
|
+
logger.warning(
|
|
44
|
+
"TinyFish Browser create_session refused (missing API key); "
|
|
45
|
+
"Hermes will fall back to a local browser"
|
|
46
|
+
)
|
|
47
|
+
raise ValueError(MISSING_KEY_ERROR)
|
|
48
|
+
if credit_policy("browser") == "deny":
|
|
49
|
+
logger.warning(
|
|
50
|
+
"TinyFish Browser create_session refused (policy deny); "
|
|
51
|
+
"Hermes will fall back to a local browser"
|
|
52
|
+
)
|
|
53
|
+
raise ValueError(block_message("browser"))
|
|
54
|
+
browser_cfg = tinyfish_config().get("browser") or {}
|
|
55
|
+
timeout_seconds = None
|
|
56
|
+
if isinstance(browser_cfg, dict) and browser_cfg.get("timeout_seconds") not in (
|
|
57
|
+
None,
|
|
58
|
+
"",
|
|
59
|
+
):
|
|
60
|
+
try:
|
|
61
|
+
timeout_seconds = int(browser_cfg["timeout_seconds"])
|
|
62
|
+
except (TypeError, ValueError):
|
|
63
|
+
logger.warning(
|
|
64
|
+
"Ignoring malformed tinyfish.browser.timeout_seconds %r",
|
|
65
|
+
browser_cfg["timeout_seconds"],
|
|
66
|
+
)
|
|
67
|
+
timeout_seconds = None
|
|
68
|
+
|
|
69
|
+
data = rest_client.create_browser_session(
|
|
70
|
+
api_key=api_key, timeout_seconds=timeout_seconds
|
|
71
|
+
)
|
|
72
|
+
session_id = str(data.get("session_id") or data.get("id") or "")
|
|
73
|
+
cdp_url = str(data.get("cdp_url") or data.get("cdpUrl") or "")
|
|
74
|
+
if not session_id or not cdp_url:
|
|
75
|
+
if session_id:
|
|
76
|
+
# A half-created session bills until the server's inactivity GC.
|
|
77
|
+
try:
|
|
78
|
+
rest_client.close_browser_session(session_id, api_key=api_key)
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
raise RuntimeError(
|
|
82
|
+
"TinyFish Browser did not return session_id and cdp_url."
|
|
83
|
+
)
|
|
84
|
+
session_name = f"tinyfish_{task_id}_{uuid.uuid4().hex[:8]}"
|
|
85
|
+
logger.info("Created TinyFish browser session")
|
|
86
|
+
return {
|
|
87
|
+
"session_name": session_name,
|
|
88
|
+
"bb_session_id": session_id,
|
|
89
|
+
"cdp_url": cdp_url,
|
|
90
|
+
"features": {
|
|
91
|
+
"tinyfish": True,
|
|
92
|
+
"credit_policy": credit_policy("browser"),
|
|
93
|
+
"base_url": str(data.get("base_url") or ""),
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
def close_session(self, session_id: str) -> bool:
|
|
98
|
+
# Hermes' BrowserProvider contract: close_session must never raise.
|
|
99
|
+
try:
|
|
100
|
+
api_key = _api_key()
|
|
101
|
+
if not api_key:
|
|
102
|
+
logger.warning("Cannot close TinyFish browser session: missing API key")
|
|
103
|
+
return False
|
|
104
|
+
return rest_client.close_browser_session(session_id, api_key=api_key)
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
logger.debug("TinyFish browser close failed (%s)", type(exc).__name__)
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
def emergency_cleanup(self, session_id: str) -> None:
|
|
110
|
+
try:
|
|
111
|
+
self.close_session(session_id)
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
logger.debug(
|
|
114
|
+
"TinyFish browser emergency cleanup failed (%s)", type(exc).__name__
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def get_setup_schema(self) -> dict[str, Any]:
|
|
118
|
+
return {
|
|
119
|
+
"name": "TinyFish",
|
|
120
|
+
"badge": "credit-gated",
|
|
121
|
+
"tag": (
|
|
122
|
+
"Remote browser sessions; each session consumes TinyFish credits. "
|
|
123
|
+
"Default policy 'request' asks for approval per session."
|
|
124
|
+
),
|
|
125
|
+
"env_vars": [
|
|
126
|
+
{
|
|
127
|
+
"key": "TINYFISH_API_KEY",
|
|
128
|
+
"prompt": "TinyFish API key",
|
|
129
|
+
"url": API_KEY_URL,
|
|
130
|
+
}
|
|
131
|
+
],
|
|
132
|
+
"post_setup": "agent_browser",
|
|
133
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Read the ``tinyfish`` section of Hermes' user configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
CreditFeature = Literal["browser"]
|
|
8
|
+
CreditPolicy = Literal["deny", "request", "allow"]
|
|
9
|
+
|
|
10
|
+
CREDIT_FEATURES: tuple[CreditFeature, ...] = ("browser",)
|
|
11
|
+
CREDIT_POLICIES: tuple[CreditPolicy, ...] = ("deny", "request", "allow")
|
|
12
|
+
# 'request' is inert until browser.cloud_provider is tinyfish, and still
|
|
13
|
+
# routes each session through Hermes approval once it is.
|
|
14
|
+
DEFAULT_CREDIT_POLICY: CreditPolicy = "request"
|
|
15
|
+
|
|
16
|
+
SearchOptions = dict[str, Any]
|
|
17
|
+
FetchOptions = dict[str, Any]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_config() -> dict[str, Any]:
|
|
21
|
+
try:
|
|
22
|
+
from hermes_cli.config import load_config as _load_config
|
|
23
|
+
|
|
24
|
+
return dict(_load_config() or {})
|
|
25
|
+
except Exception: # config layer is optional outside Hermes
|
|
26
|
+
return {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def save_config(config: dict[str, Any]) -> None:
|
|
30
|
+
from hermes_cli.config import save_config as _save_config
|
|
31
|
+
|
|
32
|
+
_save_config(config)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def tinyfish_config(config: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
36
|
+
cfg = load_config() if config is None else config
|
|
37
|
+
section = cfg.get("tinyfish") or {}
|
|
38
|
+
return section if isinstance(section, dict) else {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize_feature(value: str) -> CreditFeature:
|
|
42
|
+
if value.strip().lower().replace("_", "-") == "browser":
|
|
43
|
+
return "browser"
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"Unknown TinyFish credit feature '{value}'. Valid features: browser"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def normalize_policy(value: Any) -> CreditPolicy:
|
|
50
|
+
policy = str(value or DEFAULT_CREDIT_POLICY).strip().lower()
|
|
51
|
+
if policy not in CREDIT_POLICIES:
|
|
52
|
+
valid = ", ".join(CREDIT_POLICIES)
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"Unknown TinyFish credit policy '{value}'. Valid policies: {valid}"
|
|
55
|
+
)
|
|
56
|
+
return policy
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def credit_policy(
|
|
60
|
+
feature: CreditFeature | str, config: dict[str, Any] | None = None
|
|
61
|
+
) -> CreditPolicy:
|
|
62
|
+
normalized = normalize_feature(str(feature))
|
|
63
|
+
policies = tinyfish_config(config).get("credit_policy") or {}
|
|
64
|
+
if not isinstance(policies, dict):
|
|
65
|
+
return DEFAULT_CREDIT_POLICY
|
|
66
|
+
return normalize_policy(policies.get(normalized, DEFAULT_CREDIT_POLICY))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def set_credit_policy(
|
|
70
|
+
config: dict[str, Any], feature: CreditFeature | str, policy: CreditPolicy | str
|
|
71
|
+
) -> None:
|
|
72
|
+
normalized_feature = normalize_feature(str(feature))
|
|
73
|
+
normalized_policy = normalize_policy(policy)
|
|
74
|
+
section = config.setdefault("tinyfish", {})
|
|
75
|
+
if not isinstance(section, dict):
|
|
76
|
+
section = {}
|
|
77
|
+
config["tinyfish"] = section
|
|
78
|
+
policies = section.setdefault("credit_policy", {})
|
|
79
|
+
if not isinstance(policies, dict):
|
|
80
|
+
policies = {}
|
|
81
|
+
section["credit_policy"] = policies
|
|
82
|
+
policies[normalized_feature] = normalized_policy
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def credit_policy_summary(
|
|
86
|
+
config: dict[str, Any] | None = None,
|
|
87
|
+
) -> dict[CreditFeature, CreditPolicy]:
|
|
88
|
+
return {feature: credit_policy(feature, config) for feature in CREDIT_FEATURES}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def routing_context_enabled(config: dict[str, Any] | None = None) -> bool:
|
|
92
|
+
value = _bool_option(tinyfish_config(config).get("routing_context"))
|
|
93
|
+
return True if value is None else value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def browser_cloud_provider(config: dict[str, Any] | None = None) -> str:
|
|
97
|
+
cfg = load_config() if config is None else config
|
|
98
|
+
section = cfg.get("browser") or {}
|
|
99
|
+
if not isinstance(section, dict):
|
|
100
|
+
return ""
|
|
101
|
+
return str(section.get("cloud_provider") or "").strip().lower()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _int_option(value: Any) -> int | None:
|
|
105
|
+
if value is None or value == "":
|
|
106
|
+
return None
|
|
107
|
+
try:
|
|
108
|
+
return int(value)
|
|
109
|
+
except (TypeError, ValueError):
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _bool_option(value: Any) -> bool | None:
|
|
114
|
+
if value is None or value == "":
|
|
115
|
+
return None
|
|
116
|
+
if isinstance(value, bool):
|
|
117
|
+
return value
|
|
118
|
+
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def search_options(config: dict[str, Any] | None = None) -> SearchOptions:
|
|
122
|
+
section = tinyfish_config(config).get("search") or {}
|
|
123
|
+
if not isinstance(section, dict):
|
|
124
|
+
return {}
|
|
125
|
+
options: SearchOptions = {}
|
|
126
|
+
for key in (
|
|
127
|
+
"location",
|
|
128
|
+
"language",
|
|
129
|
+
"after_date",
|
|
130
|
+
"before_date",
|
|
131
|
+
"domain_type",
|
|
132
|
+
"purpose",
|
|
133
|
+
):
|
|
134
|
+
value = section.get(key)
|
|
135
|
+
if value not in (None, ""):
|
|
136
|
+
options[key] = str(value)
|
|
137
|
+
for key in ("recency_minutes", "page"):
|
|
138
|
+
value = _int_option(section.get(key))
|
|
139
|
+
if value is not None:
|
|
140
|
+
options[key] = value
|
|
141
|
+
return options
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def fetch_options(config: dict[str, Any] | None = None) -> FetchOptions:
|
|
145
|
+
section = tinyfish_config(config).get("fetch") or {}
|
|
146
|
+
if not isinstance(section, dict):
|
|
147
|
+
return {}
|
|
148
|
+
options: FetchOptions = {}
|
|
149
|
+
for key in ("ttl", "per_url_timeout_ms"):
|
|
150
|
+
value = _int_option(section.get(key))
|
|
151
|
+
if value is not None:
|
|
152
|
+
options[key] = value
|
|
153
|
+
for key in ("links", "image_links"):
|
|
154
|
+
value = _bool_option(section.get(key))
|
|
155
|
+
if value is not None:
|
|
156
|
+
options[key] = value
|
|
157
|
+
return options
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def default_fetch_format(config: dict[str, Any] | None = None) -> str:
|
|
161
|
+
section = tinyfish_config(config).get("fetch") or {}
|
|
162
|
+
if isinstance(section, dict) and section.get("format"):
|
|
163
|
+
return str(section["format"])
|
|
164
|
+
return "markdown"
|