Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
ecd6974
Fix tool_call_id loss in unified memory message round-trip
alcholiclg Aug 4, 2026
f8ce706
Pair tool results with pending calls when tool_call_id is missing in …
alcholiclg Aug 4, 2026
a7b86ec
Seal errored rounds so resume consumes the next prompt instead of rep…
alcholiclg Aug 4, 2026
0686305
Skip LLM call retries for non-retryable 4xx client errors
alcholiclg Aug 4, 2026
977d16b
Dedupe identical per-round error records in SessionLog
alcholiclg Aug 4, 2026
4b5f976
Use native Windows shell semantics in the local code executor
alcholiclg Aug 4, 2026
0ae7224
Infer provider from model name only when no service is configured
alcholiclg Aug 6, 2026
738640e
Ingest a round's memory before the blocking interactive input wait
alcholiclg Aug 10, 2026
ed01753
Release the mem0 vector client on close and log ingestion failures
alcholiclg Aug 10, 2026
18bfe05
Merge branch 'main' of https://github.com/modelscope/ms-agent into fi…
alcholiclg Aug 10, 2026
4335a8b
Let a handler that supports it receive a round's parallel permission …
alcholiclg Aug 10, 2026
c1654a5
Report each parallel tool call's completion as it finishes, not after…
alcholiclg Aug 10, 2026
1100a38
Take memory ingestion off the turn's critical path
alcholiclg Aug 10, 2026
41cfac2
Ingest memory on closing rounds only, and give shared stores an owner…
alcholiclg Aug 10, 2026
313a232
Make mem0 recall size configurable (recall_top_k)
alcholiclg Aug 11, 2026
e2d284b
Build the system prompt from live workspace files (SOUL/AGENTS/PROFIL…
alcholiclg Aug 13, 2026
2712ff7
Attach vector recall durably to each user turn and keep the file back…
alcholiclg Aug 13, 2026
e66e382
Ship agent_hub default configs in the wheel and merge the project con…
alcholiclg Aug 13, 2026
6d07904
Merge remote-tracking branch 'upstream/main' into fix/runtime-robustness
alcholiclg Aug 13, 2026
ec2816c
Remove the memory section from the prompt when memory is cleared or i…
alcholiclg Aug 13, 2026
67a07b1
fix ut
alcholiclg Aug 13, 2026
c715dfb
Fix unified memory losing writes and ignoring config changes
alcholiclg Aug 14, 2026
4815b4b
Merge branch 'fix/memory-config-and-rebuild' into fix/runtime-robustness
alcholiclg Aug 14, 2026
6b5fae5
Retry once with thinking off when a model rejects the thinking parame…
alcholiclg Aug 14, 2026
c9389bc
Lower a single reasoning_effort knob onto each endpoint's own thinkin…
alcholiclg Aug 17, 2026
e8a910c
Send both thinking knobs on DashScope, where the switch and the effor…
alcholiclg Aug 17, 2026
582f0ac
Lower the thinking knob once per request, repair mandatory-thinking f…
alcholiclg Aug 17, 2026
2893443
Adopt the effort vocabulary the endpoints themselves report instead o…
alcholiclg Aug 17, 2026
1f26c86
Clamp a thinking tier downward, never upward, and record only what en…
alcholiclg Aug 17, 2026
e2fa162
Ask MiniMax to deliver reasoning in its own field, the only shape it …
alcholiclg Aug 18, 2026
5ddb09c
Offer only the tiers an endpoint actually has, not the whole ladder
alcholiclg Aug 18, 2026
34bddc0
Match a bare command against its own `<cmd> *` rule
alcholiclg Aug 18, 2026
b39bd5f
Confirm network commands instead of refusing them, and remember only …
alcholiclg Aug 18, 2026
b271839
Stop a bare `*` in dangerous_removal_paths from making every path dan…
alcholiclg Aug 18, 2026
3d0b0e0
Test the remembered pattern through the allow_always path the UI actu…
alcholiclg Aug 18, 2026
20b4288
Send attached images to the model as native image content instead of …
alcholiclg Aug 18, 2026
d9f2428
Merge branch 'feat/multimodal-input' into fix/runtime-robustness
alcholiclg Aug 18, 2026
e614df6
Mark earlier image descriptions as another model's reliable history s…
alcholiclg Aug 18, 2026
8374082
Merge branch 'main' of https://github.com/modelscope/ms-agent into fi…
alcholiclg Aug 19, 2026
89a4c57
Simulate mem0's absence explicitly so the test stops depending on lef…
alcholiclg Aug 19, 2026
f3a1c6b
Resolve image support from the per-model switch, and retry refusals t…
alcholiclg Aug 20, 2026
eb0bbce
Merge branch 'fix/vision-and-stream-retry' into fix/runtime-robustness
alcholiclg Aug 20, 2026
be1b176
Fall back to Tavily's keyless tier when no API key is configured, and…
alcholiclg Aug 25, 2026
2312cc3
Let a tool declare its own output budget so the generic truncator sto…
alcholiclg Aug 25, 2026
8d4cc5f
Correct the comments.
alcholiclg Aug 25, 2026
3cb3076
Merge branch 'main' of https://github.com/modelscope/ms-agent into fi…
alcholiclg Aug 25, 2026
bd6ce98
Add socksio so a SOCKS proxy in the environment does not make every H…
alcholiclg Aug 25, 2026
3944d50
update requirements
alcholiclg Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion ms_agent/tools/base.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
import math
from abc import abstractmethod
from omegaconf import DictConfig
from typing import Any, Dict
from typing import Any, Dict, Optional

from ms_agent.utils.constants import DEFAULT_OUTPUT_DIR

#: Return this from :attr:`ToolBase.max_output_chars` to declare that a tool
#: bounds its own output and must never be cut by the generic truncator.
SELF_MANAGED_OUTPUT = math.inf

#: Where to keep text from when an oversized output IS cut generically.
TRUNCATE_KEEP_HEAD = 'head'
TRUNCATE_KEEP_TAIL = 'tail'
TRUNCATE_KEEP_BOTH = 'both'


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

@property
def max_output_chars(self) -> Optional[float]:
"""Model-facing character budget for this tool's output.

* ``None`` (default) — use the global ``MAX_TOOL_OUTPUT_LEN``.
* :data:`SELF_MANAGED_OUTPUT` — the tool guarantees its own bound
(paging, spilling to disk, …); never truncate it generically.
* a number — this tool's own budget, used instead of the global one.

Override in a subclass to opt in. Declaring a budget is a promise about
SHAPE as much as size: a tool that returns structured data should keep
itself under budget so the generic cut never has to run.
"""
return None

@property
def truncate_keep(self) -> str:
"""Which end survives when this tool's output IS cut generically.

``'head'`` (a command's first output is the useful part), ``'tail'``
(a long run whose verdict is last), or ``'both'`` (default).
"""
return TRUNCATE_KEEP_BOTH

def exclude_func(self, tool_config: DictConfig):
if tool_config is not None:
self.exclude_functions = getattr(tool_config, 'exclude', [])
Expand Down
19 changes: 12 additions & 7 deletions ms_agent/tools/search/tavily/fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,9 @@ def __init__(
include_favicon: bool = False,
include_usage: bool = False,
):
key = api_key or os.getenv('TAVILY_API_KEY')
if not key:
raise ValueError(
'TAVILY_API_KEY required for tavily_extract fetcher')
self._api_key = key
# Keyless is a supported mode here too (Tavily serves /extract without
# credentials under the same header as /search); see tavily/search.py.
self._api_key = api_key or os.getenv('TAVILY_API_KEY') or ''
self._extract_depth = extract_depth
self._format = format
self._timeout = max(1.0, min(60.0, float(timeout)))
Expand All @@ -52,7 +50,6 @@ def fetch(self,
Extract one URL. Optional ``query`` enables chunk reranking (more relevant raw_content).
"""
body: Dict[str, Any] = {
'api_key': self._api_key,
'urls': [url],
'extract_depth': self._extract_depth,
'format': self._format,
Expand All @@ -64,10 +61,18 @@ def fetch(self,
if query:
body['query'] = query
body['chunks_per_source'] = self._chunks_per_source
# Omitted when empty: any api_key in the body overrides the keyless
# header (see TavilySearchRequest.to_api_body).
if self._api_key:
body['api_key'] = self._api_key

try:
from ms_agent.tools.search.tavily.search import KEYLESS_HEADER
data = post_json(
TAVILY_EXTRACT_URL, body, timeout=self._timeout + 30.0)
TAVILY_EXTRACT_URL,
body,
timeout=self._timeout + 30.0,
headers=(dict(KEYLESS_HEADER) if not self._api_key else {}))
except Exception as e:
logger.warning(f'Tavily extract failed for {url[:80]}: {e}')
return '', {
Expand Down
139 changes: 121 additions & 18 deletions ms_agent/tools/search/tavily/http.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,124 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
"""Minimal HTTP JSON client for Tavily REST API (stdlib only)."""
import json
from typing import Any, Dict
from typing import Any, Dict, Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen


class TavilyHTTPError(RuntimeError):
"""A Tavily call that failed, with the pieces a caller can act on.

Plain ``RuntimeError`` forced every caller to re-parse the message to tell
"you are out of quota, ask the user for a key" from "that host is down".
Keyless mode makes that distinction routine rather than exceptional — the
free tier is a small hourly bucket — so the parts travel as fields:
``status`` (HTTP code, None for transport failures), ``code`` (Tavily's own
machine-readable ``error.code``, e.g. ``hourly_cap_reached``) and
``retry_after`` seconds when the response carried one.
"""

def __init__(self,
message: str,
*,
status: Optional[int] = None,
code: str = '',
retry_after: Optional[int] = None,
detail: Any = None):
super().__init__(message)
self.status = status
self.code = code
self.retry_after = retry_after
self.detail = detail

@property
def is_quota(self) -> bool:
"""Out of quota — retryable later, and fixable now with an API key."""
return self.status == 429 or self.code in ('hourly_cap_reached',
'rate_limit_exceeded')

@property
def is_auth(self) -> bool:
return self.status in (401, 403)


def _ssl_context():
"""A verifying TLS context that works on interpreters with no CA store.

``urlopen`` uses the interpreter's default store, which is empty in some
virtualenvs (``ssl.get_default_verify_paths().cafile is None`` — measured on
the WebUI backend's venv, where every Tavily call died with
CERTIFICATE_VERIFY_FAILED). certifi is already an indirect dependency there;
when it is missing we hand back None so urlopen behaves exactly as before.
Never disables verification.
"""
try:
import certifi
import ssl
return ssl.create_default_context(cafile=certifi.where())
except Exception:
return None


def _parse_error_body(raw: str) -> Any:
try:
return json.loads(raw) if raw else {}
except json.JSONDecodeError:
return {'raw': raw}


def _dig_error(detail: Any) -> tuple:
"""``(code, message, retry_after)`` out of Tavily's error envelope.

Two shapes are in the wild: ``{"error": {"code", "message",
"retry_after_seconds"}}`` (keyless quota) and ``{"detail": {"error": ...}}``
(auth). Anything else degrades to empty strings rather than raising while
already handling an error.
"""
code = message = ''
retry_after = None
node = detail
if isinstance(node, dict) and isinstance(node.get('detail'), dict):
node = node['detail']
if isinstance(node, dict):
err = node.get('error')
if isinstance(err, dict):
code = str(err.get('code') or '')
message = str(err.get('message') or '')
ra = err.get('retry_after_seconds')
if isinstance(ra, (int, float)):
retry_after = int(ra)
elif isinstance(err, str):
message = err
return code, message, retry_after


def post_json(
url: str,
body: Dict[str, Any],
*,
timeout: float = 120.0,
headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""
POST JSON and parse JSON response.

``headers`` is merged over the defaults — that is how keyless mode is
selected (``X-Tavily-Access-Mode: keyless``).

Raises:
RuntimeError: on HTTP errors or invalid JSON (includes Tavily error body).
TavilyHTTPError: on HTTP errors or invalid JSON (carries Tavily's own
error code / retry-after so callers can tell quota from outage).
"""
data = json.dumps(body, ensure_ascii=False).encode('utf-8')
req = Request(
url,
data=data,
method='POST',
headers={
'Content-Type': 'application/json',
'Accept': 'application/json',
},
)
merged = {
'Content-Type': 'application/json',
'Accept': 'application/json',
}
merged.update(headers or {})
req = Request(url, data=data, method='POST', headers=merged)
try:
with urlopen(req, timeout=timeout) as resp:
with urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
raw = resp.read().decode('utf-8', errors='replace')
if not raw.strip():
return {}
Expand All @@ -40,10 +129,24 @@ def post_json(
err_body = e.read().decode('utf-8', errors='replace')
except Exception:
pass
try:
detail = json.loads(err_body) if err_body else {}
except json.JSONDecodeError:
detail = {'raw': err_body}
raise RuntimeError(f'Tavily HTTP {e.code}: {detail}') from e
detail = _parse_error_body(err_body)
code, message, retry_after = _dig_error(detail)
if retry_after is None:
header_value = None
try:
header_value = e.headers.get('retry-after')
except Exception:
pass
if header_value:
try:
retry_after = int(float(header_value))
except (TypeError, ValueError):
retry_after = None
raise TavilyHTTPError(
f'Tavily HTTP {e.code}: {message or detail}',
status=e.code,
code=code,
retry_after=retry_after,
detail=detail) from e
except URLError as e:
raise RuntimeError(f'Tavily network error: {e}') from e
raise TavilyHTTPError(f'Tavily network error: {e}') from e
8 changes: 7 additions & 1 deletion ms_agent/tools/search/tavily/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ class TavilySearchRequest:
def to_api_body(self, api_key: str) -> Dict[str, Any]:
n = max(0, min(20, int(self.max_results)))
body: Dict[str, Any] = {
'api_key': api_key,
'query': self.query,
'max_results': n,
'search_depth': self.search_depth,
Expand Down Expand Up @@ -64,6 +63,13 @@ def to_api_body(self, api_key: str) -> Dict[str, Any]:
body['exclude_domains'] = list(self.exclude_domains)[:150]
if self.country:
body['country'] = self.country
# Sent LAST and only when non-empty. Keyless mode (the
# X-Tavily-Access-Mode header) is overridden by any api_key present in
# the body: measured 2026-08-20, an empty string is tolerated but a
# non-empty one is validated and a bogus value 401s. Omitting the field
# entirely is the only shape that is unambiguous in both modes.
if api_key:
body['api_key'] = api_key
return body


Expand Down
46 changes: 34 additions & 12 deletions ms_agent/tools/search/tavily/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@

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

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


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

@property
def keyless(self) -> bool:
return not self._api_key

def _headers(self) -> dict:
return dict(KEYLESS_HEADER) if self.keyless else {}

def search(self,
search_request: TavilySearchRequest) -> TavilySearchResult:
body = search_request.to_api_body(self._api_key)
try:
data = post_json(
TAVILY_SEARCH_URL, body, timeout=self._request_timeout)
except Exception as e:
raise RuntimeError(f'Tavily search failed: {e}') from e
# Deliberately unguarded: TavilyHTTPError carries the quota/auth fields
# the tool layer needs to tell the agent WHY a search failed. This used
# to be wrapped in a bare RuntimeError, which erased them.
data = post_json(
TAVILY_SEARCH_URL,
body,
timeout=self._request_timeout,
headers=self._headers())
safe_args = {k: v for k, v in body.items() if k != 'api_key'}
safe_args['api_key'] = '<redacted>'
if self._api_key:
safe_args['api_key'] = '<redacted>'
safe_args['access_mode'] = 'keyless' if self.keyless else 'api_key'
return TavilySearchResult(
query=search_request.query,
arguments=safe_args,
Expand Down
Loading
Loading