From cb1d782fb32ccbfdefa89943f86d8fdeeb550733 Mon Sep 17 00:00:00 2001 From: jay <1569941651@qq.com> Date: Tue, 20 Oct 2026 23:17:51 +0800 Subject: [PATCH] fix(community): reject booleans in the knowledge_search provider settings (#7042) --- .../deerflow/community/lightrag/tools.py | 21 +++++++- .../deerflow/community/ragflow/tools.py | 15 +++++- backend/tests/test_lightrag_tools.py | 33 ++++++++++++ backend/tests/test_ragflow_tools.py | 54 +++++++++++++++++++ 4 files changed, 121 insertions(+), 2 deletions(-) diff --git a/backend/packages/harness/deerflow/community/lightrag/tools.py b/backend/packages/harness/deerflow/community/lightrag/tools.py index 7e4d82f63e..cf1788e52a 100644 --- a/backend/packages/harness/deerflow/community/lightrag/tools.py +++ b/backend/packages/harness/deerflow/community/lightrag/tools.py @@ -7,10 +7,11 @@ from collections.abc import Mapping from typing import Literal from langchain_core.tools import StructuredTool -from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr, ValidationError, field_validator +from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr, ValidationError, ValidationInfo, field_validator from deerflow.community.provider_credentials import redact_secret, reject_url_userinfo, resolve_api_key from deerflow.config import get_app_config +from deerflow.config._boolean_guards import reject_boolean from deerflow.knowledge import format_result from deerflow.knowledge.providers import validate_scope_capabilities from deerflow.knowledge.sources import redact_strings, source_artifact @@ -49,6 +50,24 @@ class _LightRAGRetrievalSettings(BaseModel): def _reject_url_userinfo(cls, value: AnyHttpUrl) -> AnyHttpUrl: return reject_url_userinfo(value) + @field_validator("top_k", "max_chars_per_chunk", "max_total_chars", mode="before") + @classmethod + def _reject_boolean_integers(cls, value: object, info: ValidationInfo) -> object: + """Reject booleans before Pydantic coerces them to integers.""" + return reject_boolean(value, info, kind="an integer") + + @field_validator("chunk_top_k", mode="before") + @classmethod + def _reject_boolean_optional_integer(cls, value: object, info: ValidationInfo) -> object: + """Reject booleans before Pydantic coerces them to an optional integer.""" + return reject_boolean(value, info, kind="an integer or null") + + @field_validator("timeout", mode="before") + @classmethod + def _reject_boolean_timeout(cls, value: object, info: ValidationInfo) -> object: + """Reject booleans before Pydantic coerces them to a number.""" + return reject_boolean(value, info, kind="a number") + def _api_key(settings: _LightRAGRetrievalSettings) -> str | None: # LightRAG may run without authentication, so a missing key stays valid; diff --git a/backend/packages/harness/deerflow/community/ragflow/tools.py b/backend/packages/harness/deerflow/community/ragflow/tools.py index 5aa6784059..09b6e2ec98 100644 --- a/backend/packages/harness/deerflow/community/ragflow/tools.py +++ b/backend/packages/harness/deerflow/community/ragflow/tools.py @@ -10,10 +10,11 @@ from dataclasses import dataclass from typing import Any, cast from langchain_core.tools import StructuredTool -from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr, ValidationError, field_validator +from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr, ValidationError, ValidationInfo, field_validator from deerflow.community.provider_credentials import redact_secret, reject_url_userinfo, resolve_api_key from deerflow.config import get_app_config +from deerflow.config._boolean_guards import reject_boolean from deerflow.knowledge import format_result from deerflow.knowledge.sources import redact_strings from deerflow.knowledge_scope import ( @@ -89,6 +90,18 @@ class _RAGFlowRetrievalSettings(BaseModel): def _reject_url_userinfo(cls, value: AnyHttpUrl) -> AnyHttpUrl: return reject_url_userinfo(value) + @field_validator("page_size", "top_k", "max_chars_per_chunk", "max_total_chars", mode="before") + @classmethod + def _reject_boolean_integers(cls, value: object, info: ValidationInfo) -> object: + """Reject booleans before Pydantic coerces them to integers.""" + return reject_boolean(value, info, kind="an integer") + + @field_validator("timeout", "similarity_threshold", "vector_similarity_weight", mode="before") + @classmethod + def _reject_boolean_numbers(cls, value: object, info: ValidationInfo) -> object: + """Reject booleans before Pydantic coerces them to numbers.""" + return reject_boolean(value, info, kind="a number") + def _api_key(settings: _RAGFlowRetrievalSettings) -> str | None: return resolve_api_key(settings.api_key) diff --git a/backend/tests/test_lightrag_tools.py b/backend/tests/test_lightrag_tools.py index 755d6c5b8b..58368feca1 100644 --- a/backend/tests/test_lightrag_tools.py +++ b/backend/tests/test_lightrag_tools.py @@ -5,6 +5,7 @@ from typing import Any from unittest.mock import AsyncMock import pytest +from pydantic import ValidationError import deerflow.community.lightrag.tools as lightrag_tools from deerflow.community.lightrag.client import LightRAGAPIError, LightRAGConnectionError @@ -446,3 +447,35 @@ async def test_runtime_unfiltered_scope_preserves_lightrag_search(monkeypatch, s assert content == "evidence" assert artifact is not None search.assert_awaited_once_with("q", with_sources=True) + + +LIGHTRAG_BOOLEAN_FIELDS = ( + ("timeout", "a number"), + ("top_k", "an integer"), + ("chunk_top_k", "an integer or null"), + ("max_chars_per_chunk", "an integer"), + ("max_total_chars", "an integer"), +) + + +@pytest.mark.parametrize("value", [True, False]) +@pytest.mark.parametrize(("field", "kind"), LIGHTRAG_BOOLEAN_FIELDS) +def test_lightrag_settings_reject_booleans(field: str, kind: str, value: bool) -> None: + with pytest.raises(ValidationError, match=f"{field} must be {kind}, not a boolean"): + lightrag_tools._LightRAGRetrievalSettings.model_validate({field: value}) + + +def test_lightrag_settings_keep_numeric_values() -> None: + settings = lightrag_tools._LightRAGRetrievalSettings.model_validate({"timeout": 45, "top_k": 10, "chunk_top_k": 5, "max_chars_per_chunk": 400, "max_total_chars": 2000}) + assert (settings.timeout, settings.top_k, settings.chunk_top_k) == (45.0, 10, 5) + assert (settings.max_chars_per_chunk, settings.max_total_chars) == (400, 2000) + + +def test_lightrag_boolean_setting_reports_invalid_configuration(monkeypatch: pytest.MonkeyPatch) -> None: + """A boolean in config.yaml must not silently become one second of timeout.""" + monkeypatch.setattr(lightrag_tools, "get_app_config", lambda: _config(extra={"timeout": True})) + + settings, error = lightrag_tools._settings_or_error() + + assert settings is None + assert error == "Error: Invalid LightRAG settings for knowledge_search; check config.yaml." diff --git a/backend/tests/test_ragflow_tools.py b/backend/tests/test_ragflow_tools.py index 94ffe3d0fd..688d06a6c6 100644 --- a/backend/tests/test_ragflow_tools.py +++ b/backend/tests/test_ragflow_tools.py @@ -5,6 +5,7 @@ from pathlib import Path from types import SimpleNamespace import pytest +from pydantic import ValidationError import deerflow.community.ragflow.tools as ragflow_tools from deerflow.community.ragflow.client import RAGFlowAPIError, RAGFlowConnectionError @@ -1265,3 +1266,56 @@ async def test_large_document_scope_batches_share_global_concurrency_limit() -> assert error is None assert result == {item["dataset_id"]: item["document_ids"] for item in filters} assert peak == 4 + + +RAGFLOW_BOOLEAN_FIELDS = ( + ("timeout", "a number"), + ("page_size", "an integer"), + ("similarity_threshold", "a number"), + ("vector_similarity_weight", "a number"), + ("top_k", "an integer"), + ("max_chars_per_chunk", "an integer"), + ("max_total_chars", "an integer"), +) + + +@pytest.mark.parametrize("value", [True, False]) +@pytest.mark.parametrize(("field", "kind"), RAGFLOW_BOOLEAN_FIELDS) +def test_ragflow_settings_reject_booleans(field: str, kind: str, value: bool) -> None: + with pytest.raises(ValidationError, match=f"{field} must be {kind}, not a boolean"): + ragflow_tools._RAGFlowRetrievalSettings.model_validate({field: value}) + + +def test_ragflow_settings_keep_numeric_values() -> None: + settings = ragflow_tools._RAGFlowRetrievalSettings.model_validate( + { + "timeout": 45, + "page_size": 20, + "similarity_threshold": 0.5, + "vector_similarity_weight": 0.6, + "top_k": 64, + "max_chars_per_chunk": 400, + "max_total_chars": 2000, + } + ) + assert (settings.timeout, settings.page_size) == (45.0, 20) + assert (settings.similarity_threshold, settings.vector_similarity_weight) == (0.5, 0.6) + assert (settings.top_k, settings.max_chars_per_chunk, settings.max_total_chars) == (64, 400, 2000) + + +def test_ragflow_boolean_setting_reports_invalid_configuration() -> None: + """A boolean in config.yaml must not silently become a single result at a one-second timeout.""" + entry = ToolConfig( + name="knowledge_search", + group="knowledge", + use="deerflow.community.ragflow.tools:knowledge_search_tool", + base_url="http://ragflow.test", + api_key="ragflow-secret", + top_k=True, + ) + app_config = SimpleNamespace(get_tool_config=lambda name: entry if name == "knowledge_search" else None) + + settings, error = ragflow_tools._settings_or_error(app_config) + + assert settings is None + assert error == "Error: Invalid RAGFlow settings for knowledge_search; check config.yaml."