Skip to content

Commit 838581a

Browse files
authored
feat: support keyless search providers (#949)
1 parent 8f7fa64 commit 838581a

11 files changed

Lines changed: 749 additions & 52 deletions

File tree

ms_agent/tools/base.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
# Copyright (c) ModelScope Contributors. All rights reserved.
2+
import math
23
from abc import abstractmethod
34
from omegaconf import DictConfig
4-
from typing import Any, Dict
5+
from typing import Any, Dict, Optional
56

67
from ms_agent.utils.constants import DEFAULT_OUTPUT_DIR
78

9+
#: Return this from :attr:`ToolBase.max_output_chars` to declare that a tool
10+
#: bounds its own output and must never be cut by the generic truncator.
11+
SELF_MANAGED_OUTPUT = math.inf
12+
13+
#: Where to keep text from when an oversized output IS cut generically.
14+
TRUNCATE_KEEP_HEAD = 'head'
15+
TRUNCATE_KEEP_TAIL = 'tail'
16+
TRUNCATE_KEEP_BOTH = 'both'
17+
818

919
class ToolBase:
1020
"""The base class for all tools.
@@ -19,6 +29,30 @@ def __init__(self, config):
1929
self.output_dir = getattr(self.config, 'output_dir',
2030
DEFAULT_OUTPUT_DIR)
2131

32+
@property
33+
def max_output_chars(self) -> Optional[float]:
34+
"""Model-facing character budget for this tool's output.
35+
36+
* ``None`` (default) — use the global ``MAX_TOOL_OUTPUT_LEN``.
37+
* :data:`SELF_MANAGED_OUTPUT` — the tool guarantees its own bound
38+
(paging, spilling to disk, …); never truncate it generically.
39+
* a number — this tool's own budget, used instead of the global one.
40+
41+
Override in a subclass to opt in. Declaring a budget is a promise about
42+
SHAPE as much as size: a tool that returns structured data should keep
43+
itself under budget so the generic cut never has to run.
44+
"""
45+
return None
46+
47+
@property
48+
def truncate_keep(self) -> str:
49+
"""Which end survives when this tool's output IS cut generically.
50+
51+
``'head'`` (a command's first output is the useful part), ``'tail'``
52+
(a long run whose verdict is last), or ``'both'`` (default).
53+
"""
54+
return TRUNCATE_KEEP_BOTH
55+
2256
def exclude_func(self, tool_config: DictConfig):
2357
if tool_config is not None:
2458
self.exclude_functions = getattr(tool_config, 'exclude', [])

ms_agent/tools/search/tavily/fetcher.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,9 @@ def __init__(
3232
include_favicon: bool = False,
3333
include_usage: bool = False,
3434
):
35-
key = api_key or os.getenv('TAVILY_API_KEY')
36-
if not key:
37-
raise ValueError(
38-
'TAVILY_API_KEY required for tavily_extract fetcher')
39-
self._api_key = key
35+
# Keyless is a supported mode here too (Tavily serves /extract without
36+
# credentials under the same header as /search); see tavily/search.py.
37+
self._api_key = api_key or os.getenv('TAVILY_API_KEY') or ''
4038
self._extract_depth = extract_depth
4139
self._format = format
4240
self._timeout = max(1.0, min(60.0, float(timeout)))
@@ -52,7 +50,6 @@ def fetch(self,
5250
Extract one URL. Optional ``query`` enables chunk reranking (more relevant raw_content).
5351
"""
5452
body: Dict[str, Any] = {
55-
'api_key': self._api_key,
5653
'urls': [url],
5754
'extract_depth': self._extract_depth,
5855
'format': self._format,
@@ -64,10 +61,18 @@ def fetch(self,
6461
if query:
6562
body['query'] = query
6663
body['chunks_per_source'] = self._chunks_per_source
64+
# Omitted when empty: any api_key in the body overrides the keyless
65+
# header (see TavilySearchRequest.to_api_body).
66+
if self._api_key:
67+
body['api_key'] = self._api_key
6768

6869
try:
70+
from ms_agent.tools.search.tavily.search import KEYLESS_HEADER
6971
data = post_json(
70-
TAVILY_EXTRACT_URL, body, timeout=self._timeout + 30.0)
72+
TAVILY_EXTRACT_URL,
73+
body,
74+
timeout=self._timeout + 30.0,
75+
headers=(dict(KEYLESS_HEADER) if not self._api_key else {}))
7176
except Exception as e:
7277
logger.warning(f'Tavily extract failed for {url[:80]}: {e}')
7378
return '', {
Lines changed: 121 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,124 @@
11
# Copyright (c) ModelScope Contributors. All rights reserved.
22
"""Minimal HTTP JSON client for Tavily REST API (stdlib only)."""
33
import json
4-
from typing import Any, Dict
4+
from typing import Any, Dict, Optional
55
from urllib.error import HTTPError, URLError
66
from urllib.request import Request, urlopen
77

88

9+
class TavilyHTTPError(RuntimeError):
10+
"""A Tavily call that failed, with the pieces a caller can act on.
11+
12+
Plain ``RuntimeError`` forced every caller to re-parse the message to tell
13+
"you are out of quota, ask the user for a key" from "that host is down".
14+
Keyless mode makes that distinction routine rather than exceptional — the
15+
free tier is a small hourly bucket — so the parts travel as fields:
16+
``status`` (HTTP code, None for transport failures), ``code`` (Tavily's own
17+
machine-readable ``error.code``, e.g. ``hourly_cap_reached``) and
18+
``retry_after`` seconds when the response carried one.
19+
"""
20+
21+
def __init__(self,
22+
message: str,
23+
*,
24+
status: Optional[int] = None,
25+
code: str = '',
26+
retry_after: Optional[int] = None,
27+
detail: Any = None):
28+
super().__init__(message)
29+
self.status = status
30+
self.code = code
31+
self.retry_after = retry_after
32+
self.detail = detail
33+
34+
@property
35+
def is_quota(self) -> bool:
36+
"""Out of quota — retryable later, and fixable now with an API key."""
37+
return self.status == 429 or self.code in ('hourly_cap_reached',
38+
'rate_limit_exceeded')
39+
40+
@property
41+
def is_auth(self) -> bool:
42+
return self.status in (401, 403)
43+
44+
45+
def _ssl_context():
46+
"""A verifying TLS context that works on interpreters with no CA store.
47+
48+
``urlopen`` uses the interpreter's default store, which is empty in some
49+
virtualenvs (``ssl.get_default_verify_paths().cafile is None`` — measured on
50+
the WebUI backend's venv, where every Tavily call died with
51+
CERTIFICATE_VERIFY_FAILED). certifi is already an indirect dependency there;
52+
when it is missing we hand back None so urlopen behaves exactly as before.
53+
Never disables verification.
54+
"""
55+
try:
56+
import certifi
57+
import ssl
58+
return ssl.create_default_context(cafile=certifi.where())
59+
except Exception:
60+
return None
61+
62+
63+
def _parse_error_body(raw: str) -> Any:
64+
try:
65+
return json.loads(raw) if raw else {}
66+
except json.JSONDecodeError:
67+
return {'raw': raw}
68+
69+
70+
def _dig_error(detail: Any) -> tuple:
71+
"""``(code, message, retry_after)`` out of Tavily's error envelope.
72+
73+
Two shapes are in the wild: ``{"error": {"code", "message",
74+
"retry_after_seconds"}}`` (keyless quota) and ``{"detail": {"error": ...}}``
75+
(auth). Anything else degrades to empty strings rather than raising while
76+
already handling an error.
77+
"""
78+
code = message = ''
79+
retry_after = None
80+
node = detail
81+
if isinstance(node, dict) and isinstance(node.get('detail'), dict):
82+
node = node['detail']
83+
if isinstance(node, dict):
84+
err = node.get('error')
85+
if isinstance(err, dict):
86+
code = str(err.get('code') or '')
87+
message = str(err.get('message') or '')
88+
ra = err.get('retry_after_seconds')
89+
if isinstance(ra, (int, float)):
90+
retry_after = int(ra)
91+
elif isinstance(err, str):
92+
message = err
93+
return code, message, retry_after
94+
95+
996
def post_json(
1097
url: str,
1198
body: Dict[str, Any],
1299
*,
13100
timeout: float = 120.0,
101+
headers: Optional[Dict[str, str]] = None,
14102
) -> Dict[str, Any]:
15103
"""
16104
POST JSON and parse JSON response.
17105
106+
``headers`` is merged over the defaults — that is how keyless mode is
107+
selected (``X-Tavily-Access-Mode: keyless``).
108+
18109
Raises:
19-
RuntimeError: on HTTP errors or invalid JSON (includes Tavily error body).
110+
TavilyHTTPError: on HTTP errors or invalid JSON (carries Tavily's own
111+
error code / retry-after so callers can tell quota from outage).
20112
"""
21113
data = json.dumps(body, ensure_ascii=False).encode('utf-8')
22-
req = Request(
23-
url,
24-
data=data,
25-
method='POST',
26-
headers={
27-
'Content-Type': 'application/json',
28-
'Accept': 'application/json',
29-
},
30-
)
114+
merged = {
115+
'Content-Type': 'application/json',
116+
'Accept': 'application/json',
117+
}
118+
merged.update(headers or {})
119+
req = Request(url, data=data, method='POST', headers=merged)
31120
try:
32-
with urlopen(req, timeout=timeout) as resp:
121+
with urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
33122
raw = resp.read().decode('utf-8', errors='replace')
34123
if not raw.strip():
35124
return {}
@@ -40,10 +129,24 @@ def post_json(
40129
err_body = e.read().decode('utf-8', errors='replace')
41130
except Exception:
42131
pass
43-
try:
44-
detail = json.loads(err_body) if err_body else {}
45-
except json.JSONDecodeError:
46-
detail = {'raw': err_body}
47-
raise RuntimeError(f'Tavily HTTP {e.code}: {detail}') from e
132+
detail = _parse_error_body(err_body)
133+
code, message, retry_after = _dig_error(detail)
134+
if retry_after is None:
135+
header_value = None
136+
try:
137+
header_value = e.headers.get('retry-after')
138+
except Exception:
139+
pass
140+
if header_value:
141+
try:
142+
retry_after = int(float(header_value))
143+
except (TypeError, ValueError):
144+
retry_after = None
145+
raise TavilyHTTPError(
146+
f'Tavily HTTP {e.code}: {message or detail}',
147+
status=e.code,
148+
code=code,
149+
retry_after=retry_after,
150+
detail=detail) from e
48151
except URLError as e:
49-
raise RuntimeError(f'Tavily network error: {e}') from e
152+
raise TavilyHTTPError(f'Tavily network error: {e}') from e

ms_agent/tools/search/tavily/schema.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ class TavilySearchRequest:
3333
def to_api_body(self, api_key: str) -> Dict[str, Any]:
3434
n = max(0, min(20, int(self.max_results)))
3535
body: Dict[str, Any] = {
36-
'api_key': api_key,
3736
'query': self.query,
3837
'max_results': n,
3938
'search_depth': self.search_depth,
@@ -64,6 +63,13 @@ def to_api_body(self, api_key: str) -> Dict[str, Any]:
6463
body['exclude_domains'] = list(self.exclude_domains)[:150]
6564
if self.country:
6665
body['country'] = self.country
66+
# Sent LAST and only when non-empty. Keyless mode (the
67+
# X-Tavily-Access-Mode header) is overridden by any api_key present in
68+
# the body: measured 2026-08-20, an empty string is tolerated but a
69+
# non-empty one is validated and a bogus value 401s. Omitting the field
70+
# entirely is the only shape that is unambiguous in both modes.
71+
if api_key:
72+
body['api_key'] = api_key
6773
return body
6874

6975

ms_agent/tools/search/tavily/search.py

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@
1515

1616
TAVILY_SEARCH_URL = 'https://api.tavily.com/search'
1717

18+
#: Tavily serves /search and /extract without credentials when this header is
19+
#: present (https://docs.tavily.com/documentation/keyless). Responses are
20+
#: identical to keyed ones — same parameters, same result shape — but the quota
21+
#: is a small sliding hourly bucket rather than the free tier's monthly credits.
22+
#: Measured 2026-08-20: refill is roughly one request per 60-90s, and exhaustion
23+
#: is a clean HTTP 429 (`error.code: hourly_cap_reached`) carrying Retry-After.
24+
#: It exists so the framework works on first run with nothing configured; a key
25+
#: always takes precedence when one is available.
26+
KEYLESS_HEADER = {'X-Tavily-Access-Mode': 'keyless'}
27+
1828

1929
class TavilySearch(SearchEngine):
2030
"""
@@ -32,24 +42,36 @@ def __init__(
3242
api_key: Optional[str] = None,
3343
request_timeout: float = 120.0,
3444
):
35-
key = api_key or os.getenv('TAVILY_API_KEY')
36-
if not key:
37-
raise ValueError(
38-
'TAVILY_API_KEY must be set in environment or web_search.tavily_api_key'
39-
)
40-
self._api_key = key
45+
# No key is a supported mode, not an error: without one we fall back to
46+
# Tavily's keyless tier so a fresh install can search out of the box.
47+
# Constructing this used to raise, which WebSearchTool.connect() caught
48+
# and turned into "engine unavailable" — the reason an unconfigured
49+
# framework silently had no web search at all.
50+
self._api_key = api_key or os.getenv('TAVILY_API_KEY') or ''
4151
self._request_timeout = float(request_timeout)
4252

53+
@property
54+
def keyless(self) -> bool:
55+
return not self._api_key
56+
57+
def _headers(self) -> dict:
58+
return dict(KEYLESS_HEADER) if self.keyless else {}
59+
4360
def search(self,
4461
search_request: TavilySearchRequest) -> TavilySearchResult:
4562
body = search_request.to_api_body(self._api_key)
46-
try:
47-
data = post_json(
48-
TAVILY_SEARCH_URL, body, timeout=self._request_timeout)
49-
except Exception as e:
50-
raise RuntimeError(f'Tavily search failed: {e}') from e
63+
# Deliberately unguarded: TavilyHTTPError carries the quota/auth fields
64+
# the tool layer needs to tell the agent WHY a search failed. This used
65+
# to be wrapped in a bare RuntimeError, which erased them.
66+
data = post_json(
67+
TAVILY_SEARCH_URL,
68+
body,
69+
timeout=self._request_timeout,
70+
headers=self._headers())
5171
safe_args = {k: v for k, v in body.items() if k != 'api_key'}
52-
safe_args['api_key'] = '<redacted>'
72+
if self._api_key:
73+
safe_args['api_key'] = '<redacted>'
74+
safe_args['access_mode'] = 'keyless' if self.keyless else 'api_key'
5375
return TavilySearchResult(
5476
query=search_request.query,
5577
arguments=safe_args,

0 commit comments

Comments
 (0)