@tiny-fish/hermes 0.2.4 → 0.2.5
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/README.md +30 -7
- package/after-install.md +2 -0
- package/package.json +1 -1
- package/plugin.yaml +7 -2
- package/pyproject.toml +1 -1
- package/tinyfish_hermes/__init__.py +14 -9
- package/tinyfish_hermes/agent_tool.py +38 -6
- package/tinyfish_hermes/credit_policy.py +23 -0
- package/tinyfish_hermes/profile_tools.py +272 -0
- package/tinyfish_hermes/provider.py +7 -7
- package/tinyfish_hermes/rest_client.py +78 -5
- package/tinyfish_hermes/setup_cli.py +38 -12
package/README.md
CHANGED
|
@@ -7,20 +7,21 @@
|
|
|
7
7
|
| Search, Fetch (free) | `tinyfish` web provider behind Hermes' `web_search` / `web_extract` |
|
|
8
8
|
| Browser (credits) | `tinyfish` cloud browser provider behind Hermes' browser tools |
|
|
9
9
|
| Agent (credits) | `tinyfish_agent` tool: goal-based automation on a real site |
|
|
10
|
+
| Profiles (free) | `tinyfish_*_profile(s)` tools: saved website sign-ins that `tinyfish_agent` runs with |
|
|
10
11
|
|
|
11
12
|
## Install
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
(`@tiny-fish/hermes@latest`), seeds the API key, and points Hermes' web backends at it:
|
|
14
|
+
Install from the Hermes plugin catalog, then add your key and route web tools to TinyFish:
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
tinyfish
|
|
17
|
+
hermes plugins install tinyfish --enable
|
|
18
|
+
hermes tinyfish setup
|
|
18
19
|
```
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
Or let the TinyFish CLI do both: it installs the same catalog plugin, seeds the API key, and points Hermes' web backends at it:
|
|
21
22
|
|
|
22
23
|
```bash
|
|
23
|
-
|
|
24
|
+
npx @tiny-fish/cli connect hermes
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
## Authentication
|
|
@@ -30,7 +31,7 @@ The plugin authenticates with a TinyFish API key — create one at [agent.tinyfi
|
|
|
30
31
|
| Env var | Source |
|
|
31
32
|
| ------- | ------ |
|
|
32
33
|
| `TINYFISH_API_KEY` | Set it yourself (shell env, or `hermes tinyfish setup --api-key`) |
|
|
33
|
-
| `MCP_TINYFISH_API_KEY` | Seeded by the TinyFish npm CLI during `
|
|
34
|
+
| `MCP_TINYFISH_API_KEY` | Seeded by the TinyFish npm CLI during `npx @tiny-fish/cli connect hermes` |
|
|
34
35
|
|
|
35
36
|
## CLI
|
|
36
37
|
|
|
@@ -88,13 +89,35 @@ tinyfish:
|
|
|
88
89
|
timeout_seconds: 300 # optional session timeout
|
|
89
90
|
```
|
|
90
91
|
|
|
92
|
+
## Browser Context Profiles
|
|
93
|
+
|
|
94
|
+
A Browser Context Profile is a saved website sign-in. It is unrelated to Hermes profiles and to `tinyfish_agent`'s `browser_profile` (lite/stealth).
|
|
95
|
+
|
|
96
|
+
| Tool | Does |
|
|
97
|
+
| ---- | ---- |
|
|
98
|
+
| `tinyfish_list_profiles` | Lists profiles with their `signed_in_sites` |
|
|
99
|
+
| `tinyfish_create_profile` | Creates a profile and returns `setup_url`, where the user signs in and clicks Save profile |
|
|
100
|
+
| `tinyfish_profile_sign_in` | Returns `setup_url` to add or refresh a site on an existing profile |
|
|
101
|
+
| `tinyfish_update_profile` | Renames a profile, makes it the default, or sets its proxy country (`none` removes it) |
|
|
102
|
+
| `tinyfish_delete_profile` | Deletes a profile; asks for approval first unless Hermes approvals are off (`--yolo`) |
|
|
103
|
+
|
|
104
|
+
`tinyfish_agent` runs signed in with `profile_id` (or `use_profile: true` for the default profile). A profile's proxy country (set on create or update) is used for both sign-in and runs, and overrides the run's `proxy_country_code`. If a site blocks the sign-in page, set one and reopen the same link. `setup_url` asks the user to sign in to TinyFish first if that browser isn't already. Nothing bills until the user opens it; runs bill under `tinyfish.credit_policy.agent`.
|
|
105
|
+
|
|
91
106
|
## Tool-routing guidance
|
|
92
107
|
|
|
93
108
|
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`.
|
|
94
109
|
|
|
95
110
|
## Telemetry
|
|
96
111
|
|
|
97
|
-
|
|
112
|
+
Off unless you turn it on. When you opt in, each `hermes tinyfish setup` run sends TinyFish one event, whether it succeeds or fails: the outcome (for example `completed` or `key_rejected`), where the key came from (`flag`, `prompt`, `env` or `none`), the plugin version and OS. It sends a one-way hash of the API key, never the key itself, so the event joins your account.
|
|
113
|
+
|
|
114
|
+
| Opt in | How |
|
|
115
|
+
|---|---|
|
|
116
|
+
| Interactive setup | Answer `y` to the setup question (default No). The answer is saved. |
|
|
117
|
+
| Flag | `hermes tinyfish setup --share-usage` |
|
|
118
|
+
| Config | `tinyfish.telemetry: true` in Hermes config |
|
|
119
|
+
|
|
120
|
+
Set `tinyfish.telemetry: false` to stop sending. `TINYFISH_NO_TELEMETRY=1` (in your shell or `~/.hermes/.env`) turns it off regardless of the other settings.
|
|
98
121
|
|
|
99
122
|
## Development
|
|
100
123
|
|
package/after-install.md
CHANGED
|
@@ -9,3 +9,5 @@ hermes tinyfish setup
|
|
|
9
9
|
No key yet? Create one at [agent.tinyfish.ai/api-keys](https://agent.tinyfish.ai/api-keys?source=hermes) — Search and Fetch are free. `setup` will ask for it.
|
|
10
10
|
|
|
11
11
|
Check anytime with `hermes tinyfish doctor --live` or `/tinyfish-status` in a session.
|
|
12
|
+
|
|
13
|
+
Signed-in sites: ask Hermes to create a TinyFish profile for a site, sign in at the link it gives you, and `tinyfish_agent` runs as you.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiny-fish/hermes",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://docs.tinyfish.ai",
|
package/plugin.yaml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
name: tinyfish
|
|
2
|
-
version: 0.2.
|
|
3
|
-
description: "TinyFish: the web, built into Hermes. Free Search and Fetch power Hermes's own web_search and web_extract, and when a site needs clicks, forms or a login, a remote browser and the tinyfish_agent web agent handle it."
|
|
2
|
+
version: 0.2.5
|
|
3
|
+
description: "TinyFish: the web, built into Hermes. Free Search and Fetch power Hermes's own web_search and web_extract, and when a site needs clicks, forms or a login, a remote browser and the tinyfish_agent web agent handle it, signed in through saved profiles when needed."
|
|
4
4
|
author: TinyFish
|
|
5
5
|
kind: backend
|
|
6
6
|
# 0.19 added the pre_tool_call approve directive; older Hermes would skip credit approval.
|
|
@@ -13,6 +13,11 @@ provides_browser_providers:
|
|
|
13
13
|
- tinyfish
|
|
14
14
|
provides_tools:
|
|
15
15
|
- tinyfish_agent
|
|
16
|
+
- tinyfish_list_profiles
|
|
17
|
+
- tinyfish_create_profile
|
|
18
|
+
- tinyfish_profile_sign_in
|
|
19
|
+
- tinyfish_update_profile
|
|
20
|
+
- tinyfish_delete_profile
|
|
16
21
|
provides_hooks:
|
|
17
22
|
- pre_tool_call
|
|
18
23
|
- pre_llm_call
|
package/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tinyfish-hermes"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.5"
|
|
8
8
|
description = "TinyFish plugin for Hermes Agent - Search, Fetch, Browser, and the tinyfish_agent web agent"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -48,6 +48,7 @@ def register(ctx: Any) -> None:
|
|
|
48
48
|
from .agent_tool import TINYFISH_AGENT_SCHEMA, tinyfish_agent
|
|
49
49
|
from .browser_provider import TinyFishBrowserProvider, shutdown_cleanup
|
|
50
50
|
from .credit_policy import pre_tool_call_policy
|
|
51
|
+
from .profile_tools import PROFILE_TOOLS
|
|
51
52
|
from .provider import TinyFishWebSearchProvider, _api_key
|
|
52
53
|
from .routing_context import routing_context_hook
|
|
53
54
|
from .setup_cli import (
|
|
@@ -65,16 +66,20 @@ def register(ctx: Any) -> None:
|
|
|
65
66
|
ctx.register_hook("pre_tool_call", pre_tool_call_policy)
|
|
66
67
|
ctx.register_hook("pre_llm_call", routing_context_hook)
|
|
67
68
|
ctx.register_hook("on_session_finalize", shutdown_cleanup)
|
|
68
|
-
#
|
|
69
|
+
# Agent credits and profile delete approval both rely on the pre_tool_call gate.
|
|
69
70
|
if hasattr(ctx, "register_hook") and hasattr(ctx, "register_tool"):
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
for schema, handler in (
|
|
72
|
+
(TINYFISH_AGENT_SCHEMA, tinyfish_agent),
|
|
73
|
+
*PROFILE_TOOLS,
|
|
74
|
+
):
|
|
75
|
+
ctx.register_tool(
|
|
76
|
+
name=schema["name"],
|
|
77
|
+
toolset="tinyfish",
|
|
78
|
+
schema=schema,
|
|
79
|
+
handler=handler,
|
|
80
|
+
check_fn=lambda: bool(_api_key()),
|
|
81
|
+
emoji="🐟",
|
|
82
|
+
)
|
|
78
83
|
|
|
79
84
|
if hasattr(ctx, "register_cli_command"):
|
|
80
85
|
|
|
@@ -7,6 +7,7 @@ import time
|
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
9
|
from . import rest_client
|
|
10
|
+
from .profile_tools import PROFILE_TERM, error_json
|
|
10
11
|
from .provider import MISSING_KEY_ERROR, _api_key, _is_interrupted
|
|
11
12
|
|
|
12
13
|
TERMINAL_STATUSES = frozenset({"COMPLETED", "FAILED", "CANCELLED"})
|
|
@@ -21,8 +22,11 @@ TINYFISH_AGENT_SCHEMA: dict[str, Any] = {
|
|
|
21
22
|
"description": (
|
|
22
23
|
"Run a TinyFish web agent on a real website to complete a goal: navigate, "
|
|
23
24
|
"click, fill forms, and extract data across multiple pages. Use for "
|
|
24
|
-
"multi-step site tasks that web_search/web_extract cannot do.
|
|
25
|
-
"
|
|
25
|
+
"multi-step site tasks that web_search/web_extract cannot do. If the site "
|
|
26
|
+
"needs sign-in, call tinyfish_list_profiles and pass the profile_id that "
|
|
27
|
+
"covers it. Ask the user before buying, posting, sending, or changing "
|
|
28
|
+
"account settings. Consumes TinyFish credits. Returns the agent's result "
|
|
29
|
+
"as JSON."
|
|
26
30
|
),
|
|
27
31
|
"parameters": {
|
|
28
32
|
"type": "object",
|
|
@@ -43,7 +47,22 @@ TINYFISH_AGENT_SCHEMA: dict[str, Any] = {
|
|
|
43
47
|
"proxy_country_code": {
|
|
44
48
|
"type": "string",
|
|
45
49
|
"enum": ["US", "GB", "CA", "DE", "FR", "JP", "AU"],
|
|
46
|
-
"description":
|
|
50
|
+
"description": (
|
|
51
|
+
"Route through a proxy in this country. A profile's own proxy "
|
|
52
|
+
"country overrides this."
|
|
53
|
+
),
|
|
54
|
+
},
|
|
55
|
+
"use_profile": {
|
|
56
|
+
"type": "boolean",
|
|
57
|
+
"description": (
|
|
58
|
+
f"Run signed in with the user's default profile. {PROFILE_TERM}"
|
|
59
|
+
),
|
|
60
|
+
},
|
|
61
|
+
"profile_id": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"description": (
|
|
64
|
+
"Run signed in with this profile from tinyfish_list_profiles."
|
|
65
|
+
),
|
|
47
66
|
},
|
|
48
67
|
},
|
|
49
68
|
"required": ["url", "goal"],
|
|
@@ -68,8 +87,19 @@ def _result(payload: dict[str, Any]) -> str:
|
|
|
68
87
|
|
|
69
88
|
|
|
70
89
|
def _summary(run: dict[str, Any]) -> dict[str, Any]:
|
|
71
|
-
keys = ("run_id", "status", "result", "error", "streaming_url")
|
|
72
|
-
|
|
90
|
+
keys = ("run_id", "status", "result", "error", "streaming_url", "profile_hint")
|
|
91
|
+
summary = {key: run[key] for key in keys if run.get(key) is not None}
|
|
92
|
+
# Failed runs nest the hint under error, and its relative link opens outside Hermes.
|
|
93
|
+
for holder in (summary, summary.get("error")):
|
|
94
|
+
if not isinstance(holder, dict):
|
|
95
|
+
continue
|
|
96
|
+
hint = holder.get("profile_hint")
|
|
97
|
+
if isinstance(hint, dict) and str(hint.get("setup_url") or "").startswith("/"):
|
|
98
|
+
holder["profile_hint"] = {
|
|
99
|
+
**hint,
|
|
100
|
+
"setup_url": rest_client.AGENT_URL + hint["setup_url"],
|
|
101
|
+
}
|
|
102
|
+
return summary
|
|
73
103
|
|
|
74
104
|
|
|
75
105
|
def tinyfish_agent(args: dict[str, Any], **_: Any) -> str:
|
|
@@ -88,9 +118,11 @@ def tinyfish_agent(args: dict[str, Any], **_: Any) -> str:
|
|
|
88
118
|
api_key=api_key,
|
|
89
119
|
browser_profile=str(args.get("browser_profile") or "lite"),
|
|
90
120
|
proxy_country_code=args.get("proxy_country_code") or None,
|
|
121
|
+
use_profile=args.get("use_profile") is True,
|
|
122
|
+
profile_id=str(args.get("profile_id") or "").strip() or None,
|
|
91
123
|
)
|
|
92
124
|
except rest_client.TinyFishRestError as exc:
|
|
93
|
-
return
|
|
125
|
+
return error_json(exc)
|
|
94
126
|
run_id = str(started.get("run_id") or "")
|
|
95
127
|
if not run_id:
|
|
96
128
|
return json.dumps({"error": "TinyFish Agent did not return a run_id."})
|
|
@@ -115,6 +115,20 @@ def _directive_for_feature(
|
|
|
115
115
|
return None
|
|
116
116
|
|
|
117
117
|
|
|
118
|
+
def _profile_label(profile_id: str) -> str:
|
|
119
|
+
from . import rest_client
|
|
120
|
+
from .provider import _api_key
|
|
121
|
+
|
|
122
|
+
# The approver needs the real name, not the agent's claim; the id alone still works.
|
|
123
|
+
try:
|
|
124
|
+
name = rest_client.get_profile(profile_id, api_key=_api_key(), timeout=5.0).get(
|
|
125
|
+
"name"
|
|
126
|
+
)
|
|
127
|
+
except Exception:
|
|
128
|
+
return profile_id
|
|
129
|
+
return f"'{name}' ({profile_id})" if name else profile_id
|
|
130
|
+
|
|
131
|
+
|
|
118
132
|
def pre_tool_call_policy(
|
|
119
133
|
tool_name: str, args: dict[str, Any] | None = None, **_: Any
|
|
120
134
|
) -> dict[str, str] | None:
|
|
@@ -125,6 +139,15 @@ def pre_tool_call_policy(
|
|
|
125
139
|
|
|
126
140
|
if tool_name == "tinyfish_agent":
|
|
127
141
|
return _directive_for_feature("agent", tool_name, target)
|
|
142
|
+
if tool_name == "tinyfish_delete_profile":
|
|
143
|
+
profile_id = str(params.get("profile_id") or "")
|
|
144
|
+
label = _profile_label(profile_id)
|
|
145
|
+
# Keyed per profile so one approval never covers deleting a different profile.
|
|
146
|
+
return {
|
|
147
|
+
"action": "approve",
|
|
148
|
+
"message": f"Permanently delete TinyFish profile {label}?",
|
|
149
|
+
"rule_key": f"tinyfish:profile_delete:{profile_id}",
|
|
150
|
+
}
|
|
128
151
|
if not tool_name.startswith("browser_"):
|
|
129
152
|
return None
|
|
130
153
|
config = load_config()
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Browser Context Profile tools: saved website sign-ins for ``tinyfish_agent``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from http import HTTPStatus
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import quote, urlencode, urlparse
|
|
10
|
+
|
|
11
|
+
from . import rest_client
|
|
12
|
+
from .provider import MISSING_KEY_ERROR, _api_key
|
|
13
|
+
|
|
14
|
+
PROFILE_TERM = (
|
|
15
|
+
"A TinyFish Browser Context Profile is a saved website sign-in "
|
|
16
|
+
"(not a Hermes profile, and unrelated to browser_profile)."
|
|
17
|
+
)
|
|
18
|
+
_SUMMARY_KEYS = ("id", "name", "is_default", "proxy_country_code", "domain_count")
|
|
19
|
+
|
|
20
|
+
_PROFILE_ID = {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "Profile id from tinyfish_list_profiles.",
|
|
23
|
+
}
|
|
24
|
+
_SITE = {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "Site the user will sign in to, e.g. amazon.com or a URL on it.",
|
|
27
|
+
}
|
|
28
|
+
_PROXY = {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": (
|
|
31
|
+
"Proxy country for both sign-in and runs on this profile, as an ISO 3166-1 "
|
|
32
|
+
"alpha-2 code such as US; 'none' connects directly. Set one when a site "
|
|
33
|
+
"blocks the sign-in page or needs a local IP. Overrides tinyfish_agent's "
|
|
34
|
+
"proxy_country_code."
|
|
35
|
+
),
|
|
36
|
+
}
|
|
37
|
+
_NO_PROXY = frozenset({"none", "off", "direct"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _schema(
|
|
41
|
+
name: str, description: str, properties: dict[str, Any], required: list[str]
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
return {
|
|
44
|
+
"name": name,
|
|
45
|
+
"description": description,
|
|
46
|
+
"parameters": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": properties,
|
|
49
|
+
"required": required,
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
LIST_SCHEMA = _schema(
|
|
55
|
+
"tinyfish_list_profiles",
|
|
56
|
+
f"List the user's TinyFish Browser Context Profiles. {PROFILE_TERM} For a site "
|
|
57
|
+
"that needs sign-in, pass the profile_id whose signed_in_sites covers it (an "
|
|
58
|
+
"entry covers its subdomains) to tinyfish_agent; if signed_in_sites is absent, "
|
|
59
|
+
"match by name and ask the user. Free.",
|
|
60
|
+
{},
|
|
61
|
+
[],
|
|
62
|
+
)
|
|
63
|
+
CREATE_SCHEMA = _schema(
|
|
64
|
+
"tinyfish_create_profile",
|
|
65
|
+
"Create a TinyFish Browser Context Profile and get the link where the user "
|
|
66
|
+
f"signs in. {PROFILE_TERM} Name one per account, e.g. 'Amazon Personal'. Free "
|
|
67
|
+
"until the user opens the link.",
|
|
68
|
+
{
|
|
69
|
+
"name": {"type": "string", "description": "Profile name, 1-100 characters."},
|
|
70
|
+
"site": _SITE,
|
|
71
|
+
"set_as_default": {
|
|
72
|
+
"type": "boolean",
|
|
73
|
+
"description": "Make this the profile use_profile picks by default.",
|
|
74
|
+
},
|
|
75
|
+
"proxy_country_code": _PROXY,
|
|
76
|
+
},
|
|
77
|
+
["name"],
|
|
78
|
+
)
|
|
79
|
+
SIGN_IN_SCHEMA = _schema(
|
|
80
|
+
"tinyfish_profile_sign_in",
|
|
81
|
+
"Get a link where the user signs in to a site on an existing TinyFish Browser "
|
|
82
|
+
"Context Profile, to add a site or refresh an expired sign-in. Free until the "
|
|
83
|
+
"user opens it.",
|
|
84
|
+
{"profile_id": _PROFILE_ID, "site": _SITE},
|
|
85
|
+
["profile_id"],
|
|
86
|
+
)
|
|
87
|
+
UPDATE_SCHEMA = _schema(
|
|
88
|
+
"tinyfish_update_profile",
|
|
89
|
+
"Rename a TinyFish Browser Context Profile, make it the default, or change "
|
|
90
|
+
"its proxy country (e.g. when a site blocks the sign-in link).",
|
|
91
|
+
{
|
|
92
|
+
"profile_id": _PROFILE_ID,
|
|
93
|
+
"name": {"type": "string", "description": "New name, 1-100 characters."},
|
|
94
|
+
"set_as_default": {
|
|
95
|
+
"type": "boolean",
|
|
96
|
+
"description": "true makes this the default profile; false is ignored.",
|
|
97
|
+
},
|
|
98
|
+
"proxy_country_code": _PROXY,
|
|
99
|
+
},
|
|
100
|
+
["profile_id"],
|
|
101
|
+
)
|
|
102
|
+
DELETE_SCHEMA = _schema(
|
|
103
|
+
"tinyfish_delete_profile",
|
|
104
|
+
"Permanently delete a TinyFish Browser Context Profile and its saved sign-ins. "
|
|
105
|
+
"Only when the user asks; name the profile to them first. It cannot be undone. "
|
|
106
|
+
"Deletes one profile per call: to delete several, call once per profile, one "
|
|
107
|
+
"after another, never in parallel or batched.",
|
|
108
|
+
{"profile_id": _PROFILE_ID},
|
|
109
|
+
["profile_id"],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def setup_url(profile_id: str, site: str = "") -> str:
|
|
114
|
+
query = {"domain": site} if site else {}
|
|
115
|
+
query["source"] = "hermes"
|
|
116
|
+
path = f"/profiles/{quote(profile_id, safe='')}/setup"
|
|
117
|
+
return f"{rest_client.AGENT_URL}{path}?{urlencode(query)}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def site_host(site: str) -> str:
|
|
121
|
+
# The setup page ignores anything but a bare hostname.
|
|
122
|
+
value = site.strip()
|
|
123
|
+
if not value:
|
|
124
|
+
return ""
|
|
125
|
+
host = urlparse(value if "://" in value else f"https://{value}").hostname or ""
|
|
126
|
+
if "." not in host:
|
|
127
|
+
raise ValueError(f"'{site}' is not a site; pass a domain such as amazon.com.")
|
|
128
|
+
return host
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def sign_in_next_step(profile_id: str) -> str:
|
|
132
|
+
return (
|
|
133
|
+
"Give the user setup_url. They may be asked to sign in to TinyFish first; "
|
|
134
|
+
"that is expected. They then sign in to the site and click Save profile; "
|
|
135
|
+
"never ask for their password. Wait until they say they are done, check "
|
|
136
|
+
"tinyfish_list_profiles (if signed_in_sites is absent, take their word), "
|
|
137
|
+
f'then run tinyfish_agent with profile_id "{profile_id}". If the site blocks '
|
|
138
|
+
"the sign-in page, set proxy_country_code with tinyfish_update_profile and "
|
|
139
|
+
"send the same link again."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def error_next_step(exc: rest_client.TinyFishRestError) -> str | None:
|
|
144
|
+
message = str(exc).lower()
|
|
145
|
+
if "no default browser context profile" in message:
|
|
146
|
+
return (
|
|
147
|
+
"Call tinyfish_list_profiles and pass a profile_id; if there are none, "
|
|
148
|
+
"call tinyfish_create_profile and give the user setup_url."
|
|
149
|
+
)
|
|
150
|
+
if "not found" in message and "profile" in message:
|
|
151
|
+
return "Call tinyfish_list_profiles and use an exact profile_id from it."
|
|
152
|
+
if exc.status == HTTPStatus.CONFLICT:
|
|
153
|
+
return "Wait for the previous call on this profile to finish, then retry."
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def error_json(exc: rest_client.TinyFishRestError) -> str:
|
|
158
|
+
payload = {"error": str(exc)}
|
|
159
|
+
next_step = error_next_step(exc)
|
|
160
|
+
if next_step:
|
|
161
|
+
payload["next_step"] = next_step
|
|
162
|
+
return json.dumps(payload)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _summary(profile: dict[str, Any]) -> dict[str, Any]:
|
|
166
|
+
out = {key: profile.get(key) for key in _SUMMARY_KEYS}
|
|
167
|
+
sites = profile.get("signed_in_sites")
|
|
168
|
+
if isinstance(sites, list):
|
|
169
|
+
out["signed_in_sites"] = [s.get("site") for s in sites if isinstance(s, dict)]
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _with_key(
|
|
174
|
+
handler: Callable[[dict[str, Any], str], dict[str, Any]],
|
|
175
|
+
) -> Callable[..., str]:
|
|
176
|
+
def run(args: dict[str, Any], **_: Any) -> str:
|
|
177
|
+
api_key = _api_key()
|
|
178
|
+
if not api_key:
|
|
179
|
+
return json.dumps({"error": MISSING_KEY_ERROR})
|
|
180
|
+
try:
|
|
181
|
+
return json.dumps(handler(args or {}, api_key), ensure_ascii=False)
|
|
182
|
+
except ValueError as exc:
|
|
183
|
+
return json.dumps({"error": str(exc)})
|
|
184
|
+
except rest_client.TinyFishRestError as exc:
|
|
185
|
+
return error_json(exc)
|
|
186
|
+
|
|
187
|
+
return run
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _required(args: dict[str, Any], key: str) -> str:
|
|
191
|
+
value = str(args.get(key) or "").strip()
|
|
192
|
+
if not value:
|
|
193
|
+
raise ValueError(f"{key} is required.")
|
|
194
|
+
return value
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _list(args: dict[str, Any], api_key: str) -> dict[str, Any]:
|
|
198
|
+
profiles = rest_client.list_profiles(api_key=api_key).get("profiles") or []
|
|
199
|
+
return {"profiles": [_summary(p) for p in profiles if isinstance(p, dict)]}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _proxy_fields(args: dict[str, Any]) -> dict[str, Any]:
|
|
203
|
+
value = str(args.get("proxy_country_code") or "").strip()
|
|
204
|
+
if not value:
|
|
205
|
+
return {}
|
|
206
|
+
if value.lower() in _NO_PROXY:
|
|
207
|
+
return {"proxy_country_code": None}
|
|
208
|
+
if len(value) != 2 or not value.isalpha():
|
|
209
|
+
raise ValueError(
|
|
210
|
+
f"proxy_country_code '{value}' must be a 2-letter country code or 'none'."
|
|
211
|
+
)
|
|
212
|
+
return {"proxy_country_code": value.upper()}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _create(args: dict[str, Any], api_key: str) -> dict[str, Any]:
|
|
216
|
+
fields: dict[str, Any] = {"name": _required(args, "name")}
|
|
217
|
+
site = site_host(str(args.get("site") or ""))
|
|
218
|
+
if args.get("set_as_default") is not None:
|
|
219
|
+
fields["set_as_default"] = bool(args["set_as_default"])
|
|
220
|
+
fields.update(_proxy_fields(args))
|
|
221
|
+
profile = rest_client.create_profile(fields, api_key=api_key)
|
|
222
|
+
profile_id = str(profile.get("id") or "")
|
|
223
|
+
if not profile_id:
|
|
224
|
+
raise rest_client.TinyFishRestError(
|
|
225
|
+
"TinyFish Profiles did not return a profile id."
|
|
226
|
+
)
|
|
227
|
+
return {
|
|
228
|
+
**_summary(profile),
|
|
229
|
+
"setup_url": setup_url(profile_id, site),
|
|
230
|
+
"next_step": sign_in_next_step(profile_id),
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _sign_in(args: dict[str, Any], api_key: str) -> dict[str, Any]:
|
|
235
|
+
profile_id = _required(args, "profile_id")
|
|
236
|
+
site = site_host(str(args.get("site") or ""))
|
|
237
|
+
return {
|
|
238
|
+
"profile_id": profile_id,
|
|
239
|
+
"setup_url": setup_url(profile_id, site),
|
|
240
|
+
"next_step": sign_in_next_step(profile_id),
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _update(args: dict[str, Any], api_key: str) -> dict[str, Any]:
|
|
245
|
+
profile_id = _required(args, "profile_id")
|
|
246
|
+
fields: dict[str, Any] = {}
|
|
247
|
+
name = str(args.get("name") or "").strip()
|
|
248
|
+
if name:
|
|
249
|
+
fields["name"] = name
|
|
250
|
+
if args.get("set_as_default") is True:
|
|
251
|
+
fields["set_as_default"] = True
|
|
252
|
+
fields.update(_proxy_fields(args))
|
|
253
|
+
if not fields:
|
|
254
|
+
raise ValueError(
|
|
255
|
+
"Nothing to update: pass name, proxy_country_code, or set_as_default true."
|
|
256
|
+
)
|
|
257
|
+
return _summary(rest_client.update_profile(profile_id, fields, api_key=api_key))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _delete(args: dict[str, Any], api_key: str) -> dict[str, Any]:
|
|
261
|
+
profile_id = _required(args, "profile_id")
|
|
262
|
+
rest_client.delete_profile(profile_id, api_key=api_key)
|
|
263
|
+
return {"profile_id": profile_id, "deleted": True}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
PROFILE_TOOLS: tuple[tuple[dict[str, Any], Callable[..., str]], ...] = (
|
|
267
|
+
(LIST_SCHEMA, _with_key(_list)),
|
|
268
|
+
(CREATE_SCHEMA, _with_key(_create)),
|
|
269
|
+
(SIGN_IN_SCHEMA, _with_key(_sign_in)),
|
|
270
|
+
(UPDATE_SCHEMA, _with_key(_update)),
|
|
271
|
+
(DELETE_SCHEMA, _with_key(_delete)),
|
|
272
|
+
)
|
|
@@ -28,20 +28,20 @@ MISSING_KEY_ERROR = (
|
|
|
28
28
|
)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def
|
|
32
|
-
"""Read Hermes
|
|
31
|
+
def _secret(name: str) -> str:
|
|
32
|
+
"""Read a credential through Hermes' profile-aware secret scope."""
|
|
33
33
|
|
|
34
34
|
try:
|
|
35
|
-
from agent.
|
|
36
|
-
|
|
37
|
-
return str(get_provider_env(name) or "").strip()
|
|
38
|
-
except Exception:
|
|
35
|
+
from agent.secret_scope import get_secret_str
|
|
36
|
+
except ImportError: # Hermes before profile secret scopes (2026-06)
|
|
39
37
|
return str(os.getenv(name, "") or "").strip()
|
|
38
|
+
# Let UnscopedSecretError raise; os.environ there holds another profile's key.
|
|
39
|
+
return str(get_secret_str(name) or "").strip()
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
def _api_key() -> str:
|
|
43
43
|
for name in API_KEY_ENV_VARS:
|
|
44
|
-
value =
|
|
44
|
+
value = _secret(name)
|
|
45
45
|
if value:
|
|
46
46
|
return value
|
|
47
47
|
return ""
|
|
@@ -9,6 +9,7 @@ import sys
|
|
|
9
9
|
import time
|
|
10
10
|
import uuid
|
|
11
11
|
from typing import Any, NoReturn, cast
|
|
12
|
+
from urllib.parse import quote
|
|
12
13
|
|
|
13
14
|
import httpx
|
|
14
15
|
|
|
@@ -57,6 +58,15 @@ def _json_headers(api_key: str) -> dict[str, str]:
|
|
|
57
58
|
return headers
|
|
58
59
|
|
|
59
60
|
|
|
61
|
+
def _server_message(response: httpx.Response) -> str:
|
|
62
|
+
try:
|
|
63
|
+
error = response.json().get("error")
|
|
64
|
+
except Exception:
|
|
65
|
+
return ""
|
|
66
|
+
message = error.get("message") if isinstance(error, dict) else None
|
|
67
|
+
return message[:500] if isinstance(message, str) else ""
|
|
68
|
+
|
|
69
|
+
|
|
60
70
|
def _raise_http_error(service: str, exc: httpx.HTTPStatusError) -> NoReturn:
|
|
61
71
|
status = exc.response.status_code
|
|
62
72
|
if status == 402:
|
|
@@ -65,7 +75,12 @@ def _raise_http_error(service: str, exc: httpx.HTTPStatusError) -> NoReturn:
|
|
|
65
75
|
"TinyFish credits or billing may be required.",
|
|
66
76
|
status,
|
|
67
77
|
) from exc
|
|
68
|
-
|
|
78
|
+
# 4xx text is TinyFish's own validation message and tells the agent what to fix.
|
|
79
|
+
detail = _server_message(exc.response) if 400 <= status < 500 else ""
|
|
80
|
+
suffix = f": {detail}" if detail else ""
|
|
81
|
+
raise TinyFishRestError(
|
|
82
|
+
f"{service} returned HTTP {status}{suffix}", status
|
|
83
|
+
) from exc
|
|
69
84
|
|
|
70
85
|
|
|
71
86
|
def search(
|
|
@@ -294,6 +309,7 @@ def _agent_request(
|
|
|
294
309
|
api_key: str,
|
|
295
310
|
json_body: dict[str, Any] | None = None,
|
|
296
311
|
timeout: float = 30.0,
|
|
312
|
+
service: str = "TinyFish Agent",
|
|
297
313
|
) -> dict[str, Any]:
|
|
298
314
|
try:
|
|
299
315
|
response = httpx.request(
|
|
@@ -306,13 +322,13 @@ def _agent_request(
|
|
|
306
322
|
response.raise_for_status()
|
|
307
323
|
data = response.json()
|
|
308
324
|
except httpx.HTTPStatusError as exc:
|
|
309
|
-
_raise_http_error(
|
|
325
|
+
_raise_http_error(service, exc)
|
|
310
326
|
except httpx.RequestError as exc:
|
|
311
|
-
raise TinyFishRestError(f"Could not reach
|
|
327
|
+
raise TinyFishRestError(f"Could not reach {service}: {exc}") from exc
|
|
312
328
|
except ValueError as exc:
|
|
313
|
-
raise TinyFishRestError("
|
|
329
|
+
raise TinyFishRestError(f"{service} returned invalid JSON") from exc
|
|
314
330
|
if not isinstance(data, dict):
|
|
315
|
-
raise TinyFishRestError("
|
|
331
|
+
raise TinyFishRestError(f"{service} returned an unexpected response")
|
|
316
332
|
return data
|
|
317
333
|
|
|
318
334
|
|
|
@@ -323,6 +339,8 @@ def start_run(
|
|
|
323
339
|
api_key: str,
|
|
324
340
|
browser_profile: str = "lite",
|
|
325
341
|
proxy_country_code: str | None = None,
|
|
342
|
+
use_profile: bool = False,
|
|
343
|
+
profile_id: str | None = None,
|
|
326
344
|
) -> dict[str, Any]:
|
|
327
345
|
"""Enqueue a TinyFish Agent run; returns immediately with ``run_id``."""
|
|
328
346
|
|
|
@@ -334,6 +352,11 @@ def start_run(
|
|
|
334
352
|
}
|
|
335
353
|
if proxy_country_code:
|
|
336
354
|
body["proxy_config"] = {"enabled": True, "country_code": proxy_country_code}
|
|
355
|
+
# The API rejects profile_id without use_profile, so an id implies it.
|
|
356
|
+
if use_profile or profile_id:
|
|
357
|
+
body["use_profile"] = True
|
|
358
|
+
if profile_id:
|
|
359
|
+
body["profile_id"] = profile_id
|
|
337
360
|
return _agent_request(
|
|
338
361
|
"POST", "/v1/automation/run-async", api_key=api_key, json_body=body
|
|
339
362
|
)
|
|
@@ -375,3 +398,53 @@ def get_run(run_id: str, *, api_key: str) -> dict[str, Any]:
|
|
|
375
398
|
|
|
376
399
|
def cancel_run(run_id: str, *, api_key: str) -> dict[str, Any]:
|
|
377
400
|
return _agent_request("POST", f"/v1/runs/{run_id}/cancel", api_key=api_key)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _profile_request(
|
|
404
|
+
method: str,
|
|
405
|
+
path: str,
|
|
406
|
+
*,
|
|
407
|
+
api_key: str,
|
|
408
|
+
json_body: dict[str, Any] | None = None,
|
|
409
|
+
timeout: float = 30.0,
|
|
410
|
+
) -> dict[str, Any]:
|
|
411
|
+
return _agent_request(
|
|
412
|
+
method,
|
|
413
|
+
f"/v1/profiles{path}",
|
|
414
|
+
api_key=api_key,
|
|
415
|
+
json_body=json_body,
|
|
416
|
+
timeout=timeout,
|
|
417
|
+
service="TinyFish Profiles",
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _profile_path(profile_id: str) -> str:
|
|
422
|
+
return "/" + quote(profile_id, safe="")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def list_profiles(*, api_key: str) -> dict[str, Any]:
|
|
426
|
+
return _profile_request("GET", "", api_key=api_key)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def create_profile(fields: dict[str, Any], *, api_key: str) -> dict[str, Any]:
|
|
430
|
+
return _profile_request("POST", "", api_key=api_key, json_body=fields)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def get_profile(
|
|
434
|
+
profile_id: str, *, api_key: str, timeout: float = 30.0
|
|
435
|
+
) -> dict[str, Any]:
|
|
436
|
+
return _profile_request(
|
|
437
|
+
"GET", _profile_path(profile_id), api_key=api_key, timeout=timeout
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def update_profile(
|
|
442
|
+
profile_id: str, fields: dict[str, Any], *, api_key: str
|
|
443
|
+
) -> dict[str, Any]:
|
|
444
|
+
return _profile_request(
|
|
445
|
+
"PATCH", _profile_path(profile_id), api_key=api_key, json_body=fields
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def delete_profile(profile_id: str, *, api_key: str) -> dict[str, Any]:
|
|
450
|
+
return _profile_request("DELETE", _profile_path(profile_id), api_key=api_key)
|
|
@@ -30,6 +30,7 @@ from .provider import (
|
|
|
30
30
|
MISSING_KEY_ERROR,
|
|
31
31
|
TinyFishWebSearchProvider,
|
|
32
32
|
_api_key,
|
|
33
|
+
_secret,
|
|
33
34
|
)
|
|
34
35
|
from .routing_context import tinyfish_mcp_configured
|
|
35
36
|
|
|
@@ -51,6 +52,11 @@ def setup_tinyfish_cli(parser: argparse.ArgumentParser) -> None:
|
|
|
51
52
|
setup.add_argument(
|
|
52
53
|
"--live", action="store_true", help="Run live doctor checks after setup"
|
|
53
54
|
)
|
|
55
|
+
setup.add_argument(
|
|
56
|
+
"--share-usage",
|
|
57
|
+
action="store_true",
|
|
58
|
+
help="Send TinyFish the setup outcome (sets tinyfish.telemetry: true)",
|
|
59
|
+
)
|
|
54
60
|
|
|
55
61
|
status = sub.add_parser("status", help="Print non-secret TinyFish status")
|
|
56
62
|
status.add_argument(
|
|
@@ -140,15 +146,6 @@ def _save_config(config: dict[str, Any]) -> None:
|
|
|
140
146
|
save_config(config)
|
|
141
147
|
|
|
142
148
|
|
|
143
|
-
def _get_env(name: str) -> str:
|
|
144
|
-
try:
|
|
145
|
-
from hermes_cli.config import get_env_value
|
|
146
|
-
|
|
147
|
-
return str(get_env_value(name) or "").strip()
|
|
148
|
-
except Exception:
|
|
149
|
-
return str(os.getenv(name, "") or "").strip()
|
|
150
|
-
|
|
151
|
-
|
|
152
149
|
def _save_env_secure(name: str, value: str) -> dict[str, Any]:
|
|
153
150
|
from hermes_cli.config import save_env_value_secure
|
|
154
151
|
|
|
@@ -166,7 +163,7 @@ def _read_saved_env(name: str) -> str:
|
|
|
166
163
|
|
|
167
164
|
def _api_key_env_var() -> str:
|
|
168
165
|
for name in API_KEY_ENV_VARS:
|
|
169
|
-
if
|
|
166
|
+
if _secret(name):
|
|
170
167
|
return name
|
|
171
168
|
return ""
|
|
172
169
|
|
|
@@ -251,11 +248,41 @@ def _store_api_key(api_key: str) -> bool:
|
|
|
251
248
|
return False
|
|
252
249
|
|
|
253
250
|
|
|
251
|
+
TELEMETRY_QUESTION = (
|
|
252
|
+
"Share setup results with TinyFish (outcome, plugin version, OS and a hash "
|
|
253
|
+
"of your API key, never the key)?"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _share_usage(args: argparse.Namespace) -> bool:
|
|
258
|
+
"""Opt-in only: the flag, a saved tinyfish.telemetry, or a y/N answer."""
|
|
259
|
+
|
|
260
|
+
# Profile-scoped, so an opt-out in this profile's .env counts.
|
|
261
|
+
if rest_client.telemetry_disabled(_secret("TINYFISH_NO_TELEMETRY")):
|
|
262
|
+
return False
|
|
263
|
+
config = _load_config()
|
|
264
|
+
section = config.get("tinyfish")
|
|
265
|
+
if not isinstance(section, dict):
|
|
266
|
+
section = config["tinyfish"] = {}
|
|
267
|
+
if getattr(args, "share_usage", False):
|
|
268
|
+
choice = True
|
|
269
|
+
elif isinstance(section.get("telemetry"), bool):
|
|
270
|
+
return bool(section["telemetry"])
|
|
271
|
+
elif getattr(args, "yes", False) or not sys.stdin.isatty():
|
|
272
|
+
return False
|
|
273
|
+
else:
|
|
274
|
+
choice = _confirm(TELEMETRY_QUESTION, default=False)
|
|
275
|
+
section["telemetry"] = choice
|
|
276
|
+
_save_config(config)
|
|
277
|
+
return choice
|
|
278
|
+
|
|
279
|
+
|
|
254
280
|
def cmd_setup(
|
|
255
281
|
args: argparse.Namespace,
|
|
256
282
|
*,
|
|
257
283
|
provider: TinyFishWebSearchProvider | None = None,
|
|
258
284
|
) -> int:
|
|
285
|
+
share_usage = _share_usage(args)
|
|
259
286
|
api_key = (getattr(args, "api_key", None) or "").strip()
|
|
260
287
|
key_source = "flag" if api_key else "none"
|
|
261
288
|
existing = _api_key_env_var()
|
|
@@ -268,8 +295,7 @@ def cmd_setup(
|
|
|
268
295
|
|
|
269
296
|
# Catalog installs skip `tinyfish connect`; this is their install signal.
|
|
270
297
|
def report(outcome: str) -> None:
|
|
271
|
-
|
|
272
|
-
if rest_client.telemetry_disabled(_get_env("TINYFISH_NO_TELEMETRY")):
|
|
298
|
+
if not share_usage:
|
|
273
299
|
return
|
|
274
300
|
rest_client.report_setup(
|
|
275
301
|
outcome, key_source=key_source, api_key=api_key or _api_key()
|