feat(go-agent): Ported retrieval node, added Keenable web search tool (#16396)

Ported retrieval node, added Keenable web search tool
- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
Zhichang Yu
2026-06-26 22:55:49 +08:00
committed by yzc
parent f86a0e7386
commit f58fae5fb7
91 changed files with 5920 additions and 3817 deletions

View File

@@ -673,13 +673,13 @@ def test_dataset_update_identifier_validation_contract(rest_client):
assert not_uuid_res.status_code == 200
not_uuid_payload = not_uuid_res.json()
assert not_uuid_payload["code"] == 101, not_uuid_payload
assert "Invalid UUID1 format" in not_uuid_payload["message"], not_uuid_payload
assert "Invalid UUID format" in not_uuid_payload["message"], not_uuid_payload
not_uuid1_res = rest_client.put(f"/datasets/{uuid.uuid4().hex}", json=payload)
assert not_uuid1_res.status_code == 200
not_uuid1_payload = not_uuid1_res.json()
assert not_uuid1_payload["code"] == 101, not_uuid1_payload
assert "Invalid UUID1 format" in not_uuid1_payload["message"], not_uuid1_payload
assert not_uuid1_payload["code"] == 102, not_uuid1_payload
assert "lacks permission for dataset" in not_uuid1_payload["message"], not_uuid1_payload
wrong_uuid_res = rest_client.put("/datasets/d94a8dc02c9711f0930f7fbc369eab6d", json=payload)
assert wrong_uuid_res.status_code == 200
@@ -1835,13 +1835,13 @@ def test_dataset_delete_contract_matrix(rest_client, clear_datasets):
assert id_not_uuid_res.status_code == 200
id_not_uuid_payload = id_not_uuid_res.json()
assert id_not_uuid_payload["code"] == 101, id_not_uuid_payload
assert "Invalid UUID1 format" in id_not_uuid_payload["message"], id_not_uuid_payload
assert "Invalid UUID format" in id_not_uuid_payload["message"], id_not_uuid_payload
id_not_uuid1_res = rest_client.delete("/datasets", json={"ids": [uuid.uuid4().hex]})
assert id_not_uuid1_res.status_code == 200
id_not_uuid1_payload = id_not_uuid1_res.json()
assert id_not_uuid1_payload["code"] == 101, id_not_uuid1_payload
assert "Invalid UUID1 format" in id_not_uuid1_payload["message"], id_not_uuid1_payload
assert id_not_uuid1_payload["code"] == 102, id_not_uuid1_payload
assert "lacks permission for dataset" in id_not_uuid1_payload["message"], id_not_uuid1_payload
id_wrong_uuid_res = rest_client.delete("/datasets", json={"ids": ["d94a8dc02c9711f0930f7fbc369eab6d"]})
assert id_wrong_uuid_res.status_code == 200
@@ -2113,13 +2113,13 @@ def test_dataset_list_query_contract_matrix(rest_client, clear_datasets):
assert id_not_uuid_res.status_code == 200
id_not_uuid_payload = id_not_uuid_res.json()
assert id_not_uuid_payload["code"] == 101, id_not_uuid_payload
assert "Invalid UUID1 format" in id_not_uuid_payload["message"], id_not_uuid_payload
assert "Invalid UUID format" in id_not_uuid_payload["message"], id_not_uuid_payload
id_not_uuid1_res = rest_client.get("/datasets", params={"id": uuid.uuid4().hex})
assert id_not_uuid1_res.status_code == 200
id_not_uuid1_payload = id_not_uuid1_res.json()
assert id_not_uuid1_payload["code"] == 101, id_not_uuid1_payload
assert "Invalid UUID1 format" in id_not_uuid1_payload["message"], id_not_uuid1_payload
assert id_not_uuid1_payload["code"] == 102, id_not_uuid1_payload
assert "lacks permission for dataset" in id_not_uuid1_payload["message"], id_not_uuid1_payload
id_wrong_uuid_res = rest_client.get("/datasets", params={"id": "d94a8dc02c9711f0930f7fbc369eab6d"})
assert id_wrong_uuid_res.status_code == 200
@@ -2131,7 +2131,7 @@ def test_dataset_list_query_contract_matrix(rest_client, clear_datasets):
assert id_empty_res.status_code == 200
id_empty_payload = id_empty_res.json()
assert id_empty_payload["code"] == 101, id_empty_payload
assert "Invalid UUID1 format" in id_empty_payload["message"], id_empty_payload
assert "Invalid UUID format" in id_empty_payload["message"], id_empty_payload
id_none_res = rest_client.get("/datasets", params={"id": None})
assert id_none_res.status_code == 200

View File

@@ -142,7 +142,7 @@ class TestDatasetsDelete:
payload = {"ids": ["not_uuid"]}
res = delete_datasets(HttpApiAuth, payload)
assert res["code"] == 101, res
assert "Invalid UUID1 format" in res["message"], res
assert "Invalid UUID format" in res["message"], res
res = list_datasets(HttpApiAuth)
assert len(res["data"]) == 1, res
@@ -152,8 +152,8 @@ class TestDatasetsDelete:
def test_id_not_uuid1(self, HttpApiAuth):
payload = {"ids": [uuid.uuid4().hex]}
res = delete_datasets(HttpApiAuth, payload)
assert res["code"] == 101, res
assert "Invalid UUID1 format" in res["message"], res
assert res["code"] == 102, res
assert "lacks permission for dataset" in res["message"], res
@pytest.mark.p2
@pytest.mark.usefixtures("add_dataset_func")

View File

@@ -268,14 +268,14 @@ class TestDatasetsList:
params = {"id": "not_uuid"}
res = list_datasets(HttpApiAuth, params)
assert res["code"] == 101, res
assert "Invalid UUID1 format" in res["message"], res
assert "Invalid UUID format" in res["message"], res
@pytest.mark.p2
def test_id_not_uuid1(self, HttpApiAuth):
params = {"id": uuid.uuid4().hex}
res = list_datasets(HttpApiAuth, params)
assert res["code"] == 101, res
assert "Invalid UUID1 format" in res["message"], res
assert res["code"] == 102, res
assert "lacks permission for dataset" in res["message"], res
@pytest.mark.p2
def test_id_wrong_uuid(self, HttpApiAuth):
@@ -289,7 +289,7 @@ class TestDatasetsList:
params = {"id": ""}
res = list_datasets(HttpApiAuth, params)
assert res["code"] == 101, res
assert "Invalid UUID1 format" in res["message"], res
assert "Invalid UUID format" in res["message"], res
@pytest.mark.p2
def test_id_none(self, HttpApiAuth):

View File

@@ -105,14 +105,14 @@ class TestDatasetUpdate:
payload = {"name": "not uuid"}
res = update_dataset(HttpApiAuth, "not_uuid", payload)
assert res["code"] == 101, res
assert "Invalid UUID1 format" in res["message"], res
assert "Invalid UUID format" in res["message"], res
@pytest.mark.p3
def test_dataset_id_not_uuid1(self, HttpApiAuth):
payload = {"name": "not uuid1"}
res = update_dataset(HttpApiAuth, uuid.uuid4().hex, payload)
assert res["code"] == 101, res
assert "Invalid UUID1 format" in res["message"], res
assert res["code"] == 102, res
assert "lacks permission for dataset" in res["message"], res
@pytest.mark.p3
def test_dataset_id_wrong_uuid(self, HttpApiAuth):

View File

@@ -103,7 +103,7 @@ class TestDatasetsDelete:
payload = {"ids": ["not_uuid"]}
with pytest.raises(Exception) as exception_info:
client.delete_datasets(**payload)
assert "Invalid UUID1 format" in str(exception_info.value), str(exception_info.value)
assert "Invalid UUID format" in str(exception_info.value), str(exception_info.value)
datasets = client.list_datasets()
assert len(datasets) == 1, str(datasets)
@@ -114,7 +114,7 @@ class TestDatasetsDelete:
payload = {"ids": [uuid.uuid4().hex]}
with pytest.raises(Exception) as exception_info:
client.delete_datasets(**payload)
assert "Invalid UUID1 format" in str(exception_info.value), str(exception_info.value)
assert "lacks permission for dataset" in str(exception_info.value), str(exception_info.value)
@pytest.mark.p2
@pytest.mark.usefixtures("add_dataset_func")

View File

@@ -250,14 +250,14 @@ class TestDatasetsList:
params = {"id": "not_uuid"}
with pytest.raises(Exception) as exception_info:
client.list_datasets(**params)
assert "Invalid UUID1 format" in str(exception_info.value), str(exception_info.value)
assert "Invalid UUID format" in str(exception_info.value), str(exception_info.value)
@pytest.mark.p2
def test_id_not_uuid1(self, client):
params = {"id": uuid.uuid4().hex}
with pytest.raises(Exception) as exception_info:
client.list_datasets(**params)
assert "Invalid UUID1 format" in str(exception_info.value), str(exception_info.value)
assert "lacks permission for dataset" in str(exception_info.value), str(exception_info.value)
@pytest.mark.p2
def test_id_wrong_uuid(self, client):
@@ -271,7 +271,7 @@ class TestDatasetsList:
params = {"id": ""}
with pytest.raises(Exception) as exception_info:
client.list_datasets(**params)
assert "Invalid UUID1 format" in str(exception_info.value), str(exception_info.value)
assert "Invalid UUID format" in str(exception_info.value), str(exception_info.value)
@pytest.mark.p2
def test_id_none(self, client):

View File

@@ -0,0 +1,157 @@
#
# 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 importlib.util
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
import pytest
def _load_fillup_module(monkeypatch):
repo_root = Path(__file__).resolve().parents[4]
agent_pkg = ModuleType("agent")
agent_pkg.__path__ = [str(repo_root / "agent")]
monkeypatch.setitem(sys.modules, "agent", agent_pkg)
component_pkg = ModuleType("agent.component")
component_pkg.__path__ = [str(repo_root / "agent" / "component")]
monkeypatch.setitem(sys.modules, "agent.component", component_pkg)
base_mod = ModuleType("agent.component.base")
class _ComponentParamBase:
def __init__(self):
self.inputs = {}
self.outputs = {}
class _ComponentBase:
def get_input_elements(self):
return self._param.inputs
def check_if_canceled(self, *_args, **_kwargs):
return False
def get_input_elements_from_text(self, *_args, **_kwargs):
return {}
def set_output(self, key, value):
if key not in self._param.outputs:
self._param.outputs[key] = {"value": None}
self._param.outputs[key]["value"] = value
def set_input_value(self, key, value):
if key not in self._param.inputs:
self._param.inputs[key] = {"value": None}
self._param.inputs[key]["value"] = value
base_mod.ComponentBase = _ComponentBase
base_mod.ComponentParamBase = _ComponentParamBase
monkeypatch.setitem(sys.modules, "agent.component.base", base_mod)
api_pkg = ModuleType("api")
api_pkg.__path__ = [str(repo_root / "api")]
monkeypatch.setitem(sys.modules, "api", api_pkg)
services_pkg = ModuleType("api.db.services")
services_pkg.__path__ = [str(repo_root / "api" / "db" / "services")]
monkeypatch.setitem(sys.modules, "api.db.services", services_pkg)
file_service_mod = ModuleType("api.db.services.file_service")
class _FileService:
@staticmethod
def get_files(files, layout_recognize=None):
return {"files": files, "layout_recognize": layout_recognize}
file_service_mod.FileService = _FileService
monkeypatch.setitem(sys.modules, "api.db.services.file_service", file_service_mod)
module_path = repo_root / "agent" / "component" / "fillup.py"
spec = importlib.util.spec_from_file_location(
"test_fillup_unit_module", module_path
)
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, "test_fillup_unit_module", module)
spec.loader.exec_module(module)
return module
def _make_fillup(module, *, query, inputs):
component = module.UserFillUp.__new__(module.UserFillUp)
component._canvas = SimpleNamespace(
globals={
"sys.query": query,
"sys.__initial_user_input_consumed__": False,
}
)
component._param = SimpleNamespace(
enable_tips=False,
tips="",
layout_recognize="",
inputs=inputs,
outputs={},
)
return component
@pytest.mark.p2
def test_user_fillup_auto_consumes_initial_query_for_single_field(monkeypatch):
module = _load_fillup_module(monkeypatch)
component = _make_fillup(
module,
query="code",
inputs={"demo": {"type": "options", "name": "Demo"}},
)
component._invoke(inputs={})
assert component._param.inputs["demo"]["value"] == "code"
assert component._param.outputs["demo"]["value"] == "code"
assert component._canvas.globals["sys.__initial_user_input_consumed__"] is True
@pytest.mark.p2
def test_user_fillup_only_auto_consumes_initial_query_once(monkeypatch):
module = _load_fillup_module(monkeypatch)
component = _make_fillup(
module,
query="code",
inputs={"demo": {"type": "options", "name": "Demo"}},
)
component._invoke(inputs={})
component._param.outputs = {}
component._invoke(inputs={})
assert component._param.outputs == {}
@pytest.mark.p2
def test_user_fillup_does_not_consume_unmatched_structured_query(monkeypatch):
module = _load_fillup_module(monkeypatch)
component = _make_fillup(
module,
query={"x": 8},
inputs={"demo": {"type": "options", "name": "Demo"}},
)
component._invoke(inputs={})
assert component._param.outputs == {}
assert component._canvas.globals["sys.__initial_user_input_consumed__"] is False

View File

@@ -212,6 +212,54 @@ def _load_canvas_runtime(monkeypatch):
def thoughts(self):
return "sink"
class UserFillUpParam(base_mod.ComponentParamBase):
def __init__(self):
super().__init__()
self.enable_tips = True
self.tips = "Please fill"
self.inputs = {"value": {"type": "line", "name": "Value"}}
def get_input_form(self):
return self.inputs
def check(self):
return True
class UserFillUp(base_mod.ComponentBase):
component_name = "UserFillUp"
def _invoke(self, **kwargs):
incoming = kwargs.get("inputs", {})
if "value" in incoming:
raw = incoming["value"]
value = raw.get("value") if isinstance(raw, dict) else raw
self.set_output("value", value)
if self._param.enable_tips:
self.set_output("tips", self._param.tips)
def get_input_elements(self):
return self._param.inputs
def thoughts(self):
return "fill"
class MessageParam(base_mod.ComponentParamBase):
def __init__(self):
super().__init__()
self.content = "{UserFillUp:1@value}"
def check(self):
return True
class Message(base_mod.ComponentBase):
component_name = "Message"
def _invoke(self, **kwargs):
self.set_output("content", self.string_format(self._param.content, {}))
def thoughts(self):
return "message"
class_map = {
"Begin": Begin,
"BeginParam": BeginParam,
@@ -223,6 +271,10 @@ def _load_canvas_runtime(monkeypatch):
"ProbeParam": ProbeParam,
"Sink": Sink,
"SinkParam": SinkParam,
"UserFillUp": UserFillUp,
"UserFillUpParam": UserFillUpParam,
"Message": Message,
"MessageParam": MessageParam,
}
component_pkg.component_class = lambda name: class_map[name]
@@ -237,9 +289,9 @@ def _load_canvas_runtime(monkeypatch):
return canvas_mod
async def _collect_events(canvas):
async def _collect_events(stream):
events = []
async for event in canvas.run():
async for event in stream:
events.append(event)
return events
@@ -308,7 +360,7 @@ def test_iteration_runtime_processes_all_array_items(monkeypatch):
}
canvas = canvas_mod.Canvas(json.dumps(dsl))
events = asyncio.run(_collect_events(canvas))
events = asyncio.run(_collect_events(canvas.run()))
assert canvas.globals["probe.calls"] == ["a", "b", "c"]
assert any(event["event"] == "workflow_finished" for event in events)
@@ -386,6 +438,78 @@ def test_iteration_runtime_supports_bare_iteration_aliases(monkeypatch, query, e
}
canvas = canvas_mod.Canvas(json.dumps(dsl))
asyncio.run(_collect_events(canvas))
asyncio.run(_collect_events(canvas.run()))
assert canvas.globals["probe.calls"] == expected_calls
@pytest.mark.p2
def test_canvas_resume_does_not_emit_duplicate_workflow_started(monkeypatch):
canvas_mod = _load_canvas_runtime(monkeypatch)
dsl = {
"components": {
"begin": {
"obj": {"component_name": "Begin", "params": {}},
"downstream": ["UserFillUp:1"],
"upstream": [],
},
"UserFillUp:1": {
"obj": {
"component_name": "UserFillUp",
"params": {
"enable_tips": True,
"tips": "Enter value",
"inputs": {"value": {"type": "line", "name": "Value"}},
},
},
"downstream": ["Message:1"],
"upstream": ["begin"],
},
"Message:1": {
"obj": {
"component_name": "Message",
"params": {"content": "{UserFillUp:1@value}"},
},
"downstream": [],
"upstream": ["UserFillUp:1"],
},
},
"graph": {
"nodes": [
{"id": "begin", "data": {"name": "Begin"}},
{"id": "UserFillUp:1", "data": {"name": "UserFillUp"}},
{"id": "Message:1", "data": {"name": "Message"}},
]
},
"history": [],
"path": [],
"retrieval": [],
"globals": {
"sys.query": "",
"sys.user_id": "",
"sys.conversation_turns": 0,
"sys.files": [],
"sys.history": [],
"sys.date": "",
},
}
canvas = canvas_mod.Canvas(json.dumps(dsl))
first_events = asyncio.run(_collect_events(canvas.run()))
first_kinds = [event["event"] for event in first_events]
assert first_kinds == [
"workflow_started",
"node_started",
"node_finished",
"user_inputs",
]
resumed_events = asyncio.run(
_collect_events(canvas.run(query="hello", inputs={"value": {"value": "hello"}}))
)
resumed_kinds = [event["event"] for event in resumed_events]
assert resumed_kinds[0] == "node_started"
assert "workflow_started" not in resumed_kinds
assert "message" in resumed_kinds
assert resumed_kinds[-1] == "workflow_finished"

View File

@@ -189,3 +189,13 @@ def test_set_outputs_tracks_first_and_last(monkeypatch):
assert component._param.outputs["result"]["value"] == ["c", "d", "e"]
assert component._param.outputs["first"]["value"] == "c"
assert component._param.outputs["last"]["value"] == "e"
@pytest.mark.p2
def test_topn_operation_alias_normalizes_to_head(monkeypatch):
module = _load_list_operations_module(monkeypatch)
param = module.ListOperationsParam()
param.query = "items"
param.operations = "topN"
param.check()
assert param.operations == "head"

View File

@@ -0,0 +1,48 @@
#
# 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.
#
from types import SimpleNamespace
from common.constants import ActiveStatusEnum
from api.db.joint_services import tenant_model_service as module
def test_resolve_instance_for_model_falls_back_from_default_to_single_active_instance(monkeypatch):
provider = SimpleNamespace(id="provider-1", provider_name="SILICONFLOW")
resolved = SimpleNamespace(
id="instance-1",
instance_name="yy2",
status=ActiveStatusEnum.ACTIVE.value,
)
monkeypatch.setattr(
module.TenantModelInstanceService,
"get_by_provider_id_and_instance_name",
lambda provider_id, instance_name: None,
)
monkeypatch.setattr(
module.TenantModelInstanceService,
"get_all_by_provider_id",
lambda provider_id: [resolved],
)
got = module._resolve_instance_for_model(
provider,
"default",
"Qwen/Qwen3-8B@default@SILICONFLOW",
)
assert got is resolved