diff --git a/agent/tools/querit.py b/agent/tools/querit.py
index 6725342a94..51822e7965 100644
--- a/agent/tools/querit.py
+++ b/agent/tools/querit.py
@@ -20,6 +20,7 @@ import re
import time
from abc import ABC
from typing import Any
+from urllib.parse import urlparse
import requests
@@ -28,8 +29,10 @@ from common.connection_utils import timeout
from common.http_client import DEFAULT_TIMEOUT
QUERIT_SEARCH_URL = "https://api.querit.ai/v1/search"
+QUERIT_CONTENTS_URL = "https://api.querit.ai/v1/contents"
QUERIT_MAX_ATTEMPTS = 3
QUERIT_RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
+QUERIT_CONTENT_FORMATS = {"text", "markdown", "html"}
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__)
@@ -192,35 +195,7 @@ class QueritSearch(ToolBase, ABC):
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.")
+ return _post_querit(self, QUERIT_SEARCH_URL, payload, api_key, "QueritSearch")
def _wait_before_retry(self) -> None:
if self.check_if_canceled("QueritSearch processing"):
@@ -236,6 +211,96 @@ class QueritSearch(ToolBase, ABC):
return "Searching Querit for `{}`.".format(self.get_input().get("query", "-_-!"))
+class QueritContentsParam(ToolParamBase):
+ def __init__(self):
+ self.meta: ToolMeta = {
+ "name": "querit_contents",
+ "description": "Crawl one or more web pages with Querit and return their contents.",
+ "parameters": {
+ "urls": {
+ "type": "array",
+ "description": "The absolute HTTP or HTTPS URLs to crawl. Supports 1 to 10 URLs.",
+ "default": [],
+ "items": {"type": "string"},
+ "required": True,
+ },
+ "format": {
+ "type": "string",
+ "description": "Content format: text, markdown, or html. Defaults to markdown.",
+ "enum": ["text", "markdown", "html"],
+ "default": "markdown",
+ "required": False,
+ },
+ "crawl_timeout": {
+ "type": "integer",
+ "description": "Per-page crawl timeout in seconds. Must be between 1 and 60.",
+ "default": 10,
+ "required": False,
+ },
+ "extras_meta": {
+ "type": "boolean",
+ "description": "Whether to include page metadata in each result.",
+ "default": False,
+ "required": False,
+ },
+ },
+ }
+ super().__init__()
+ self.api_key = ""
+
+ def check(self):
+ self.urls = _normalize_contents_urls(self.urls)
+ _validate_contents_inputs(self.urls, self.format, self.crawl_timeout, self.extras_meta)
+
+ def get_input_form(self) -> dict[str, dict]:
+ return {"urls": {"name": "URLs", "type": "line"}}
+
+
+class QueritContents(ToolBase, ABC):
+ component_name = "QueritContents"
+
+ @timeout(int(os.environ.get("COMPONENT_EXEC_TIMEOUT", "70")))
+ def _invoke(self, **kwargs):
+ if self.check_if_canceled("QueritContents processing"):
+ return
+
+ values = {name: kwargs[name] if name in kwargs else getattr(self._param, name) for name in ("urls", "format", "crawl_timeout", "extras_meta")}
+ values["urls"] = _normalize_contents_urls(values["urls"])
+
+ 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.")
+
+ try:
+ _validate_contents_inputs(**values)
+ response_data = self._request(_build_contents_payload(**values), api_key)
+ _validate_contents_response(response_data)
+ self.set_output("json", response_data)
+ return self.output("json")
+ except _QueritCanceled:
+ return
+ except (requests.RequestException, RuntimeError, TypeError, ValueError) as error:
+ return self._fail(_safe_error_message(error, api_key))
+
+ def _request(self, payload: dict[str, Any], api_key: str) -> Any:
+ request_timeout = max(DEFAULT_TIMEOUT, payload["crawlTimeout"] + 5)
+ return _post_querit(self, QUERIT_CONTENTS_URL, payload, api_key, "QueritContents", request_timeout)
+
+ def _wait_before_retry(self) -> None:
+ if self.check_if_canceled("QueritContents processing"):
+ raise _QueritCanceled
+ time.sleep(self._param.delay_after_error)
+
+ def _fail(self, message: str) -> str:
+ self.set_output("_ERROR", message)
+ logger.error("Querit contents failed: %s", message)
+ return f"Querit contents error: {message}"
+
+ def thoughts(self) -> str:
+ return "Reading web page contents with Querit."
+
+
def _build_payload(query: str, **values: Any) -> dict[str, Any]:
payload: dict[str, Any] = {
"query": query,
@@ -262,6 +327,72 @@ def _build_payload(query: str, **values: Any) -> dict[str, Any]:
return payload
+def _post_querit(tool: Any, endpoint: str, payload: dict[str, Any], api_key: str, operation: str, request_timeout: float = DEFAULT_TIMEOUT) -> Any:
+ headers = {
+ "Accept": "application/json",
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ }
+ for attempt in range(QUERIT_MAX_ATTEMPTS):
+ if tool.check_if_canceled(f"{operation} processing"):
+ raise _QueritCanceled
+ try:
+ response = requests.post(endpoint, headers=headers, json=payload, timeout=request_timeout)
+ if response.status_code in QUERIT_RETRYABLE_STATUS_CODES and attempt + 1 < QUERIT_MAX_ATTEMPTS:
+ tool._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
+ tool._wait_before_retry()
+ raise RuntimeError("Querit request failed after three attempts.")
+
+
+def _build_contents_payload(urls: list[str], format: str, crawl_timeout: int, extras_meta: bool) -> dict[str, Any]:
+ return {
+ "urls": urls,
+ "format": format,
+ "crawlTimeout": crawl_timeout,
+ "extrasMeta": extras_meta,
+ }
+
+
+def _normalize_contents_urls(urls: Any) -> Any:
+ if isinstance(urls, str):
+ return [url.strip() for url in urls.split(",") if url.strip()]
+ return urls
+
+
+def _validate_contents_inputs(urls: Any, format: Any, crawl_timeout: Any, extras_meta: Any) -> None:
+ if not isinstance(urls, list) or not 1 <= len(urls) <= 10 or any(not isinstance(url, str) or not url.strip() for url in urls):
+ raise ValueError("Querit urls must contain between 1 and 10 non-empty strings.")
+ for url in urls:
+ parsed = urlparse(url)
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
+ raise ValueError("Querit urls must be absolute HTTP or HTTPS URLs.")
+ if format not in QUERIT_CONTENT_FORMATS:
+ raise ValueError("Querit format must be text, markdown, or html.")
+ if type(crawl_timeout) is not int or not 1 <= crawl_timeout <= 60:
+ raise ValueError("Querit crawl_timeout must be an integer from 1 to 60.")
+ if type(extras_meta) is not bool:
+ raise ValueError("Querit extras_meta must be a boolean.")
+
+
+def _validate_contents_response(response_data: Any) -> None:
+ if not isinstance(response_data, dict):
+ raise TypeError("Querit API response must be a JSON object.")
+ if "results" in response_data and not isinstance(response_data["results"], list):
+ raise TypeError("Querit API response field results must be an array.")
+ if "statuses" in response_data and not isinstance(response_data["statuses"], list):
+ raise TypeError("Querit API response field statuses must be an array.")
+
+
def _validate_search_inputs(
count: Any,
chunks_per_doc: Any,
diff --git a/internal/agent/component/tool_component.go b/internal/agent/component/tool_component.go
index 2eaca204b5..9d7ac01859 100644
--- a/internal/agent/component/tool_component.go
+++ b/internal/agent/component/tool_component.go
@@ -134,6 +134,7 @@ var toolComponentRegistrations = []struct {
{componentName: "GoogleScholar", toolName: "google_scholar"},
{componentName: "KeenableSearch", toolName: "keenable"},
{componentName: "PubMed", toolName: "pubmed"},
+ {componentName: "QueritContents", toolName: "querit_contents"},
{componentName: "QueritSearch", toolName: "querit_search"},
{componentName: "SearXNG", toolName: "searxng"},
{componentName: "TavilySearch", toolName: "tavily"},
diff --git a/internal/agent/component/tool_component_test.go b/internal/agent/component/tool_component_test.go
index 64decfa181..13ffaf5c01 100644
--- a/internal/agent/component/tool_component_test.go
+++ b/internal/agent/component/tool_component_test.go
@@ -193,6 +193,13 @@ func TestToolBackedComponentRegisteredFactories(t *testing.T) {
outputKey: "success",
inputKey: "to_email",
},
+ {
+ name: "QueritContents",
+ toolName: "QueritContents",
+ params: map[string]any{"api_key": "stored-key", "format": "markdown", "crawl_timeout": float64(10), "extras_meta": true, "outputs": map[string]any{"json": map[string]any{}}},
+ outputKey: "json",
+ inputKey: "urls",
+ },
{
name: "QueritSearch",
toolName: "QueritSearch",
@@ -283,7 +290,7 @@ func TestToolBackedComponentWenCaiInvoke(t *testing.T) {
}
func TestToolBackedComponentRegisteredBuildWorkflow(t *testing.T) {
- for _, componentName := range []string{"ArXiv", "BGPT", "DuckDuckGo", "Email", "Google", "GoogleScholar", "KeenableSearch", "PubMed", "QueritSearch", "SearXNG", "WenCai", "TavilyExtract", "TavilySearch", "Wikipedia", "YahooFinance"} {
+ for _, componentName := range []string{"ArXiv", "BGPT", "DuckDuckGo", "Email", "Google", "GoogleScholar", "KeenableSearch", "PubMed", "QueritContents", "QueritSearch", "SearXNG", "WenCai", "TavilyExtract", "TavilySearch", "Wikipedia", "YahooFinance"} {
t.Run(componentName, func(t *testing.T) {
c := &canvas.Canvas{
Components: map[string]canvas.CanvasComponent{
diff --git a/internal/agent/tool/querit.go b/internal/agent/tool/querit.go
index 5555c97f6e..a94d40a0ca 100644
--- a/internal/agent/tool/querit.go
+++ b/internal/agent/tool/querit.go
@@ -248,44 +248,58 @@ func (q *QueritTool) InvokableRun(ctx context.Context, argsJSON string, _ ...too
if err != nil {
return queritErrorJSON(fmt.Errorf("encode request: %w", err), apiKey), nil
}
+ raw, requestErr := doQueritRequest(ctx, q.helper, q.retryWait, queritEndpoint, body, apiKey)
+ if requestErr != nil {
+ return queritErrorJSON(requestErr, apiKey), nil
+ }
+ if _, err := decodeQueritResponse(raw); err != nil {
+ return queritErrorJSON(err, apiKey), nil
+ }
+ return string(raw), nil
+}
+
+func doQueritRequest(
+ ctx context.Context,
+ helper *HTTPHelper,
+ retryWait func(context.Context, int) bool,
+ endpoint string,
+ body []byte,
+ apiKey string,
+) ([]byte, error) {
for attempt := 1; attempt <= queritMaxAttempts; attempt++ {
- resp, requestErr := q.helper.Do(
+ resp, err := helper.Do(
ctx,
http.MethodPost,
- queritEndpoint,
+ endpoint,
string(body),
"application/json",
map[string]string{"Authorization": "Bearer " + apiKey},
)
- if requestErr != nil {
- return queritErrorJSON(fmt.Errorf("request failed: %w", requestErr), apiKey), nil
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
}
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
+ if attempt == queritMaxAttempts || !retryWait(ctx, attempt) {
+ return nil, fmt.Errorf("upstream returned %d after %d attempts", resp.StatusCode, attempt)
}
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
+ return nil, fmt.Errorf("upstream returned %d", resp.StatusCode)
}
raw, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil {
- return queritErrorJSON(fmt.Errorf("read response: %w", readErr), apiKey), nil
+ return nil, fmt.Errorf("read response: %w", readErr)
}
- if _, err := decodeQueritResponse(raw); err != nil {
- return queritErrorJSON(err, apiKey), nil
- }
- return string(raw), nil
+ return raw, nil
}
-
- return queritErrorJSON(fmt.Errorf("request exhausted retries"), apiKey), nil
+ return nil, fmt.Errorf("request exhausted retries")
}
func mergeQueritParams(defaults, runtimeParams queritParams, provided map[string]json.RawMessage) queritParams {
@@ -360,21 +374,9 @@ func buildQueritRequest(params queritParams) queritRequest {
}
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")
+ response, err := decodeQueritJSONObject(raw)
+ if err != nil {
+ return nil, err
}
resultsValue, exists := response["results"]
if !exists {
@@ -394,6 +396,26 @@ func decodeQueritResponse(raw []byte) (map[string]any, error) {
return response, nil
}
+func decodeQueritJSONObject(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")
+ }
+ return response, nil
+}
+
func waitForQueritRetry(ctx context.Context, attempt int) bool {
delay := 200 * time.Millisecond
for current := 1; current < attempt; current++ {
@@ -588,9 +610,13 @@ func queritStringSlice(value any) ([]string, bool) {
}
func queritErrorJSON(err error, apiKeys ...string) string {
- message := "querit_search: unknown error"
+ return queritToolErrorJSON(queritToolName, err, apiKeys...)
+}
+
+func queritToolErrorJSON(toolName string, err error, apiKeys ...string) string {
+ message := toolName + ": unknown error"
if err != nil {
- message = "querit_search: " + err.Error()
+ message = toolName + ": " + err.Error()
}
for _, apiKey := range apiKeys {
if apiKey = strings.TrimSpace(apiKey); apiKey != "" {
@@ -599,7 +625,7 @@ func queritErrorJSON(err error, apiKeys ...string) string {
}
raw, marshalErr := json.Marshal(map[string]any{"_ERROR": message})
if marshalErr != nil {
- return `{"_ERROR":"querit_search: marshal error"}`
+ return fmt.Sprintf(`{"_ERROR":%q}`, toolName+": marshal error")
}
return string(raw)
}
diff --git a/internal/agent/tool/querit_contents.go b/internal/agent/tool/querit_contents.go
new file mode 100644
index 0000000000..20ed212dff
--- /dev/null
+++ b/internal/agent/tool/querit_contents.go
@@ -0,0 +1,271 @@
+//
+// 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"
+ "fmt"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/cloudwego/eino/components/tool"
+ "github.com/cloudwego/eino/schema"
+)
+
+const (
+ queritContentsToolName = "querit_contents"
+ queritContentsEndpoint = "https://api.querit.ai/v1/contents"
+)
+
+type queritContentsParams struct {
+ APIKey string `json:"api_key"`
+ URLs any `json:"urls"`
+ Format string `json:"format"`
+ CrawlTimeout int `json:"crawl_timeout"`
+ ExtrasMeta bool `json:"extras_meta"`
+}
+
+type queritContentsRequest struct {
+ URLs []string `json:"urls"`
+ Format string `json:"format"`
+ CrawlTimeout int `json:"crawlTimeout"`
+ ExtrasMeta bool `json:"extrasMeta"`
+}
+
+// QueritContentsTool crawls public web pages through the Querit Contents API.
+type QueritContentsTool struct {
+ helper *HTTPHelper
+ envKey func() string
+ defaults queritContentsParams
+ retryWait func(context.Context, int) bool
+}
+
+var _ ToolComponent = (*QueritContentsTool)(nil)
+
+func NewQueritContentsTool() *QueritContentsTool {
+ return newQueritContentsTool(nil, nil, queritContentsParams{}, nil)
+}
+
+func NewQueritContentsToolWith(helper *HTTPHelper) *QueritContentsTool {
+ return newQueritContentsTool(helper, nil, queritContentsParams{}, nil)
+}
+
+func NewQueritContentsToolWithEnvKey(helper *HTTPHelper, envKey func() string) *QueritContentsTool {
+ return newQueritContentsTool(helper, envKey, queritContentsParams{}, nil)
+}
+
+func newQueritContentsTool(
+ helper *HTTPHelper,
+ envKey func() string,
+ defaults queritContentsParams,
+ retryWait func(context.Context, int) bool,
+) *QueritContentsTool {
+ if helper == nil {
+ helper = NewHTTPHelper()
+ helper.client.Timeout = 65 * time.Second
+ }
+ if envKey == nil {
+ envKey = defaultQueritEnvKey
+ }
+ if defaults.Format == "" {
+ defaults.Format = "markdown"
+ }
+ if defaults.CrawlTimeout == 0 {
+ defaults.CrawlTimeout = 10
+ }
+ if retryWait == nil {
+ retryWait = waitForQueritRetry
+ }
+ return &QueritContentsTool{helper: helper, envKey: envKey, defaults: defaults, retryWait: retryWait}
+}
+
+func (q *QueritContentsTool) Info(_ context.Context) (*schema.ToolInfo, error) {
+ return &schema.ToolInfo{
+ Name: queritContentsToolName,
+ Desc: "Crawl one or more web pages with Querit and return their contents.",
+ ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
+ "urls": {
+ Type: schema.Array,
+ ElemInfo: &schema.ParameterInfo{Type: schema.String},
+ Desc: "One to ten absolute HTTP or HTTPS URLs to crawl.",
+ Required: true,
+ },
+ "format": {
+ Type: schema.String,
+ Desc: `Content format: "text", "markdown" (default), or "html".`,
+ Required: false,
+ },
+ "crawl_timeout": {
+ Type: schema.Integer,
+ Desc: "Per-page crawl timeout in seconds. Defaults to 10 and must be between 1 and 60.",
+ Required: false,
+ },
+ "extras_meta": {
+ Type: schema.Boolean,
+ Desc: "Whether to include page metadata in each result.",
+ Required: false,
+ },
+ }),
+ }, nil
+}
+
+func (q *QueritContentsTool) 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 queritContentsErrorJSON(fmt.Errorf("parse arguments: %w", err)), nil
+ }
+ var runtimeParams queritContentsParams
+ if err := json.Unmarshal([]byte(argsJSON), &runtimeParams); err != nil {
+ return queritContentsErrorJSON(fmt.Errorf("parse arguments: %w", err)), nil
+ }
+ params := mergeQueritContentsParams(q.defaults, runtimeParams, provided)
+ urls := normalizeQueritContentURLs(params.URLs)
+ if err := validateQueritContentsParams(params, urls); err != nil {
+ return queritContentsErrorJSON(err, params.APIKey), nil
+ }
+
+ apiKey := strings.TrimSpace(params.APIKey)
+ if apiKey == "" {
+ apiKey = strings.TrimSpace(q.envKey())
+ }
+ if apiKey == "" {
+ return queritContentsErrorJSON(fmt.Errorf("api_key is required (or set QUERIT_API_KEY)")), nil
+ }
+
+ body, err := json.Marshal(queritContentsRequest{
+ URLs: urls,
+ Format: params.Format,
+ CrawlTimeout: params.CrawlTimeout,
+ ExtrasMeta: params.ExtrasMeta,
+ })
+ if err != nil {
+ return queritContentsErrorJSON(fmt.Errorf("encode request: %w", err), apiKey), nil
+ }
+ raw, requestErr := doQueritRequest(ctx, q.helper, q.retryWait, queritContentsEndpoint, body, apiKey)
+ if requestErr != nil {
+ return queritContentsErrorJSON(requestErr, apiKey), nil
+ }
+ if _, err := decodeQueritContentsResponse(raw); err != nil {
+ return queritContentsErrorJSON(err, apiKey), nil
+ }
+ return string(raw), nil
+}
+
+func mergeQueritContentsParams(
+ defaults queritContentsParams,
+ runtimeParams queritContentsParams,
+ provided map[string]json.RawMessage,
+) queritContentsParams {
+ merged := defaults
+ if _, ok := provided["urls"]; ok {
+ merged.URLs = runtimeParams.URLs
+ }
+ if _, ok := provided["format"]; ok {
+ merged.Format = runtimeParams.Format
+ }
+ if _, ok := provided["crawl_timeout"]; ok {
+ merged.CrawlTimeout = runtimeParams.CrawlTimeout
+ }
+ if _, ok := provided["extras_meta"]; ok {
+ merged.ExtrasMeta = runtimeParams.ExtrasMeta
+ }
+ return merged
+}
+
+func validateQueritContentsParams(params queritContentsParams, urls []string) error {
+ if len(urls) < 1 || len(urls) > 10 {
+ return fmt.Errorf("urls must contain between 1 and 10 values")
+ }
+ for _, rawURL := range urls {
+ parsed, err := url.ParseRequestURI(rawURL)
+ if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
+ return fmt.Errorf("urls must be absolute HTTP or HTTPS URLs")
+ }
+ }
+ if params.Format != "text" && params.Format != "markdown" && params.Format != "html" {
+ return fmt.Errorf("format must be text, markdown, or html")
+ }
+ if params.CrawlTimeout < 1 || params.CrawlTimeout > 60 {
+ return fmt.Errorf("crawl_timeout must be between 1 and 60")
+ }
+ return nil
+}
+
+func normalizeQueritContentURLs(raw any) []string {
+ switch value := raw.(type) {
+ case string:
+ return compactStrings(strings.Split(value, ","))
+ case []string:
+ return compactStrings(value)
+ case []any:
+ urls := make([]string, 0, len(value))
+ for _, item := range value {
+ text, ok := item.(string)
+ if !ok {
+ return nil
+ }
+ urls = append(urls, text)
+ }
+ return compactStrings(urls)
+ default:
+ return nil
+ }
+}
+
+func decodeQueritContentsResponse(raw []byte) (map[string]any, error) {
+ response, err := decodeQueritJSONObject(raw)
+ if err != nil {
+ return nil, err
+ }
+ for _, field := range []string{"results", "statuses"} {
+ if value, exists := response[field]; exists {
+ if _, ok := value.([]any); !ok {
+ return nil, fmt.Errorf("decode response: %s must be a JSON array", field)
+ }
+ }
+ }
+ return response, nil
+}
+
+func (q *QueritContentsTool) ComponentSpec() ComponentSpec {
+ return ComponentSpec{
+ PreserveJSONNumbers: true,
+ Inputs: map[string]string{
+ "api_key": "Querit API key. Uses QUERIT_API_KEY when empty.",
+ "urls": "One to ten absolute HTTP or HTTPS URLs to crawl.",
+ "format": `Content format: "text", "markdown", or "html".`,
+ "crawl_timeout": "Per-page crawl timeout in seconds.",
+ "extras_meta": "Whether to include page metadata.",
+ },
+ Outputs: map[string]string{
+ "json": "Complete raw Querit Contents JSON response.",
+ },
+ InputForm: map[string]any{
+ "urls": map[string]any{"name": "URLs", "type": "line"},
+ },
+ }
+}
+
+func (q *QueritContentsTool) BuildComponentOutputs(response map[string]any) map[string]any {
+ return map[string]any{"json": response}
+}
+
+func queritContentsErrorJSON(err error, apiKeys ...string) string {
+ return queritToolErrorJSON(queritContentsToolName, err, apiKeys...)
+}
diff --git a/internal/agent/tool/querit_contents_test.go b/internal/agent/tool/querit_contents_test.go
new file mode 100644
index 0000000000..76645d57da
--- /dev/null
+++ b/internal/agent/tool/querit_contents_test.go
@@ -0,0 +1,188 @@
+//
+// 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"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestQueritContentsBuildsRequestAndPreservesResponse(t *testing.T) {
+ var gotMethod, gotPath, gotAuthorization 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")
+ _ = json.NewDecoder(request.Body).Decode(&gotBody)
+ writer.Header().Set("Content-Type", "application/json")
+ _, _ = writer.Write([]byte(`{"error_code":0,"search_id":"crawl-1","results":[{"id":"1","url":"https://example.com","content":"# Example"}],"statuses":[{"id":"1","status":"success"}],"searchTime":1}`))
+ }))
+ defer server.Close()
+
+ helper := NewHTTPHelper().WithClient(&http.Client{Transport: rewriteQueritHostTransport(server.URL)})
+ contents := newQueritContentsTool(helper, func() string { return "" }, queritContentsParams{APIKey: "key-test"}, nil)
+ out, err := contents.InvokableRun(context.Background(), `{"urls":["https://example.com"],"format":"html","crawl_timeout":20,"extras_meta":true}`)
+ if err != nil {
+ t.Fatalf("InvokableRun: %v", err)
+ }
+ if gotMethod != http.MethodPost || gotPath != "/v1/contents" {
+ t.Fatalf("request = %s %s, want POST /v1/contents", gotMethod, gotPath)
+ }
+ if gotAuthorization != "Bearer key-test" {
+ t.Fatalf("Authorization = %q", gotAuthorization)
+ }
+ if gotBody["format"] != "html" || gotBody["crawlTimeout"] != float64(20) || gotBody["extrasMeta"] != true {
+ t.Fatalf("request body = %#v", gotBody)
+ }
+ urls, ok := gotBody["urls"].([]any)
+ if !ok || len(urls) != 1 || urls[0] != "https://example.com" {
+ t.Fatalf("urls = %#v", gotBody["urls"])
+ }
+ if !strings.Contains(out, `"search_id":"crawl-1"`) || !strings.Contains(out, `"statuses"`) {
+ t.Fatalf("complete response was not retained: %s", out)
+ }
+}
+
+func TestQueritContentsMergesDefaultsAndExplicitFalse(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":[],"statuses":[]}`))
+ }))
+ defer server.Close()
+
+ helper := NewHTTPHelper().WithClient(&http.Client{Transport: rewriteQueritHostTransport(server.URL)})
+ contents := newQueritContentsTool(helper, func() string { return "" }, queritContentsParams{
+ APIKey: "stored-key",
+ URLs: []string{"https://stored.example"},
+ Format: "text",
+ CrawlTimeout: 30,
+ ExtrasMeta: true,
+ }, nil)
+ _, err := contents.InvokableRun(context.Background(), `{"urls":"https://runtime.example","extras_meta":false}`)
+ if err != nil {
+ t.Fatalf("InvokableRun: %v", err)
+ }
+ if gotBody["format"] != "text" || gotBody["crawlTimeout"] != float64(30) || gotBody["extrasMeta"] != false {
+ t.Fatalf("merged defaults = %#v", gotBody)
+ }
+ if gotBody["urls"].([]any)[0] != "https://runtime.example" {
+ t.Fatalf("runtime urls = %#v", gotBody["urls"])
+ }
+}
+
+func TestQueritContentsValidatesInputsAndAPIKey(t *testing.T) {
+ tests := []struct {
+ name string
+ args string
+ want string
+ }{
+ {name: "missing urls", args: `{}`, want: "urls must contain"},
+ {name: "too many urls", args: `{"urls":["https://1.example","https://2.example","https://3.example","https://4.example","https://5.example","https://6.example","https://7.example","https://8.example","https://9.example","https://10.example","https://11.example"]}`, want: "between 1 and 10"},
+ {name: "relative url", args: `{"urls":["example.com"]}`, want: "absolute HTTP or HTTPS"},
+ {name: "unsupported scheme", args: `{"urls":["file:///tmp/page"]}`, want: "absolute HTTP or HTTPS"},
+ {name: "bad format", args: `{"urls":["https://example.com"],"format":"xml"}`, want: "format must be"},
+ {name: "bad timeout", args: `{"urls":["https://example.com"],"crawl_timeout":61}`, want: "between 1 and 60"},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ contents := NewQueritContentsToolWithEnvKey(NewHTTPHelper(), func() string { return "key-test" })
+ out, err := contents.InvokableRun(context.Background(), test.args)
+ if err != nil || !strings.Contains(out, "_ERROR") || !strings.Contains(out, test.want) {
+ t.Fatalf("result = %s, err = %v", out, err)
+ }
+ })
+ }
+
+ contents := NewQueritContentsToolWithEnvKey(NewHTTPHelper(), func() string { return "" })
+ out, err := contents.InvokableRun(context.Background(), `{"urls":["https://example.com"]}`)
+ if err != nil || !strings.Contains(out, "api_key is required") {
+ t.Fatalf("missing key result = %s, err = %v", out, err)
+ }
+}
+
+func TestQueritContentsRejectsMalformedResponses(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ body string
+ want string
+ }{
+ {name: "top-level array", body: `[]`, want: "JSON object"},
+ {name: "results object", body: `{"results":{}}`, want: "results must be a JSON array"},
+ {name: "statuses object", body: `{"results":[],"statuses":{}}`, want: "statuses must be a JSON array"},
+ {name: "trailing content", body: `{"results":[]} trailing`, want: "trailing content"},
+ } {
+ 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)})
+ contents := NewQueritContentsToolWithEnvKey(helper, func() string { return "key-test" })
+ out, err := contents.InvokableRun(context.Background(), `{"urls":["https://example.com"]}`)
+ if err != nil || !strings.Contains(out, test.want) {
+ t.Fatalf("result = %s, err = %v", out, err)
+ }
+ })
+ }
+}
+
+func TestQueritContentsRedactsAPIKey(t *testing.T) {
+ const secret = "secret-contents-key"
+ helper := NewHTTPHelper().WithClient(&http.Client{Transport: roundTripperErrorFunc(func(*http.Request) error {
+ return errors.New("failed with " + secret)
+ })})
+ contents := NewQueritContentsToolWithEnvKey(helper, func() string { return secret })
+ out, err := contents.InvokableRun(context.Background(), `{"urls":["https://example.com"]}`)
+ if err != nil || strings.Contains(out, secret) || !strings.Contains(out, "[REDACTED]") {
+ t.Fatalf("result = %s, err = %v", out, err)
+ }
+}
+
+func TestQueritContentsInfoAndComponentContract(t *testing.T) {
+ contents := NewQueritContentsTool()
+ if contents.helper.client.Timeout != 65*time.Second {
+ t.Fatalf("HTTP timeout = %s, want 65s", contents.helper.client.Timeout)
+ }
+ info, err := contents.Info(context.Background())
+ if err != nil || info.Name != queritContentsToolName || info.ParamsOneOf == nil {
+ t.Fatalf("Info = %#v, %v", info, err)
+ }
+ encoded, _ := json.Marshal(info)
+ if strings.Contains(string(encoded), "api_key") {
+ t.Fatalf("Info exposed API key: %s", encoded)
+ }
+ spec := contents.ComponentSpec()
+ if spec.Inputs["urls"] == "" || spec.Outputs["json"] == "" || !spec.PreserveJSONNumbers {
+ t.Fatalf("ComponentSpec = %#v", spec)
+ }
+ if len(spec.InputForm) != 1 || spec.InputForm["urls"] == nil {
+ t.Fatalf("InputForm = %#v, want URLs only", spec.InputForm)
+ }
+ response := map[string]any{"search_id": "crawl-1", "results": []any{map[string]any{"content": "page"}}}
+ outputs := contents.BuildComponentOutputs(response)
+ if outputs["json"].(map[string]any)["search_id"] != "crawl-1" {
+ t.Fatalf("outputs = %#v", outputs)
+ }
+}
diff --git a/internal/agent/tool/registry.go b/internal/agent/tool/registry.go
index f5143b9ec2..4f8eb71d75 100644
--- a/internal/agent/tool/registry.go
+++ b/internal/agent/tool/registry.go
@@ -55,6 +55,7 @@ var registry = map[string]Factory{
"pubmed": buildPubMedTool,
"qweather": noConfig("qweather", func() einotool.BaseTool { return NewQWeatherTool() }),
"querit": buildQueritTool,
+ "querit_contents": buildQueritContentsTool,
"querit_search": buildQueritTool,
"retrieval": buildRetrievalTool,
"search_my_dataset": buildRetrievalTool,
@@ -78,6 +79,7 @@ var canvasToolNames = map[string]string{
"codeexec": "code_exec",
"googlescholar": "google_scholar",
"keenablesearch": "keenable",
+ "queritcontents": "querit_contents",
"queritsearch": "querit_search",
"tavilyextract": "tavily_extract",
"tavilysearch": "tavily",
@@ -657,6 +659,42 @@ func buildQueritTool(params map[string]any) (einotool.BaseTool, error) {
return newQueritTool(nil, nil, defaults, nil), nil
}
+func buildQueritContentsTool(params map[string]any) (einotool.BaseTool, error) {
+ defaults := queritContentsParams{}
+ if value, exists := params["api_key"]; exists {
+ apiKey, valid := value.(string)
+ if !valid {
+ return nil, fmt.Errorf("agent tool: tool %q requires string node-level param api_key", queritContentsToolName)
+ }
+ defaults.APIKey = apiKey
+ }
+ if value, exists := params["urls"]; exists {
+ defaults.URLs = value
+ }
+ if value, exists := params["format"]; exists {
+ format, valid := value.(string)
+ if !valid || (format != "text" && format != "markdown" && format != "html") {
+ return nil, fmt.Errorf("agent tool: tool %q has unsupported format %q", queritContentsToolName, format)
+ }
+ defaults.Format = format
+ }
+ if value, exists := params["crawl_timeout"]; exists {
+ crawlTimeout, valid := strictInt(value)
+ if !valid || crawlTimeout < 1 || crawlTimeout > 60 {
+ return nil, fmt.Errorf("agent tool: tool %q requires integer node-level param crawl_timeout within [1, 60]", queritContentsToolName)
+ }
+ defaults.CrawlTimeout = crawlTimeout
+ }
+ if value, exists := params["extras_meta"]; exists {
+ extrasMeta, valid := value.(bool)
+ if !valid {
+ return nil, fmt.Errorf("agent tool: tool %q requires boolean node-level param extras_meta", queritContentsToolName)
+ }
+ defaults.ExtrasMeta = extrasMeta
+ }
+ return newQueritContentsTool(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 1f71a40f9f..a1d2151ba9 100644
--- a/internal/agent/tool/registry_test.go
+++ b/internal/agent/tool/registry_test.go
@@ -48,6 +48,7 @@ func TestBuildByName_CanvasComponentNames(t *testing.T) {
{name: "CodeExec", wantToolName: "execute_code"},
{name: "GoogleScholar", wantToolName: "google_scholar_search"},
{name: "KeenableSearch", wantToolName: "keenable_search"},
+ {name: "QueritContents", wantToolName: "querit_contents"},
{name: "QueritSearch", wantToolName: "querit_search"},
{name: "TavilyExtract", wantToolName: "tavily_extract"},
{name: "TavilySearch", wantToolName: "tavily_search"},
@@ -134,13 +135,34 @@ func TestBuildByName_QueritAliases(t *testing.T) {
}
}
+func TestBuildByName_QueritContentsAliases(t *testing.T) {
+ for _, name := range []string{"querit_contents", "queritcontents", "QueritContents"} {
+ built, err := BuildByName(name, map[string]any{
+ "api_key": "stored-key",
+ "format": "html",
+ "crawl_timeout": float64(20),
+ "extras_meta": true,
+ })
+ if err != nil {
+ t.Fatalf("BuildByName(%q): %v", name, err)
+ }
+ contents, ok := built.(*QueritContentsTool)
+ if !ok {
+ t.Fatalf("BuildByName(%q) returned %T, want *QueritContentsTool", name, built)
+ }
+ if contents.defaults.Format != "html" || contents.defaults.CrawlTimeout != 20 || !contents.defaults.ExtrasMeta {
+ t.Fatalf("BuildByName(%q) defaults = %#v", name, contents.defaults)
+ }
+ }
+}
+
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",
+ "querit", "querit_contents", "querit_search",
"retrieval", "search_my_dataset", "search_my_dateset", "searxng",
"tavily", "tavily_extract", "tushare", "web_crawler", "wencai", "wikipedia", "wikipedia_search",
"yahoo_finance",
@@ -204,7 +226,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",
+ "querit", "querit_contents", "querit_search",
"retrieval", "search_my_dataset", "search_my_dateset", "searxng",
"tavily", "tavily_extract", "tushare", "web_crawler", "wencai", "wikipedia", "wikipedia_search",
"yahoo_finance",
@@ -275,6 +297,7 @@ func TestToolRegistry_SchemasAreComplete(t *testing.T) {
"wikipedia": "wikipedia_search",
"wikipedia_search": "wikipedia_search",
"querit": "querit_search",
+ "querit_contents": "querit_contents",
"querit_search": "querit_search",
}
for _, name := range names {
diff --git a/test/unit_test/agent/tools/test_querit_contents_unit.py b/test/unit_test/agent/tools/test_querit_contents_unit.py
new file mode 100644
index 0000000000..dbf4f8df8c
--- /dev/null
+++ b/test/unit_test/agent/tools/test_querit_contents_unit.py
@@ -0,0 +1,212 @@
+#
+# 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 QueritContents, QueritContentsParam
+
+
+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 = QueritContents.__new__(QueritContents)
+ param = QueritContentsParam()
+ param.api_key = api_key
+ param.delay_after_error = 0
+ tool._param = param
+ tool.check_if_canceled = lambda *args, **kwargs: False
+
+ outputs = {}
+ tool.set_output = lambda key, value: outputs.__setitem__(key, value)
+ tool.output = lambda key=None: outputs.get(key) if key else outputs
+ return tool, outputs
+
+
+def test_contents_posts_documented_defaults_and_preserves_response(monkeypatch):
+ raw_response = {
+ "error_code": 0,
+ "error_msg": "",
+ "search_id": "crawl-1",
+ "results": [
+ {
+ "id": "1",
+ "url": "https://example.com/article",
+ "content": "# Article",
+ "extrasMeta": {"title": "Article", "siteName": "Example"},
+ }
+ ],
+ "statuses": [{"id": "1", "status": "success"}],
+ "searchTime": 1,
+ }
+ calls = []
+
+ def fake_post(url, **kwargs):
+ calls.append((url, kwargs))
+ return _FakeResponse(raw_response)
+
+ monkeypatch.setattr(querit_module.requests, "post", fake_post)
+ tool, outputs = _make_tool()
+
+ result = tool._invoke(urls=["https://example.com/article"])
+
+ assert result == raw_response
+ assert calls == [
+ (
+ "https://api.querit.ai/v1/contents",
+ {
+ "headers": {
+ "Accept": "application/json",
+ "Authorization": "Bearer test-api-key",
+ "Content-Type": "application/json",
+ },
+ "json": {
+ "urls": ["https://example.com/article"],
+ "format": "markdown",
+ "crawlTimeout": 10,
+ "extrasMeta": False,
+ },
+ "timeout": querit_module.DEFAULT_TIMEOUT,
+ },
+ )
+ ]
+ assert outputs["json"] == raw_response
+
+
+def test_contents_accepts_canvas_url_string_and_runtime_options(monkeypatch):
+ calls = []
+
+ def fake_post(url, **kwargs):
+ calls.append(kwargs)
+ return _FakeResponse({"results": [], "statuses": []})
+
+ monkeypatch.setattr(querit_module.requests, "post", fake_post)
+ tool, _ = _make_tool()
+
+ tool._invoke(
+ urls="https://one.example, https://two.example",
+ format="html",
+ crawl_timeout=60,
+ extras_meta=True,
+ )
+
+ assert calls[0]["json"] == {
+ "urls": ["https://one.example", "https://two.example"],
+ "format": "html",
+ "crawlTimeout": 60,
+ "extrasMeta": True,
+ }
+ assert calls[0]["timeout"] == 65
+
+
+def test_contents_param_normalizes_canvas_url_string_before_validation():
+ param = QueritContentsParam()
+ param.urls = "https://one.example, https://two.example"
+ param.format = "markdown"
+ param.crawl_timeout = 10
+ param.extras_meta = False
+
+ param.check()
+
+ assert param.urls == ["https://one.example", "https://two.example"]
+ assert param.get_input_form() == {"urls": {"name": "URLs", "type": "line"}}
+
+
+@pytest.mark.parametrize(
+ ("kwargs", "message"),
+ [
+ ({"urls": []}, "between 1 and 10"),
+ ({"urls": [f"https://{index}.example" for index in range(11)]}, "between 1 and 10"),
+ ({"urls": ["example.com"]}, "absolute HTTP or HTTPS"),
+ ({"urls": ["file:///tmp/page"]}, "absolute HTTP or HTTPS"),
+ ({"urls": ["https://example.com"], "format": "xml"}, "format must be"),
+ ({"urls": ["https://example.com"], "crawl_timeout": 0}, "integer from 1 to 60"),
+ ({"urls": ["https://example.com"], "extras_meta": 1}, "must be a boolean"),
+ ],
+)
+def test_contents_rejects_invalid_inputs(kwargs, message):
+ tool, outputs = _make_tool()
+
+ result = tool._invoke(**kwargs)
+
+ assert message in result
+ assert message in outputs["_ERROR"]
+
+
+@pytest.mark.parametrize(
+ ("payload", "message"),
+ [
+ ([], "JSON object"),
+ ({"results": {}}, "results must be an array"),
+ ({"results": [], "statuses": {}}, "statuses must be an array"),
+ ],
+)
+def test_contents_rejects_malformed_response(monkeypatch, payload, message):
+ monkeypatch.setattr(querit_module.requests, "post", lambda *args, **kwargs: _FakeResponse(payload))
+ tool, outputs = _make_tool()
+
+ result = tool._invoke(urls=["https://example.com"])
+
+ assert message in result
+ assert message in outputs["_ERROR"]
+
+
+def test_contents_redacts_api_key_from_errors_and_logs(monkeypatch, caplog):
+ secret = f"secret-contents-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(urls=["https://example.com"])
+
+ assert secret not in result
+ assert secret not in outputs["_ERROR"]
+ assert secret not in caplog.text
+ assert "[REDACTED]" in result
+
+
+def test_contents_contract_and_dynamic_discovery():
+ import agent.tools as tools_package
+
+ metadata = QueritContentsParam().get_meta()["function"]
+ assert metadata["name"] == "querit_contents"
+ assert metadata["parameters"]["required"] == ["urls"]
+ assert set(metadata["parameters"]["properties"]) == {
+ "urls",
+ "format",
+ "crawl_timeout",
+ "extras_meta",
+ }
+ assert "api_key" not in metadata["parameters"]["properties"]
+ assert tools_package.QueritContents is QueritContents
+ assert tools_package.QueritContentsParam is QueritContentsParam
diff --git a/web/src/components/operator-icon.tsx b/web/src/components/operator-icon.tsx
index 79982d10b3..9e8528c37e 100644
--- a/web/src/components/operator-icon.tsx
+++ b/web/src/components/operator-icon.tsx
@@ -74,6 +74,7 @@ export const SVGIconMap = {
[Operator.KeenableSearch]: 'keenable',
[Operator.TavilyExtract]: 'tavily',
[Operator.TavilySearch]: 'tavily',
+ [Operator.QueritContents]: 'querit',
[Operator.QueritSearch]: 'querit',
[Operator.Wikipedia]: 'wikipedia',
[Operator.YahooFinance]: 'yahoo-finance',
@@ -123,7 +124,7 @@ const OperatorIcon = ({ name, className }: IProps) => {
const svgIcon = SVGIconMap[name as keyof typeof SVGIconMap];
const LucideIcon = LucideIconMap[name as keyof typeof LucideIconMap];
- if (name === Operator.QueritSearch) {
+ if (name === Operator.QueritContents || name === Operator.QueritSearch) {
return (
>({
+ defaultValues: values,
+ resolver: zodResolver(FormSchema),
+ mode: 'onChange',
+ });
+
+ useWatchFormChange(node?.id, form);
+
+ return (
+