mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(exa): coerce configured result limits before calling the SDK (#5839)
* fix(exa): coerce configured result limits before calling the SDK
`$VAR` references in config.yaml resolve to strings, and exa-py rejects a
string `num_results` ("Invalid value for option 'num_results': 10. Expected
one of [<class 'int'>]"), so `max_results: $EXA_MAX_RESULTS` made every Exa
web search return an error. `contents_max_characters` reached the API as a
string the same way.
Coerce both like the DDG and SearXNG providers do: parse an integer, and warn
and keep the default for a blank, non-numeric, zero or negative value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(exa): reject fractional and boolean limits instead of truncating them
Review follow-up: int() turned an unquoted YAML `3.5` into 3 and `true`
into 1, so a misconfigured limit was used with a different value. Accept
only non-boolean integers and integer-form strings; everything else warns
and keeps the default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(feishu): stabilize file batch timing
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
2a76c9db71
commit
71a115d926
@ -1,10 +1,38 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from exa_py import Exa
|
||||
from langchain.tools import tool
|
||||
|
||||
from deerflow.config import get_app_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_RESULTS = 5
|
||||
DEFAULT_CONTENTS_MAX_CHARACTERS = 1000
|
||||
|
||||
|
||||
def _coerce_positive_int(value: object, default: int, option: str) -> int:
|
||||
"""Normalize a config value before handing it to the Exa SDK.
|
||||
|
||||
``$VAR`` references in config.yaml resolve to strings, and exa-py rejects a
|
||||
string ``num_results`` outright, so every search would fail. Only an integer
|
||||
or an integer-form string is accepted: ``int()`` would silently truncate a
|
||||
float such as an unquoted ``3.5`` and turn ``true`` into 1. Anything else
|
||||
(blank, non-numeric, fractional, boolean, zero or negative) warns and keeps
|
||||
the default instead.
|
||||
"""
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
count = value
|
||||
elif isinstance(value, str) and value.strip().isdecimal():
|
||||
count = int(value.strip())
|
||||
else:
|
||||
count = 0
|
||||
if count <= 0:
|
||||
logger.warning("Invalid Exa %s=%r; using default %s", option, value, default)
|
||||
return default
|
||||
return count
|
||||
|
||||
|
||||
def _get_exa_client(tool_name: str = "web_search") -> Exa:
|
||||
config = get_app_config().get_tool_config(tool_name)
|
||||
@ -23,13 +51,17 @@ def web_search_tool(query: str) -> str:
|
||||
"""
|
||||
try:
|
||||
config = get_app_config().get_tool_config("web_search")
|
||||
max_results = 5
|
||||
max_results = DEFAULT_MAX_RESULTS
|
||||
search_type = "auto"
|
||||
contents_max_characters = 1000
|
||||
contents_max_characters = DEFAULT_CONTENTS_MAX_CHARACTERS
|
||||
if config is not None:
|
||||
max_results = config.model_extra.get("max_results", max_results)
|
||||
max_results = _coerce_positive_int(config.model_extra.get("max_results", max_results), DEFAULT_MAX_RESULTS, "max_results")
|
||||
search_type = config.model_extra.get("search_type", search_type)
|
||||
contents_max_characters = config.model_extra.get("contents_max_characters", contents_max_characters)
|
||||
contents_max_characters = _coerce_positive_int(
|
||||
config.model_extra.get("contents_max_characters", contents_max_characters),
|
||||
DEFAULT_CONTENTS_MAX_CHARACTERS,
|
||||
"contents_max_characters",
|
||||
)
|
||||
|
||||
client = _get_exa_client()
|
||||
res = client.search(
|
||||
|
||||
@ -133,6 +133,52 @@ class TestWebSearchTool:
|
||||
|
||||
assert result == "Error: API rate limit exceeded"
|
||||
|
||||
def test_search_accepts_env_var_string_limits(self):
|
||||
"""``$VAR`` config references resolve to strings; exa-py rejects a string num_results."""
|
||||
from exa_py import Exa
|
||||
|
||||
with patch("deerflow.community.exa.tools.get_app_config") as mock_config:
|
||||
tool_config = MagicMock()
|
||||
tool_config.model_extra = {"max_results": "10", "contents_max_characters": "2000", "api_key": "test-key"}
|
||||
mock_config.return_value.get_tool_config.return_value = tool_config
|
||||
|
||||
sent = {}
|
||||
|
||||
def fake_request(self, endpoint, data=None, *args, **kwargs):
|
||||
sent.update(data or {})
|
||||
return {"results": [], "requestId": "req"}
|
||||
|
||||
# The real SDK does the type check, so only its HTTP layer is replaced.
|
||||
with patch.object(Exa, "request", fake_request):
|
||||
from deerflow.community.exa.tools import web_search_tool
|
||||
|
||||
result = web_search_tool.invoke({"query": "test query"})
|
||||
|
||||
assert json.loads(result) == []
|
||||
assert sent["numResults"] == 10
|
||||
assert sent["contents"] == {"highlights": {"maxCharacters": 2000}}
|
||||
|
||||
@pytest.mark.parametrize("raw", [None, "", "many", "3.5", 3.5, 10.0, True, False, 0, -2, "-2"])
|
||||
def test_search_invalid_limits_fall_back_to_defaults(self, mock_exa_client, raw):
|
||||
"""An unusable value warns and keeps the default instead of failing the search."""
|
||||
with patch("deerflow.community.exa.tools.get_app_config") as mock_config:
|
||||
tool_config = MagicMock()
|
||||
tool_config.model_extra = {"max_results": raw, "contents_max_characters": raw, "api_key": "test-key"}
|
||||
mock_config.return_value.get_tool_config.return_value = tool_config
|
||||
mock_exa_client.search.return_value = MagicMock(results=[])
|
||||
|
||||
from deerflow.community.exa.tools import web_search_tool
|
||||
|
||||
result = web_search_tool.invoke({"query": "test query"})
|
||||
|
||||
assert json.loads(result) == []
|
||||
mock_exa_client.search.assert_called_once_with(
|
||||
"test query",
|
||||
type="auto",
|
||||
num_results=5,
|
||||
contents={"highlights": {"max_characters": 1000}},
|
||||
)
|
||||
|
||||
|
||||
class TestWebFetchTool:
|
||||
def test_basic_fetch(self, mock_app_config, mock_exa_client):
|
||||
|
||||
@ -563,6 +563,8 @@ def _make_file_event(
|
||||
def test_feishu_batches_top_level_file_messages_from_same_user(monkeypatch):
|
||||
async def go():
|
||||
monkeypatch.setattr("app.channels.feishu.FEISHU_INBOUND_BATCH_WINDOW_SECONDS", 0.01)
|
||||
# Keep both callbacks inside the batch window even on a busy CI runner.
|
||||
monkeypatch.setattr("app.channels.feishu.time.time", lambda: 1_000.0)
|
||||
bus = MessageBus()
|
||||
channel = FeishuChannel(bus, {"app_id": "test", "app_secret": "test"})
|
||||
channel._main_loop = asyncio.get_running_loop()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user