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:
Jin 2026-09-24 21:08:21 -04:00 committed by GitHub
parent 2a76c9db71
commit 71a115d926
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 84 additions and 4 deletions

View File

@ -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(

View File

@ -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):

View File

@ -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()