Hyeonsang Cho c46906aa41
fix(community): resolve async tool config off the event loop (#7580)
* fix(community): resolve async tool config off the event loop

Agent runs execute on the Gateway event loop, and the async community
tools resolved their tool config through get_app_config() inline. That
call stats and hashes config.yaml and extensions_config.json on every
call and reparses them after an edit, so each tool call held the loop
for it, up to three times per call: Tavily, SearXNG and Firecrawl read
the config again to build their client, InfoQuest read it three times,
and the browser tools read the launch config for every action.

Resolve it through asyncio.to_thread once per call, as image_search and
groundroute already do, and build the client from that same read. This
covers jina_ai, crawl4ai, browserless, tavily, searxng, firecrawl,
infoquest, ragflow, lightrag and browser_automation.

* docs(changelog): reference #7580 for the off-loop community tool config reads

* docs(changelog): use the existing community-tools label in the zh entry for #7580

* fix(community): resolve the jsonify dataset_rows config off the event loop

dataset_rows_tool is async and called _load_settings() inline, so every
call ran get_app_config()'s stat + sha256 revalidation on the loop.
Read it through asyncio.to_thread like the other async community tools,
and add the tool to the off-loop anchors.

* docs(changelog): include Jsonify in #7580 and drop trailing whitespace

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-26 23:40:32 +08:00

200 lines
9.0 KiB
Python

"""Read-only Agent tool for operator-scoped LightRAG knowledge retrieval."""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Mapping
from typing import Literal
from langchain_core.tools import StructuredTool
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
from deerflow.knowledge_scope import KNOWLEDGE_SCOPE_RUNTIME_KEY
from deerflow.tools.types import Runtime
from .client import LightRAGAPIError, LightRAGClient, LightRAGConnectionError, LightRAGProtocolError
from .formatting import CAPABILITIES, format_retrieval_result, normalize_retrieval_result
logger = logging.getLogger(__name__)
_NO_RELEVANT_CONTENT = "No relevant content found."
class _LightRAGRetrievalSettings(BaseModel):
"""Validated provider settings stored on the knowledge_search tool entry."""
model_config = ConfigDict(validate_default=True)
base_url: AnyHttpUrl = Field(default="http://localhost:9621")
api_key: SecretStr | None = Field(default=None)
# LightRAG's QueryRequest also accepts "bypass", which skips the index and
# answers straight from the LLM; that would defeat a retrieval tool, so it
# is excluded on purpose. "mix" matches the LightRAG API's own default.
mode: Literal["naive", "local", "global", "hybrid", "mix"] = Field(default="mix")
timeout: float = Field(default=30, gt=0, le=600)
# The server caps both fields at MAX_QUERY_TOP_K = 1000; match that limit
# instead of inventing a tighter client-side one.
top_k: int = Field(default=60, ge=1, le=1000)
chunk_top_k: int | None = Field(default=None, ge=1, le=1000)
max_chars_per_chunk: int = Field(default=800, ge=1, le=100_000)
max_total_chars: int = Field(default=8000, ge=1, le=1_000_000)
@field_validator("base_url")
@classmethod
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;
# blank values are treated as unconfigured rather than rejected.
return resolve_api_key(settings.api_key)
def _settings_from_extra(extra: Mapping[str, object]) -> _LightRAGRetrievalSettings:
return _LightRAGRetrievalSettings.model_validate(dict(extra))
def _settings_or_error() -> tuple[_LightRAGRetrievalSettings | None, str | None]:
tool_config = get_app_config().get_tool_config("knowledge_search")
if tool_config is None:
return None, "Error: knowledge_search is not configured; add its LightRAG settings to the tools list in config.yaml."
try:
settings = _settings_from_extra(tool_config.model_extra or {})
except ValidationError:
logger.warning("LightRAG knowledge_search tool configuration is invalid")
return None, "Error: Invalid LightRAG settings for knowledge_search; check config.yaml."
return settings, None
def _build_client(settings: _LightRAGRetrievalSettings) -> LightRAGClient:
return LightRAGClient(
base_url=str(settings.base_url).rstrip("/"),
api_key=_api_key(settings),
timeout=settings.timeout,
)
def _tool_error(exc: Exception, settings: _LightRAGRetrievalSettings) -> str:
key = _api_key(settings)
safe_detail = redact_secret(exc, key)
base_url = redact_secret(str(settings.base_url).rstrip("/"), key)
if isinstance(exc, LightRAGAPIError):
logger.warning("LightRAG API rejected a read-only tool request: %s", safe_detail)
return f"Error: {safe_detail}"
if isinstance(exc, LightRAGConnectionError):
logger.warning("LightRAG connection failed for %s (%s)", base_url, type(exc).__name__)
return f"Error: Unable to connect to LightRAG ({base_url}): {safe_detail}"
if isinstance(exc, LightRAGProtocolError):
logger.warning("LightRAG returned an invalid response for a read-only tool request (%s)", type(exc).__name__)
return f"Error: LightRAG request failed: {safe_detail}"
logger.warning("Unexpected LightRAG read-only tool failure (%s)", type(exc).__name__)
return "Error: An unexpected LightRAG retrieval error occurred; try again later."
async def _search(query: str, *, with_sources: bool = False) -> str | tuple[str, dict]:
"""Search the operator-configured LightRAG instance.
LightRAG has no dataset catalog to scope: the deployment's single indexed
workspace is always searched with the configured retrieval mode, so no
binding resolution happens before the one read-only request.
"""
query = query.strip()
if not query:
return "Error: query must not be empty."
# Config resolution stats and hashes the config files; keep it off the event loop.
settings, error = await asyncio.to_thread(_settings_or_error)
if settings is None:
return error or "Error: Invalid LightRAG settings for knowledge_search; check config.yaml."
client = _build_client(settings)
try:
result = await client.query_data(
query,
mode=settings.mode,
top_k=settings.top_k,
chunk_top_k=settings.chunk_top_k,
)
if with_sources:
normalized = normalize_retrieval_result(redact_strings(result, _api_key(settings)))
return format_result(normalized, max_chars_per_chunk=settings.max_chars_per_chunk, max_total_chars=settings.max_total_chars)
formatted = format_retrieval_result(
result,
max_chars_per_chunk=settings.max_chars_per_chunk,
max_total_chars=settings.max_total_chars,
)
# API-key redaction remains mandatory on the success path; chunk and
# reference identifiers never enter the formatted text at all.
return redact_secret(formatted, _api_key(settings))
except Exception as exc:
return _tool_error(exc, settings)
async def knowledge_search(query: str) -> str:
"""Legacy text-only API; graph tool calls additionally carry source artifacts."""
result = await _search(query)
assert isinstance(result, str)
return result
def _tool_description() -> str:
base = "Search the operator-approved LightRAG knowledge base and return compact, citation-numbered source chunks retrieved with the configured graph/vector mode."
base += " Cite only returned evidence using the exact [citation:Title](#knowledge-source-ID) links supplied with each chunk; never invent source locators."
return f"{base} Internal identifiers are never shown to the model."
async def _knowledge_search_entrypoint(query: str, runtime: Runtime = None) -> tuple[str, dict | None]:
"""Search the operator-configured LightRAG knowledge base.
Args:
query: Specific question or search terms to retrieve from the configured private documents.
"""
context = getattr(runtime, "context", None)
scope = context.get(KNOWLEDGE_SCOPE_RUNTIME_KEY) if isinstance(context, Mapping) else None
try:
validated = validate_scope_capabilities(scope, CAPABILITIES) if scope is not None else None
if validated is not None and validated["mode"] == "disabled":
return "Error: Knowledge retrieval is disabled for this turn.", None
except ValueError:
return "Error: LightRAG does not support the selected knowledge scope.", None
result = await _search(query, with_sources=True)
return result if isinstance(result, tuple) else (result, source_artifact([]))
knowledge_search_tool = StructuredTool.from_function(
coroutine=_knowledge_search_entrypoint,
name="knowledge_search",
description=_tool_description(),
parse_docstring=True,
response_format="content_and_artifact",
metadata={"knowledge_capabilities": CAPABILITIES.model_dump(mode="json")},
)