From 33e581a8b3ecb0ac6ed83ecc9f6d500963ddfa75 Mon Sep 17 00:00:00 2001 From: EthanZhang Date: Thu, 30 Jul 2026 09:36:16 +0800 Subject: [PATCH] feat(agent): add Querit search tool (#17548) --- agent/tools/querit.py | 298 +++++++++ internal/agent/component/tool_component.go | 22 +- .../agent/component/tool_component_test.go | 69 +- internal/agent/tool/querit.go | 605 ++++++++++++++++++ internal/agent/tool/querit_test.go | 594 +++++++++++++++++ internal/agent/tool/registry.go | 58 ++ internal/agent/tool/registry_test.go | 35 + internal/agent/tool/tool2component.go | 3 + internal/common/environments.go | 1 + .../unit_test/agent/tools/test_querit_unit.py | 560 ++++++++++++++++ tools/gen-component-parity/main.go | 2 +- web/src/assets/querit.png | Bin 0 -> 49633 bytes web/src/constants/agent.tsx | 1 + web/src/locales/en.ts | 24 + web/src/locales/zh.ts | 21 + .../node/dropdown/accordion-operators.tsx | 1 + web/src/pages/agent/constant/index.tsx | 24 + .../agent/form-sheet/form-config-map.tsx | 4 + .../agent-form/tool-popover/tool-command.tsx | 1 + .../agent/form/querit-form/dynamic-list.tsx | 73 +++ .../pages/agent/form/querit-form/index.tsx | 136 ++++ .../agent/form/querit-form/use-values.ts | 14 + .../querit-form/use-watch-change.test.tsx | 114 ++++ .../form/querit-form/use-watch-change.ts | 25 + .../agent/form/querit-form/utils.test.ts | 224 +++++++ web/src/pages/agent/form/querit-form/utils.ts | 72 +++ .../pages/agent/form/tool-form/constant.tsx | 2 + .../form/tool-form/querit-form/index.tsx | 41 ++ .../querit-form/use-watch-change.test.tsx | 96 +++ .../tool-form/querit-form/use-watch-change.ts | 40 ++ .../form/tool-form/querit-form/utils.test.ts | 66 ++ .../agent/form/tool-form/querit-form/utils.ts | 22 + .../agent/hooks/clear-sensitive-fields.ts | 53 ++ .../hooks/querit-agent-initial-values.ts | 7 + web/src/pages/agent/hooks/use-add-node.ts | 2 + .../use-agent-tool-initial-values.test.ts | 32 + .../hooks/use-agent-tool-initial-values.ts | 3 + .../pages/agent/hooks/use-export-json.test.ts | 126 ++++ web/src/pages/agent/hooks/use-export-json.ts | 23 +- .../pages/agent/log-sheet/tool-name.test.ts | 24 + web/src/pages/agent/log-sheet/tool-name.ts | 17 + .../agent/log-sheet/tool-timeline-item.tsx | 4 +- .../agent/log-sheet/workflow-timeline.tsx | 1 + web/src/pages/agent/operator-icon.tsx | 12 + .../pages/agent/webhook-sheet/timeline.tsx | 1 + 45 files changed, 3527 insertions(+), 26 deletions(-) create mode 100644 agent/tools/querit.py create mode 100644 internal/agent/tool/querit.go create mode 100644 internal/agent/tool/querit_test.go create mode 100644 test/unit_test/agent/tools/test_querit_unit.py create mode 100644 web/src/assets/querit.png create mode 100644 web/src/pages/agent/form/querit-form/dynamic-list.tsx create mode 100644 web/src/pages/agent/form/querit-form/index.tsx create mode 100644 web/src/pages/agent/form/querit-form/use-values.ts create mode 100644 web/src/pages/agent/form/querit-form/use-watch-change.test.tsx create mode 100644 web/src/pages/agent/form/querit-form/use-watch-change.ts create mode 100644 web/src/pages/agent/form/querit-form/utils.test.ts create mode 100644 web/src/pages/agent/form/querit-form/utils.ts create mode 100644 web/src/pages/agent/form/tool-form/querit-form/index.tsx create mode 100644 web/src/pages/agent/form/tool-form/querit-form/use-watch-change.test.tsx create mode 100644 web/src/pages/agent/form/tool-form/querit-form/use-watch-change.ts create mode 100644 web/src/pages/agent/form/tool-form/querit-form/utils.test.ts create mode 100644 web/src/pages/agent/form/tool-form/querit-form/utils.ts create mode 100644 web/src/pages/agent/hooks/clear-sensitive-fields.ts create mode 100644 web/src/pages/agent/hooks/querit-agent-initial-values.ts create mode 100644 web/src/pages/agent/hooks/use-agent-tool-initial-values.test.ts create mode 100644 web/src/pages/agent/hooks/use-export-json.test.ts create mode 100644 web/src/pages/agent/log-sheet/tool-name.test.ts create mode 100644 web/src/pages/agent/log-sheet/tool-name.ts diff --git a/agent/tools/querit.py b/agent/tools/querit.py new file mode 100644 index 0000000000..6725342a94 --- /dev/null +++ b/agent/tools/querit.py @@ -0,0 +1,298 @@ +# +# Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import logging +import os +import re +import time +from abc import ABC +from typing import Any + +import requests + +from agent.tools.base import ToolBase, ToolMeta, ToolParamBase +from common.connection_utils import timeout +from common.http_client import DEFAULT_TIMEOUT + +QUERIT_SEARCH_URL = "https://api.querit.ai/v1/search" +QUERIT_MAX_ATTEMPTS = 3 +QUERIT_RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504} +TIME_RANGE_PATTERN = re.compile(r"^([dwmy][1-9][0-9]*|\d{4}-\d{2}-\d{2}to\d{4}-\d{2}-\d{2})$") +logger = logging.getLogger(__name__) + + +class _QueritCanceled(Exception): + pass + + +class QueritSearchParam(ToolParamBase): + def __init__(self): + self.meta: ToolMeta = { + "name": "querit_search", + "description": "Search the live web with Querit and return the complete Querit API response.", + "parameters": { + "query": { + "type": "string", + "description": "The search query to execute with Querit.", + "default": "{sys.query}", + "required": True, + }, + "count": { + "type": "integer", + "description": "The maximum number of results to return. Defaults to 10.", + "default": 10, + "required": False, + }, + "chunks_per_doc": { + "type": "integer", + "description": "The number of summary chunks per document. Supports values from 1 to 3.", + "default": 3, + "required": False, + }, + "site_include": { + "type": "array", + "description": "Sites that search results must include.", + "default": [], + "items": {"type": "string"}, + "required": False, + }, + "site_exclude": { + "type": "array", + "description": "Sites that search results must exclude.", + "default": [], + "items": {"type": "string"}, + "required": False, + }, + "time_range": { + "type": "string", + "description": "A Querit time range such as d7, w1, m3, y1, or YYYY-MM-DDtoYYYY-MM-DD.", + "default": "", + "required": False, + }, + "country_include": { + "type": "array", + "description": "Return results associated with the specified countries.", + "default": [], + "items": {"type": "string"}, + "required": False, + }, + "language_include": { + "type": "array", + "description": "Languages that search results must include.", + "default": [], + "items": {"type": "string"}, + "required": False, + }, + }, + } + super().__init__() + self.api_key = "" + + def check(self): + _validate_search_inputs( + count=self.count, + chunks_per_doc=self.chunks_per_doc, + time_range=self.time_range, + site_include=self.site_include, + site_exclude=self.site_exclude, + country_include=self.country_include, + language_include=self.language_include, + ) + + def get_input_form(self) -> dict[str, dict]: + return { + "query": {"name": "Query", "type": "line"}, + "count": {"name": "Count", "type": "line"}, + "chunks_per_doc": {"name": "Chunks per document", "type": "line"}, + "site_include": {"name": "Include sites", "type": "line"}, + "site_exclude": {"name": "Exclude sites", "type": "line"}, + "time_range": {"name": "Time range", "type": "line"}, + "country_include": {"name": "Include countries", "type": "line"}, + "language_include": {"name": "Include languages", "type": "line"}, + } + + +class QueritSearch(ToolBase, ABC): + component_name = "QueritSearch" + + @timeout(int(os.environ.get("COMPONENT_EXEC_TIMEOUT", "12"))) + def _invoke(self, **kwargs): + if self.check_if_canceled("QueritSearch processing"): + return + + query = kwargs.get("query") + if not isinstance(query, str): + return self._fail("Querit query must be a string.") + if not query: + self.set_output("formalized_content", "") + self.set_output("json", {}) + return "" + + node_api_key = (self._param.api_key or "").strip() + api_key = node_api_key or (os.environ.get("QUERIT_API_KEY") or "").strip() + if not api_key: + return self._fail("Querit API key is required. Configure api_key or set QUERIT_API_KEY.") + + values = { + name: kwargs[name] if name in kwargs else getattr(self._param, name) + for name in ( + "count", + "chunks_per_doc", + "site_include", + "site_exclude", + "time_range", + "country_include", + "language_include", + ) + } + + try: + _validate_search_inputs(**values) + payload = _build_payload(query, **values) + response_data = self._search(payload, api_key) + if not isinstance(response_data, dict): + raise TypeError("Querit API response must be a JSON object.") + + result_container = response_data.get("results", {}) + if not isinstance(result_container, dict): + raise TypeError("Querit API response field results must be an object.") + results = result_container.get("result", []) + if not isinstance(results, list): + raise TypeError("Querit API response field results.result must be an array.") + + reference_results = [item for item in results if isinstance(item, dict)] + if reference_results: + self._retrieve_chunks( + reference_results, + get_title=lambda item: _querit_text(item.get("title")), + get_url=lambda item: _querit_text(item.get("url")), + get_content=lambda item: _querit_text(item.get("snippet")), + get_score=lambda _item: 1, + ) + else: + self.set_output("formalized_content", "") + self.set_output("json", response_data) + return self.output("formalized_content") + except _QueritCanceled: + return + except (requests.RequestException, RuntimeError, TypeError, ValueError) as error: + return self._fail(_safe_error_message(error, api_key)) + + def _search(self, payload: dict[str, Any], api_key: str) -> Any: + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + for attempt in range(QUERIT_MAX_ATTEMPTS): + if self.check_if_canceled("QueritSearch processing"): + raise _QueritCanceled + try: + response = requests.post( + QUERIT_SEARCH_URL, + headers=headers, + json=payload, + timeout=DEFAULT_TIMEOUT, + ) + if response.status_code in QUERIT_RETRYABLE_STATUS_CODES and attempt + 1 < QUERIT_MAX_ATTEMPTS: + self._wait_before_retry() + continue + response.raise_for_status() + return response.json() + except requests.JSONDecodeError: + raise + except requests.HTTPError: + raise + except requests.RequestException: + if attempt + 1 >= QUERIT_MAX_ATTEMPTS: + raise + self._wait_before_retry() + raise RuntimeError("Querit request failed after three attempts.") + + def _wait_before_retry(self) -> None: + if self.check_if_canceled("QueritSearch processing"): + raise _QueritCanceled + time.sleep(self._param.delay_after_error) + + def _fail(self, message: str) -> str: + self.set_output("_ERROR", message) + logger.error("Querit search failed: %s", message) + return f"Querit error: {message}" + + def thoughts(self) -> str: + return "Searching Querit for `{}`.".format(self.get_input().get("query", "-_-!")) + + +def _build_payload(query: str, **values: Any) -> dict[str, Any]: + payload: dict[str, Any] = { + "query": query, + "count": values["count"], + } + if values["chunks_per_doc"] is not None: + payload["chunksPerDoc"] = values["chunks_per_doc"] + + filters: dict[str, Any] = {} + if values["site_include"] or values["site_exclude"]: + filters["sites"] = {} + if values["site_include"]: + filters["sites"]["include"] = values["site_include"] + if values["site_exclude"]: + filters["sites"]["exclude"] = values["site_exclude"] + if values["time_range"]: + filters["timeRange"] = {"date": values["time_range"]} + if values["country_include"]: + filters["geo"] = {"countries": {"include": values["country_include"]}} + if values["language_include"]: + filters["languages"] = {"include": values["language_include"]} + if filters: + payload["filters"] = filters + return payload + + +def _validate_search_inputs( + count: Any, + chunks_per_doc: Any, + time_range: Any, + site_include: Any, + site_exclude: Any, + country_include: Any, + language_include: Any, +) -> None: + if type(count) is not int or count < 1: + raise ValueError("Querit count must be an integer greater than or equal to 1.") + if chunks_per_doc is not None and (type(chunks_per_doc) is not int or not 1 <= chunks_per_doc <= 3): + raise ValueError("Querit chunks_per_doc must be an integer from 1 to 3.") + if type(time_range) is not str: + raise ValueError("Querit time_range must be a string.") + if time_range and not TIME_RANGE_PATTERN.fullmatch(time_range): + raise ValueError("Querit time_range must use dN, wN, mN, yN, or YYYY-MM-DDtoYYYY-MM-DD.") + for name, value in ( + ("site_include", site_include), + ("site_exclude", site_exclude), + ("country_include", country_include), + ("language_include", language_include), + ): + if not isinstance(value, list) or any(not isinstance(item, str) for item in value): + raise ValueError(f"Querit {name} must be an array of strings.") + + +def _safe_error_message(error: Exception, api_key: str) -> str: + message = str(error) or error.__class__.__name__ + return message.replace(api_key, "[REDACTED]") if api_key else message + + +def _querit_text(value: Any) -> str: + return "" if value is None else str(value) diff --git a/internal/agent/component/tool_component.go b/internal/agent/component/tool_component.go index f4b5919a36..2eaca204b5 100644 --- a/internal/agent/component/tool_component.go +++ b/internal/agent/component/tool_component.go @@ -20,6 +20,7 @@ import ( "context" "encoding/json" "fmt" + "io" "strings" "ragflow/internal/agent/runtime" @@ -69,7 +70,12 @@ func (c *ToolBackedComponent) Invoke(ctx context.Context, db *gorm.DB, inputs ma } raw, invokeErr := c.tool.InvokableRun(ctx, string(argsJSON)) - decoded := parseToolEnvelope(raw) + var decoded map[string]any + if c.spec.PreserveJSONNumbers { + decoded = parseToolEnvelopeLossless(raw) + } else { + decoded = parseToolEnvelope(raw) + } if rawValue, invalid := decoded["_raw"]; invalid { if invokeErr != nil { return nil, fmt.Errorf("canvas: %s: %w", c.name, invokeErr) @@ -97,6 +103,19 @@ func (c *ToolBackedComponent) Invoke(ctx context.Context, db *gorm.DB, inputs ma return c.tool.BuildComponentOutputs(decoded), nil } +func parseToolEnvelopeLossless(jsonStr string) map[string]any { + var out map[string]any + decoder := json.NewDecoder(strings.NewReader(jsonStr)) + decoder.UseNumber() + if err := decoder.Decode(&out); err != nil { + return map[string]any{"_raw": jsonStr} + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return map[string]any{"_raw": jsonStr} + } + return out +} + func (c *ToolBackedComponent) Stream(_ context.Context, _ *gorm.DB, _ map[string]any) (<-chan map[string]any, error) { return nil, nil } @@ -115,6 +134,7 @@ var toolComponentRegistrations = []struct { {componentName: "GoogleScholar", toolName: "google_scholar"}, {componentName: "KeenableSearch", toolName: "keenable"}, {componentName: "PubMed", toolName: "pubmed"}, + {componentName: "QueritSearch", toolName: "querit_search"}, {componentName: "SearXNG", toolName: "searxng"}, {componentName: "TavilySearch", toolName: "tavily"}, {componentName: "TavilyExtract", toolName: "tavily_extract"}, diff --git a/internal/agent/component/tool_component_test.go b/internal/agent/component/tool_component_test.go index c2b0b9caf4..64decfa181 100644 --- a/internal/agent/component/tool_component_test.go +++ b/internal/agent/component/tool_component_test.go @@ -25,6 +25,7 @@ import ( "net/url" "reflect" "strings" + "sync/atomic" "testing" einotool "github.com/cloudwego/eino/components/tool" @@ -192,6 +193,13 @@ func TestToolBackedComponentRegisteredFactories(t *testing.T) { outputKey: "success", inputKey: "to_email", }, + { + name: "QueritSearch", + toolName: "QueritSearch", + params: map[string]any{"api_key": "stored-key", "count": float64(10), "chunks_per_doc": float64(3), "outputs": map[string]any{"json": map[string]any{}}}, + outputKey: "json", + inputKey: "query", + }, { name: "SearXNG", toolName: "SearXNG", @@ -275,7 +283,7 @@ func TestToolBackedComponentWenCaiInvoke(t *testing.T) { } func TestToolBackedComponentRegisteredBuildWorkflow(t *testing.T) { - for _, componentName := range []string{"ArXiv", "BGPT", "DuckDuckGo", "Email", "Google", "GoogleScholar", "KeenableSearch", "PubMed", "SearXNG", "WenCai", "TavilyExtract", "TavilySearch", "Wikipedia", "YahooFinance"} { + for _, componentName := range []string{"ArXiv", "BGPT", "DuckDuckGo", "Email", "Google", "GoogleScholar", "KeenableSearch", "PubMed", "QueritSearch", "SearXNG", "WenCai", "TavilyExtract", "TavilySearch", "Wikipedia", "YahooFinance"} { t.Run(componentName, func(t *testing.T) { c := &canvas.Canvas{ Components: map[string]canvas.CanvasComponent{ @@ -468,6 +476,65 @@ func TestToolBackedComponentTavilyIntegration(t *testing.T) { } } +func TestToolBackedComponentQueritIntegration(t *testing.T) { + var serverCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + serverCalls.Add(1) + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"results":{"result":[{"title":"RAGFlow","url":"https://ragflow.io","snippet":"RAG article","custom":"preserved"}]},"search_id":11099848653006015581,"query_context":{"rewritten":"rag flow"}}`)) + })) + defer server.Close() + target, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse test server URL: %v", err) + } + helper := agenttool.NewHTTPHelper().WithClient(&http.Client{Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) { + cloned := request.Clone(request.Context()) + cloned.URL.Scheme = target.Scheme + cloned.URL.Host = target.Host + return http.DefaultTransport.RoundTrip(cloned) + })}) + querit := agenttool.NewQueritToolWithEnvKey(helper, func() string { return "key" }) + component := &ToolBackedComponent{name: "QueritSearch", tool: querit, spec: querit.ComponentSpec()} + state := runtime.NewCanvasState("run-querit", "task-querit") + empty, err := component.Invoke(context.Background(), nil, map[string]any{"query": ""}) + if err != nil { + t.Fatalf("Invoke(empty query): %v", err) + } + emptyJSON, emptyJSONOK := empty["json"].(map[string]any) + if serverCalls.Load() != 0 || empty["formalized_content"] != "" || !emptyJSONOK || len(emptyJSON) != 0 { + t.Fatalf("empty query result = %#v, server calls = %d", empty, serverCalls.Load()) + } + out, err := component.Invoke(runtime.WithState(context.Background(), state), nil, map[string]any{"query": "ragflow"}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + rendered, _ := out["formalized_content"].(string) + if !strings.Contains(rendered, "Title: RAGFlow") || !strings.Contains(rendered, "RAG article") { + t.Fatalf("formalized_content = %q", rendered) + } + complete, ok := out["json"].(map[string]any) + searchID, searchIDOK := complete["search_id"].(json.Number) + if !ok || !searchIDOK || searchID.String() != "11099848653006015581" || complete["query_context"] == nil { + t.Fatalf("complete json = %#v", out["json"]) + } + encoded, err := json.Marshal(complete) + if err != nil || !strings.Contains(string(encoded), `"search_id":11099848653006015581`) { + t.Fatalf("re-encoded complete json = %s, %v", encoded, err) + } + chunks := state.GetRetrievalChunks() + if len(chunks) != 1 || chunks[0]["document_name"] != "RAGFlow" || chunks[0]["similarity"] != 1 { + t.Fatalf("recorded references = %#v", chunks) + } +} + +func TestParseToolEnvelopeLosslessRejectsTrailingContent(t *testing.T) { + out := parseToolEnvelopeLossless(`{"results":{"result":[]}} trailing`) + if out["_raw"] == nil { + t.Fatalf("trailing content was accepted: %#v", out) + } +} + func TestToolBackedComponentYahooFinanceIntegration(t *testing.T) { serverCalls := 0 server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { diff --git a/internal/agent/tool/querit.go b/internal/agent/tool/querit.go new file mode 100644 index 0000000000..5555c97f6e --- /dev/null +++ b/internal/agent/tool/querit.go @@ -0,0 +1,605 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package tool + +import ( + "context" + "crypto/sha1" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/schema" + + "ragflow/internal/common" + "ragflow/internal/tokenizer" +) + +const ( + queritToolName = "querit_search" + queritToolDescription = "Search the web via the Querit API and return the complete search response." + queritEndpoint = "https://api.querit.ai/v1/search" + queritMaxAttempts = 3 + queritPromptMaxTokens = 200000 +) + +var ( + queritRelativeTimeRangePattern = regexp.MustCompile(`^[dwmy][1-9][0-9]*$`) + queritDateRangePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}to\d{4}-\d{2}-\d{2}$`) + queritDataImagePattern = regexp.MustCompile(`!?\[[a-z]+\]\(data:image/png;base64,[ 0-9A-Za-z/_=+\-]+\)`) + queritNewlinePattern = regexp.MustCompile(`\n+`) +) + +type queritParams struct { + APIKey string `json:"api_key"` + Query string `json:"query"` + Count int `json:"count"` + ChunksPerDoc *int `json:"chunks_per_doc"` + SiteInclude []string `json:"site_include"` + SiteExclude []string `json:"site_exclude"` + TimeRange string `json:"time_range"` + CountryInclude []string `json:"country_include"` + LanguageInclude []string `json:"language_include"` +} + +type queritRequest struct { + Query string `json:"query"` + Count int `json:"count"` + ChunksPerDoc *int `json:"chunksPerDoc,omitempty"` + Filters *queritFilters `json:"filters,omitempty"` +} + +type queritFilters struct { + Sites *queritIncludeExcludeFilter `json:"sites,omitempty"` + TimeRange *queritDateFilter `json:"timeRange,omitempty"` + Geo *queritGeoFilter `json:"geo,omitempty"` + Languages *queritIncludeFilter `json:"languages,omitempty"` +} + +type queritIncludeExcludeFilter struct { + Include []string `json:"include,omitempty"` + Exclude []string `json:"exclude,omitempty"` +} + +type queritIncludeFilter struct { + Include []string `json:"include,omitempty"` +} + +type queritDateFilter struct { + Date string `json:"date"` +} + +type queritGeoFilter struct { + Countries *queritIncludeFilter `json:"countries,omitempty"` +} + +// QueritTool searches the web through Querit. Node-level parameters are +// retained as defaults and model-emitted parameters override them per call. +type QueritTool struct { + helper *HTTPHelper + envKey func() string + defaults queritParams + retryWait func(context.Context, int) bool +} + +var _ ToolComponent = (*QueritTool)(nil) +var _ ReferenceBuilder = (*QueritTool)(nil) + +// NewQueritTool returns a QueritTool using the shared HTTP helper and the +// QUERIT_API_KEY environment variable. +func NewQueritTool() *QueritTool { + return newQueritTool(nil, nil, queritParams{}, nil) +} + +// NewQueritToolWith returns a QueritTool using the supplied HTTP helper. +func NewQueritToolWith(helper *HTTPHelper) *QueritTool { + return newQueritTool(helper, nil, queritParams{}, nil) +} + +// NewQueritToolWithEnvKey returns a QueritTool with an injectable API-key +// resolver. It is intended for tests that must not depend on process state. +func NewQueritToolWithEnvKey(helper *HTTPHelper, envKey func() string) *QueritTool { + return newQueritTool(helper, envKey, queritParams{}, nil) +} + +func newQueritTool( + helper *HTTPHelper, + envKey func() string, + defaults queritParams, + retryWait func(context.Context, int) bool, +) *QueritTool { + if helper == nil { + helper = NewHTTPHelper() + } + if envKey == nil { + envKey = defaultQueritEnvKey + } + if defaults.Count == 0 { + defaults.Count = 10 + } + if defaults.ChunksPerDoc == nil { + defaults.ChunksPerDoc = queritInt(3) + } + if retryWait == nil { + retryWait = waitForQueritRetry + } + return &QueritTool{helper: helper, envKey: envKey, defaults: defaults, retryWait: retryWait} +} + +func defaultQueritEnvKey() string { return common.GetEnv(common.EnvQueritAPIKey) } + +// Info returns the arguments that a chat model may provide. Credentials stay +// in node configuration or the environment and are never exposed to the model. +func (q *QueritTool) Info(_ context.Context) (*schema.ToolInfo, error) { + return &schema.ToolInfo{ + Name: queritToolName, + Desc: queritToolDescription, + ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{ + "query": { + Type: schema.String, + Desc: "Search query.", + Required: true, + }, + "count": { + Type: schema.Integer, + Desc: "Maximum number of documents to return. Defaults to 10 and must be at least 1.", + Required: false, + }, + "chunks_per_doc": { + Type: schema.Integer, + Desc: "Number of snippets per document. Defaults to 3 and must be between 1 and 3.", + Required: false, + }, + "site_include": { + Type: schema.Array, + ElemInfo: &schema.ParameterInfo{Type: schema.String}, + Desc: "Sites that search results must include.", + Required: false, + }, + "site_exclude": { + Type: schema.Array, + ElemInfo: &schema.ParameterInfo{Type: schema.String}, + Desc: "Sites that search results must exclude.", + Required: false, + }, + "time_range": { + Type: schema.String, + Desc: "Relative time range such as d7, w1, m3, or y1, or YYYY-MM-DDtoYYYY-MM-DD.", + Required: false, + }, + "country_include": { + Type: schema.Array, + ElemInfo: &schema.ParameterInfo{Type: schema.String}, + Desc: "Countries that search results must include.", + Required: false, + }, + "language_include": { + Type: schema.Array, + ElemInfo: &schema.ParameterInfo{Type: schema.String}, + Desc: "Languages that search results must include.", + Required: false, + }, + }), + }, nil +} + +// InvokableRun performs a Querit search. All expected operational failures are +// returned as soft-error JSON with a nil Go error so an Agent run can continue. +func (q *QueritTool) InvokableRun(ctx context.Context, argsJSON string, _ ...tool.Option) (string, error) { + provided := make(map[string]json.RawMessage) + if err := json.Unmarshal([]byte(argsJSON), &provided); err != nil { + return queritErrorJSON(fmt.Errorf("parse arguments: %w", err)), nil + } + queryJSON, hasQuery := provided["query"] + if hasQuery { + if strings.TrimSpace(string(queryJSON)) == "null" { + return queritErrorJSON(fmt.Errorf("query must be provided as a string")), nil + } + var query string + if err := json.Unmarshal(queryJSON, &query); err != nil { + return queritErrorJSON(fmt.Errorf("query must be provided as a string")), nil + } + } + var runtimeParams queritParams + if err := json.Unmarshal([]byte(argsJSON), &runtimeParams); err != nil { + return queritErrorJSON(fmt.Errorf("parse arguments: %w", err)), nil + } + params := mergeQueritParams(q.defaults, runtimeParams, provided) + if !hasQuery && strings.TrimSpace(params.Query) == "" { + return queritErrorJSON(fmt.Errorf("query must be provided as a string")), nil + } + if params.Query == "" { + return `{}`, nil + } + if err := validateQueritParams(params); err != nil { + return queritErrorJSON(err, params.APIKey), nil + } + + apiKey := strings.TrimSpace(params.APIKey) + if apiKey == "" { + apiKey = strings.TrimSpace(q.envKey()) + } + if apiKey == "" { + return queritErrorJSON(fmt.Errorf("api_key is required (or set QUERIT_API_KEY)")), nil + } + + body, err := json.Marshal(buildQueritRequest(params)) + if err != nil { + return queritErrorJSON(fmt.Errorf("encode request: %w", err), apiKey), nil + } + for attempt := 1; attempt <= queritMaxAttempts; attempt++ { + resp, requestErr := q.helper.Do( + ctx, + http.MethodPost, + queritEndpoint, + string(body), + "application/json", + map[string]string{"Authorization": "Bearer " + apiKey}, + ) + if requestErr != nil { + return queritErrorJSON(fmt.Errorf("request failed: %w", requestErr), apiKey), nil + } + if resp.StatusCode == http.StatusTooManyRequests { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if attempt == queritMaxAttempts || !q.retryWait(ctx, attempt) { + return queritErrorJSON(fmt.Errorf("upstream returned %d after %d attempts", resp.StatusCode, attempt), apiKey), nil + } + continue + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + return queritErrorJSON(fmt.Errorf("upstream returned %d", resp.StatusCode), apiKey), nil + } + + raw, readErr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if readErr != nil { + return queritErrorJSON(fmt.Errorf("read response: %w", readErr), apiKey), nil + } + if _, err := decodeQueritResponse(raw); err != nil { + return queritErrorJSON(err, apiKey), nil + } + return string(raw), nil + } + + return queritErrorJSON(fmt.Errorf("request exhausted retries"), apiKey), nil +} + +func mergeQueritParams(defaults, runtimeParams queritParams, provided map[string]json.RawMessage) queritParams { + merged := defaults + if _, ok := provided["query"]; ok { + merged.Query = runtimeParams.Query + } + if _, ok := provided["count"]; ok { + merged.Count = runtimeParams.Count + } + if _, ok := provided["chunks_per_doc"]; ok { + merged.ChunksPerDoc = runtimeParams.ChunksPerDoc + } + if _, ok := provided["site_include"]; ok { + merged.SiteInclude = runtimeParams.SiteInclude + } + if _, ok := provided["site_exclude"]; ok { + merged.SiteExclude = runtimeParams.SiteExclude + } + if _, ok := provided["time_range"]; ok { + merged.TimeRange = runtimeParams.TimeRange + } + if _, ok := provided["country_include"]; ok { + merged.CountryInclude = runtimeParams.CountryInclude + } + if _, ok := provided["language_include"]; ok { + merged.LanguageInclude = runtimeParams.LanguageInclude + } + return merged +} + +func validateQueritParams(params queritParams) error { + if params.Count < 1 { + return fmt.Errorf("count must be at least 1") + } + if params.ChunksPerDoc != nil && (*params.ChunksPerDoc < 1 || *params.ChunksPerDoc > 3) { + return fmt.Errorf("chunks_per_doc must be between 1 and 3") + } + if !isValidQueritTimeRange(strings.TrimSpace(params.TimeRange)) { + return fmt.Errorf("time_range must use dN, wN, mN, yN, or YYYY-MM-DDtoYYYY-MM-DD") + } + return nil +} + +func isValidQueritTimeRange(value string) bool { + return value == "" || queritRelativeTimeRangePattern.MatchString(value) || queritDateRangePattern.MatchString(value) +} + +func buildQueritRequest(params queritParams) queritRequest { + request := queritRequest{ + Query: params.Query, + Count: params.Count, + ChunksPerDoc: params.ChunksPerDoc, + } + filters := &queritFilters{} + if len(params.SiteInclude) > 0 || len(params.SiteExclude) > 0 { + filters.Sites = &queritIncludeExcludeFilter{Include: params.SiteInclude, Exclude: params.SiteExclude} + } + if strings.TrimSpace(params.TimeRange) != "" { + filters.TimeRange = &queritDateFilter{Date: params.TimeRange} + } + if len(params.CountryInclude) > 0 { + filters.Geo = &queritGeoFilter{Countries: &queritIncludeFilter{Include: params.CountryInclude}} + } + if len(params.LanguageInclude) > 0 { + filters.Languages = &queritIncludeFilter{Include: params.LanguageInclude} + } + if filters.Sites != nil || filters.TimeRange != nil || filters.Geo != nil || filters.Languages != nil { + request.Filters = filters + } + return request +} + +func decodeQueritResponse(raw []byte) (map[string]any, error) { + var decoded any + decoder := json.NewDecoder(strings.NewReader(string(raw))) + decoder.UseNumber() + if err := decoder.Decode(&decoded); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return nil, fmt.Errorf("decode response: expected exactly one JSON value") + } + return nil, fmt.Errorf("decode response: trailing content: %w", err) + } + response, ok := decoded.(map[string]any) + if !ok || response == nil { + return nil, fmt.Errorf("decode response: expected a JSON object") + } + resultsValue, exists := response["results"] + if !exists { + return response, nil + } + results, ok := resultsValue.(map[string]any) + if !ok { + return nil, fmt.Errorf("decode response: results must be a JSON object") + } + resultValue, exists := results["result"] + if !exists { + return response, nil + } + if _, ok := resultValue.([]any); !ok { + return nil, fmt.Errorf("decode response: results.result must be a JSON array") + } + return response, nil +} + +func waitForQueritRetry(ctx context.Context, attempt int) bool { + delay := 200 * time.Millisecond + for current := 1; current < attempt; current++ { + delay *= 2 + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + +// ComponentSpec returns the QueritSearch Canvas-facing metadata. +func (q *QueritTool) ComponentSpec() ComponentSpec { + return ComponentSpec{ + PreserveJSONNumbers: true, + Inputs: map[string]string{ + "api_key": "Querit API key. Uses QUERIT_API_KEY when empty.", + "query": "Search query.", + "count": "Maximum number of documents.", + "chunks_per_doc": "Number of snippets per document.", + "site_include": "Sites that search results must include.", + "site_exclude": "Sites that search results must exclude.", + "time_range": "dN, wN, mN, yN, or YYYY-MM-DDtoYYYY-MM-DD.", + "country_include": "Countries that search results must include.", + "language_include": "Languages that search results must include.", + }, + Outputs: map[string]string{ + "formalized_content": "Rendered Querit references for downstream prompts.", + "json": "Complete raw Querit JSON response.", + }, + InputForm: map[string]any{ + "api_key": map[string]any{"name": "API key", "type": "password"}, + "query": map[string]any{"name": "Query", "type": "line"}, + "count": map[string]any{"name": "Count", "type": "line"}, + "chunks_per_doc": map[string]any{"name": "Chunks per document", "type": "line"}, + "site_include": map[string]any{"name": "Site include", "type": "line"}, + "site_exclude": map[string]any{"name": "Site exclude", "type": "line"}, + "time_range": map[string]any{"name": "Time range", "type": "line"}, + "country_include": map[string]any{"name": "Country include", "type": "line"}, + "language_include": map[string]any{"name": "Language include", "type": "line"}, + }, + } +} + +// BuildReferences converts results.result into RAGFlow retrieval references. +func (q *QueritTool) BuildReferences(_ context.Context, response map[string]any) ([]map[string]any, []map[string]any) { + results := queritResults(response) + chunks := make([]map[string]any, 0, len(results)) + docAggs := make([]map[string]any, 0, len(results)) + for _, result := range results { + title := queritText(result["title"]) + resultURL := queritText(result["url"]) + content := queritText(result["snippet"]) + if content == "" { + continue + } + content = queritDataImagePattern.ReplaceAllString(content, "") + content = truncateQueritRunes(content, 10000) + if content == "" { + continue + } + documentID := strconv.FormatInt(queritHashInt(content, 100000000), 10) + displayID := strconv.FormatInt(queritHashInt(documentID, 500), 10) + chunks = append(chunks, map[string]any{ + "id": displayID, + "chunk_id": documentID, + "content": content, + "doc_id": documentID, + "document_id": documentID, + "docnm_kwd": title, + "document_name": title, + "similarity": 1, + "score": 1, + "url": resultURL, + }) + docAggs = append(docAggs, map[string]any{ + "doc_name": title, + "doc_id": documentID, + "count": 1, + "url": resultURL, + }) + } + return chunks, docAggs +} + +// BuildComponentOutputs keeps the complete upstream object in json and adds a +// reference-formatted string for downstream Canvas components. +func (q *QueritTool) BuildComponentOutputs(response map[string]any) map[string]any { + chunks, _ := q.BuildReferences(context.Background(), response) + return map[string]any{ + "formalized_content": renderQueritReferences(chunks, queritPromptMaxTokens), + "json": response, + } +} + +func queritResults(response map[string]any) []map[string]any { + results, ok := response["results"].(map[string]any) + if !ok { + return nil + } + raw, ok := results["result"].([]any) + if !ok { + if typed, typedOK := results["result"].([]map[string]any); typedOK { + return typed + } + return nil + } + items := make([]map[string]any, 0, len(raw)) + for _, value := range raw { + if item, itemOK := value.(map[string]any); itemOK { + items = append(items, item) + } + } + return items +} + +func renderQueritReferences(chunks []map[string]any, maxTokens int) string { + usedTokens := 0 + blocks := make([]string, 0, len(chunks)) + for _, chunk := range chunks { + content := queritText(chunk["content"]) + usedTokens += tokenizer.NumTokensFromString(content) + blocks = append(blocks, strings.Join([]string{ + "\nID: " + queritText(chunk["id"]), + "├── Title: " + queritPromptField(chunk["document_name"]), + "├── URL: " + queritPromptField(chunk["url"]), + "└── Content:\n" + content, + }, "\n")) + if maxTokens > 0 && float64(maxTokens)*0.97 < float64(usedTokens) { + break + } + } + return strings.Join(blocks, "\n") +} + +func queritText(value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return text + } + return fmt.Sprint(value) +} + +func queritPromptField(value any) string { + return queritNewlinePattern.ReplaceAllString(queritText(value), " ") +} + +func queritHashInt(value string, modulus int64) int64 { + digest := sha1.Sum([]byte(value)) + number := new(big.Int).SetBytes(digest[:]) + return new(big.Int).Mod(number, big.NewInt(modulus)).Int64() +} + +func truncateQueritRunes(value string, limit int) string { + if limit <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) +} + +func queritInt(value int) *int { return &value } + +func queritStringSlice(value any) ([]string, bool) { + switch items := value.(type) { + case []string: + return append([]string(nil), items...), true + case []any: + result := make([]string, 0, len(items)) + for _, item := range items { + text, ok := item.(string) + if !ok { + return nil, false + } + result = append(result, text) + } + return result, true + case nil: + return nil, true + default: + return nil, false + } +} + +func queritErrorJSON(err error, apiKeys ...string) string { + message := "querit_search: unknown error" + if err != nil { + message = "querit_search: " + err.Error() + } + for _, apiKey := range apiKeys { + if apiKey = strings.TrimSpace(apiKey); apiKey != "" { + message = strings.ReplaceAll(message, apiKey, "[REDACTED]") + } + } + raw, marshalErr := json.Marshal(map[string]any{"_ERROR": message}) + if marshalErr != nil { + return `{"_ERROR":"querit_search: marshal error"}` + } + return string(raw) +} diff --git a/internal/agent/tool/querit_test.go b/internal/agent/tool/querit_test.go new file mode 100644 index 0000000000..97d87b1a8d --- /dev/null +++ b/internal/agent/tool/querit_test.go @@ -0,0 +1,594 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package tool + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestQueritBuildsMinimalRequest(t *testing.T) { + var gotMethod, gotPath, gotAuthorization, gotContentType string + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + gotMethod = request.Method + gotPath = request.URL.Path + gotAuthorization = request.Header.Get("Authorization") + gotContentType = request.Header.Get("Content-Type") + _ = json.NewDecoder(request.Body).Decode(&gotBody) + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"results":{"result":[]},"search_id":"search-1"}`)) + })) + defer server.Close() + + helper := NewHTTPHelper().WithClient(&http.Client{Transport: rewriteQueritHostTransport(server.URL)}) + querit := newQueritTool(helper, func() string { return "" }, queritParams{APIKey: "key-test"}, nil) + out, err := querit.InvokableRun(context.Background(), `{"query":"ragflow"}`) + if err != nil { + t.Fatalf("InvokableRun: %v", err) + } + if gotMethod != http.MethodPost || gotPath != "/v1/search" { + t.Fatalf("request = %s %s, want POST /v1/search", gotMethod, gotPath) + } + if gotAuthorization != "Bearer key-test" { + t.Fatalf("Authorization = %q", gotAuthorization) + } + if !strings.HasPrefix(gotContentType, "application/json") { + t.Fatalf("Content-Type = %q", gotContentType) + } + if gotBody["query"] != "ragflow" || gotBody["count"] != float64(10) || gotBody["chunksPerDoc"] != float64(3) { + t.Fatalf("request body = %#v", gotBody) + } + if _, exists := gotBody["filters"]; exists { + t.Fatalf("empty filters must be omitted: %#v", gotBody) + } + if !strings.Contains(out, `"search_id":"search-1"`) { + t.Fatalf("complete response was not retained: %s", out) + } +} + +func TestQueritBuildsFiltersAndMergesRuntimeOverrides(t *testing.T) { + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + _ = json.NewDecoder(request.Body).Decode(&gotBody) + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"results":{"result":[]}}`)) + })) + defer server.Close() + + defaults := queritParams{ + APIKey: "stored-key", + Count: 20, + ChunksPerDoc: queritInt(2), + SiteInclude: []string{"stored.example"}, + SiteExclude: []string{"blocked.example"}, + TimeRange: "w1", + CountryInclude: []string{"CN"}, + LanguageInclude: []string{"zh"}, + } + helper := NewHTTPHelper().WithClient(&http.Client{Transport: rewriteQueritHostTransport(server.URL)}) + querit := newQueritTool(helper, func() string { return "" }, defaults, func(context.Context, int) bool { return true }) + _, err := querit.InvokableRun(context.Background(), `{"query":"ragflow","count":5,"site_include":[],"language_include":["en"]}`) + if err != nil { + t.Fatalf("InvokableRun: %v", err) + } + if gotBody["count"] != float64(5) || gotBody["chunksPerDoc"] != float64(2) { + t.Fatalf("merged scalar defaults = %#v", gotBody) + } + filters := gotBody["filters"].(map[string]any) + sites := filters["sites"].(map[string]any) + if _, exists := sites["include"]; exists || len(sites["exclude"].([]any)) != 1 { + t.Fatalf("explicit empty site_include did not clear node default: %#v", sites) + } + if filters["timeRange"].(map[string]any)["date"] != "w1" { + t.Fatalf("timeRange = %#v", filters["timeRange"]) + } + if filters["geo"].(map[string]any)["countries"].(map[string]any)["include"].([]any)[0] != "CN" { + t.Fatalf("geo filter = %#v", filters["geo"]) + } + if filters["languages"].(map[string]any)["include"].([]any)[0] != "en" { + t.Fatalf("language filter = %#v", filters["languages"]) + } +} + +func TestQueritUsesNodeQueryWhenRuntimeQueryIsOmitted(t *testing.T) { + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + _ = json.NewDecoder(request.Body).Decode(&gotBody) + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"results":{"result":[]}}`)) + })) + defer server.Close() + + helper := NewHTTPHelper().WithClient(&http.Client{Transport: rewriteQueritHostTransport(server.URL)}) + querit := newQueritTool( + helper, + func() string { return "" }, + queritParams{APIKey: "stored-key", Query: "node query"}, + nil, + ) + out, err := querit.InvokableRun(context.Background(), `{}`) + if err != nil || strings.Contains(out, "_ERROR") { + t.Fatalf("InvokableRun = %s, %v", out, err) + } + if gotBody["query"] != "node query" { + t.Fatalf("request query = %#v, want node query", gotBody["query"]) + } +} + +func TestQueritAPIKeyResolutionAndEmptyQuery(t *testing.T) { + var calls atomic.Int32 + var authorization string + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + calls.Add(1) + authorization = request.Header.Get("Authorization") + _, _ = writer.Write([]byte(`{"results":{"result":[]}}`)) + })) + defer server.Close() + + helper := NewHTTPHelper().WithClient(&http.Client{Transport: rewriteQueritHostTransport(server.URL)}) + querit := NewQueritToolWithEnvKey(helper, func() string { return "environment-secret" }) + if _, err := querit.InvokableRun(context.Background(), `{"query":"ragflow"}`); err != nil { + t.Fatalf("InvokableRun: %v", err) + } + if authorization != "Bearer environment-secret" { + t.Fatalf("Authorization = %q", authorization) + } + emptyOut, err := querit.InvokableRun(context.Background(), `{"query":""}`) + if err != nil { + t.Fatalf("empty query: %v", err) + } + if calls.Load() != 1 || emptyOut != `{}` { + t.Fatalf("empty query result = %s; calls = %d", emptyOut, calls.Load()) + } + + missing := NewQueritToolWithEnvKey(helper, func() string { return "" }) + out, err := missing.InvokableRun(context.Background(), `{"query":"ragflow"}`) + if err != nil { + t.Fatalf("missing key returned Go error: %v", err) + } + if !strings.Contains(out, "api_key") || strings.Contains(out, "environment-secret") || calls.Load() != 1 { + t.Fatalf("missing-key result = %s, calls = %d", out, calls.Load()) + } +} + +func TestQueritRuntimeAPIKeyCannotOverrideNodeConfiguration(t *testing.T) { + var authorization string + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + authorization = request.Header.Get("Authorization") + _, _ = writer.Write([]byte(`{"results":{"result":[]}}`)) + })) + defer server.Close() + + helper := NewHTTPHelper().WithClient(&http.Client{Transport: rewriteQueritHostTransport(server.URL)}) + querit := newQueritTool(helper, func() string { return "" }, queritParams{APIKey: "stored-key"}, nil) + out, err := querit.InvokableRun(context.Background(), `{"query":"ragflow","api_key":"runtime-key"}`) + if err != nil || strings.Contains(out, "_ERROR") { + t.Fatalf("InvokableRun = %s, %v", out, err) + } + if authorization != "Bearer stored-key" { + t.Fatalf("Authorization = %q, want stored node key", authorization) + } +} + +func TestQueritRejectsMissingNullAndNonStringQueries(t *testing.T) { + var calls atomic.Int32 + helper := NewHTTPHelper().WithClient(&http.Client{Transport: roundTripperErrorFunc(func(*http.Request) error { + calls.Add(1) + return errors.New("network must not be called") + })}) + querit := NewQueritToolWith(helper) + for _, args := range []string{`{}`, `{"query":null}`, `{"query":123}`} { + t.Run(args, func(t *testing.T) { + out, err := querit.InvokableRun(context.Background(), args) + if err != nil || !strings.Contains(out, "_ERROR") || !strings.Contains(out, "query") { + t.Fatalf("InvokableRun(%s) = %s, %v", args, out, err) + } + }) + } + if calls.Load() != 0 { + t.Fatalf("invalid queries made %d network calls", calls.Load()) + } +} + +func TestQueritExplicitNullChunksPerDocIsOmitted(t *testing.T) { + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + _ = json.NewDecoder(request.Body).Decode(&gotBody) + _, _ = writer.Write([]byte(`{"results":{"result":[]}}`)) + })) + defer server.Close() + helper := NewHTTPHelper().WithClient(&http.Client{Transport: rewriteQueritHostTransport(server.URL)}) + querit := NewQueritToolWithEnvKey(helper, func() string { return "k" }) + out, err := querit.InvokableRun(context.Background(), `{"query":"x","chunks_per_doc":null}`) + if err != nil || strings.Contains(out, "_ERROR") { + t.Fatalf("InvokableRun = %s, %v", out, err) + } + if _, exists := gotBody["chunksPerDoc"]; exists { + t.Fatalf("explicit null chunks_per_doc was not omitted: %#v", gotBody) + } +} + +func TestQueritValidatesParametersBeforeRequest(t *testing.T) { + tests := []string{ + `{"query":"x","count":0}`, + `{"query":"x","chunks_per_doc":4}`, + `{"query":"x","time_range":"last week"}`, + `{"query":"x","time_range":"2026-01-01,2026-01-31"}`, + `{"query":"x","site_include":[1]}`, + } + for _, args := range tests { + t.Run(args, func(t *testing.T) { + out, err := NewQueritTool().InvokableRun(context.Background(), args) + if err != nil || !strings.Contains(out, "_ERROR") { + t.Fatalf("InvokableRun(%s) = %s, %v", args, out, err) + } + }) + } +} + +func TestQueritTimeRangeContract(t *testing.T) { + tests := []struct { + value string + valid bool + }{ + {value: "", valid: true}, + {value: "d7", valid: true}, + {value: "w2", valid: true}, + {value: "m3", valid: true}, + {value: "y1", valid: true}, + {value: "2026-01-01to2026-01-31", valid: true}, + {value: "d0", valid: false}, + {value: "7d", valid: false}, + {value: "2026-01-01,2026-01-31", valid: false}, + {value: "2026-01-01..2026-01-31", valid: false}, + {value: "2026-01-01-2026-01-31", valid: false}, + {value: "last week", valid: false}, + } + + for _, test := range tests { + t.Run(test.value, func(t *testing.T) { + if got := isValidQueritTimeRange(test.value); got != test.valid { + t.Fatalf("isValidQueritTimeRange(%q) = %v, want %v", test.value, got, test.valid) + } + if !test.valid || test.value == "" { + return + } + request := buildQueritRequest(queritParams{TimeRange: test.value}) + if request.Filters == nil || request.Filters.TimeRange == nil || request.Filters.TimeRange.Date != test.value { + t.Fatalf("time range request mapping = %#v", request.Filters) + } + }) + } +} + +func TestQueritHTTPFailuresAreSoftErrors(t *testing.T) { + t.Run("unauthorized is not retried", func(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + calls.Add(1) + writer.WriteHeader(http.StatusUnauthorized) + _, _ = writer.Write([]byte(`{"message":"do not expose upstream bodies"}`)) + })) + defer server.Close() + helper := NewHTTPHelper().WithClient(&http.Client{Transport: rewriteQueritHostTransport(server.URL)}) + querit := NewQueritToolWithEnvKey(helper, func() string { return "secret-key" }) + out, err := querit.InvokableRun(context.Background(), `{"query":"x"}`) + if err != nil || calls.Load() != 1 || !strings.Contains(out, "401") || strings.Contains(out, "secret-key") { + t.Fatalf("result = %s, err = %v, calls = %d", out, err, calls.Load()) + } + }) + + t.Run("rate limit is retried at most three times", func(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + calls.Add(1) + writer.WriteHeader(http.StatusTooManyRequests) + })) + defer server.Close() + helper := NewHTTPHelper().WithClient(&http.Client{Transport: rewriteQueritHostTransport(server.URL)}) + querit := newQueritTool(helper, nil, queritParams{APIKey: "secret-key"}, func(context.Context, int) bool { return true }) + out, err := querit.InvokableRun(context.Background(), `{"query":"x"}`) + if err != nil || calls.Load() != queritMaxAttempts || !strings.Contains(out, "429") || strings.Contains(out, "secret-key") { + t.Fatalf("result = %s, err = %v, calls = %d", out, err, calls.Load()) + } + }) + + t.Run("HTTP helper retries temporary server errors", func(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + attempt := calls.Add(1) + if attempt < 3 { + writer.WriteHeader(http.StatusServiceUnavailable) + return + } + _, _ = writer.Write([]byte(`{"results":{"result":[]}}`)) + })) + defer server.Close() + helper := NewHTTPHelperWithRetry(RetryConfig{ + MaxAttempts: 3, + BaseBackoff: time.Nanosecond, + MaxBackoff: time.Nanosecond, + }).WithClient(&http.Client{Transport: rewriteQueritHostTransport(server.URL)}) + out, err := NewQueritToolWithEnvKey(helper, func() string { return "k" }).InvokableRun(context.Background(), `{"query":"x"}`) + if err != nil || calls.Load() != 3 || strings.Contains(out, "_ERROR") { + t.Fatalf("result = %s, err = %v, calls = %d", out, err, calls.Load()) + } + }) + + t.Run("persistent server errors are soft", func(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + calls.Add(1) + writer.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + helper := NewHTTPHelperWithRetry(RetryConfig{ + MaxAttempts: 3, + BaseBackoff: time.Nanosecond, + MaxBackoff: time.Nanosecond, + }).WithClient(&http.Client{Transport: rewriteQueritHostTransport(server.URL)}) + querit := NewQueritToolWithEnvKey(helper, func() string { return "environment-secret" }) + out, err := querit.InvokableRun(context.Background(), `{"query":"x"}`) + if err != nil || calls.Load() != 3 || !strings.Contains(out, "_ERROR") || !strings.Contains(out, "500") { + t.Fatalf("result = %s, err = %v, calls = %d", out, err, calls.Load()) + } + }) + + for _, test := range []struct { + name string + secret string + node bool + }{ + {name: "node API key is redacted from transport errors", secret: "node-secret", node: true}, + {name: "environment API key is redacted from transport errors", secret: "environment-secret"}, + } { + t.Run(test.name, func(t *testing.T) { + helper := NewHTTPHelperWithRetry(RetryConfig{ + MaxAttempts: 1, + BaseBackoff: time.Nanosecond, + MaxBackoff: time.Nanosecond, + }).WithClient(&http.Client{Transport: roundTripperErrorFunc(func(*http.Request) error { + return fmt.Errorf("transport rejected Bearer %s", test.secret) + })}) + querit := NewQueritToolWithEnvKey(helper, func() string { return test.secret }) + if test.node { + querit = newQueritTool(helper, func() string { return "" }, queritParams{APIKey: test.secret}, nil) + } + out, err := querit.InvokableRun(context.Background(), `{"query":"x"}`) + if err != nil || !strings.Contains(out, "_ERROR") || !strings.Contains(out, "[REDACTED]") { + t.Fatalf("result = %s, err = %v", out, err) + } + if strings.Contains(out, test.secret) { + t.Fatalf("soft error exposed API key: %s", out) + } + }) + } + + t.Run("network errors are soft", func(t *testing.T) { + var calls atomic.Int32 + helper := NewHTTPHelperWithRetry(RetryConfig{ + MaxAttempts: 3, + BaseBackoff: time.Nanosecond, + MaxBackoff: time.Nanosecond, + }).WithClient(&http.Client{Transport: roundTripperErrorFunc(func(*http.Request) error { + calls.Add(1) + return errors.New("offline") + })}) + out, err := NewQueritToolWithEnvKey(helper, func() string { return "k" }).InvokableRun(context.Background(), `{"query":"x"}`) + if err != nil || calls.Load() != 3 || !strings.Contains(out, "_ERROR") { + t.Fatalf("result = %s, err = %v, calls = %d", out, err, calls.Load()) + } + }) +} + +func TestQueritRejectsInvalidJSONResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write([]byte(`not-json`)) + })) + defer server.Close() + helper := NewHTTPHelper().WithClient(&http.Client{Transport: rewriteQueritHostTransport(server.URL)}) + querit := NewQueritToolWithEnvKey(helper, func() string { return "k" }) + out, err := querit.InvokableRun(context.Background(), `{"query":"x"}`) + if err != nil || !strings.Contains(out, "decode response") { + t.Fatalf("result = %s, err = %v", out, err) + } +} + +func TestQueritRejectsMalformedResponseShapes(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + {name: "top-level array", body: `[]`, want: "JSON object"}, + {name: "results is not an object", body: `{"results":[]}`, want: "results must be a JSON object"}, + {name: "results is null", body: `{"results":null}`, want: "results must be a JSON object"}, + {name: "result is not an array", body: `{"results":{"result":{}}}`, want: "results.result must be a JSON array"}, + {name: "result is null", body: `{"results":{"result":null}}`, want: "results.result must be a JSON array"}, + {name: "trailing content", body: `{"results":{"result":[]}} trailing`, want: "trailing content"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write([]byte(test.body)) + })) + defer server.Close() + helper := NewHTTPHelper().WithClient(&http.Client{Transport: rewriteQueritHostTransport(server.URL)}) + querit := NewQueritToolWithEnvKey(helper, func() string { return "k" }) + out, err := querit.InvokableRun(context.Background(), `{"query":"x"}`) + if err != nil || !strings.Contains(out, "_ERROR") || !strings.Contains(out, test.want) { + t.Fatalf("result = %s, err = %v", out, err) + } + }) + } +} + +func TestQueritAcceptsMissingResultContainers(t *testing.T) { + for _, body := range []string{`{}`, `{"results":{}}`} { + t.Run(body, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write([]byte(body)) + })) + defer server.Close() + helper := NewHTTPHelper().WithClient(&http.Client{Transport: rewriteQueritHostTransport(server.URL)}) + querit := NewQueritToolWithEnvKey(helper, func() string { return "k" }) + out, err := querit.InvokableRun(context.Background(), `{"query":"x"}`) + if err != nil || strings.Contains(out, "_ERROR") { + t.Fatalf("result = %s, err = %v", out, err) + } + }) + } +} + +func TestQueritReferencesAndCompleteComponentOutput(t *testing.T) { + response := map[string]any{ + "search_id": "search-1", + "query_context": map[string]any{"rewritten": "rag flow"}, + "results": map[string]any{"result": []any{ + map[string]any{"title": "RAGFlow", "url": "https://ragflow.io", "snippet": "RAG engine", "extra": true}, + map[string]any{"title": "Missing optional values"}, + "invalid item", + }}, + } + querit := NewQueritTool() + chunks, docAggs := querit.BuildReferences(context.Background(), response) + if len(chunks) != 1 || len(docAggs) != 1 { + t.Fatalf("references = %#v / %#v", chunks, docAggs) + } + if chunks[0]["content"] != "RAG engine" || chunks[0]["score"] != 1 || chunks[0]["similarity"] != 1 { + t.Fatalf("reference = %#v", chunks[0]) + } + outputs := querit.BuildComponentOutputs(response) + if outputs["json"].(map[string]any)["search_id"] != "search-1" { + t.Fatalf("complete json output = %#v", outputs["json"]) + } + formatted := outputs["formalized_content"].(string) + for _, expected := range []string{"Title: RAGFlow", "URL: https://ragflow.io", "RAG engine"} { + if !strings.Contains(formatted, expected) { + t.Fatalf("formalized_content missing %q: %s", expected, formatted) + } + } + if chunks, docAggs := querit.BuildReferences(context.Background(), map[string]any{"results": nil}); len(chunks) != 0 || len(docAggs) != 0 { + t.Fatalf("malformed response references = %#v / %#v", chunks, docAggs) + } +} + +func TestQueritReferencesSanitizeAndLimitSnippets(t *testing.T) { + longSnippet := strings.Repeat("界", 10001) + response := map[string]any{"results": map[string]any{"result": []any{ + map[string]any{"title": "empty", "snippet": ""}, + map[string]any{"title": "image only", "snippet": "![img](data:image/png;base64,AAAA)"}, + map[string]any{"title": "cleaned", "snippet": "before ![img](data:image/png;base64,AAAA) after"}, + map[string]any{"title": "limited", "snippet": longSnippet}, + }}} + chunks, docAggs := NewQueritTool().BuildReferences(context.Background(), response) + if len(chunks) != 2 || len(docAggs) != 2 { + t.Fatalf("references = %#v / %#v", chunks, docAggs) + } + if chunks[0]["content"] != "before after" { + t.Fatalf("base64 image was not removed: %#v", chunks[0]) + } + limited, _ := chunks[1]["content"].(string) + if len([]rune(limited)) != 10000 { + t.Fatalf("limited snippet length = %d", len([]rune(limited))) + } +} + +func TestQueritInfoDoesNotExposeAPIKey(t *testing.T) { + info, err := NewQueritTool().Info(context.Background()) + if err != nil { + t.Fatalf("Info: %v", err) + } + if info.Name != queritToolName || info.Desc == "" || info.ParamsOneOf == nil { + t.Fatalf("Info = %#v", info) + } + encoded, err := json.Marshal(info) + if err != nil { + t.Fatalf("marshal Info: %v", err) + } + if strings.Contains(string(encoded), "api_key") { + t.Fatalf("Info exposed API key: %s", encoded) + } + jsonSchema, err := info.ParamsOneOf.ToJSONSchema() + if err != nil { + t.Fatalf("ToJSONSchema: %v", err) + } + rawSchema, err := json.Marshal(jsonSchema) + if err != nil { + t.Fatalf("marshal schema: %v", err) + } + var paramsSchema map[string]any + if err := json.Unmarshal(rawSchema, ¶msSchema); err != nil { + t.Fatalf("decode schema: %v", err) + } + properties, ok := paramsSchema["properties"].(map[string]any) + if !ok { + t.Fatalf("schema properties = %#v", paramsSchema["properties"]) + } + for _, name := range []string{"site_include", "site_exclude", "country_include", "language_include"} { + property, ok := properties[name].(map[string]any) + if !ok { + t.Fatalf("%s schema = %#v", name, properties[name]) + } + items, ok := property["items"].(map[string]any) + if !ok || items["type"] != "string" { + t.Fatalf("%s items schema = %#v", name, property["items"]) + } + } +} + +type roundTripperErrorFunc func(*http.Request) error + +func (f roundTripperErrorFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return nil, f(request) +} + +func rewriteQueritHostTransport(serverURL string) http.RoundTripper { + target, err := url.Parse(serverURL) + if err != nil { + panic("rewriteQueritHostTransport: bad server URL: " + err.Error()) + } + return &queritHostSwapTransport{ + inner: http.DefaultTransport, + host: target.Host, + scheme: target.Scheme, + } +} + +type queritHostSwapTransport struct { + inner http.RoundTripper + host string + scheme string +} + +func (transport *queritHostSwapTransport) RoundTrip(request *http.Request) (*http.Response, error) { + cloned := request.Clone(request.Context()) + cloned.URL.Scheme = transport.scheme + cloned.URL.Host = transport.host + cloned.Host = transport.host + return transport.inner.RoundTrip(cloned) +} diff --git a/internal/agent/tool/registry.go b/internal/agent/tool/registry.go index b64e209291..ca43ca7830 100644 --- a/internal/agent/tool/registry.go +++ b/internal/agent/tool/registry.go @@ -49,6 +49,9 @@ var registry = map[string]Factory{ "keenable": buildKeenableTool, "pubmed": buildPubMedTool, "qweather": noConfig("qweather", func() einotool.BaseTool { return NewQWeatherTool() }), + "querit": buildQueritTool, + "querit_search": buildQueritTool, + "queritsearch": buildQueritTool, "retrieval": buildRetrievalTool, "search_my_dataset": buildRetrievalTool, "search_my_dateset": buildRetrievalTool, @@ -533,6 +536,61 @@ func buildTavilyTool(params map[string]any) (einotool.BaseTool, error) { return newTavilyTool(nil, nil, defaults), nil } +func buildQueritTool(params map[string]any) (einotool.BaseTool, error) { + defaults := queritParams{} + stringFields := map[string]*string{ + "api_key": &defaults.APIKey, + "query": &defaults.Query, + "time_range": &defaults.TimeRange, + } + for key, destination := range stringFields { + value, exists := params[key] + if !exists { + continue + } + text, valid := value.(string) + if !valid { + return nil, fmt.Errorf("agent tool: tool %q requires string node-level param %s", "querit_search", key) + } + *destination = text + } + if value, exists := params["count"]; exists { + count, valid := strictInt(value) + if !valid || count < 1 { + return nil, fmt.Errorf("agent tool: tool %q requires integer node-level param count of at least 1", "querit_search") + } + defaults.Count = count + } + if value, exists := params["chunks_per_doc"]; exists { + chunksPerDoc, valid := strictInt(value) + if !valid || chunksPerDoc < 1 || chunksPerDoc > 3 { + return nil, fmt.Errorf("agent tool: tool %q requires integer node-level param chunks_per_doc within [1, 3]", "querit_search") + } + defaults.ChunksPerDoc = queritInt(chunksPerDoc) + } + listFields := map[string]*[]string{ + "site_include": &defaults.SiteInclude, + "site_exclude": &defaults.SiteExclude, + "country_include": &defaults.CountryInclude, + "language_include": &defaults.LanguageInclude, + } + for key, destination := range listFields { + value, exists := params[key] + if !exists { + continue + } + items, valid := queritStringSlice(value) + if !valid { + return nil, fmt.Errorf("agent tool: tool %q requires string array node-level param %s", "querit_search", key) + } + *destination = items + } + if !isValidQueritTimeRange(strings.TrimSpace(defaults.TimeRange)) { + return nil, fmt.Errorf("agent tool: tool %q has invalid node-level param time_range", "querit_search") + } + return newQueritTool(nil, nil, defaults, nil), nil +} + func buildKeenableTool(params map[string]any) (einotool.BaseTool, error) { defaults := keenableParams{} apiKey := "" diff --git a/internal/agent/tool/registry_test.go b/internal/agent/tool/registry_test.go index 03b5fb8118..27b695fbef 100644 --- a/internal/agent/tool/registry_test.go +++ b/internal/agent/tool/registry_test.go @@ -64,12 +64,43 @@ func TestBuildByName_TavilyCanvasComponentNames(t *testing.T) { } } +func TestBuildByName_QueritAliases(t *testing.T) { + for _, name := range []string{"querit", "querit_search", "queritsearch", "QueritSearch"} { + built, err := BuildByName(name, map[string]any{ + "api_key": "stored-key", + "count": float64(8), + "chunks_per_doc": float64(2), + "site_include": []any{"example.com"}, + "site_exclude": []string{"blocked.example"}, + "time_range": "d7", + "country_include": []any{"CN"}, + "language_include": []any{"zh"}, + "outputs": map[string]any{"json": map[string]any{}}, + }) + if err != nil { + t.Fatalf("BuildByName(%q): %v", name, err) + } + querit, ok := built.(*QueritTool) + if !ok { + t.Fatalf("BuildByName(%q) returned %T, want *QueritTool", name, built) + } + if querit.defaults.Count != 8 || querit.defaults.ChunksPerDoc == nil || *querit.defaults.ChunksPerDoc != 2 || len(querit.defaults.SiteInclude) != 1 { + t.Fatalf("BuildByName(%q) defaults = %#v", name, querit.defaults) + } + info, infoErr := built.Info(context.Background()) + if infoErr != nil || info.Name != "querit_search" { + t.Fatalf("BuildByName(%q).Info() = %#v, %v", name, info, infoErr) + } + } +} + func TestBuildAll_AllRegisteredTools(t *testing.T) { // Every key in registry. names := []string{ "akshare", "arxiv", "bgpt", "code_exec", "crawler", "deepl", "duckduckgo", "email", "exesql", "execute_sql", "github", "google", "google_scholar", "google_scholar_search", "jin10", "keenable", "pubmed", "qweather", + "querit", "querit_search", "queritsearch", "retrieval", "search_my_dataset", "search_my_dateset", "searxng", "tavily", "tavily_extract", "tushare", "web_crawler", "wencai", "wikipedia", "wikipedia_search", "yahoo_finance", @@ -133,6 +164,7 @@ func TestToolRegistry_SchemasAreComplete(t *testing.T) { "akshare", "arxiv", "bgpt", "code_exec", "crawler", "deepl", "duckduckgo", "email", "execute_sql", "exesql", "github", "google", "google_scholar", "google_scholar_search", "jin10", "keenable", "pubmed", "qweather", + "querit", "querit_search", "queritsearch", "retrieval", "search_my_dataset", "search_my_dateset", "searxng", "tavily", "tavily_extract", "tushare", "web_crawler", "wencai", "wikipedia", "wikipedia_search", "yahoo_finance", @@ -202,6 +234,9 @@ func TestToolRegistry_SchemasAreComplete(t *testing.T) { "web_crawler": "web_crawler", "wikipedia": "wikipedia_search", "wikipedia_search": "wikipedia_search", + "querit": "querit_search", + "querit_search": "querit_search", + "queritsearch": "querit_search", } for _, name := range names { canonical, ok := canonicalByAlias[name] diff --git a/internal/agent/tool/tool2component.go b/internal/agent/tool/tool2component.go index df487921d7..f637fc0969 100644 --- a/internal/agent/tool/tool2component.go +++ b/internal/agent/tool/tool2component.go @@ -34,6 +34,9 @@ type ComponentSpec struct { Inputs map[string]string Outputs map[string]string InputForm map[string]any + // PreserveJSONNumbers keeps numeric response tokens as json.Number when + // Canvas must expose the upstream JSON without float64 precision loss. + PreserveJSONNumbers bool } // ToolComponent is the required Canvas adaptation contract implemented by a diff --git a/internal/common/environments.go b/internal/common/environments.go index 8f28502c38..1e0fd91db4 100644 --- a/internal/common/environments.go +++ b/internal/common/environments.go @@ -113,6 +113,7 @@ const ( EnvSSHEnableAPIURL = "SSH_ENABLE_API_URL" EnvAllowAnyHost = "ALLOW_ANY_HOST" EnvTavilyApiKey = "TAVILY_API_KEY" + EnvQueritAPIKey = "QUERIT_API_KEY" EnvHome = "HOME" EnvUserProfile = "USERPROFILE" EnvHttpHTTPProxy = "http_proxy" diff --git a/test/unit_test/agent/tools/test_querit_unit.py b/test/unit_test/agent/tools/test_querit_unit.py new file mode 100644 index 0000000000..3c88cdda43 --- /dev/null +++ b/test/unit_test/agent/tools/test_querit_unit.py @@ -0,0 +1,560 @@ +# +# Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import logging + +import pytest + +import agent.tools.querit as querit_module +from agent.tools.querit import QueritSearch, QueritSearchParam + + +class _FakeResponse: + def __init__(self, payload, status_code=200): + self._payload = payload + self.status_code = status_code + + def json(self): + return self._payload + + def raise_for_status(self): + if self.status_code >= 400: + raise querit_module.requests.HTTPError( + f"{self.status_code} error", + response=self, + ) + + +def _make_tool(api_key="test-api-key"): + tool = QueritSearch.__new__(QueritSearch) + param = QueritSearchParam() + param.api_key = api_key + param.delay_after_error = 0 + tool._param = param + tool.check_if_canceled = lambda *args, **kwargs: False + + captured = {} + outputs = {} + + def fake_retrieve(results, get_title, get_url, get_content, get_score): + items = list(results) + captured["references"] = [ + { + "title": get_title(item), + "url": get_url(item), + "content": get_content(item), + "score": get_score(item), + } + for item in items + ] + outputs["formalized_content"] = "FORMALIZED" + + tool._retrieve_chunks = fake_retrieve + tool.set_output = lambda key, value: outputs.__setitem__(key, value) + tool.output = lambda key=None: outputs.get(key) if key else outputs + return tool, captured, outputs + + +def test_minimal_search_posts_defaults_and_preserves_raw_response(monkeypatch): + raw_response = { + "took": "300ms", + "error_code": 200, + "error_msg": "", + "search_id": 11099848653006015581, + "query_context": {"query": "expanded query", "count": 99}, + "results": { + "result": [ + { + "title": "LLM progress", + "url": "https://example.com/llm", + "snippet": "Recent LLM progress summary", + "custom": {"request_id": "kept"}, + } + ] + }, + } + calls = [] + + def fake_post(url, **kwargs): + calls.append((url, kwargs)) + return _FakeResponse(raw_response) + + monkeypatch.setattr(querit_module.requests, "post", fake_post) + tool, captured, outputs = _make_tool() + + result = tool._invoke(query="latest LLM progress") + + assert result == "FORMALIZED" + assert calls == [ + ( + "https://api.querit.ai/v1/search", + { + "headers": { + "Accept": "application/json", + "Authorization": "Bearer test-api-key", + "Content-Type": "application/json", + }, + "json": { + "query": "latest LLM progress", + "count": 10, + "chunksPerDoc": 3, + }, + "timeout": querit_module.DEFAULT_TIMEOUT, + }, + ) + ] + assert captured["references"] == [ + { + "title": "LLM progress", + "url": "https://example.com/llm", + "content": "Recent LLM progress summary", + "score": 1, + } + ] + assert outputs["json"] == raw_response + assert outputs["json"]["search_id"] == 11099848653006015581 + + +def test_search_maps_flat_filters_to_querit_request(monkeypatch): + calls = [] + + def fake_post(url, **kwargs): + calls.append((url, kwargs)) + return _FakeResponse({"results": {"result": []}}) + + monkeypatch.setattr(querit_module.requests, "post", fake_post) + tool, _, outputs = _make_tool() + + result = tool._invoke( + query="AI search news", + count=5, + chunks_per_doc=1, + site_include=["example.com"], + site_exclude=["archive.example.com"], + time_range="w1", + country_include=["US", "CA"], + language_include=["en", "fr"], + ) + + assert result == "" + assert calls[0][1]["json"] == { + "query": "AI search news", + "count": 5, + "chunksPerDoc": 1, + "filters": { + "sites": { + "include": ["example.com"], + "exclude": ["archive.example.com"], + }, + "timeRange": {"date": "w1"}, + "geo": {"countries": {"include": ["US", "CA"]}}, + "languages": {"include": ["en", "fr"]}, + }, + } + assert outputs["json"] == {"results": {"result": []}} + + +def test_unauthorized_response_is_not_retried(monkeypatch): + calls = [] + + def fake_post(url, **kwargs): + calls.append((url, kwargs)) + return _FakeResponse({"error": "unauthorized"}, status_code=401) + + monkeypatch.setattr(querit_module.requests, "post", fake_post) + tool, _, outputs = _make_tool() + + result = tool._invoke(query="AI news") + + assert len(calls) == 1 + assert "401" in result + assert "401" in outputs["_ERROR"] + + +def test_blank_node_api_key_falls_back_to_environment(monkeypatch): + received_authorization = [] + + def fake_post(_url, **kwargs): + received_authorization.append(kwargs["headers"]["Authorization"]) + return _FakeResponse({"results": {"result": []}}) + + monkeypatch.setenv("QUERIT_API_KEY", "environment-key") + monkeypatch.setattr(querit_module.requests, "post", fake_post) + tool, _, outputs = _make_tool(api_key=" ") + + result = tool._invoke(query="AI news") + + assert result == "" + assert outputs.get("_ERROR", "") == "" + assert received_authorization == ["Bearer environment-key"] + + +def test_missing_api_key_fails_before_network(monkeypatch): + calls = [] + monkeypatch.delenv("QUERIT_API_KEY", raising=False) + monkeypatch.setattr(querit_module.requests, "post", lambda *args, **kwargs: calls.append((args, kwargs))) + tool, _, outputs = _make_tool(api_key="") + + result = tool._invoke(query="AI news") + + assert calls == [] + assert "QUERIT_API_KEY" in result + assert "QUERIT_API_KEY" in outputs["_ERROR"] + + +def test_empty_query_returns_empty_outputs_without_network(monkeypatch): + calls = [] + monkeypatch.setattr(querit_module.requests, "post", lambda *args, **kwargs: calls.append((args, kwargs))) + tool, _, outputs = _make_tool() + + result = tool._invoke(query="") + + assert result == "" + assert calls == [] + assert outputs == {"formalized_content": "", "json": {}} + + +def test_explicit_none_omits_chunks_per_doc(monkeypatch): + payloads = [] + + def fake_post(_url, **kwargs): + payloads.append(kwargs["json"]) + return _FakeResponse({"results": {"result": []}}) + + monkeypatch.setattr(querit_module.requests, "post", fake_post) + tool, _, _ = _make_tool() + + tool._invoke(query="AI news", chunks_per_doc=None) + + assert payloads == [{"query": "AI news", "count": 10}] + + +def test_runtime_values_override_node_defaults(monkeypatch): + payloads = [] + + def fake_post(_url, **kwargs): + payloads.append(kwargs["json"]) + return _FakeResponse({"results": {"result": []}}) + + monkeypatch.setattr(querit_module.requests, "post", fake_post) + tool, _, _ = _make_tool() + tool._param.count = 8 + tool._param.chunks_per_doc = 2 + tool._param.site_include = ["node.example.com"] + + tool._invoke( + query="AI news", + count=1, + site_include=[], + time_range="2026-01-01to2026-01-31", + ) + + assert payloads == [ + { + "query": "AI news", + "count": 1, + "chunksPerDoc": 2, + "filters": {"timeRange": {"date": "2026-01-01to2026-01-31"}}, + } + ] + + +@pytest.mark.parametrize( + ("runtime_values", "expected_error"), + [ + ({"query": 123}, "query"), + ({"query": "AI news", "count": 0}, "count"), + ({"query": "AI news", "count": 1.5}, "count"), + ({"query": "AI news", "chunks_per_doc": 0}, "chunks_per_doc"), + ({"query": "AI news", "chunks_per_doc": 4}, "chunks_per_doc"), + ({"query": "AI news", "time_range": "past_week"}, "time_range"), + ({"query": "AI news", "site_include": ["example.com", 1]}, "site_include"), + ], +) +def test_invalid_runtime_values_fail_before_network(monkeypatch, runtime_values, expected_error): + calls = [] + monkeypatch.setattr(querit_module.requests, "post", lambda *args, **kwargs: calls.append((args, kwargs))) + tool, _, outputs = _make_tool() + + result = tool._invoke(**runtime_values) + + assert calls == [] + assert expected_error in result + assert expected_error in outputs["_ERROR"] + + +def test_retryable_http_errors_are_retried_until_success(monkeypatch): + responses = [ + _FakeResponse({"error": "rate limited"}, status_code=429), + _FakeResponse({"error": "temporary"}, status_code=503), + _FakeResponse({"results": {"result": []}, "request_id": "success"}), + ] + calls = [] + + def fake_post(*args, **kwargs): + calls.append((args, kwargs)) + return responses.pop(0) + + monkeypatch.setattr(querit_module.requests, "post", fake_post) + tool, _, outputs = _make_tool() + + result = tool._invoke(query="AI news") + + assert result == "" + assert len(calls) == 3 + assert outputs["json"]["request_id"] == "success" + + +def test_retry_wait_uses_configured_delay(monkeypatch): + responses = [ + _FakeResponse({"error": "rate limited"}, status_code=429), + _FakeResponse({"results": {"result": []}}), + ] + delays = [] + + monkeypatch.setattr(querit_module.requests, "post", lambda *args, **kwargs: responses.pop(0)) + monkeypatch.setattr(querit_module.time, "sleep", delays.append) + tool, _, _ = _make_tool() + tool._param.delay_after_error = 0.25 + + result = tool._invoke(query="AI news") + + assert result == "" + assert delays == [0.25] + + +def test_cancellation_stops_before_retry(monkeypatch): + calls = [] + + def fake_post(*args, **kwargs): + calls.append((args, kwargs)) + return _FakeResponse({"error": "rate limited"}, status_code=429) + + monkeypatch.setattr(querit_module.requests, "post", fake_post) + tool, _, outputs = _make_tool() + cancellation_checks = iter([False, False, True]) + tool.check_if_canceled = lambda *args, **kwargs: next(cancellation_checks) + + result = tool._invoke(query="AI news") + + assert result is None + assert len(calls) == 1 + assert "_ERROR" not in outputs + + +def test_network_errors_are_retried_until_success(monkeypatch): + outcomes = [ + querit_module.requests.ConnectTimeout("first timeout"), + querit_module.requests.ConnectionError("second failure"), + _FakeResponse({"results": {"result": []}}), + ] + calls = [] + + def fake_post(*args, **kwargs): + calls.append((args, kwargs)) + outcome = outcomes.pop(0) + if isinstance(outcome, Exception): + raise outcome + return outcome + + monkeypatch.setattr(querit_module.requests, "post", fake_post) + tool, _, outputs = _make_tool() + + result = tool._invoke(query="AI news") + + assert result == "" + assert len(calls) == 3 + assert outputs["json"] == {"results": {"result": []}} + + +def test_invalid_json_response_is_not_retried(monkeypatch): + calls = [] + + class _InvalidJSONResponse(_FakeResponse): + def json(self): + raise querit_module.requests.JSONDecodeError("invalid JSON", "not-json", 0) + + def fake_post(*args, **kwargs): + calls.append((args, kwargs)) + return _InvalidJSONResponse(None) + + monkeypatch.setattr(querit_module.requests, "post", fake_post) + tool, _, outputs = _make_tool() + + result = tool._invoke(query="AI news") + + assert len(calls) == 1 + assert "invalid JSON" in result + assert "invalid JSON" in outputs["_ERROR"] + + +def test_persistent_retryable_error_stops_after_three_attempts(monkeypatch): + calls = [] + + def fake_post(*args, **kwargs): + calls.append((args, kwargs)) + return _FakeResponse({"error": "temporary"}, status_code=500) + + monkeypatch.setattr(querit_module.requests, "post", fake_post) + tool, _, outputs = _make_tool() + + result = tool._invoke(query="AI news") + + assert len(calls) == 3 + assert "500" in result + assert "500" in outputs["_ERROR"] + + +def test_non_object_response_is_rejected(monkeypatch): + monkeypatch.setattr( + querit_module.requests, + "post", + lambda *args, **kwargs: _FakeResponse(["unexpected"]), + ) + tool, _, outputs = _make_tool() + + result = tool._invoke(query="AI news") + + assert "JSON object" in result + assert "JSON object" in outputs["_ERROR"] + + +@pytest.mark.parametrize( + ("raw_response", "expected_error"), + [ + ({"results": []}, "results must be an object"), + ({"results": None}, "results must be an object"), + ({"results": {"result": {}}}, "results.result must be an array"), + ({"results": {"result": None}}, "results.result must be an array"), + ], +) +def test_invalid_result_container_types_are_rejected( + monkeypatch, + raw_response, + expected_error, +): + monkeypatch.setattr( + querit_module.requests, + "post", + lambda *args, **kwargs: _FakeResponse(raw_response), + ) + tool, _, outputs = _make_tool() + + result = tool._invoke(query="AI news") + + assert expected_error in result + assert expected_error in outputs["_ERROR"] + + +def test_results_with_missing_optional_fields_do_not_fail(monkeypatch): + raw_response = { + "results": { + "result": [ + {"title": "Title only"}, + {"url": "https://example.com"}, + {"snippet": "Snippet only"}, + "unexpected", + ] + } + } + monkeypatch.setattr( + querit_module.requests, + "post", + lambda *args, **kwargs: _FakeResponse(raw_response), + ) + tool, captured, outputs = _make_tool() + + result = tool._invoke(query="AI news") + + assert result == "FORMALIZED" + assert len(captured["references"]) == 3 + assert outputs["json"] == raw_response + + +def test_non_string_reference_fields_match_go_string_coercion(monkeypatch): + raw_response = { + "results": { + "result": [ + { + "title": 123, + "url": 456, + "snippet": 789, + } + ] + } + } + monkeypatch.setattr( + querit_module.requests, + "post", + lambda *args, **kwargs: _FakeResponse(raw_response), + ) + tool, captured, outputs = _make_tool() + + result = tool._invoke(query="AI news") + + assert result == "FORMALIZED" + assert captured["references"] == [ + { + "title": "123", + "url": "456", + "content": "789", + "score": 1, + } + ] + assert outputs["json"] == raw_response + + +def test_api_key_is_redacted_from_errors_and_logs(monkeypatch, caplog): + secret = f"secret-test-key-{id(caplog)}" + + def fake_post(*args, **kwargs): + raise querit_module.requests.ConnectionError(f"connection failed for {secret}") + + monkeypatch.setattr(querit_module.requests, "post", fake_post) + tool, _, outputs = _make_tool(api_key=secret) + + with caplog.at_level(logging.ERROR): + result = tool._invoke(query="AI news") + + assert secret not in result + assert secret not in outputs["_ERROR"] + assert secret not in caplog.text + assert "[REDACTED]" in result + + +def test_querit_classes_are_available_through_dynamic_tool_discovery(): + import agent.tools as tools_package + + assert tools_package.QueritSearch is QueritSearch + assert tools_package.QueritSearchParam is QueritSearchParam + + +def test_tool_metadata_exposes_only_supported_runtime_parameters(): + metadata = QueritSearchParam().get_meta()["function"] + + assert metadata["name"] == "querit_search" + assert metadata["parameters"]["required"] == ["query"] + assert set(metadata["parameters"]["properties"]) == { + "query", + "count", + "chunks_per_doc", + "site_include", + "site_exclude", + "time_range", + "country_include", + "language_include", + } + assert "api_key" not in metadata["parameters"]["properties"] diff --git a/tools/gen-component-parity/main.go b/tools/gen-component-parity/main.go index fbe7ab392f..8c644f283d 100644 --- a/tools/gen-component-parity/main.go +++ b/tools/gen-component-parity/main.go @@ -159,7 +159,7 @@ func sortedToolNames() []string { known := []string{ "akshare", "arxiv", "code_exec", "crawler", "deepl", "duckduckgo", "email", "execute_sql", "exesql", "github", "google", "google_scholar", - "jin10", "pubmed", "qweather", "retrieval", "search_my_dataset", + "jin10", "pubmed", "qweather", "querit", "querit_search", "queritsearch", "retrieval", "search_my_dataset", "search_my_dateset", "searxng", "tavily", "tushare", "wencai", "web_crawler", "wikipedia", "yahoo_finance", } diff --git a/web/src/assets/querit.png b/web/src/assets/querit.png new file mode 100644 index 0000000000000000000000000000000000000000..223524516a2bd87e0522b81a7dc23e9678892d1d GIT binary patch literal 49633 zcmXt9Wl$UM*9}f7?pma{I|MD#V#VExLvbrmBuH_0mlP+sOL2Gk;ZCvOP$WpOm;Z&ktn)fCiu-E2Z@_=d=@)!nmSOeOvLLVg-(=fp8pfs&|fZH>dyhTt&%r z_<;WMW~eS!ikrhCAuz%A^GDjCfjIV{n7YPJdFfea4wf{Cv=!k*^gw!w~sgR5i+9bcl@P*uv?J?ZsN_%5yRe9@Z5MW8 zRwnoGLdlnCRdWKOML=bKpS5yjHcSxUfGBLwcwAJ;yBzUSUbdpJ68rtJ*2x#Ghf1sG z_2#qi-re%sa{QbLDr}^e^%@JR9+ALFJLy#{Z|8zOeL$8*$(EVsX6NYojOyTHr}`4r z&ffFp>%EN&0d}|r?O)4t7ukFF?qAA)lcw@b*-#I_f%~WzJWc60rWwhD;-;ZQkx1W{ z<%9N&vWNcitTH%7z$#H^Kaag|rl< z3u0lTS;_H}kPejr2vxl6-4c7!?4p_8CPh?%#-;YB8x4~ORvm}5asftkN6GW==uJN& z9CyV$s(o$-TCOvJR8zIj>J6KINg})aynm?Pq>96T`=e0Fkm_Kr3LqPQ0 zw`UMao}Op4G3~h*NPIva>xh_u{26_NInhmxDEzb+0Np!x(R-suPtk95T6xs>eUCSQ z-;6YZJN#Z9O9o0zt;3rOXQQm8R|K-@oP4q{u&Q&u^Cl6fLlVB<1K%`u^h@=QuGbOX z?|^%KP#l&po_>AlAVy@T)9D9Dor>~FXu}jOITFk2a2!o4mqr_Ivy59_hy#Pm`+A-I z@=VANT=?JEVrTEVzff7Y6AW~RhMIn9H|KS3=0F~~&w7_hal&AwGPE*J7O5{brB#^& zuCDnAKr3?r{QW2N5>nwgd=#48W|9AIP!g!UwI=U0#JdOEU>LBA4Wj=``%dK2R(&|Q zXa$I}5kA2pTAT+%F{+7t{wwsN$@I4L*W1$b`Zq%e^+{V-iL)j@95#Hn0>#oXa+(P)$a zK=p5(zk{-hE3#mPddgq?M12k`dvG!lPEyB|t^twvnM(frq9s^Lw=hqezq2oXRpl=K zP69B|T!eMd-xQ;Mc@=Nk$#}u`_xulWz=vPL^d`iE-)TEO1P9ZOu3MJgj6Teu3!lKf z7$j0BAQ`PbB~EbHs|WNR3D(nB_yN59=kQhk=sFL^H-D_PvRb6S=G5j>>R=!}^J=LT zQqqH(rDTMpjEVkIhSIQX6yGh&J>E%MSJHG$c{j>mR#wU!FkQ(tE@yXk>^Bdj`*!7Rp7}Rv%-)gU zqD_^F;L&2UYM^FFQyMC6kv;0y&A=$rvD39TKzf&|3HIYusiSxzu-u&AK&gGK0H0kS z$348&Tx!QkGKT7_PKikdv2~KXL7GhCdj+V%Mx&}sKw2wmH$`sh#CtgND)pF7!#ITL zSYG;^Pwn?HQ@rg4ipxJX@P2wT5Q@a)F87}65!aJjeP@oM}6V}LNC3sH|YlgDu7w&ghh&~X}G zt6A|rZD26@;1lv%X_exK^+=I$%y)g@TXDin_h(Fy5p(@4{QGf=1xFLZZr~%@4;)rUNIn zditngKl#vortK!?wur+9&78X6uFEnn*I)G{15PeSRQov2)vGN``LTcWDaHI6tu{0g zA3PChD4$+G4m2|yEr0l$tA<7+yx`B=7e8j>E#58<{USN*WhT!{Ef(rTYdPj+{;?{K z{+q|vKu+g|dq$+1^G{CSM4dzs7D9TSJ?TJzWB#jr@A zF(7?_fLnd@VIsE@Qje%%y_+wedLlzh!M0tt1X2n{

BaQlC9t?Hb%4(qn&pbfDO2 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 literal 0 HcmV?d00001 diff --git a/web/src/constants/agent.tsx b/web/src/constants/agent.tsx index 5a2b3f9565..6eb5dcce0e 100644 --- a/web/src/constants/agent.tsx +++ b/web/src/constants/agent.tsx @@ -95,6 +95,7 @@ export enum Operator { Tool = 'Tool', TavilySearch = 'TavilySearch', TavilyExtract = 'TavilyExtract', + QueritSearch = 'QueritSearch', UserFillUp = 'UserFillUp', StringTransform = 'StringTransform', SearXNG = 'SearXNG', diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 43647640ef..5d4b6d418f 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -2491,6 +2491,26 @@ Best for: Documents with flowing, contextually connected content — such as boo includeImageDescriptions: 'Include image descriptions', includeDomains: 'Include domains', ExcludeDomains: 'Exclude domains', + queritCount: 'Result count', + queritCountTip: 'Set the maximum number of search results to return.', + queritChunksPerDoc: 'Chunks per document', + queritChunksPerDocTip: + 'Set the number of relevant text chunks returned for each result, from 1 to 3.', + queritSiteInclude: 'Sites to include', + queritSiteIncludeTip: + 'Return results only from the specified domains or websites.', + queritSiteExclude: 'Sites to exclude', + queritSiteExcludeTip: + 'Exclude results from the specified domains or websites.', + queritTimeRange: 'Time range', + queritTimeRangeTip: + 'Use a relative range such as d7, w2, m1, or y1, or an absolute range such as 2026-01-01to2026-01-31.', + queritCountryInclude: 'Countries to include', + queritCountryIncludeTip: + 'Return results associated with the specified countries.', + queritLanguageInclude: 'Languages to include', + queritLanguageIncludeTip: 'Return results in the specified languages.', + queritListPlaceholder: 'Enter a value', Days: 'Days', comma: 'Comma', semicolon: 'Semicolon', @@ -3147,6 +3167,9 @@ This process aggregates variables from multiple branches into a single variable codeExec: 'Code', tavilySearch: 'Tavily search', tavilySearchDescription: 'Search results via Tavily service.', + queritSearch: 'Querit search', + queritSearchDescription: + 'Search the web with Querit and return source-backed results for agents.', tavilyExtract: 'Tavily extract', tavilyExtractDescription: 'Tavily Extract', log: 'Log', @@ -3179,6 +3202,7 @@ This process aggregates variables from multiple branches into a single variable code: 'Running a quick script', textProcessing: 'Tidying up text', tavilySearch: 'Searching the web', + queritSearch: 'Searching the web with Querit', tavilyExtract: 'Reading the page', exeSQL: 'Querying database', google: 'Searching the web', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index d5c82ba222..97923e30f5 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -2141,6 +2141,23 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系 includeImageDescriptions: '包含图片描述', includeDomains: '包含域名', ExcludeDomains: '排除域名', + queritCount: '结果数量', + queritCountTip: '设置本次搜索最多返回的结果数量。', + queritChunksPerDoc: '每篇文档片段数', + queritChunksPerDocTip: + '设置每条结果返回的相关文本片段数量,可填写 1 至 3。', + queritSiteInclude: '包含的网站', + queritSiteIncludeTip: '仅返回来自指定域名或网站的结果。', + queritSiteExclude: '排除的网站', + queritSiteExcludeTip: '排除来自指定域名或网站的结果。', + queritTimeRange: '时间范围', + queritTimeRangeTip: + '可填写 d7、w2、m1、y1 等相对时间,或 2026-01-01to2026-01-31 形式的绝对日期范围。', + queritCountryInclude: '包含的国家', + queritCountryIncludeTip: '返回与指定国家相关的结果。', + queritLanguageInclude: '包含的语言', + queritLanguageIncludeTip: '返回使用指定语言的结果。', + queritListPlaceholder: '请输入内容', days: '天数', comma: '逗号', semicolon: '分号', @@ -2758,6 +2775,9 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系 codeExec: '代码', tavilySearch: 'Tavily 搜索', tavilySearchDescription: '通过 Tavily 服务搜索结果', + queritSearch: 'Querit 搜索', + queritSearchDescription: + '使用 Querit 搜索网络,并为智能体返回带来源的搜索结果。', tavilyExtract: 'Tavily 提取', tavilyExtractDescription: 'Tavily 提取', log: '日志', @@ -2778,6 +2798,7 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系 code: '运行小段代码', textProcessing: '整理文字', tavilySearch: '正在网上搜索', + queritSearch: '正在使用 Querit 搜索网络', tavilyExtract: '读取网页内容', exeSQL: '查询数据库', google: '正在网上搜索', diff --git a/web/src/pages/agent/canvas/node/dropdown/accordion-operators.tsx b/web/src/pages/agent/canvas/node/dropdown/accordion-operators.tsx index b19b4a7b6d..a4a6cdd9e5 100644 --- a/web/src/pages/agent/canvas/node/dropdown/accordion-operators.tsx +++ b/web/src/pages/agent/canvas/node/dropdown/accordion-operators.tsx @@ -109,6 +109,7 @@ export function AccordionOperators({ operators={[ Operator.TavilySearch, Operator.TavilyExtract, + Operator.QueritSearch, Operator.ExeSQL, Operator.Google, Operator.YahooFinance, diff --git a/web/src/pages/agent/constant/index.tsx b/web/src/pages/agent/constant/index.tsx index 5f1e4050bc..15242fa3d1 100644 --- a/web/src/pages/agent/constant/index.tsx +++ b/web/src/pages/agent/constant/index.tsx @@ -572,6 +572,28 @@ export const initialTavilyValues = { }, }; +export const initialQueritValues = { + api_key: '', + query: AgentGlobals.SysQuery, + count: 10, + chunks_per_doc: 3, + site_include: [], + site_exclude: [], + time_range: '', + country_include: [], + language_include: [], + outputs: { + formalized_content: { + value: '', + type: 'string', + }, + json: { + value: {}, + type: 'object', + }, + }, +}; + export enum TavilyExtractDepth { Basic = 'basic', Advanced = 'advanced', @@ -722,6 +744,7 @@ export const RestrictedUpstreamMap = { [Operator.WaitingDialogue]: [Operator.Begin], [Operator.Agent]: [Operator.Begin], [Operator.TavilySearch]: [Operator.Begin], + [Operator.QueritSearch]: [Operator.Begin], [Operator.TavilyExtract]: [Operator.Begin], [Operator.StringTransform]: [Operator.Begin], [Operator.UserFillUp]: [Operator.Begin], @@ -777,6 +800,7 @@ export const NodeMap = { [Operator.Agent]: 'agentNode', [Operator.Tool]: 'toolNode', [Operator.TavilySearch]: 'ragNode', + [Operator.QueritSearch]: 'ragNode', [Operator.UserFillUp]: 'ragNode', [Operator.StringTransform]: 'ragNode', [Operator.TavilyExtract]: 'ragNode', diff --git a/web/src/pages/agent/form-sheet/form-config-map.tsx b/web/src/pages/agent/form-sheet/form-config-map.tsx index 7e6b12c678..5d7261dbe1 100644 --- a/web/src/pages/agent/form-sheet/form-config-map.tsx +++ b/web/src/pages/agent/form-sheet/form-config-map.tsx @@ -26,6 +26,7 @@ import LoopForm from '../form/loop-form'; import MessageForm from '../form/message-form'; import ParserForm from '../form/parser-form'; import PubMedForm from '../form/pubmed-form'; +import QueritForm from '../form/querit-form'; import BGPTForm from '../form/bgpt-form'; import RetrievalForm from '../form/retrieval-form/next'; import RewriteQuestionForm from '../form/rewrite-question-form'; @@ -145,6 +146,9 @@ export const FormConfigMap = { [Operator.TavilySearch]: { component: TavilyForm, }, + [Operator.QueritSearch]: { + component: QueritForm, + }, [Operator.UserFillUp]: { component: UserFillUpForm, }, diff --git a/web/src/pages/agent/form/agent-form/tool-popover/tool-command.tsx b/web/src/pages/agent/form/agent-form/tool-popover/tool-command.tsx index 400bba78f5..7ed2af7b87 100644 --- a/web/src/pages/agent/form/agent-form/tool-popover/tool-command.tsx +++ b/web/src/pages/agent/form/agent-form/tool-popover/tool-command.tsx @@ -23,6 +23,7 @@ const Menus = [ list: [ Operator.TavilySearch, Operator.TavilyExtract, + Operator.QueritSearch, Operator.Google, // Operator.Bing, Operator.DuckDuckGo, diff --git a/web/src/pages/agent/form/querit-form/dynamic-list.tsx b/web/src/pages/agent/form/querit-form/dynamic-list.tsx new file mode 100644 index 0000000000..197e8e332c --- /dev/null +++ b/web/src/pages/agent/form/querit-form/dynamic-list.tsx @@ -0,0 +1,73 @@ +import { BlockButton, Button } from '@/components/ui/button'; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { X } from 'lucide-react'; +import { useFieldArray, useFormContext } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +type DynamicListProps = { + name: + | 'site_include' + | 'site_exclude' + | 'country_include' + | 'language_include'; + label: string; + tooltip: string; + placeholder: string; +}; + +export function DynamicList({ + name, + label, + tooltip, + placeholder, +}: DynamicListProps) { + const { t } = useTranslation(); + const form = useFormContext(); + const { fields, append, remove } = useFieldArray({ + name, + control: form.control, + }); + + return ( + + {label} +

+ append({ value: '' })}> + {t('common.add')} + + + ); +} diff --git a/web/src/pages/agent/form/querit-form/index.tsx b/web/src/pages/agent/form/querit-form/index.tsx new file mode 100644 index 0000000000..1fb5e4aa50 --- /dev/null +++ b/web/src/pages/agent/form/querit-form/index.tsx @@ -0,0 +1,136 @@ +import { FormContainer } from '@/components/form-container'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { useTranslate } from '@/hooks/common-hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { memo } from 'react'; +import { useForm, useFormContext } from 'react-hook-form'; +import { z } from 'zod'; +import { initialQueritValues } from '../../constant'; +import { INextOperatorForm } from '../../interface'; +import { buildOutputList } from '../../utils/build-output-list'; +import { ApiKeyField } from '../components/api-key-field'; +import { FormWrapper } from '../components/form-wrapper'; +import { Output } from '../components/output'; +import { QueryVariable } from '../components/query-variable'; +import { DynamicList } from './dynamic-list'; +import { QueritFormSchema } from './utils'; +import { useValues } from './use-values'; +import { useWatchFormChange } from './use-watch-change'; + +const outputList = buildOutputList(initialQueritValues.outputs); + +export function QueritFormWidgets({ includeQuery = true }) { + const form = useFormContext(); + const { t } = useTranslate('flow'); + const listPlaceholder = t('queritListPlaceholder'); + + return ( + <> + + {includeQuery && } + ( + + + {t('queritCount')} + + + + + + + )} + /> + ( + + + {t('queritChunksPerDoc')} + + + + + + + )} + /> + + + ( + + + {t('queritTimeRange')} + + + + + + + )} + /> + + + + ); +} + +function QueritForm({ node }: INextOperatorForm) { + const values = useValues(node); + const form = useForm>({ + defaultValues: values, + resolver: zodResolver(QueritFormSchema), + mode: 'onChange', + }); + + useWatchFormChange(node?.id, form); + + return ( +
+ + + + + +
+ +
+
+ ); +} + +export default memo(QueritForm); diff --git a/web/src/pages/agent/form/querit-form/use-values.ts b/web/src/pages/agent/form/querit-form/use-values.ts new file mode 100644 index 0000000000..8d77013601 --- /dev/null +++ b/web/src/pages/agent/form/querit-form/use-values.ts @@ -0,0 +1,14 @@ +import { RAGFlowNodeType } from '@/interfaces/database/agent'; +import { isEmpty } from 'lodash'; +import { useMemo } from 'react'; +import { initialQueritValues } from '../../constant'; +import { deserializeQueritFormValues } from './utils'; + +export function useValues(node?: RAGFlowNodeType) { + return useMemo(() => { + const formData = node?.data?.form; + const values = isEmpty(formData) ? initialQueritValues : formData; + + return deserializeQueritFormValues(values); + }, [node?.data?.form]); +} diff --git a/web/src/pages/agent/form/querit-form/use-watch-change.test.tsx b/web/src/pages/agent/form/querit-form/use-watch-change.test.tsx new file mode 100644 index 0000000000..d6e9cd62f2 --- /dev/null +++ b/web/src/pages/agent/form/querit-form/use-watch-change.test.tsx @@ -0,0 +1,114 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { StrictMode } from 'react'; +import { useForm } from 'react-hook-form'; +import { useWatchFormChange } from './use-watch-change'; + +const mockUpdateNodeForm = jest.fn(); + +jest.mock('../../store', () => ({ + __esModule: true, + default: (selector: (state: Record) => unknown) => + selector({ updateNodeForm: mockUpdateNodeForm }), +})); + +const validValues = { + api_key: '', + query: 'begin.query', + count: 10, + chunks_per_doc: 3, + site_include: [], + site_exclude: [], + time_range: '', + country_include: [], + language_include: [], +}; + +function useQueritFormWatcher(defaultValues: Record) { + const form = useForm({ defaultValues }); + useWatchFormChange('querit:0', form); + return form; +} + +describe('useWatchFormChange', () => { + beforeEach(() => { + mockUpdateNodeForm.mockClear(); + }); + + it('skips the initial write and normalizes subsequent valid edits', async () => { + const { result } = renderHook(() => + useQueritFormWatcher({ + ...validValues, + count: '5', + chunks_per_doc: '2', + site_include: [{ value: ' docs.example.com ' }], + }), + ); + + expect(mockUpdateNodeForm).not.toHaveBeenCalled(); + + act(() => { + result.current.setValue('count', '6'); + }); + + await waitFor(() => { + expect(mockUpdateNodeForm).toHaveBeenCalledWith( + 'querit:0', + expect.objectContaining({ + count: 6, + chunks_per_doc: 2, + site_include: ['docs.example.com'], + }), + ); + }); + }); + + it('does not write an invalid initial form to the Canvas node', async () => { + renderHook(() => + useQueritFormWatcher({ + ...validValues, + count: 0, + }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockUpdateNodeForm).not.toHaveBeenCalled(); + }); + + it('does not write a valid initial form in Strict Mode', async () => { + renderHook(() => useQueritFormWatcher(validValues), { + wrapper: StrictMode, + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockUpdateNodeForm).not.toHaveBeenCalled(); + }); + + it('does not overwrite the last valid state after an invalid edit', async () => { + const { result } = renderHook(() => useQueritFormWatcher(validValues)); + + act(() => { + result.current.setValue('count', 5); + }); + + await waitFor(() => { + expect(mockUpdateNodeForm).toHaveBeenCalled(); + }); + mockUpdateNodeForm.mockClear(); + + act(() => { + result.current.setValue('time_range', 'last week'); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockUpdateNodeForm).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/pages/agent/form/querit-form/use-watch-change.ts b/web/src/pages/agent/form/querit-form/use-watch-change.ts new file mode 100644 index 0000000000..e494a9289a --- /dev/null +++ b/web/src/pages/agent/form/querit-form/use-watch-change.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef } from 'react'; +import { UseFormReturn, useWatch } from 'react-hook-form'; +import useGraphStore from '../../store'; +import { validateQueritFormValuesForPersistence } from './utils'; + +export function useWatchFormChange(id?: string, form?: UseFormReturn) { + const values = useWatch({ control: form?.control }); + const updateNodeForm = useGraphStore((state) => state.updateNodeForm); + const previousValues = useRef(values); + + useEffect(() => { + if (Object.is(previousValues.current, values)) { + return; + } + previousValues.current = values; + if (id && form) { + const nextValues = validateQueritFormValuesForPersistence( + form.getValues(), + ); + if (nextValues) { + updateNodeForm(id, nextValues); + } + } + }, [form, id, updateNodeForm, values]); +} diff --git a/web/src/pages/agent/form/querit-form/utils.test.ts b/web/src/pages/agent/form/querit-form/utils.test.ts new file mode 100644 index 0000000000..615cec4c93 --- /dev/null +++ b/web/src/pages/agent/form/querit-form/utils.test.ts @@ -0,0 +1,224 @@ +import { + QueritFormSchema, + deserializeQueritFormValues, + serializeQueritFormValues, + validateQueritFormValuesForPersistence, +} from './utils'; + +describe('Querit form contract', () => { + const validValues = { + api_key: '', + query: 'begin.query', + count: 10, + chunks_per_doc: 3, + site_include: [], + site_exclude: [], + time_range: '', + country_include: [], + language_include: [], + }; + + it.each(['d7', 'w2', 'm1', 'y1', '2026-01-01to2026-01-31', ''])( + 'accepts the supported time range %p', + (timeRange) => { + expect( + QueritFormSchema.safeParse({ + ...validValues, + time_range: timeRange, + }).success, + ).toBe(true); + }, + ); + + it.each(['d0', '7d', '2026-01-01-2026-01-31', 'last week'])( + 'rejects the unsupported time range %p', + (timeRange) => { + expect( + QueritFormSchema.safeParse({ + ...validValues, + time_range: timeRange, + }).success, + ).toBe(false); + }, + ); + + it.each([0, -1, 1.5])('rejects the invalid result count %p', (count) => { + expect( + QueritFormSchema.safeParse({ + ...validValues, + count, + }).success, + ).toBe(false); + }); + + it.each([0, 1.5, 4])( + 'rejects the invalid chunks per document value %p', + (chunksPerDoc) => { + expect( + QueritFormSchema.safeParse({ + ...validValues, + chunks_per_doc: chunksPerDoc, + }).success, + ).toBe(false); + }, + ); + + it.each(['', ' '])('rejects an empty dynamic list item %p', (listItem) => { + expect( + QueritFormSchema.safeParse({ + ...validValues, + site_include: [{ value: listItem }], + }).success, + ).toBe(false); + }); + + it.each(['', ' '])('rejects an empty query %p', (query) => { + expect( + QueritFormSchema.safeParse({ + ...validValues, + query, + }).success, + ).toBe(false); + }); +}); + +describe('Querit form persistence', () => { + it('rehydrates persisted string arrays for dynamic form fields', () => { + expect( + deserializeQueritFormValues({ + api_key: '', + query: 'begin.query', + count: 5, + chunks_per_doc: 2, + site_include: ['docs.example.com'], + site_exclude: ['spam.example.com'], + time_range: 'w1', + country_include: ['united states'], + language_include: ['en'], + }), + ).toMatchObject({ + site_include: [{ value: 'docs.example.com' }], + site_exclude: [{ value: 'spam.example.com' }], + country_include: [{ value: 'united states' }], + language_include: [{ value: 'en' }], + }); + }); + + it('serializes dynamic form fields as string arrays', () => { + expect( + serializeQueritFormValues({ + api_key: '', + query: 'begin.query', + count: 5, + chunks_per_doc: 2, + site_include: [{ value: 'docs.example.com' }], + site_exclude: [{ value: 'spam.example.com' }], + time_range: 'w1', + country_include: [{ value: 'united states' }], + language_include: [{ value: 'en' }], + }), + ).toEqual({ + api_key: '', + query: 'begin.query', + count: 5, + chunks_per_doc: 2, + site_include: ['docs.example.com'], + site_exclude: ['spam.example.com'], + time_range: 'w1', + country_include: ['united states'], + language_include: ['en'], + }); + }); + + it('serializes valid numeric input values as numbers', () => { + expect( + serializeQueritFormValues({ + api_key: '', + query: 'begin.query', + count: '5', + chunks_per_doc: '2', + site_include: [], + site_exclude: [], + time_range: '', + country_include: [], + language_include: [], + }), + ).toMatchObject({ + count: 5, + chunks_per_doc: 2, + }); + }); + + it('does not produce persisted values when the current form is invalid', () => { + expect( + validateQueritFormValuesForPersistence({ + api_key: '', + query: 'begin.query', + count: 0, + chunks_per_doc: 3, + site_include: [], + site_exclude: [], + time_range: '', + country_include: [], + language_include: [], + }), + ).toBeUndefined(); + }); + + it('produces normalized persisted values when the current form is valid', () => { + expect( + validateQueritFormValuesForPersistence({ + api_key: '', + query: 'begin.query', + count: '5', + chunks_per_doc: '2', + site_include: [{ value: 'docs.example.com' }], + site_exclude: [], + time_range: 'w1', + country_include: [], + language_include: [{ value: 'en' }], + }), + ).toMatchObject({ + count: 5, + chunks_per_doc: 2, + site_include: ['docs.example.com'], + language_include: ['en'], + }); + }); + + it('trims dynamic list values before persistence', () => { + expect( + validateQueritFormValuesForPersistence({ + api_key: '', + query: 'begin.query', + count: 5, + chunks_per_doc: 2, + site_include: [{ value: ' docs.example.com ' }], + site_exclude: [], + time_range: '', + country_include: [], + language_include: [{ value: ' en ' }], + }), + ).toMatchObject({ + site_include: ['docs.example.com'], + language_include: ['en'], + }); + }); + + it('normalizes missing persisted list fields to empty arrays', () => { + expect( + deserializeQueritFormValues({ + api_key: '', + query: 'begin.query', + count: 10, + chunks_per_doc: 3, + time_range: '', + }), + ).toMatchObject({ + site_include: [], + site_exclude: [], + country_include: [], + language_include: [], + }); + }); +}); diff --git a/web/src/pages/agent/form/querit-form/utils.ts b/web/src/pages/agent/form/querit-form/utils.ts new file mode 100644 index 0000000000..a25cf4a1aa --- /dev/null +++ b/web/src/pages/agent/form/querit-form/utils.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; + +export const QueritTimeRangePattern = + /^([dwmy][1-9][0-9]*|\d{4}-\d{2}-\d{2}to\d{4}-\d{2}-\d{2})$/; + +const dynamicStringListSchema = z.array( + z.object({ + value: z.string().trim().min(1), + }), +); + +export const QueritFormSchema = z.object({ + api_key: z.string(), + query: z.string().trim().min(1), + count: z.coerce.number().int().min(1), + chunks_per_doc: z.coerce.number().int().min(1).max(3), + site_include: dynamicStringListSchema, + site_exclude: dynamicStringListSchema, + time_range: z + .string() + .refine((value) => value === '' || QueritTimeRangePattern.test(value)), + country_include: dynamicStringListSchema, + language_include: dynamicStringListSchema, +}); + +type QueritFormRecord = Record; + +function toObjectArray(values: unknown) { + return Array.isArray(values) ? values.map((value) => ({ value })) : []; +} + +function toStringArray(values: unknown) { + return Array.isArray(values) + ? values.map((item) => item?.value as string) + : []; +} + +export function deserializeQueritFormValues(values: QueritFormRecord) { + return { + ...values, + site_include: toObjectArray(values.site_include), + site_exclude: toObjectArray(values.site_exclude), + country_include: toObjectArray(values.country_include), + language_include: toObjectArray(values.language_include), + }; +} + +export function serializeQueritFormValues(values: QueritFormRecord) { + return { + ...values, + count: Number(values.count), + chunks_per_doc: Number(values.chunks_per_doc), + site_include: toStringArray(values.site_include), + site_exclude: toStringArray(values.site_exclude), + country_include: toStringArray(values.country_include), + language_include: toStringArray(values.language_include), + }; +} + +export function validateQueritFormValuesForPersistence( + values: QueritFormRecord, +) { + const result = QueritFormSchema.safeParse(values); + if (!result.success) { + return undefined; + } + + return serializeQueritFormValues({ + ...values, + ...result.data, + }); +} diff --git a/web/src/pages/agent/form/tool-form/constant.tsx b/web/src/pages/agent/form/tool-form/constant.tsx index 34621adb22..772ed10c6a 100644 --- a/web/src/pages/agent/form/tool-form/constant.tsx +++ b/web/src/pages/agent/form/tool-form/constant.tsx @@ -10,6 +10,7 @@ import GoogleForm from './google-form'; import GoogleScholarForm from './google-scholar-form'; import KeenableForm from './keenable-form'; import PubMedForm from './pubmed-form'; +import QueritForm from './querit-form'; import BGPTForm from './bgpt-form'; import RetrievalForm from './retrieval-form'; import SearXNGForm from './searxng-form'; @@ -36,6 +37,7 @@ export const ToolFormConfigMap = { [Operator.Email]: EmailForm, [Operator.TavilySearch]: TavilyForm, [Operator.TavilyExtract]: TavilyForm, + [Operator.QueritSearch]: QueritForm, [Operator.WenCai]: WenCaiForm, [Operator.SearXNG]: SearXNGForm, [Operator.KeenableSearch]: KeenableForm, diff --git a/web/src/pages/agent/form/tool-form/querit-form/index.tsx b/web/src/pages/agent/form/tool-form/querit-form/index.tsx new file mode 100644 index 0000000000..42df1ed88f --- /dev/null +++ b/web/src/pages/agent/form/tool-form/querit-form/index.tsx @@ -0,0 +1,41 @@ +import { FormContainer } from '@/components/form-container'; +import { Form } from '@/components/ui/form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { memo, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { FormWrapper } from '../../components/form-wrapper'; +import { QueritFormWidgets } from '../../querit-form'; +import { useValues } from '../use-values'; +import { useQueritAgentFormChange } from './use-watch-change'; +import { + QueritAgentFormSchema, + deserializeQueritAgentFormValues, +} from './utils'; + +function QueritForm() { + const persistedValues = useValues(); + const values = useMemo( + () => deserializeQueritAgentFormValues(persistedValues), + [persistedValues], + ); + const form = useForm>({ + defaultValues: values, + resolver: zodResolver(QueritAgentFormSchema), + mode: 'onChange', + }); + + useQueritAgentFormChange(form); + + return ( +
+ + + + + +
+ ); +} + +export default memo(QueritForm); diff --git a/web/src/pages/agent/form/tool-form/querit-form/use-watch-change.test.tsx b/web/src/pages/agent/form/tool-form/querit-form/use-watch-change.test.tsx new file mode 100644 index 0000000000..4a1b83d122 --- /dev/null +++ b/web/src/pages/agent/form/tool-form/querit-form/use-watch-change.test.tsx @@ -0,0 +1,96 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { useForm } from 'react-hook-form'; +import { useQueritAgentFormChange } from './use-watch-change'; + +const mockUpdateAgentToolById = jest.fn(); +const agentNode = { id: 'agent:0' }; + +jest.mock('../../../store', () => ({ + __esModule: true, + default: (selector: (state: Record) => unknown) => + selector({ + clickedToolId: 'querit:0', + clickedNodeId: 'agent:0', + findUpstreamNodeById: () => agentNode, + updateAgentToolById: mockUpdateAgentToolById, + }), +})); + +const validValues = { + api_key: '', + count: 10, + chunks_per_doc: 3, + site_include: [], + site_exclude: [], + time_range: '', + country_include: [], + language_include: [], +}; + +function useQueritAgentFormWatcher() { + const form = useForm>({ defaultValues: validValues }); + useQueritAgentFormChange(form); + return form; +} + +describe('useQueritAgentFormChange', () => { + beforeEach(() => { + mockUpdateAgentToolById.mockClear(); + }); + + it('does not write untouched initial values to the Agent node', async () => { + renderHook(() => useQueritAgentFormWatcher()); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockUpdateAgentToolById).not.toHaveBeenCalled(); + }); + + it('writes normalized valid edits without a query', async () => { + const { result } = renderHook(() => useQueritAgentFormWatcher()); + + act(() => { + result.current.setValue( + 'site_include', + [{ value: ' docs.example.com ' }], + { shouldDirty: true }, + ); + result.current.setValue('count', '5', { shouldDirty: true }); + }); + + await waitFor(() => { + expect(mockUpdateAgentToolById).toHaveBeenLastCalledWith( + agentNode, + 'querit:0', + { + params: expect.objectContaining({ + count: 5, + site_include: ['docs.example.com'], + }), + }, + ); + }); + + const persistedParams = + mockUpdateAgentToolById.mock.calls.at(-1)?.[2]?.params; + expect(persistedParams).not.toHaveProperty('query'); + }); + + it('does not overwrite Agent tool params after an invalid edit', async () => { + const { result } = renderHook(() => useQueritAgentFormWatcher()); + + act(() => { + result.current.setValue('time_range', 'last week', { + shouldDirty: true, + }); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockUpdateAgentToolById).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/pages/agent/form/tool-form/querit-form/use-watch-change.ts b/web/src/pages/agent/form/tool-form/querit-form/use-watch-change.ts new file mode 100644 index 0000000000..e24213f5d7 --- /dev/null +++ b/web/src/pages/agent/form/tool-form/querit-form/use-watch-change.ts @@ -0,0 +1,40 @@ +import { useEffect } from 'react'; +import { UseFormReturn, useWatch } from 'react-hook-form'; +import useGraphStore from '../../../store'; +import { validateQueritAgentFormValuesForPersistence } from './utils'; + +export function useQueritAgentFormChange(form?: UseFormReturn) { + const values = useWatch({ control: form?.control }); + const clickedToolId = useGraphStore((state) => state.clickedToolId); + const clickedNodeId = useGraphStore((state) => state.clickedNodeId); + const findUpstreamNodeById = useGraphStore( + (state) => state.findUpstreamNodeById, + ); + const updateAgentToolById = useGraphStore( + (state) => state.updateAgentToolById, + ); + + useEffect(() => { + const agentNode = findUpstreamNodeById(clickedNodeId); + if (!agentNode || !form?.formState.isDirty) { + return; + } + + const nextValues = validateQueritAgentFormValuesForPersistence( + form.getValues(), + ); + if (nextValues) { + updateAgentToolById(agentNode, clickedToolId, { + params: nextValues, + }); + } + }, [ + clickedNodeId, + clickedToolId, + findUpstreamNodeById, + form, + form?.formState.isDirty, + updateAgentToolById, + values, + ]); +} diff --git a/web/src/pages/agent/form/tool-form/querit-form/utils.test.ts b/web/src/pages/agent/form/tool-form/querit-form/utils.test.ts new file mode 100644 index 0000000000..9c0e69669b --- /dev/null +++ b/web/src/pages/agent/form/tool-form/querit-form/utils.test.ts @@ -0,0 +1,66 @@ +import { + deserializeQueritAgentFormValues, + validateQueritAgentFormValuesForPersistence, +} from './utils'; + +describe('Querit Agent form persistence', () => { + it('rehydrates persisted string arrays for the embedded form', () => { + expect( + deserializeQueritAgentFormValues({ + api_key: '', + count: 5, + chunks_per_doc: 2, + site_include: ['docs.example.com'], + site_exclude: ['spam.example.com'], + time_range: 'w1', + country_include: ['united states'], + language_include: ['en'], + }), + ).toMatchObject({ + site_include: [{ value: 'docs.example.com' }], + site_exclude: [{ value: 'spam.example.com' }], + country_include: [{ value: 'united states' }], + language_include: [{ value: 'en' }], + }); + }); + + it('normalizes valid values without persisting a runtime query', () => { + expect( + validateQueritAgentFormValuesForPersistence({ + api_key: 'secret', + query: 'must-not-be-persisted', + count: '5', + chunks_per_doc: '2', + site_include: [{ value: ' docs.example.com ' }], + site_exclude: [], + time_range: 'w1', + country_include: [{ value: ' united states ' }], + language_include: [{ value: ' en ' }], + }), + ).toEqual({ + api_key: 'secret', + count: 5, + chunks_per_doc: 2, + site_include: ['docs.example.com'], + site_exclude: [], + time_range: 'w1', + country_include: ['united states'], + language_include: ['en'], + }); + }); + + it('does not produce persisted values when the embedded form is invalid', () => { + expect( + validateQueritAgentFormValuesForPersistence({ + api_key: '', + count: 0, + chunks_per_doc: 3, + site_include: [], + site_exclude: [], + time_range: '', + country_include: [], + language_include: [], + }), + ).toBeUndefined(); + }); +}); diff --git a/web/src/pages/agent/form/tool-form/querit-form/utils.ts b/web/src/pages/agent/form/tool-form/querit-form/utils.ts new file mode 100644 index 0000000000..29c106cf87 --- /dev/null +++ b/web/src/pages/agent/form/tool-form/querit-form/utils.ts @@ -0,0 +1,22 @@ +import { + QueritFormSchema, + deserializeQueritFormValues, + serializeQueritFormValues, +} from '../../querit-form/utils'; + +export const QueritAgentFormSchema = QueritFormSchema.omit({ query: true }); + +export function deserializeQueritAgentFormValues(values: Record) { + return deserializeQueritFormValues(values); +} + +export function validateQueritAgentFormValuesForPersistence( + values: Record, +) { + const result = QueritAgentFormSchema.safeParse(values); + if (!result.success) { + return undefined; + } + + return serializeQueritFormValues(result.data); +} diff --git a/web/src/pages/agent/hooks/clear-sensitive-fields.ts b/web/src/pages/agent/hooks/clear-sensitive-fields.ts new file mode 100644 index 0000000000..2129cd39f0 --- /dev/null +++ b/web/src/pages/agent/hooks/clear-sensitive-fields.ts @@ -0,0 +1,53 @@ +import { Operator } from '@/constants/agent'; +import { cloneDeepWith, get, isPlainObject } from 'lodash'; + +const apiKeyOperators = [ + Operator.TavilySearch, + Operator.TavilyExtract, + Operator.Google, + Operator.KeenableSearch, + Operator.BGPT, + Operator.QueritSearch, +]; + +function isQueritOperator(value: unknown) { + if (typeof value !== 'string') { + return false; + } + + return ['querit', 'queritsearch'].includes( + value.replace(/_/g, '').toLowerCase(), + ); +} + +export function clearSensitiveFields(obj: T): T { + return cloneDeepWith(obj, (value) => { + if (!isPlainObject(value)) { + return; + } + + if ( + (apiKeyOperators.includes(value.component_name) || + isQueritOperator(value.component_name)) && + get(value, 'params.api_key') + ) { + return { ...value, params: { ...value.params, api_key: '' } }; + } + + if ( + isQueritOperator(get(value, 'data.label')) && + get(value, 'data.form.api_key') + ) { + return { + ...value, + data: { + ...value.data, + form: { + ...value.data.form, + api_key: '', + }, + }, + }; + } + }); +} diff --git a/web/src/pages/agent/hooks/querit-agent-initial-values.ts b/web/src/pages/agent/hooks/querit-agent-initial-values.ts new file mode 100644 index 0000000000..b54a62da59 --- /dev/null +++ b/web/src/pages/agent/hooks/querit-agent-initial-values.ts @@ -0,0 +1,7 @@ +import { omit } from 'lodash'; + +export function getQueritAgentInitialValues( + initialValues: Record, +) { + return omit(initialValues, 'query', 'outputs'); +} diff --git a/web/src/pages/agent/hooks/use-add-node.ts b/web/src/pages/agent/hooks/use-add-node.ts index 4d7e6fe9a9..6bb13d9ae7 100644 --- a/web/src/pages/agent/hooks/use-add-node.ts +++ b/web/src/pages/agent/hooks/use-add-node.ts @@ -38,6 +38,7 @@ import { initialParserValues, initialPubMedValues, initialBGPTValues, + initialQueritValues, initialRetrievalValues, initialRewriteQuestionValues, initialSearXNGValues, @@ -167,6 +168,7 @@ export const useInitializeOperatorParams = () => { [Operator.Agent]: { ...initialAgentValues, llm_id: llmId }, [Operator.Tool]: {}, [Operator.TavilySearch]: initialTavilyValues, + [Operator.QueritSearch]: initialQueritValues, [Operator.KeenableSearch]: initialKeenableValues, [Operator.UserFillUp]: initialUserFillUpValues, [Operator.StringTransform]: initialStringTransformValues, diff --git a/web/src/pages/agent/hooks/use-agent-tool-initial-values.test.ts b/web/src/pages/agent/hooks/use-agent-tool-initial-values.test.ts new file mode 100644 index 0000000000..02a0422d6b --- /dev/null +++ b/web/src/pages/agent/hooks/use-agent-tool-initial-values.test.ts @@ -0,0 +1,32 @@ +import { getQueritAgentInitialValues } from './querit-agent-initial-values'; + +describe('getQueritAgentInitialValues', () => { + it('creates Querit Agent params without runtime query or outputs', () => { + expect( + getQueritAgentInitialValues({ + api_key: '', + query: 'sys.query', + count: 10, + chunks_per_doc: 3, + site_include: [], + site_exclude: [], + time_range: '', + country_include: [], + language_include: [], + outputs: { + formalized_content: { value: '', type: 'string' }, + json: { value: {}, type: 'object' }, + }, + }), + ).toEqual({ + api_key: '', + count: 10, + chunks_per_doc: 3, + site_include: [], + site_exclude: [], + time_range: '', + country_include: [], + language_include: [], + }); + }); +}); diff --git a/web/src/pages/agent/hooks/use-agent-tool-initial-values.ts b/web/src/pages/agent/hooks/use-agent-tool-initial-values.ts index 3d4b8112e0..3250a55fde 100644 --- a/web/src/pages/agent/hooks/use-agent-tool-initial-values.ts +++ b/web/src/pages/agent/hooks/use-agent-tool-initial-values.ts @@ -1,6 +1,7 @@ import { omit, pick } from 'lodash'; import { useCallback } from 'react'; import { Operator } from '../constant'; +import { getQueritAgentInitialValues } from './querit-agent-initial-values'; import { useInitializeOperatorParams } from './use-add-node'; export function useAgentToolInitialValues() { @@ -20,6 +21,8 @@ export function useAgentToolInitialValues() { return { api_key: '', }; + case Operator.QueritSearch: + return getQueritAgentInitialValues(initialValues); case Operator.ExeSQL: return omit(initialValues, 'sql'); case Operator.Bing: diff --git a/web/src/pages/agent/hooks/use-export-json.test.ts b/web/src/pages/agent/hooks/use-export-json.test.ts new file mode 100644 index 0000000000..1fd1a4fd1e --- /dev/null +++ b/web/src/pages/agent/hooks/use-export-json.test.ts @@ -0,0 +1,126 @@ +jest.mock('@/constants/agent', () => ({ + Operator: { + TavilySearch: 'TavilySearch', + TavilyExtract: 'TavilyExtract', + Google: 'Google', + KeenableSearch: 'KeenableSearch', + BGPT: 'Bing', + QueritSearch: 'QueritSearch', + }, +})); + +import { Operator } from '@/constants/agent'; +import { clearSensitiveFields } from './clear-sensitive-fields'; + +describe('clearSensitiveFields', () => { + it('clears a Querit API key without removing non-sensitive params', () => { + const dsl = { + tools: [ + { + component_name: Operator.QueritSearch, + params: { + api_key: 'querit-secret', + count: 5, + chunks_per_doc: 2, + site_include: ['docs.example.com'], + }, + }, + ], + }; + + expect(clearSensitiveFields(dsl)).toEqual({ + tools: [ + { + component_name: Operator.QueritSearch, + params: { + api_key: '', + count: 5, + chunks_per_doc: 2, + site_include: ['docs.example.com'], + }, + }, + ], + }); + expect(dsl.tools[0].params.api_key).toBe('querit-secret'); + }); + + it.each(['querit', 'querit_search', 'queritsearch'])( + 'clears a Querit API key for the %s registry alias', + (componentName) => { + const dsl = { + tools: [ + { + component_name: componentName, + params: { + api_key: 'querit-secret', + count: 5, + }, + }, + ], + }; + + const sanitized = clearSensitiveFields(dsl); + + expect(sanitized.tools[0].params.api_key).toBe(''); + expect(sanitized.tools[0].params.count).toBe(5); + expect(dsl.tools[0].params.api_key).toBe('querit-secret'); + }, + ); + + it('clears a standalone Querit Canvas key from graph and components', () => { + const dsl = { + graph: { + nodes: [ + { + data: { + label: Operator.QueritSearch, + form: { + api_key: 'graph-secret', + count: 5, + }, + }, + }, + ], + }, + components: { + querit: { + obj: { + component_name: Operator.QueritSearch, + params: { + api_key: 'component-secret', + count: 5, + }, + }, + }, + }, + }; + + const sanitized = clearSensitiveFields(dsl); + + expect(sanitized.graph.nodes[0].data.form.api_key).toBe(''); + expect(sanitized.graph.nodes[0].data.form.count).toBe(5); + expect(sanitized.components.querit.obj.params.api_key).toBe(''); + expect(sanitized.components.querit.obj.params.count).toBe(5); + expect(dsl.graph.nodes[0].data.form.api_key).toBe('graph-secret'); + expect(dsl.components.querit.obj.params.api_key).toBe('component-secret'); + }); + + it('does not change standalone graph export behavior for other tools', () => { + const dsl = { + graph: { + nodes: [ + { + data: { + label: Operator.TavilySearch, + form: { + api_key: 'existing-tavily-key', + }, + }, + }, + ], + }, + }; + + expect(clearSensitiveFields(dsl)).toEqual(dsl); + }); +}); diff --git a/web/src/pages/agent/hooks/use-export-json.ts b/web/src/pages/agent/hooks/use-export-json.ts index ff9191aa84..9f2e312159 100644 --- a/web/src/pages/agent/hooks/use-export-json.ts +++ b/web/src/pages/agent/hooks/use-export-json.ts @@ -1,30 +1,9 @@ -import { Operator } from '@/constants/agent'; import { useFetchAgent } from '@/hooks/use-agent-request'; import { downloadJsonFile } from '@/utils/file-util'; -import { cloneDeepWith, get, isPlainObject } from 'lodash'; import { useCallback } from 'react'; import useGraphStore from '../store'; import { exportDsl } from '../utils/dsl-bridge'; - -/** - * Recursively clear sensitive fields (api_key) from the DSL object - */ -const clearSensitiveFields = (obj: T): T => - cloneDeepWith(obj, (value) => { - if ( - isPlainObject(value) && - [ - Operator.TavilySearch, - Operator.TavilyExtract, - Operator.Google, - Operator.KeenableSearch, - Operator.BGPT, - ].includes(value.component_name) && - get(value, 'params.api_key') - ) { - return { ...value, params: { ...value.params, api_key: '' } }; - } - }); +import { clearSensitiveFields } from './clear-sensitive-fields'; export const useHandleExportJsonFile = () => { const { data } = useFetchAgent(); diff --git a/web/src/pages/agent/log-sheet/tool-name.test.ts b/web/src/pages/agent/log-sheet/tool-name.test.ts new file mode 100644 index 0000000000..2f99622724 --- /dev/null +++ b/web/src/pages/agent/log-sheet/tool-name.test.ts @@ -0,0 +1,24 @@ +jest.mock('@/constants/agent', () => ({ + Operator: { + QueritSearch: 'QueritSearch', + }, +})); + +import { Operator } from '@/constants/agent'; +import { getToolOperatorName } from './tool-name'; + +describe('getToolOperatorName', () => { + it.each(['QueritSearch', 'querit_search'])( + 'maps the Querit timeline name %p to its operator', + (toolName) => { + expect(getToolOperatorName(toolName)).toBe(Operator.QueritSearch); + }, + ); + + it.each([undefined, null, ''])( + 'returns an empty name for the missing value %p', + (toolName) => { + expect(getToolOperatorName(toolName)).toBe(''); + }, + ); +}); diff --git a/web/src/pages/agent/log-sheet/tool-name.ts b/web/src/pages/agent/log-sheet/tool-name.ts new file mode 100644 index 0000000000..80e87f2c15 --- /dev/null +++ b/web/src/pages/agent/log-sheet/tool-name.ts @@ -0,0 +1,17 @@ +import { Operator } from '@/constants/agent'; + +export function getToolOperatorName(toolName?: string | null) { + if (!toolName) { + return ''; + } + + const normalizedName = toolName.replaceAll('_', '').toLowerCase(); + if (normalizedName === Operator.QueritSearch.toLowerCase()) { + return Operator.QueritSearch; + } + + return toolName + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(''); +} diff --git a/web/src/pages/agent/log-sheet/tool-timeline-item.tsx b/web/src/pages/agent/log-sheet/tool-timeline-item.tsx index c40741e14e..8ea32586b2 100644 --- a/web/src/pages/agent/log-sheet/tool-timeline-item.tsx +++ b/web/src/pages/agent/log-sheet/tool-timeline-item.tsx @@ -16,6 +16,7 @@ import { isEmpty } from 'lodash'; import { Operator } from '../constant'; import { JsonViewer } from '../form/components/json-viewer'; import OperatorIcon, { SVGIconMap } from '../operator-icon'; +import { getToolOperatorName } from './tool-name'; import { toLowerCaseStringAndDeleteChar, typeMap } from './workflow-timeline'; type IToolIcon = | Operator.ArXiv @@ -28,6 +29,7 @@ type IToolIcon = | Operator.BGPT | Operator.TavilyExtract | Operator.TavilySearch + | Operator.QueritSearch | Operator.KeenableSearch | Operator.Wikipedia | Operator.YahooFinance @@ -73,7 +75,7 @@ const ToolTimelineItem = ({ return ( <> {filteredTools?.map((tool, idx) => { - const toolName = capitalizeWords(tool.tool_name, '_').join(''); + const toolName = getToolOperatorName(tool.tool_name); return ( { const svgIcon = SVGIconMap[name as keyof typeof SVGIconMap]; const LucideIcon = LucideIconMap[name as keyof typeof LucideIconMap]; + if (name === Operator.QueritSearch) { + return ( + + ); + } + if (name === Operator.Begin) { return (