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