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

201 lines
8.0 KiB
Python

"""Read rows from maintained Jsonify datasets.
Jsonify pipelines run on a schedule, repair themselves when a source changes,
and publish the result as a dataset. Each dataset has a serve URL that returns
its latest rows as JSON with paging, column selection, exact-match filters and
version pinning. The URL is the credential: it is configured by the operator,
never passed by the model, and scrubbed from any error text.
Configure one entry per deployment with a named map of datasets::
- name: jsonify_dataset_rows
group: data
use: deerflow.community.jsonify.tools:dataset_rows_tool
datasets:
llm_api_pricing: $JSONIFY_LLM_PRICING_URL
max_rows: 100
timeout: 30
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any
import httpx
from langchain_core.tools import StructuredTool
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, ValidationError, field_validator
from deerflow.community.provider_credentials import redact_secret, reject_url_userinfo
from deerflow.community.url_safety import validate_public_http_url
from deerflow.config import get_app_config
logger = logging.getLogger(__name__)
_TOOL_NAME = "jsonify_dataset_rows"
_PAGE_SIZE = 1000
_MAX_FILTERS = 10
_SYSTEM_COLUMNS = ("_parent_row_id", "_source_key")
class _JsonifySettings(BaseModel):
"""Validated provider settings stored on the tool entry."""
model_config = ConfigDict(validate_default=True)
datasets: dict[str, AnyHttpUrl] = Field(min_length=1, max_length=50)
max_rows: int = Field(default=100, ge=1, le=10_000)
timeout: float = Field(default=30, gt=0, le=600)
@field_validator("datasets")
@classmethod
def _check_urls(cls, value: dict[str, AnyHttpUrl]) -> dict[str, AnyHttpUrl]:
for name, url in value.items():
if not name.strip():
raise ValueError("dataset names must not be blank")
reject_url_userinfo(url)
if url.scheme != "https":
raise ValueError(f"dataset '{name}': serve URL must use https")
return value
class _RowsArgs(BaseModel):
dataset: str = Field(description="Name of the configured dataset to read.")
filters: dict[str, str] | None = Field(
default=None,
description="Exact, case-sensitive column matches combined with AND, for example {'provider': 'Anthropic'}. Up to 10.",
)
columns: list[str] | None = Field(default=None, description="Column names to return. Omit for all columns.")
limit: int = Field(default=50, ge=1, description="Maximum number of rows to return.")
def _load_settings() -> _JsonifySettings | str:
config = get_app_config().get_tool_config(_TOOL_NAME)
extra = dict(config.model_extra or {}) if config is not None else {}
try:
return _JsonifySettings(**extra)
except ValidationError as exc:
# Log only where and why validation failed; the input values are serve URLs, which are credentials.
problems = "; ".join(f"{'.'.join(str(part) for part in err['loc']) or '<root>'}: {err['type']}" for err in exc.errors())
logger.warning("Invalid %s configuration: %s", _TOOL_NAME, problems)
return f"Error: {_TOOL_NAME} is not configured. Add a 'datasets' map of name to serve URL in config.yaml."
def _build_params(
*,
limit: int,
offset: int,
columns: list[str] | None,
filters: dict[str, str] | None,
version: int | None,
) -> dict[str, Any]:
params: dict[str, Any] = {"limit": limit, "offset": offset}
if columns:
params["columns"] = ",".join(c.strip() for c in columns if c.strip())
if version is not None:
params["version"] = version
for column, value in (filters or {}).items():
params[f"filter[{column}]"] = value
return params
async def _read_rows(settings: _JsonifySettings, args: _RowsArgs) -> dict[str, Any]:
serve_url = str(settings.datasets[args.dataset])
url_error = await asyncio.to_thread(validate_public_http_url, serve_url, action="read")
if url_error:
raise ValueError(url_error)
max_rows = min(args.limit, settings.max_rows)
rows: list[dict[str, Any]] = []
meta: dict[str, Any] = {}
offset = 0
version: int | None = None
truncated = False
async with httpx.AsyncClient(timeout=settings.timeout) as client:
while True:
limit = min(_PAGE_SIZE, max_rows - len(rows))
response = await client.get(
serve_url,
params=_build_params(limit=limit, offset=offset, columns=args.columns, filters=args.filters, version=version),
)
response.raise_for_status()
page = _parse_page(response)
if not meta:
meta = {
"dataset": args.dataset,
"dataset_name": page.get("dataset_name"),
"table": page.get("table"),
"version": page.get("version"),
"updated_at": page.get("updated_at"),
}
if version is None:
version = page.get("version")
page_rows = [{k: v for k, v in row.items() if k not in _SYSTEM_COLUMNS} for row in page["rows"]]
rows.extend(page_rows)
has_more = bool(page.get("has_more"))
next_offset = page.get("next_offset")
if len(rows) >= max_rows:
truncated = has_more or len(rows) > max_rows
break
# Stop on an empty page or a cursor that does not advance, so a misbehaving endpoint cannot spin the loop.
if not has_more or not page_rows or not isinstance(next_offset, int) or next_offset <= offset:
break
offset = next_offset
del rows[max_rows:]
return {**meta, "row_count": len(rows), "truncated": truncated, "rows": rows}
def _parse_page(response: httpx.Response) -> dict[str, Any]:
"""Decode one serve response and check it has the expected shape."""
try:
page = response.json()
except ValueError as exc:
raise ValueError("Jsonify returned a response that is not JSON") from exc
rows = page.get("rows") if isinstance(page, dict) else None
if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows):
raise ValueError("Jsonify returned an unexpected response shape")
return page
async def _dataset_rows(
dataset: str,
filters: dict[str, str] | None = None,
columns: list[str] | None = None,
limit: int = 50,
) -> str:
settings = await asyncio.to_thread(_load_settings)
if isinstance(settings, str):
return settings
if dataset not in settings.datasets:
available = ", ".join(sorted(settings.datasets))
return f"Error: Unknown dataset '{dataset}'. Available datasets: {available}"
if filters and len(filters) > _MAX_FILTERS:
return f"Error: At most {_MAX_FILTERS} filters are supported"
serve_url = str(settings.datasets[dataset])
try:
args = _RowsArgs(dataset=dataset, filters=filters, columns=columns, limit=limit)
result = await _read_rows(settings, args)
except (ValidationError, ValueError, TypeError, AttributeError) as exc:
return redact_secret(f"Error: {exc}", serve_url)
except httpx.HTTPStatusError as exc:
return redact_secret(f"Error: Jsonify returned HTTP {exc.response.status_code}", serve_url)
except httpx.HTTPError as exc:
return f"Error: Could not reach Jsonify: {type(exc).__name__}"
return json.dumps(result, ensure_ascii=False)
dataset_rows_tool = StructuredTool.from_function(
coroutine=_dataset_rows,
name=_TOOL_NAME,
description=(
"Read rows from a maintained Jsonify dataset. Rows are the latest published output of a scheduled, "
"self-repairing pipeline, so they are current without scraping. Pass the dataset name, optional exact-match "
"filters, optional columns and a row limit. Returns JSON with dataset metadata, version, updated_at, rows and a truncated flag that is true when more rows matched than were returned."
),
args_schema=_RowsArgs,
)