diff --git a/README.md b/README.md index 1adc6e30dd..c0e47d23d4 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,8 @@ streams simulate chunks from a non-streaming response, while no-tool streams sta Brave web search preserves valid entries in mixed result lists. Malformed response containers or lists containing no usable entries return a structured format error; missing, null, or empty results keep the existing "No results found" response. Format errors also log the malformed container's path and type, or the absence of usable result objects, without including search queries, credentials, or payload values. + GroundRoute web search and fetch contain malformed HTTP-success payloads the same way: a non-object payload, a `results` container that is not a list, or a non-empty list with no object entries returns the provider's format error, while non-object entries in an otherwise valid list are skipped in order. Missing, null, or empty results keep the existing "No results found" response. + Jina, Browserless, and InfoQuest web fetches resolve relative links and image sources using the requested page URL (or a usable HTML base URL), so returned Markdown includes complete destinations. Link resolution preserves the surrounding HTML source, including malformed-page formatting. Jina fetches support opt-in bounded retries via `max_retries` (default `0`) and `retry_budget_seconds` (default `30`) in the tool configuration. Valid `Retry-After` hints set a minimum wait for HTTP 429/503; 429 without a valid hint stays terminal. Hints that cannot fit the remaining budget stop retries. Local backoff remains randomized. Retries may increase upstream requests and cost; see [Jina fetch retries](backend/docs/CONFIGURATION.md#jina-fetch-retries). diff --git a/backend/packages/harness/deerflow/community/groundroute/AGENTS.md b/backend/packages/harness/deerflow/community/groundroute/AGENTS.md new file mode 100644 index 0000000000..55f6f0f2de --- /dev/null +++ b/backend/packages/harness/deerflow/community/groundroute/AGENTS.md @@ -0,0 +1,22 @@ +# GroundRoute + +Validate an HTTP-success payload before normalizing it. A top level that is not an +object, a `results` container that is not a list, or a non-empty list whose entries +hold no objects is a format error, not an empty search. Skip non-object entries while +preserving valid objects in order. Missing or null `results` and an empty list keep +"No results found". + +`web_fetch` coerces non-string `content` (or its `snippet` fallback) to text before +truncating to the snippet limit, and renders a missing or falsy title as empty, so a +well-formed result object whose leaf fields are not strings still returns text rather +than raising. + +`web_search` returns the structured `{"error": ..., "query": ...}` response; `web_fetch` +returns the `Error: ...` string. Log the malformed container's type, or that no usable +result objects remain, before returning — never the query, URL, credentials, or payload +values. + +`tests/test_groundroute_response_shapes.py` exercises the real tools through an offline +HTTPX transport with synthetic JSON bodies. Patch the provider's `httpx` reference with a +local client factory and `HTTPStatusError`; never replace the shared `httpx.Client`. +Cover both the unchanged global client and HTTP error handling. diff --git a/backend/packages/harness/deerflow/community/groundroute/tools.py b/backend/packages/harness/deerflow/community/groundroute/tools.py index d666e03eb3..bbb3f9fa20 100644 --- a/backend/packages/harness/deerflow/community/groundroute/tools.py +++ b/backend/packages/harness/deerflow/community/groundroute/tools.py @@ -71,6 +71,34 @@ def _missing_key_error(tool_name: str, **context: str) -> str: return json.dumps({"error": "GROUNDROUTE_API_KEY is not configured", **context}, ensure_ascii=False) +def _unexpected_format_error(query: str) -> str: + return json.dumps({"error": "GroundRoute returned an unexpected response format", "query": query}, ensure_ascii=False) + + +def _response_results(data: object) -> list[dict] | None: + """Return the result objects of a GroundRoute response, or None if malformed. + + A missing or null ``results`` is an empty search. A non-list container, or a + non-empty list holding no objects, is malformed: the caller reports a format + error rather than "No results found", which would tell the agent the query + legitimately matched nothing. + """ + if not isinstance(data, dict): + logger.error("GroundRoute returned unexpected payload type: %s", type(data).__name__) + return None + results = data.get("results") + if results is None: + return [] + if not isinstance(results, list): + logger.error("GroundRoute returned unexpected 'results' payload type: %s", type(results).__name__) + return None + usable = [item for item in results if isinstance(item, dict)] + if results and not usable: + logger.error("GroundRoute returned 'results' with no usable result objects") + return None + return usable + + def _post_search(api_key: str, body: dict) -> dict: with httpx.Client(timeout=_TIMEOUT_S) as client: response = client.post( @@ -116,7 +144,9 @@ def web_search_tool(query: str, max_results: int | None = None) -> str: logger.error("GroundRoute search failed: %s: %s", type(e).__name__, e) return json.dumps({"error": str(e), "query": query}, ensure_ascii=False) - results = data.get("results") or [] + results = _response_results(data) + if results is None: + return _unexpected_format_error(query) if not results: return json.dumps({"error": "No results found", "query": query}, ensure_ascii=False) @@ -156,11 +186,14 @@ def web_fetch_tool(url: str) -> str: logger.error("GroundRoute fetch failed: %s: %s", type(e).__name__, e) return f"Error: {e}" - results = data.get("results") or [] + results = _response_results(data) + if results is None: + return "Error: GroundRoute returned an unexpected response format" if not results: return "Error: No results found" result = results[0] content = result.get("content") or result.get("snippet") or "" - title = result.get("title", "") + content = content if isinstance(content, str) else str(content) + title = result.get("title") or "" return f"# {title}\n\n{content[:_FETCH_SNIPPET_LIMIT]}" diff --git a/backend/tests/test_agent_guidance_check.py b/backend/tests/test_agent_guidance_check.py index 59cf664404..7ec83d9950 100644 --- a/backend/tests/test_agent_guidance_check.py +++ b/backend/tests/test_agent_guidance_check.py @@ -21,6 +21,7 @@ EXPECTED_GUIDANCE_PATHS = { "backend/packages/harness/deerflow/agents/memory/AGENTS.md", "backend/packages/harness/deerflow/agents/task_continuity/AGENTS.md", "backend/packages/harness/deerflow/community/brave/AGENTS.md", + "backend/packages/harness/deerflow/community/groundroute/AGENTS.md", "backend/packages/harness/deerflow/community/jina_ai/AGENTS.md", "backend/packages/harness/deerflow/community/ragflow/AGENTS.md", "backend/packages/harness/deerflow/community/serper/AGENTS.md", diff --git a/backend/tests/test_groundroute_response_shapes.py b/backend/tests/test_groundroute_response_shapes.py new file mode 100644 index 0000000000..ed9f940cbd --- /dev/null +++ b/backend/tests/test_groundroute_response_shapes.py @@ -0,0 +1,178 @@ +"""GroundRoute web_search/web_fetch must contain malformed HTTP-success payloads.""" + +import json +import logging +from types import SimpleNamespace + +import httpx +import pytest + +_LOGGER_NAME = "deerflow.community.groundroute.tools" +_FORMAT_ERROR = "GroundRoute returned an unexpected response format" +_SEARCH_QUERY = "文档 search" +_FETCH_URL = "https://example.com/文档" + + +@pytest.fixture(autouse=True) +def reset_api_key_warned(): + import deerflow.community.groundroute.tools as gr_mod + + gr_mod._api_key_warned = set() + yield + gr_mod._api_key_warned = set() + + +def _invoker(monkeypatch, tool_name): + """Patch the provider's httpx reference with a local MockTransport client factory.""" + from deerflow.community.groundroute import tools + + config = SimpleNamespace(get_tool_config=lambda _: SimpleNamespace(model_extra={"api_key": "synthetic-test-key"})) + monkeypatch.setattr(tools, "get_app_config", lambda: config) + real_client = httpx.Client + + def invoke(payload, *, status_code=200): + requests = [] + + def respond(request): + requests.append(request) + return httpx.Response(status_code, content=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}) + + transport = httpx.MockTransport(respond) + monkeypatch.setattr( + tools, + "httpx", + SimpleNamespace(Client=lambda **kwargs: real_client(transport=transport, **kwargs), HTTPStatusError=httpx.HTTPStatusError), + ) + if tool_name == "web_search": + result = tools.web_search_tool.invoke({"query": _SEARCH_QUERY}) + else: + result = tools.web_fetch_tool.invoke({"url": _FETCH_URL}) + assert len(requests) == 1 + return result + + return invoke + + +@pytest.fixture +def invoke_search(monkeypatch): + return _invoker(monkeypatch, "web_search") + + +@pytest.fixture +def invoke_fetch(monkeypatch): + return _invoker(monkeypatch, "web_fetch") + + +def _search(invoke_search, payload, *, status_code=200): + return json.loads(invoke_search(payload, status_code=status_code)) + + +def _error_logs(caplog): + return [record.getMessage() for record in caplog.records if record.name == _LOGGER_NAME and record.levelno == logging.ERROR] + + +def _assert_no_payload_values_leaked(caplog): + assert "文档 search" not in caplog.text + assert "example.com" not in caplog.text + assert "synthetic-test-key" not in caplog.text + assert "provider-private-payload" not in caplog.text + + +@pytest.mark.parametrize("payload", [None, False, 0, "invalid", [], ["provider-private-payload"]]) +def test_malformed_top_level_payload_returns_format_error(invoke_search, payload, caplog): + assert _search(invoke_search, payload) == {"error": _FORMAT_ERROR, "query": "文档 search"} + assert _error_logs(caplog) == [f"GroundRoute returned unexpected payload type: {type(payload).__name__}"] + _assert_no_payload_values_leaked(caplog) + + +@pytest.mark.parametrize("results", [False, 0, "", "provider-private-payload", {}, {"title": "provider-private-payload"}]) +def test_malformed_results_container_returns_format_error(invoke_search, results, caplog): + assert _search(invoke_search, {"results": results}) == {"error": _FORMAT_ERROR, "query": "文档 search"} + assert _error_logs(caplog) == [f"GroundRoute returned unexpected 'results' payload type: {type(results).__name__}"] + _assert_no_payload_values_leaked(caplog) + + +@pytest.mark.parametrize("results", [[None], [False, 0, "provider-private-payload", []]]) +def test_all_malformed_entries_return_format_error(invoke_search, results, caplog): + assert _search(invoke_search, {"results": results}) == {"error": _FORMAT_ERROR, "query": "文档 search"} + assert _error_logs(caplog) == ["GroundRoute returned 'results' with no usable result objects"] + _assert_no_payload_values_leaked(caplog) + + +def test_mixed_entries_preserve_valid_results_in_order(invoke_search, caplog): + payload = {"results": [None, {"title": "文档", "url": "https://other.example/one", "snippet": "概述", "source_engine": "serper"}, "invalid", {}, [], {"title": "Second"}]} + assert _search(invoke_search, payload) == [ + {"title": "文档", "url": "https://other.example/one", "snippet": "概述", "source_engine": "serper"}, + {"title": "", "url": "", "snippet": "", "source_engine": ""}, + {"title": "Second", "url": "", "snippet": "", "source_engine": ""}, + ] + assert _error_logs(caplog) == [] + + +@pytest.mark.parametrize("payload", [{}, {"results": None}, {"request_id": "r1"}, {"results": []}]) +def test_missing_or_empty_results_keep_no_results_response(invoke_search, payload, caplog): + assert _search(invoke_search, payload) == {"error": "No results found", "query": "文档 search"} + assert _error_logs(caplog) == [] + + +def test_valid_results_keep_existing_normalization(invoke_search, caplog): + payload = {"results": [{"title": "T", "url": "https://other.example", "snippet": "s", "source_engine": "exa"}, {}]} + assert _search(invoke_search, payload) == [ + {"title": "T", "url": "https://other.example", "snippet": "s", "source_engine": "exa"}, + {"title": "", "url": "", "snippet": "", "source_engine": ""}, + ] + assert _error_logs(caplog) == [] + + +@pytest.mark.parametrize("payload", [None, False, [], "provider-private-payload", {"results": {"0": "provider-private-payload"}}, {"results": [None]}]) +def test_fetch_malformed_payload_returns_format_error(invoke_fetch, payload, caplog): + assert invoke_fetch(payload) == "Error: GroundRoute returned an unexpected response format" + assert len(_error_logs(caplog)) == 1 + _assert_no_payload_values_leaked(caplog) + + +def test_fetch_missing_or_empty_results_keep_no_results_error(invoke_fetch, caplog): + assert invoke_fetch({"results": []}) == "Error: No results found" + assert invoke_fetch({}) == "Error: No results found" + assert _error_logs(caplog) == [] + + +def test_fetch_skips_malformed_entries_and_returns_the_first_usable_result(invoke_fetch): + assert invoke_fetch({"results": [None, {"title": "Page", "content": "Body text", "url": "https://other.example"}]}) == "# Page\n\nBody text" + + +def test_valid_fetch_keeps_existing_response(invoke_fetch, caplog): + assert invoke_fetch({"results": [{"title": "Page", "content": "Body text"}]}) == "# Page\n\nBody text" + assert _error_logs(caplog) == [] + + +@pytest.mark.parametrize( + ("payload", "expected"), + [ + ({"results": [{"title": "Page", "content": 12345}]}, "# Page\n\n12345"), + ({"results": [{"title": "Page", "content": True}]}, "# Page\n\nTrue"), + ({"results": [{"title": "Page", "content": {"a": 1}}]}, "# Page\n\n{'a': 1}"), + ({"results": [{"title": "Page", "snippet": 6789}]}, "# Page\n\n6789"), + ({"results": [{"title": None, "content": "Body text"}]}, "# \n\nBody text"), + ], +) +def test_fetch_coerces_non_string_fields(invoke_fetch, payload, expected, caplog): + assert invoke_fetch(payload) == expected + assert _error_logs(caplog) == [] + + +def test_http_transport_patch_does_not_replace_global_client(invoke_search): + real_client = httpx.Client + invoke_search({"results": []}) + assert httpx.Client is real_client + with httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(204))) as client: + assert client.get("https://unrelated.invalid/probe").status_code == 204 + + +@pytest.mark.parametrize("status_code", [402, 503]) +def test_search_http_error_keeps_existing_structured_response(invoke_search, status_code): + assert _search(invoke_search, {"error": "synthetic-denial"}, status_code=status_code) == {"error": f"GroundRoute API error: HTTP {status_code}", "query": "文档 search"} + + +def test_fetch_http_error_keeps_existing_error_string(invoke_fetch): + assert invoke_fetch({"error": "synthetic-denial"}, status_code=402) == "Error: GroundRoute API error: HTTP 402"