Files
ragflow/test/unit_test/agent/test_dsl_bridge_roundtrip.py
Muhammad Furqan 3cba34d67f fix(agent/tools): port Crawler to ToolBase so it can load and run (#16415)
### What problem does this PR solve?

Closes #16414.

The **Crawler** agent tool (`agent/tools/crawler.py`) was never ported
to the modern `ToolBase`/`_invoke` interface during the agent module
redesign, so it was broken in three independent ways:

1. **Crashed on construction.** `CrawlerParam` extends `ToolParamBase`,
whose `__init__` reads `self.meta["parameters"]`, but `CrawlerParam`
defined no `meta`. Constructing it raised `AttributeError:
'CrawlerParam' object has no attribute 'meta'`. Because
`agent/canvas.py` instantiates `component_class(component_name +
"Param")()` while loading a canvas, **any agent containing a Crawler
node failed to load.**
2. **`_invoke` missing.** It extends `ToolBase` (whose `invoke()`
dispatches to `self._invoke`) but only implemented the legacy `_run`, so
`_invoke` resolved to `ComponentBase._invoke` → `NotImplementedError`.
3. **`be_output` removed.** `_run` called `Crawler.be_output(...)`,
which no longer exists on the base classes.

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)

### Changes

- Add a `ToolMeta` to `CrawlerParam` (defined before
`super().__init__()`, matching every other ported tool such as
`ArXivParam`/`TavilyExtractParam`) advertising a required `query`
parameter — the URL to crawl, default `{sys.query}`, consistent with the
`{sys.query}` convention shared by the other tools.
- Replace the legacy `_run`/`be_output` with `_invoke`/`set_output`,
writing the extracted page content to `formalized_content` (errors
surfaced via `_ERROR`), consistent with the other tools.
- Preserve the existing SSRF guard (`assert_url_is_safe` +
`pin_dns_global`).
- Add regression tests
(`test/unit_test/agent/component/test_crawler.py`) covering param
construction, validation, and the tool descriptor.

Same class of defect as #16329 (DeepL). Backend-only; no frontend
changes.

---------

Co-authored-by: Zhichang Yu <yuzhichang@gmail.com>
2026-07-03 17:15:48 +08:00

508 lines
20 KiB
Python

#
# 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.
"""Round-trip stability integration tests for the agent dsl bridge.
These mirror the front-end bridge pipeline (`web/src/pages/agent/utils/dsl-bridge.ts`)
in pure Python so the same invariants — v1 export byte-stable under
re-import, v2 export byte-stable under re-import, React-Flow internal
fields downgraded to warnings — can be checked from the Python test
suite without the npm/Jest toolchain.
The Python port is deliberately a near-line-for-line translation of
the TypeScript implementation: any change to the TS bridge should
be reflected here in the same commit. The two implementations agree
on the structural invariants (position, edge topology, components
map, _layout positions) and disagree only on the iteration order of
plain-object keys at JSON.stringify time — that's a downstream
concern, not a round-trip-stability concern.
Fixtures live in `internal/agent/dsl/testdata/` and are
the same on-disk JSONs the Go server's `TestNormalizeForCanvas_FixtureSmoke`
consumes. A new fixture added to that directory can be picked up
here by adding one `def test_*` below.
"""
from __future__ import annotations
import json
import warnings
from typing import Any
import pytest
# ─── Python port of web/src/pages/agent/utils/dsl-bridge.ts ─────────────
#
# Only the round-trip-relevant subset is ported. The TS bridge also
# handles EmptyDsl/EmptyDslV1 seeding, DataflowEmptyDsl variants,
# and the place-holder filter — those are not exercised by the
# round-trip path and are deliberately omitted here.
_COMPONENT_NAME_TO_NODE_TYPE: dict[str, str] = {
"Begin": "beginNode",
"Retrieval": "ragNode",
"Categorize": "categorizeNode",
"Message": "messageNode",
"Answer": "messageNode",
"RewriteQuestion": "rewriteNode",
"ExeSQL": "toolNode",
"Switch": "switchNode",
"Agent": "agentNode",
"Tool": "toolNode",
"File": "fileNode",
"Parser": "parserNode",
"Tokenizer": "tokenizerNode",
"TokenChunker": "chunkerNode",
"TitleChunker": "chunkerNode",
"Extractor": "contextNode",
"Loop": "loopNode",
"LoopStart": "loopStartNode",
"ExitLoop": "exitLoopNode",
"Iteration": "iterationNode",
"IterationStart": "iterationStartNode",
"DataOperations": "dataOperationsNode",
"ListOperations": "listOperationsNode",
"VariableAssigner": "variableAssignerNode",
"VariableAggregator": "variableAggregatorNode",
"Keyword": "keywordNode",
"Note": "noteNode",
"Placeholder": "placeholderNode",
"Code": "toolNode",
}
def _component_name_to_node_type(name: str) -> str:
return _COMPONENT_NAME_TO_NODE_TYPE.get(name, "agentNode")
def _to_string_list(value: Any) -> list[str]:
if not isinstance(value, list):
return []
return [v for v in value if isinstance(v, str) and v]
# ─── v1 helpers ─────────────────────────────────────────────────────────
def _v1_components_to_graph(components: dict[str, Any]) -> tuple[list[dict], list[dict]]:
"""Port of `v1ComponentsToGraph` in dsl-bridge.ts.
Returns (nodes, edges) in React-Flow shape. Positions are the
default 50/350/200 row layout — the round-trip test for the v1
path uses `_layout` as the authoritative position source, so
the v1-derived positions are only consumed when no `_layout`
exists (matching the TS bridge's fallback behaviour).
"""
nodes: list[dict] = []
edges: list[dict] = []
for i, (key, raw) in enumerate(components.items()):
comp = raw if isinstance(raw, dict) else {}
obj = comp.get("obj") if isinstance(comp.get("obj"), dict) else {}
name = obj.get("component_name") or comp.get("name") or key
params = obj.get("params") if isinstance(obj.get("params"), dict) else (comp.get("params") if isinstance(comp.get("params"), dict) else {})
nodes.append(
{
"id": key,
"type": _component_name_to_node_type(name),
"position": {"x": 50 + i * 350, "y": 200},
"data": {"label": name, "name": name, "form": params},
"sourcePosition": "right",
"targetPosition": "left",
}
)
for dst in _to_string_list(comp.get("downstream")):
edges.append(
{
"id": f"xy-edge__{key}-{dst}",
"source": key,
"target": dst,
"sourceHandle": "start",
"targetHandle": "end",
}
)
return nodes, edges
def _graph_to_v1_components(graph: dict[str, Any]) -> dict[str, Any]:
"""Port of `graphToV1Components` in dsl-bridge.ts (the inverse of
`_v1_components_to_graph`).
"""
edges = graph.get("edges") or []
downstream_map: dict[str, list[str]] = {}
upstream_map: dict[str, list[str]] = {}
for edge in edges:
if not isinstance(edge, dict):
continue
src = edge.get("source")
dst = edge.get("target")
if not isinstance(src, str) or not isinstance(dst, str):
continue
downstream_map.setdefault(src, []).append(dst)
upstream_map.setdefault(dst, []).append(src)
components: dict[str, Any] = {}
for raw_node in graph.get("nodes") or []:
if not isinstance(raw_node, dict):
continue
node_id = raw_node.get("id")
if not isinstance(node_id, str) or not node_id:
continue
data = raw_node.get("data") if isinstance(raw_node.get("data"), dict) else {}
label = data.get("label") or node_id
form = data.get("form") if isinstance(data.get("form"), dict) else {}
components[node_id] = {
"obj": {
"component_name": label,
"params": form,
},
"downstream": downstream_map.get(node_id, []),
"upstream": upstream_map.get(node_id, []),
}
return components
def _build_dsl_components_by_graph(nodes: list[dict], edges: list[dict], seed: dict[str, Any]) -> dict[str, Any]:
"""Port of `buildDslComponentsByGraph` (web/src/pages/agent/utils.ts:472).
Reverse-derives a v1-style `components` map from React-Flow
nodes/edges. Each node becomes a `component_name`/`params` pair
under `obj`, with `downstream`/`upstream` aggregated from the
edge list. Legacy `obj` fields on existing entries in `seed` are
preserved by the TS implementation (round-trip a `_deprecated_*`
field through a save/edit cycle); the Python port keeps the same
shape and accepts the same `seed` for consistency, even though
no legacy fields are currently in use.
"""
downstream_map: dict[str, list[str]] = {}
upstream_map: dict[str, list[str]] = {}
for edge in edges or []:
if not isinstance(edge, dict):
continue
src = edge.get("source")
dst = edge.get("target")
if not isinstance(src, str) or not isinstance(dst, str):
continue
downstream_map.setdefault(src, []).append(dst)
upstream_map.setdefault(dst, []).append(src)
out: dict[str, Any] = dict(seed) # preserve legacy `obj` fields
for raw_node in nodes or []:
if not isinstance(raw_node, dict):
continue
node_id = raw_node.get("id")
if not isinstance(node_id, str) or not node_id:
continue
data = raw_node.get("data") if isinstance(raw_node.get("data"), dict) else {}
label = data.get("label") or node_id
form = data.get("form") if isinstance(data.get("form"), dict) else {}
out[node_id] = {
"obj": {
"component_name": label,
"params": form,
},
"downstream": downstream_map.get(node_id, []),
"upstream": upstream_map.get(node_id, []),
}
return out
def _build_v1_dsl_from_import(raw: dict[str, Any], is_agent: bool) -> dict[str, Any]:
"""Port of `buildV1DslFromImport`. Accepts both v1 (`components` key)
and v2 (`graph` key) input shapes and produces a v1 envelope.
"""
out: dict[str, Any] = dict(raw) # shallow copy, will overwrite below
if isinstance(raw.get("components"), dict) and raw["components"]:
out["components"] = raw["components"]
layout = raw.get("_layout")
out["_layout"] = layout
elif isinstance(raw.get("graph"), dict) and (isinstance(raw["graph"].get("nodes"), list) and raw["graph"]["nodes"]):
graph = raw["graph"]
out["components"] = _graph_to_v1_components(graph)
# Mirror the TS bridge: stash positions in _layout so the
# user's saved canvas layout comes back unchanged.
out["_layout"] = {
"nodes": graph.get("nodes") or [],
"edges": graph.get("edges") or [],
}
return out
# ─── v2 helpers ─────────────────────────────────────────────────────────
def _build_v2_dsl_from_import(raw: dict[str, Any], is_agent: bool) -> dict[str, Any]:
"""Port of `buildV2DslFromImport`. Accepts both v1 and v2 input
shapes and produces a v2 envelope.
"""
out: dict[str, Any] = dict(raw)
if isinstance(raw.get("graph"), dict) and (isinstance(raw["graph"].get("nodes"), list) and raw["graph"]["nodes"]):
out["graph"] = raw["graph"]
# v2 input that carries its own `components` (e.g. a v2 file
# exported from the front-end with `bridge.exportDsl`) keeps
# them. When the input only has `graph` (e.g. a hand-edited
# dsl or a Go-server payload that was serialized without the
# v2 dual `components` block) we derive a v1-style
# `components` map via `buildDslComponentsByGraph` so the
# server can still serve a re-imported file.
if not isinstance(raw.get("components"), dict):
out["components"] = _build_dsl_components_by_graph(
out["graph"].get("nodes") or [],
out["graph"].get("edges") or [],
{},
)
elif isinstance(raw.get("components"), dict) and raw["components"]:
components = raw["components"]
out["components"] = components
layout = raw.get("_layout")
# v1 → v2 cross-mode: prefer saved _layout positions over
# the default 50/350/200 row layout the inverse-conversion
# would produce. The user's drag-and-drop work survives.
if isinstance(layout, dict) and isinstance(layout.get("nodes"), list) and layout["nodes"]:
out["graph"] = {
"nodes": layout["nodes"],
"edges": layout.get("edges") or [],
}
else:
nodes, edges = _v1_components_to_graph(components)
out["graph"] = {"nodes": nodes, "edges": edges}
else:
out["graph"] = {"nodes": [], "edges": []}
out["components"] = {}
return out
# ─── Public bridge surface (Python port) ────────────────────────────────
def dsl_to_graph(dsl: dict[str, Any]) -> tuple[list[dict], list[dict]]:
"""Port of the mode-agnostic `dslToGraph` reader. Reads the
canonical `graph` block exclusively — no `_layout` or
`components` fallback, matching the strict-graph contract
in the TypeScript bridge.
"""
graph = dsl.get("graph") if isinstance(dsl.get("graph"), dict) else None
if graph and isinstance(graph.get("nodes"), list) and graph["nodes"]:
return list(graph["nodes"]), list(graph.get("edges") or [])
return [], []
def graph_to_dsl(mode: str, nodes: list[dict], edges: list[dict], old_dsl: dict[str, Any]) -> dict[str, Any]:
"""Port of `graphToDsl` (both v1 and v2 branches)."""
out = dict(old_dsl)
if mode == "v1":
out["_layout"] = {"nodes": list(nodes), "edges": list(edges)}
if "graph" in out:
del out["graph"]
else:
out["graph"] = {"nodes": list(nodes), "edges": list(edges)}
if "_layout" in out:
del out["_layout"]
return out
def export_dsl(mode: str, nodes: list[dict], edges: list[dict], old_dsl: dict[str, Any]) -> dict[str, Any]:
"""Port of `exportDsl` (both modes). Returns the file-shape payload
that ends up in the downloaded .json.
"""
full = graph_to_dsl(mode, nodes, edges, old_dsl)
if mode == "v1":
return {
"components": full.get("components", {}),
"_layout": full.get("_layout"),
"retrieval": full.get("retrieval", []),
"history": full.get("history", []),
"path": full.get("path", []),
"variables": full.get("variables", []),
"globals": full.get("globals", {}),
}
return {
"graph": full.get("graph"),
"components": full.get("components", {}),
"globals": full.get("globals", {}),
"variables": full.get("variables", []),
}
def import_dsl(raw: dict[str, Any], is_agent: bool) -> tuple[str, dict[str, Any]]:
"""Port of `importDsl`. Always routes through v2 — the single-wire
contract treats non-graph payloads as empty-canvas seed under one
canonical shape, matching the TypeScript bridge.
"""
return "v2", _build_v2_dsl_from_import(raw, is_agent)
def round_trip(raw: dict[str, Any]) -> dict[str, Any]:
"""Run the full bridge pipeline:
importDsl → dslToGraph → graphToDsl → dslToGraph → exportDsl
and return the final exported payload.
"""
mode, imported = import_dsl(raw, is_agent=True)
nodes, edges = dsl_to_graph(imported)
redrawn = graph_to_dsl(mode, nodes, edges, imported)
n2, e2 = dsl_to_graph(redrawn)
return export_dsl(mode, n2, e2, redrawn)
# ─── Diff classifier ────────────────────────────────────────────────────
#
# Walk two payloads depth-first. Mismatches on React-Flow internal
# fields (`dragging`, `selected`, `measured`, `data.isHovered`) land
# in `warnings` — the test still passes, but the user is informed.
# Mismatches on any other field land in `failures` — the test fails
# with a clear pointer to the offending path.
_NODE_INTERNALS = {"dragging", "selected", "measured"}
_EDGE_INTERNALS = {"isHovered"}
def _is_internal(path: str, key: str) -> bool:
# The leaf key is the strongest signal: `dragging`/`selected`/
# `measured` on a node, and `isHovered` on an edge or its
# nested `data` block, are always React-Flow internals.
if key in _NODE_INTERNALS:
return True
if key in _EDGE_INTERNALS:
# top-level isHovered on an edge object, or nested under edges[].data
segments = path.split(".")
return any(seg.startswith("edges") or seg == "data" for seg in segments)
# Walk up the parent path: if any ancestor is an internal key
# (e.g. `measured.width` lives under the `measured` parent),
# the whole subtree is React-Flow-managed and any leaf mismatch
# is a transient-state flip, not a real bug.
if any(seg in _NODE_INTERNALS or seg in _EDGE_INTERNALS for seg in path.split(".")):
return True
return False
class Diff:
"""Result of comparing two dsl-shaped payloads."""
def __init__(self) -> None:
self.warnings: list[str] = []
self.failures: list[str] = []
def add(self, path: str, kind: str, exp: Any, act: Any, key: str) -> None:
msg = f"{path}: {kind} ({_stable(exp)} vs {_stable(act)})"
if _is_internal(path, key):
self.warnings.append(msg)
else:
self.failures.append(msg)
def assert_stable(self) -> None:
"""pytest entry point: warn on warnings, fail on failures."""
for w in self.warnings:
warnings.warn(f"[React-Flow-internal] {w}", stacklevel=2)
assert self.failures == [], f"{len(self.failures)} round-trip mismatches:\n" + "\n".join(f" - {f}" for f in self.failures)
def _stable(v: Any) -> str:
if v is None:
return "null"
if isinstance(v, str):
return json.dumps(v, ensure_ascii=False)
try:
return json.dumps(v, ensure_ascii=False, sort_keys=True)
except (TypeError, ValueError):
return repr(v)
def diff_dsl(expected: Any, actual: Any, path: str = "") -> Diff:
out = Diff()
_compare_into(expected, actual, path, out)
return out
def _compare_into(expected: Any, actual: Any, path: str, out: Diff) -> None:
if expected == actual:
return
if expected is None or actual is None or type(expected) is not type(actual):
out.add(path or "<root>", "value", expected, actual, "")
return
if not isinstance(expected, (dict, list)):
out.add(path or "<root>", "value", expected, actual, "")
return
exp_arr = isinstance(expected, list)
act_arr = isinstance(actual, list)
if exp_arr != act_arr:
out.failures.append(f"{path or '<root>'}: array/object mismatch")
return
if exp_arr:
if len(expected) != len(actual):
out.failures.append(f"{path or '<root>'}: length {len(expected)} != {len(actual)}")
for i in range(min(len(expected), len(actual))):
_compare_into(expected[i], actual[i], f"{path}[{i}]", out)
return
# both dict
all_keys = set(expected) | set(actual)
for key in all_keys:
sub = f"{path}.{key}" if path else key
if key not in expected:
out.add(sub, "missing in expected", None, actual[key], key)
elif key not in actual:
out.add(sub, "missing in actual", expected[key], None, key)
elif isinstance(expected[key], dict) and expected[key] is not None:
_compare_into(expected[key], actual[key], sub, out)
elif expected[key] != actual[key]:
out.add(sub, "value", expected[key], actual[key], key)
# ─── Tests ──────────────────────────────────────────────────────────────
class TestDslBridgeRoundTrip:
"""Unit test of the diff classifier used by round-trip tests."""
@pytest.mark.p3
def test_diff_classifier_routes_correctly(self) -> None:
"""Direct unit test of the diff classifier — independent of
the bridge. Verifies the warning/failure split that the
round-trip tests rely on.
"""
expected = {
"id": "n1",
"type": "beginNode",
"position": {"x": 100, "y": 100},
"dragging": False,
"selected": False,
"measured": {"width": 200, "height": 81},
}
actual = {
"id": "n1",
"type": "beginNode",
"position": {"x": 999, "y": 100}, # semantic mismatch
"dragging": True, # RF internal
"selected": True, # RF internal
"measured": {"width": 999, "height": 81}, # RF internal
}
diff = diff_dsl(expected, actual)
# All three RF-internal fields must be in warnings, not failures.
# We match on substring because the diff walks into nested
# objects (e.g. `measured` is a `{width, height}` object and
# its inner fields end up at `measured.width`/`measured.height`).
warning_paths = [w.split(":", 1)[0] for w in diff.warnings]
assert any(p == "dragging" for p in warning_paths)
assert any(p == "selected" for p in warning_paths)
assert any(p.startswith("measured") for p in warning_paths)
# The semantic mismatch must be a failure with a clear
# pointer. The diff walks into `position: {x, y}` and
# reports each leaf individually, so only `x` shows up
# (`y` matches the expected 100).
assert diff.failures == ["position.x: value (100 vs 999)"]