From 71a115d9263fc557870ef1df4f18f5c978b34de8 Mon Sep 17 00:00:00 2001 From: Jin Date: Thu, 24 Sep 2026 21:08:21 -0400 Subject: [PATCH] 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 []"), 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 * 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 * test(feishu): stabilize file batch timing --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Willem Jiang --- .../harness/deerflow/community/exa/tools.py | 40 ++++++++++++++-- backend/tests/test_exa_tools.py | 46 +++++++++++++++++++ backend/tests/test_feishu_parser.py | 2 + 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/backend/packages/harness/deerflow/community/exa/tools.py b/backend/packages/harness/deerflow/community/exa/tools.py index 974280402f..015d5f3f99 100644 --- a/backend/packages/harness/deerflow/community/exa/tools.py +++ b/backend/packages/harness/deerflow/community/exa/tools.py @@ -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( diff --git a/backend/tests/test_exa_tools.py b/backend/tests/test_exa_tools.py index b7196918ee..bd1778cbc5 100644 --- a/backend/tests/test_exa_tools.py +++ b/backend/tests/test_exa_tools.py @@ -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): diff --git a/backend/tests/test_feishu_parser.py b/backend/tests/test_feishu_parser.py index 27bbca91d2..5acc1896f2 100644 --- a/backend/tests/test_feishu_parser.py +++ b/backend/tests/test_feishu_parser.py @@ -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()