mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
feat(extensions): allow ModelTool plugins to return structured artifacts (#6861)
* test(plugins): capture opt-in artifact RED (18 failed, 13 passed) * feat(plugins): add typed opt-in artifacts (31 ToolNode tests pass) * test(extensions): require API 0.2.6 (version RED verified) * build(extensions): pin API 0.2.6 (installed wheel guards GREEN) * test(examples): verify plugin artifacts through checkpoint restart * test(extensions): reproduce artifact key coercion (8 RED cases) * fix(extensions): reject non-string artifact keys (185 tests GREEN) * fix(frontend): list bundled retrieval example in extension catalog * test(mcp): reconcile recovered notification at stored due time --------- Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
This commit is contained in:
parent
8f0ca58653
commit
517a8309a2
@ -1826,6 +1826,14 @@ authenticated backend operations and model tools through the
|
||||
[plugin APIs](docs/full-stack-plugins.md). The independent
|
||||
[bookmarks example](examples/deerflow-extension-bookmarks/README.md) demonstrates
|
||||
one package with persistent user data, its own sidebar page and a read-only search tool.
|
||||
Model tools can opt into separate structured artifacts with
|
||||
`response_format="content_and_artifact"` and `PluginToolResult` (extension-api
|
||||
0.2.6). Artifact object keys must be strings at every depth. Existing tools keep
|
||||
their JSON text output. The
|
||||
[local retrieval example](examples/deerflow-extension-retrieval/README.md)
|
||||
demonstrates bounded, read-only results and checkpoint replay without an external
|
||||
knowledge service; artifacts do not automatically enable citations. It also appears
|
||||
in the extension catalog with localized installation-guide metadata.
|
||||
Reopening a bookmark resolves the conversation's current agent through the host, so
|
||||
custom-agent conversations retain their original chat entry point, including older bookmarks.
|
||||
The independent [Agent teams example](examples/deerflow-extension-agent-teams/README.md)
|
||||
|
||||
@ -62,7 +62,7 @@ from deerflow_extension_api.placement import (
|
||||
MiddlewarePlacement,
|
||||
Placement,
|
||||
)
|
||||
from deerflow_extension_api.plugins import ActionContext, BackendAction, BrowserAssets, BrowserModule, ModelTool, PluginContribution, ToolContext
|
||||
from deerflow_extension_api.plugins import ActionContext, BackendAction, BrowserAssets, BrowserModule, ModelTool, PluginContribution, PluginToolResult, ToolContext
|
||||
from deerflow_extension_api.provenance import (
|
||||
MESSAGE_CONTENT_KIND_KEY,
|
||||
MESSAGE_PRODUCER_ENTITY_ID_KEY,
|
||||
@ -99,7 +99,7 @@ from deerflow_extension_api.state import ExtensionData
|
||||
|
||||
#: Contract version. Before 1.0, minors may break and patches are additive.
|
||||
#: From 1.0 on, bump the major for breaking changes.
|
||||
API_VERSION = "0.2.5"
|
||||
API_VERSION = "0.2.6"
|
||||
|
||||
__all__ = [
|
||||
"AGENT_RUNS_CONTEXT_KEY",
|
||||
@ -124,6 +124,7 @@ __all__ = [
|
||||
"BrowserAssets",
|
||||
"BrowserModule",
|
||||
"ModelTool",
|
||||
"PluginToolResult",
|
||||
"PluginContribution",
|
||||
"ToolContext",
|
||||
"SettingsField",
|
||||
|
||||
@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from deerflow_extension_api.agent_runs import AgentRuns
|
||||
from deerflow_extension_api.auth import ExtensionPrincipal
|
||||
@ -40,6 +40,14 @@ class ToolContext(ActionContext):
|
||||
thread_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginToolResult:
|
||||
"""Model-visible text plus optional JSON artifact for an opted-in tool."""
|
||||
|
||||
content: str
|
||||
artifact: Mapping[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelTool:
|
||||
name: str
|
||||
@ -47,6 +55,7 @@ class ModelTool:
|
||||
input_schema: Mapping[str, Any]
|
||||
handler: Callable[[Mapping[str, Any], ToolContext], Awaitable[Any]]
|
||||
group: str = "extensions"
|
||||
response_format: Literal["content", "content_and_artifact"] = field(default="content", kw_only=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "deerflow-extension-api"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
description = "Public contracts for DeerFlow extensions"
|
||||
requires-python = ">=3.12"
|
||||
# Keep the contract package import-light and independent from the host. Public
|
||||
|
||||
@ -418,14 +418,16 @@ Tests for hook behavior must create and invoke their own hook fixtures.
|
||||
|
||||
## Full-stack contributions
|
||||
|
||||
`registry.plugin(PluginContribution(...))` registers optional browser code, backend actions
|
||||
and model tools under one deployment-owned namespace. The public method defaults to False
|
||||
for older hosts; accepted contributions share source attribution and positional rollback.
|
||||
`plugins.py` in Gateway serves descriptors, hashed JS assets and authenticated action calls.
|
||||
No online settings write API is added. `plugin_tools.py` joins normal tool assembly with
|
||||
the run's extension snapshot; task delegation passes that snapshot explicitly. Browser
|
||||
public-field projection is an allowlist. Package code is trusted, not sandboxed. See
|
||||
`docs/full-stack-plugins.md` and the independently packaged bookmark example.
|
||||
`registry.plugin(PluginContribution(...))` adds browser/backend/tools under one
|
||||
namespace; old hosts return False. Preserve source attribution and positional rollback.
|
||||
Gateway `plugins.py`: descriptors, hashed JS, authenticated actions; no online
|
||||
settings write API. `plugin_tools.py` uses the run snapshot, passed explicitly
|
||||
to delegation. Browser fields are allowlisted. Code is trusted, not sandboxed.
|
||||
|
||||
`ModelTool` defaults to JSON content. Opt-in `content_and_artifact` requires
|
||||
`PluginToolResult`. Require string artifact keys throughout; validate JSON jointly
|
||||
within 64 KiB and snapshot artifacts. Keep identity, policy/deadline and citation
|
||||
gates. Test ToolNode and checkpoint replay. See `docs/full-stack-plugins.md`.
|
||||
|
||||
Full Agent run control is an optional `deerflow_extension_api.AgentRuns` handle
|
||||
on action/tool contexts and the request resolver. Gateway owns principal binding,
|
||||
|
||||
@ -13,7 +13,7 @@ from types import MappingProxyType
|
||||
|
||||
from deerflow_extension_api.agent_runs import AGENT_RUNS_CONTEXT_KEY
|
||||
from deerflow_extension_api.auth import ExtensionPrincipal
|
||||
from deerflow_extension_api.plugins import ToolContext
|
||||
from deerflow_extension_api.plugins import PluginToolResult, ToolContext
|
||||
from jsonschema import Draft202012Validator
|
||||
from langchain.tools import ToolRuntime
|
||||
from langchain_core.tools import StructuredTool, ToolException
|
||||
@ -60,6 +60,17 @@ def plugin_tool_name(namespace, name):
|
||||
return f"ext_{re.sub('[^a-z0-9_]', '_', namespace)[:20]}_{name[:25]}_{digest}"
|
||||
|
||||
|
||||
def _validate_artifact_keys(value):
|
||||
if isinstance(value, Mapping):
|
||||
if any(not isinstance(key, str) for key in value):
|
||||
raise TypeError("Artifact object keys must be strings")
|
||||
for child in value.values():
|
||||
_validate_artifact_keys(child)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for child in value:
|
||||
_validate_artifact_keys(child)
|
||||
|
||||
|
||||
def _build_tool(source, plugin, declaration):
|
||||
schema = deepcopy(dict(declaration.input_schema))
|
||||
validator = Draft202012Validator(schema)
|
||||
@ -81,6 +92,19 @@ def _build_tool(source, plugin, declaration):
|
||||
)
|
||||
async with asyncio.timeout(30):
|
||||
result = await declaration.handler(MappingProxyType(payload), tool_context)
|
||||
if declaration.response_format == "content_and_artifact":
|
||||
if not isinstance(result, PluginToolResult) or not isinstance(result.content, str) or (result.artifact is not None and not isinstance(result.artifact, Mapping)):
|
||||
raise TypeError("Expected PluginToolResult with text and a JSON object artifact")
|
||||
_validate_artifact_keys(result.artifact)
|
||||
artifact = result.artifact
|
||||
if artifact is not None and not isinstance(artifact, dict):
|
||||
artifact = dict(artifact)
|
||||
envelope = {"content": result.content, "artifact": artifact}
|
||||
encoded = json.dumps(envelope, ensure_ascii=False, allow_nan=False)
|
||||
if len(encoded.encode()) > 64 * 1024:
|
||||
raise ToolException("Plugin result exceeds 64 KiB.")
|
||||
# A JSON roundtrip detaches nested values retained by the handler.
|
||||
return result.content, json.loads(encoded)["artifact"]
|
||||
encoded = json.dumps(result, ensure_ascii=False, allow_nan=False)
|
||||
if len(encoded.encode()) > 64 * 1024:
|
||||
raise ToolException("Plugin result exceeds 64 KiB.")
|
||||
@ -91,7 +115,8 @@ def _build_tool(source, plugin, declaration):
|
||||
logger.warning("Plugin tool failed: %s/%s (%s)", plugin.namespace, declaration.name, type(exc).__name__)
|
||||
raise ToolException("Plugin tool unavailable or input rejected.") from None
|
||||
|
||||
tool = StructuredTool(name=plugin_tool_name(plugin.namespace, declaration.name), description=declaration.description, args_schema=schema, coroutine=invoke, handle_tool_error=True)
|
||||
options = {"response_format": "content_and_artifact"} if declaration.response_format == "content_and_artifact" else {}
|
||||
tool = StructuredTool(name=plugin_tool_name(plugin.namespace, declaration.name), description=declaration.description, args_schema=schema, coroutine=invoke, handle_tool_error=True, **options)
|
||||
tag_plugin_tool(tool, namespace=plugin.namespace, declaration=declaration.name, installation=source)
|
||||
return tool
|
||||
|
||||
|
||||
@ -114,6 +114,8 @@ class ExtensionRegistry(ExtensionRegistryContract):
|
||||
|
||||
tool_names = set()
|
||||
for tool in contribution.tools:
|
||||
if tool.response_format not in ("content", "content_and_artifact"):
|
||||
raise ValueError("Unsupported plugin tool response format")
|
||||
if not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", tool.name) or tool.name in tool_names or not tool.description or not inspect.iscoroutinefunction(tool.handler):
|
||||
raise ValueError("Model tools require unique names, descriptions and async handlers")
|
||||
validate_schema(tool.input_schema, tool=True)
|
||||
|
||||
@ -13,7 +13,7 @@ dependencies = [
|
||||
# the contract version it implements, extensions declare ranges. A range
|
||||
# here would let pip resolve a newer contract package than this harness
|
||||
# implements, making newer extensions look supported at runtime.
|
||||
"deerflow-extension-api==0.2.5",
|
||||
"deerflow-extension-api==0.2.6",
|
||||
"dotenv>=0.9.9",
|
||||
"exa-py>=1.0.0",
|
||||
"httpx>=0.28.0",
|
||||
|
||||
@ -203,6 +203,10 @@ tests never need credentials or network access.
|
||||
|
||||
## MCP claim fencing
|
||||
|
||||
Notification recovery tests advance to the persisted `next_notification_at` after
|
||||
dispatch. It records completion time; a fixed scan-time offset can query before
|
||||
the notification is due on a slower runner. Preserve delivery and counter assertions.
|
||||
|
||||
`test_mcp_task_repository.py` covers same-worker reclaim during an in-flight
|
||||
release, poll/cancel snapshot, or notification completion. Use explicit events
|
||||
to pause the old operation at the persistence boundary, reclaim via the real
|
||||
|
||||
@ -342,7 +342,7 @@ def test_runtime_api_version_matches_the_installed_contract_package():
|
||||
"""Every additive contract slice bumps both gates together."""
|
||||
from importlib.metadata import version
|
||||
|
||||
assert API_VERSION == "0.2.5"
|
||||
assert API_VERSION == "0.2.6"
|
||||
assert API_VERSION == version("deerflow-extension-api")
|
||||
|
||||
|
||||
|
||||
@ -40,6 +40,7 @@ def test_every_contract_kind_added_for_the_0_2_series_is_exported():
|
||||
"BackendAction",
|
||||
"BrowserModule",
|
||||
"ModelTool",
|
||||
"PluginToolResult",
|
||||
"PluginContribution",
|
||||
"SettingsField",
|
||||
"ToolContext",
|
||||
|
||||
@ -1269,7 +1269,10 @@ async def test_recovered_launching_at_retry_budget_reconciles_successful_run(tmp
|
||||
now=now,
|
||||
)
|
||||
|
||||
reconcile_at = now + timedelta(seconds=1)
|
||||
# Dispatch stores the completion time, which can be later than the scan time.
|
||||
launched = await repo.get("task-launching-success", user_id="user-1", thread_id="thread-1", thread_incarnation=None)
|
||||
assert launched is not None
|
||||
reconcile_at = datetime.fromisoformat(launched["next_notification_at"])
|
||||
dispatched = await repo.claim_notification_work(
|
||||
now=reconcile_at,
|
||||
lease_owner="notifier-b",
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
"""Exercise actual LangGraph tool dispatch, not just manifest serialization."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
from dataclasses import FrozenInstanceError, dataclass, replace
|
||||
from types import MappingProxyType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from deerflow_extension_api.plugins import ModelTool, PluginContribution
|
||||
@ -164,3 +165,224 @@ async def test_model_tool_receives_run_control_from_host_runtime(installed):
|
||||
)
|
||||
handle.for_plugin.assert_called_once_with(plugin.namespace)
|
||||
assert calls[0].agent_runs is scoped
|
||||
|
||||
|
||||
def _tool_graph(plugin, declaration):
|
||||
registry = ExtensionRegistry()
|
||||
with registry.attributed_to("test"):
|
||||
registry.plugin(replace(plugin, tools=(declaration,)))
|
||||
(tool,) = build_plugin_tools(registry.build())
|
||||
graph = StateGraph(MessagesState)
|
||||
graph.add_node("tools", ToolNode([tool]))
|
||||
graph.add_edge(START, "tools")
|
||||
graph.add_edge("tools", END)
|
||||
return graph.compile(), tool
|
||||
|
||||
|
||||
async def _call_tool(graph, tool, **context):
|
||||
result = await graph.ainvoke(
|
||||
{"messages": [AIMessage(content="", tool_calls=[{"id": "artifact-call", "name": tool.name, "args": {"query": "hello"}}])]},
|
||||
context={"user_id": "trusted-user", "thread_id": "thread-a", **context},
|
||||
)
|
||||
return result["messages"][-1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_opt_in_mode_rejects_legacy_tuple_through_real_tool_node(installed):
|
||||
# A trusted package can subclass the old declaration. This drives ToolNode
|
||||
# even on a pre-feature host, exposing ignored response-format declarations.
|
||||
@dataclass(frozen=True)
|
||||
class ArtifactTool(ModelTool):
|
||||
response_format: str = "content_and_artifact"
|
||||
|
||||
async def handler(payload, context):
|
||||
return ("visible", {"private": "must not become content"})
|
||||
|
||||
_, plugin, _ = installed
|
||||
old = plugin.tools[0]
|
||||
graph, tool = _tool_graph(plugin, ArtifactTool(old.name, old.description, old.input_schema, handler))
|
||||
message = await _call_tool(graph, tool)
|
||||
assert message.status == "error"
|
||||
assert message.artifact is None
|
||||
assert "must not become content" not in message.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("artifact", [None, {"rows": [{"label": "故障", "score": 0.9}]}])
|
||||
async def test_typed_result_separates_content_from_artifact_and_preserves_identity(installed, artifact):
|
||||
from deerflow_extension_api import PluginToolResult
|
||||
|
||||
_, plugin, _ = installed
|
||||
contexts = []
|
||||
|
||||
async def handler(payload, context):
|
||||
contexts.append(context)
|
||||
return PluginToolResult("One record classified.", artifact)
|
||||
|
||||
declaration = replace(plugin.tools[0], handler=handler, response_format="content_and_artifact")
|
||||
graph, tool = _tool_graph(plugin, declaration)
|
||||
message = await _call_tool(graph, tool)
|
||||
assert (message.content, message.artifact, message.status) == ("One record classified.", artifact, "success")
|
||||
assert (message.tool_call_id, message.name) == ("artifact-call", tool.name)
|
||||
assert (contexts[0].principal.user_id, contexts[0].thread_id) == ("trusted-user", "thread-a")
|
||||
assert "runtime" not in json.dumps(tool.tool_call_schema)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_artifact_is_independent_json_snapshot(installed):
|
||||
from deerflow_extension_api import PluginToolResult
|
||||
|
||||
_, plugin, _ = installed
|
||||
rows = [{"label": "original"}]
|
||||
artifact = MappingProxyType({"rows": rows})
|
||||
|
||||
async def handler(payload, context):
|
||||
return PluginToolResult("ready", artifact)
|
||||
|
||||
graph, tool = _tool_graph(plugin, replace(plugin.tools[0], handler=handler, response_format="content_and_artifact"))
|
||||
message = await _call_tool(graph, tool)
|
||||
rows[0]["label"] = "changed after return"
|
||||
assert message.artifact == {"rows": [{"label": "original"}]}
|
||||
json.dumps(message.artifact, allow_nan=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", ["plain", "tuple", "bad-content", "list-artifact", "nan", "object", "int-key", "bool-key", "null-key", "float-key", "key-collision", "nested-key", "list-key", "tuple-key"])
|
||||
async def test_opt_in_invalid_result_is_sanitized_without_artifact(installed, kind):
|
||||
from deerflow_extension_api import PluginToolResult
|
||||
|
||||
_, plugin, _ = installed
|
||||
values = {
|
||||
"plain": {"sensitive": "secret"},
|
||||
"tuple": ("visible", {"sensitive": "secret"}),
|
||||
"bad-content": PluginToolResult({"sensitive": "secret"}, None),
|
||||
"list-artifact": PluginToolResult("visible", ["secret"]),
|
||||
"nan": PluginToolResult("visible", {"score": float("nan")}),
|
||||
"object": PluginToolResult("visible", {"sensitive": object()}),
|
||||
"int-key": PluginToolResult("visible", {1: "secret"}),
|
||||
"bool-key": PluginToolResult("visible", {True: "secret"}),
|
||||
"null-key": PluginToolResult("visible", {None: "secret"}),
|
||||
"float-key": PluginToolResult("visible", {1.5: "secret"}),
|
||||
"key-collision": PluginToolResult("visible", {1: "secret", "1": "other"}),
|
||||
"nested-key": PluginToolResult("visible", {"record": {1: "secret", "1": "other"}}),
|
||||
"list-key": PluginToolResult("visible", {"rows": [{1: "secret"}]}),
|
||||
"tuple-key": PluginToolResult("visible", {"rows": ({1: "secret"},)}),
|
||||
}
|
||||
|
||||
async def handler(payload, context):
|
||||
return values[kind]
|
||||
|
||||
graph, tool = _tool_graph(plugin, replace(plugin.tools[0], handler=handler, response_format="content_and_artifact"))
|
||||
message = await _call_tool(graph, tool)
|
||||
assert message.status == "error"
|
||||
assert message.artifact is None
|
||||
assert message.content == "Plugin tool unavailable or input rejected."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("over_limit", [False, True])
|
||||
async def test_artifact_mode_combines_utf8_content_and_artifact_budget(installed, over_limit):
|
||||
from deerflow_extension_api import PluginToolResult
|
||||
|
||||
_, plugin, _ = installed
|
||||
artifact = {"value": "中" * 1000}
|
||||
overhead = len(json.dumps({"content": "", "artifact": artifact}, ensure_ascii=False).encode())
|
||||
content = "a" * (64 * 1024 - overhead + int(over_limit))
|
||||
|
||||
async def handler(payload, context):
|
||||
return PluginToolResult(content, artifact)
|
||||
|
||||
graph, tool = _tool_graph(plugin, replace(plugin.tools[0], handler=handler, response_format="content_and_artifact"))
|
||||
message = await _call_tool(graph, tool)
|
||||
if over_limit:
|
||||
assert message.status == "error"
|
||||
assert message.artifact is None
|
||||
assert message.content == "Plugin result exceeds 64 KiB."
|
||||
else:
|
||||
assert message.status == "success"
|
||||
assert message.content == content
|
||||
assert message.artifact == artifact
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("value", [{"text": "中文"}, ("legacy tuple", {"a": 1}), "quoted string", None, {1: "integer", "nested": {None: "null"}}])
|
||||
async def test_default_mode_keeps_legacy_json_encoding(installed, value):
|
||||
_, plugin, _ = installed
|
||||
|
||||
async def handler(payload, context):
|
||||
return value
|
||||
|
||||
graph, tool = _tool_graph(plugin, replace(plugin.tools[0], handler=handler))
|
||||
message = await _call_tool(graph, tool)
|
||||
assert message.content == json.dumps(value, ensure_ascii=False, allow_nan=False)
|
||||
assert message.artifact is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_mode_cannot_serialize_typed_artifact_result(installed):
|
||||
from deerflow_extension_api import PluginToolResult
|
||||
|
||||
_, plugin, _ = installed
|
||||
result = PluginToolResult("visible", {"sensitive": "secret"})
|
||||
# This is also what the old host's unchanged JSON path does.
|
||||
with pytest.raises(TypeError):
|
||||
json.dumps(result)
|
||||
|
||||
async def handler(payload, context):
|
||||
return result
|
||||
|
||||
graph, tool = _tool_graph(plugin, replace(plugin.tools[0], handler=handler))
|
||||
message = await _call_tool(graph, tool)
|
||||
assert message.status == "error" and message.artifact is None
|
||||
assert message.content == "Plugin tool unavailable or input rejected."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_artifact_mode_propagates_caller_cancellation(installed):
|
||||
_, plugin, _ = installed
|
||||
entered, cleaned = asyncio.Event(), asyncio.Event()
|
||||
|
||||
async def handler(payload, context):
|
||||
entered.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
cleaned.set()
|
||||
|
||||
graph, tool = _tool_graph(plugin, replace(plugin.tools[0], handler=handler, response_format="content_and_artifact"))
|
||||
task = asyncio.create_task(_call_tool(graph, tool))
|
||||
try:
|
||||
await asyncio.wait_for(entered.wait(), 2)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert cleaned.is_set()
|
||||
finally:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
|
||||
def test_new_contract_keeps_old_positional_signature_and_is_frozen(installed):
|
||||
from deerflow_extension_api import PluginToolResult
|
||||
|
||||
_, plugin, _ = installed
|
||||
old = plugin.tools[0]
|
||||
declaration = ModelTool(old.name, old.description, old.input_schema, old.handler, "custom-group")
|
||||
assert declaration.response_format == "content" and declaration.group == "custom-group"
|
||||
with pytest.raises(TypeError):
|
||||
ModelTool(old.name, old.description, old.input_schema, old.handler, "custom-group", "content_and_artifact")
|
||||
result = PluginToolResult("visible", None)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
result.content = "changed"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["unknown", None, []])
|
||||
def test_invalid_response_format_rejects_whole_plugin_before_registration(installed, mode):
|
||||
_, plugin, _ = installed
|
||||
registry = ExtensionRegistry()
|
||||
declaration = replace(plugin.tools[0], name="other", response_format=mode)
|
||||
with registry.attributed_to("test"):
|
||||
with pytest.raises(ValueError, match="response format"):
|
||||
registry.plugin(replace(plugin, tools=(*plugin.tools, declaration)))
|
||||
assert not registry.build().plugins
|
||||
|
||||
131
backend/tests/test_retrieval_plugin_artifacts.py
Normal file
131
backend/tests/test_retrieval_plugin_artifacts.py
Normal file
@ -0,0 +1,131 @@
|
||||
"""Real plugin/tool/budget/checkpoint roundtrip, with no provider or model calls."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage, convert_to_messages, convert_to_openai_messages
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from langgraph.prebuilt import ToolNode
|
||||
|
||||
from deerflow.agents.middlewares.tool_output_budget_middleware import ToolOutputBudgetMiddleware
|
||||
from deerflow.agents.thread_state import get_thread_state_schema
|
||||
from deerflow.config.tool_output_config import ToolOutputConfig
|
||||
from deerflow.extensions.loader import ExtensionSpec, load_extensions
|
||||
from deerflow.extensions.plugin_tools import build_plugin_tools
|
||||
from deerflow.runtime.checkpoint_mode import inject_checkpoint_mode
|
||||
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
|
||||
from deerflow.runtime.serialization import serialize_channel_values_for_api
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def retrieval_plugin(monkeypatch):
|
||||
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-retrieval"))
|
||||
|
||||
def load(config=None):
|
||||
loaded, diagnostics = load_extensions([ExtensionSpec(use="deerflow_extension_retrieval:install", config={} if config is None else config)])
|
||||
assert diagnostics == []
|
||||
return loaded
|
||||
|
||||
return load
|
||||
|
||||
|
||||
def _graph(tool, saver, *, mode="full", limit=10000):
|
||||
budget = ToolOutputBudgetMiddleware(ToolOutputConfig(externalize_min_chars=0, fallback_max_chars=limit))
|
||||
graph = StateGraph(get_thread_state_schema(mode))
|
||||
graph.add_node("tools", ToolNode([tool], awrap_tool_call=budget.awrap_tool_call))
|
||||
graph.add_edge(START, "tools")
|
||||
graph.add_edge("tools", END)
|
||||
return graph.compile(checkpointer=saver)
|
||||
|
||||
|
||||
def _call(tool, query, *, call_id="call-retrieval-1"):
|
||||
return AIMessage(content="", id=f"request-{call_id}", tool_calls=[{"id": call_id, "name": tool.name, "args": query, "type": "tool_call"}])
|
||||
|
||||
|
||||
def test_retrieval_example_is_disabled_until_the_operator_enables_it(retrieval_plugin):
|
||||
loaded = retrieval_plugin()
|
||||
assert len(loaded.plugins) == 1
|
||||
assert build_plugin_tools(loaded) == []
|
||||
(tool,) = build_plugin_tools(retrieval_plugin({"enabled": True}))
|
||||
assert tool.name.startswith("ext_")
|
||||
assert tool.name not in {"knowledge_search", "task"}
|
||||
assert "synthetic" in tool.description.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("mode", ["full", "delta"])
|
||||
@pytest.mark.parametrize("limit", [120, 10000])
|
||||
async def test_plugin_artifact_survives_budget_sqlite_reopen_and_later_turn(tmp_path, retrieval_plugin, mode, limit):
|
||||
(tool,) = build_plugin_tools(retrieval_plugin({"enabled": True}))
|
||||
config = {"configurable": {"thread_id": "retrieval-roundtrip"}}
|
||||
inject_checkpoint_mode(config, mode)
|
||||
context = {"user_id": "alice", "thread_id": "retrieval-roundtrip"}
|
||||
database = str(tmp_path / "checkpoints.db")
|
||||
async with AsyncSqliteSaver.from_conn_string(database) as saver:
|
||||
graph = _graph(tool, saver, mode=mode, limit=limit)
|
||||
result = await graph.ainvoke({"messages": [HumanMessage(content="Find the synthetic release note.", id="human-1"), _call(tool, {"query": "RELEASE"})]}, config, context=context)
|
||||
message = result["messages"][-1]
|
||||
assert isinstance(message, ToolMessage)
|
||||
assert (message.name, message.tool_call_id, message.status) == (tool.name, "call-retrieval-1", "success")
|
||||
assert message.id
|
||||
assert isinstance(message.content, str) and len(message.content) <= limit
|
||||
assert message.artifact["schema"] == "example.retrieval.v1"
|
||||
assert message.artifact["synthetic"] is True
|
||||
assert [item["document_id"] for item in message.artifact["matches"]] == ["demo-release"]
|
||||
assert "Tuesday" in message.artifact["matches"][0]["excerpt"]
|
||||
if limit == 120:
|
||||
assert message.additional_kwargs["deerflow_tool_transforms"][-1]["kind"] == "truncated"
|
||||
assert len(message.artifact["matches"][0]["excerpt"]) > len(message.content)
|
||||
else:
|
||||
assert "Tuesday" in message.content
|
||||
saved_message = message.model_dump()
|
||||
|
||||
# New saver and graph, not a live object/cache from the first invocation.
|
||||
async with AsyncSqliteSaver.from_conn_string(database) as reopened:
|
||||
graph = _graph(tool, reopened, mode=mode, limit=limit)
|
||||
accessor = CheckpointStateAccessor.bind(graph, reopened, mode=mode)
|
||||
snapshot = await accessor.aget(config)
|
||||
restored = snapshot.values["messages"][-1]
|
||||
assert restored.model_dump() == saved_message
|
||||
api_state = json.loads(json.dumps(serialize_channel_values_for_api(snapshot.values)))
|
||||
assert api_state["messages"][-1]["artifact"] == saved_message["artifact"]
|
||||
assert api_state["messages"][-1]["content"] == saved_message["content"]
|
||||
assert api_state["messages"][-1]["name"] == tool.name
|
||||
assert api_state["messages"][-1]["id"] == saved_message["id"]
|
||||
assert api_state["messages"][-1]["tool_call_id"] == "call-retrieval-1"
|
||||
assert convert_to_messages(api_state["messages"])[-1].model_dump() == saved_message
|
||||
|
||||
continued = await graph.ainvoke({"messages": [HumanMessage(content="Now find support.", id="human-2"), _call(tool, {"query": "support"}, call_id="call-retrieval-2")]}, config, context=context)
|
||||
old = next(msg for msg in continued["messages"] if isinstance(msg, ToolMessage) and msg.tool_call_id == "call-retrieval-1")
|
||||
assert old.model_dump() == saved_message
|
||||
newest = continued["messages"][-1]
|
||||
assert newest.artifact["matches"][0]["document_id"] == "demo-support"
|
||||
model_messages = convert_to_openai_messages(continued["messages"])
|
||||
model_tools = [msg for msg in model_messages if msg["role"] == "tool"]
|
||||
assert [msg["tool_call_id"] for msg in model_tools] == ["call-retrieval-1", "call-retrieval-2"]
|
||||
assert model_tools[0]["content"] == saved_message["content"]
|
||||
assert model_tools[-1]["content"] == newest.content
|
||||
assert all("artifact" not in msg for msg in model_tools)
|
||||
assert "example.retrieval.v1" not in json.dumps(model_messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("payload", [{"query": ""}, {"query": " "}, {"query": 7}, {"query": "release", "user_id": "bob"}])
|
||||
async def test_invalid_retrieval_arguments_are_rejected_by_real_tool_dispatch(retrieval_plugin, payload):
|
||||
(tool,) = build_plugin_tools(retrieval_plugin({"enabled": True}))
|
||||
result = await _graph(tool, None).ainvoke({"messages": [_call(tool, payload)]}, context={"user_id": "alice"})
|
||||
message = result["messages"][-1]
|
||||
assert (message.name, message.tool_call_id, message.status) == (tool.name, "call-retrieval-1", "error")
|
||||
assert message.artifact is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_matches_is_a_successful_empty_result(retrieval_plugin):
|
||||
(tool,) = build_plugin_tools(retrieval_plugin({"enabled": True}))
|
||||
result = await _graph(tool, None).ainvoke({"messages": [_call(tool, {"query": "not-in-this-corpus"})]}, context={"user_id": "alice"})
|
||||
message = result["messages"][-1]
|
||||
assert message.status == "success"
|
||||
assert "No matching synthetic documents" in message.content
|
||||
assert message.artifact == {"schema": "example.retrieval.v1", "synthetic": True, "matches": []}
|
||||
2
backend/uv.lock
generated
2
backend/uv.lock
generated
@ -922,7 +922,7 @@ extensions = []
|
||||
|
||||
[[package]]
|
||||
name = "deerflow-extension-api"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
source = { editable = "packages/extension-api" }
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
A deployment-installed Python extension can register a `PluginContribution` with
|
||||
optional browser code, authenticated backend actions and model tools. This extends
|
||||
the existing `install(registry, config)` workflow. MCP and Skills keep their existing
|
||||
APIs and lifecycles. Public contracts live in `deerflow_extension_api` (0.2.5).
|
||||
APIs and lifecycles. Public contracts live in `deerflow_extension_api` (0.2.6).
|
||||
|
||||
Backend actions and model tools may use the optional host-bound
|
||||
`context.agent_runs` capability to create, continue, inspect, resume, and cancel
|
||||
@ -53,6 +53,45 @@ Tool names are namespace-derived and collision-checked. Tool inputs are bounded
|
||||
inputs up to 256 KiB and have a 30-second timeout. Cancellation does not guarantee
|
||||
rollback of external effects or already-running worker-thread operations.
|
||||
|
||||
### Structured tool artifacts
|
||||
|
||||
Existing model tools return JSON-encoded content. To return separate structured
|
||||
metadata, declare the keyword-only `response_format="content_and_artifact"` and
|
||||
return `PluginToolResult` from the async handler:
|
||||
|
||||
```python
|
||||
from deerflow_extension_api import ModelTool, PluginToolResult
|
||||
|
||||
async def search(payload, context):
|
||||
return PluginToolResult(
|
||||
content="Retrieved excerpt from the selected document.",
|
||||
artifact={"documents": [{"title": "Guide", "excerpt": "Retrieved excerpt"}]},
|
||||
)
|
||||
|
||||
tool = ModelTool(
|
||||
"search", "Search selected documents",
|
||||
{"type": "object", "properties": {"query": {"type": "string"}}},
|
||||
search, response_format="content_and_artifact",
|
||||
)
|
||||
```
|
||||
|
||||
Content must be a string and artifact must be a JSON object or `None`, with string
|
||||
keys in every nested object. Non-string keys are rejected rather than coerced.
|
||||
Content and artifact share the 64 KiB UTF-8 JSON output cap. The host validates and snapshots the artifact
|
||||
before attaching it to `ToolMessage.artifact`; it does not append it to ordinary
|
||||
tool content or let it replace the tool name or call ID. Invalid result shapes,
|
||||
non-JSON values and oversize output use the normal sanitized tool-error path.
|
||||
The default `response_format="content"` preserves existing JSON encoding,
|
||||
including tuple/list results. The structured mode requires a typed result rather
|
||||
than a bare tuple; an older host cannot silently JSON-encode that carrier.
|
||||
|
||||
Use the matching host and extension-api 0.2.6 or newer, and declare that API floor
|
||||
through the existing extension decorator and package requirement. Artifacts can
|
||||
be persisted and exposed to host clients; they are not secret storage. Generic
|
||||
artifact support does not grant citation trust or change native RAGFlow tool-name
|
||||
checks. See the [local retrieval example](../examples/deerflow-extension-retrieval/README.md)
|
||||
for a small, default-disabled plugin without a knowledge-management service.
|
||||
|
||||
A plugin can also contribute tools alone. The
|
||||
[text classification example](../examples/deerflow-extension-jev-classify/README.md)
|
||||
registers one model tool and a status action, no browser code: the agent labels a
|
||||
|
||||
22
examples/deerflow-extension-retrieval/LICENSE
Normal file
22
examples/deerflow-extension-retrieval/LICENSE
Normal file
@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
Copyright (c) 2025-2026 DeerFlow Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
58
examples/deerflow-extension-retrieval/README.md
Normal file
58
examples/deerflow-extension-retrieval/README.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Synthetic retrieval with plugin tool artifacts
|
||||
|
||||
This is a small, read-only example for `ModelTool(response_format="content_and_artifact")`.
|
||||
It searches two fixed **synthetic** documents in memory. It makes no network or
|
||||
model requests, reads no user files, and has no UI, database, ingestion, or write
|
||||
operations. All users see the same demonstration corpus; it is not a private
|
||||
knowledge base or a factual company source.
|
||||
|
||||
The package imports only Python's standard library and `deerflow-extension-api`
|
||||
0.2.6 or newer. It does not import private DeerFlow harness modules.
|
||||
|
||||
## Enable explicitly
|
||||
|
||||
Install this trusted local package using the normal extension workflow:
|
||||
|
||||
```sh
|
||||
deerflow extensions install /absolute/path/to/examples/deerflow-extension-retrieval
|
||||
```
|
||||
|
||||
The contribution is disabled by default. To enable it, set the installed entry's
|
||||
configuration to `enabled: true` and restart the Gateway. For a source package
|
||||
already on the Python path, the equivalent deployment entry is:
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
- use: deerflow_extension_retrieval:install
|
||||
config:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
The registered model tool performs case-insensitive literal matching. For
|
||||
example, the query `release` returns the synthetic release note. Empty queries
|
||||
and additional arguments are rejected by the tool schema.
|
||||
|
||||
## What the example proves
|
||||
|
||||
The handler returns `PluginToolResult`: readable text is model-visible, while a
|
||||
JSON artifact contains `schema`, `synthetic`, and structured `matches`. The host
|
||||
assigns the tool name and call ID. The artifact contains no secret information;
|
||||
artifacts are retained in state and API output, not a secret-storage boundary.
|
||||
|
||||
The integration test loads this package through `load_extensions`, dispatches
|
||||
the real `ToolNode` with `ToolOutputBudgetMiddleware`, saves to SQLite, closes
|
||||
and reopens the saver, reads through `CheckpointStateAccessor`, and serializes
|
||||
the restored state. A later turn also checks the standard model-message
|
||||
conversion receives text rather than the artifact. Both full and delta
|
||||
checkpoint modes are covered, including content truncation.
|
||||
|
||||
The artifact is generic example metadata. It does **not** claim to be a native
|
||||
RAGFlow citation, enable citation cards, or participate in source-aware budget
|
||||
pruning or subagent source forwarding. Generic artifacts can outlive truncated
|
||||
content; adding an artifact does not establish evidence authority.
|
||||
|
||||
From an existing DeerFlow backend development environment with API 0.2.6:
|
||||
|
||||
```sh
|
||||
uv run pytest tests/test_retrieval_plugin_artifacts.py -q
|
||||
```
|
||||
@ -0,0 +1,62 @@
|
||||
"""Read-only synthetic retrieval; no provider, model, or harness imports."""
|
||||
|
||||
from deerflow_extension_api import extension
|
||||
from deerflow_extension_api.plugins import ModelTool, PluginContribution, PluginToolResult
|
||||
|
||||
_DOCUMENTS = (
|
||||
(
|
||||
"demo-release",
|
||||
"Synthetic release note",
|
||||
"The fictional Cedar team publishes its demo release on Tuesday. "
|
||||
"The example checklist asks the team to review a sample changelog, verify "
|
||||
"the demonstration package, and record a rollback contact. This paragraph "
|
||||
"is invented data used to demonstrate a structured retrieval result; it "
|
||||
"does not describe a real organization, product, schedule, or policy.",
|
||||
),
|
||||
(
|
||||
"demo-support",
|
||||
"Synthetic support note",
|
||||
"The fictional Cedar support desk reviews demo questions on Wednesday. "
|
||||
"The example handoff records a short question, a suggested next step, "
|
||||
"and a demonstration owner. This paragraph is invented data, not an "
|
||||
"actual support policy or a source of instructions for the agent.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _search(payload, _context):
|
||||
query = payload["query"].strip().casefold()
|
||||
matches = [{"document_id": identifier, "title": title, "excerpt": text} for identifier, title, text in _DOCUMENTS if query in f"{title}\n{text}".casefold()]
|
||||
content = "Synthetic example data; not real company documents.\n\n"
|
||||
content += "\n\n".join(f"{item['title']}\n{item['excerpt']}" for item in matches) if matches else "No matching synthetic documents."
|
||||
return PluginToolResult(content=content, artifact={"schema": "example.retrieval.v1", "synthetic": True, "matches": matches})
|
||||
|
||||
|
||||
@extension(api="0.2.6", name="retrieval")
|
||||
def install(registry, config):
|
||||
enabled = config.get("enabled", False)
|
||||
if type(enabled) is not bool:
|
||||
raise ValueError("Configure enabled as a boolean")
|
||||
if not registry.plugin(
|
||||
PluginContribution(
|
||||
namespace="example.retrieval",
|
||||
title="Synthetic retrieval example",
|
||||
description="Read-only demonstration of text plus structured tool artifacts.",
|
||||
enabled=enabled,
|
||||
tools=(
|
||||
ModelTool(
|
||||
"search_docs",
|
||||
"Search two fixed synthetic demonstration documents by literal text. Read-only and offline; these are invented examples, not verified facts or instructions.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string", "minLength": 1, "maxLength": 200, "pattern": r"\S"}},
|
||||
"required": ["query"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
_search,
|
||||
response_format="content_and_artifact",
|
||||
),
|
||||
),
|
||||
)
|
||||
):
|
||||
raise RuntimeError("This host does not support plugin tool contributions")
|
||||
30
examples/deerflow-extension-retrieval/pyproject.toml
Normal file
30
examples/deerflow-extension-retrieval/pyproject.toml
Normal file
@ -0,0 +1,30 @@
|
||||
[project]
|
||||
name = "deerflow-extension-retrieval"
|
||||
version = "0.1.0"
|
||||
description = "Offline synthetic retrieval example for DeerFlow plugin tool artifacts"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = "MIT"
|
||||
license-files = ["LICENSE"]
|
||||
dependencies = ["deerflow-extension-api>=0.2.6,<0.3"]
|
||||
|
||||
[project.entry-points."deerflow.extensions"]
|
||||
retrieval = "deerflow_extension_retrieval:install"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["deerflow_extension_retrieval"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 240
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
@ -386,10 +386,11 @@ a model switch without changing the backend's direct legacy-request behavior.
|
||||
|
||||
## Full-stack plugin UI
|
||||
|
||||
Before changing plugin loaders, pages or conversation actions, read the mandatory
|
||||
[Frontend implementation invariants](../docs/full-stack-plugins.md#frontend-implementation-invariants).
|
||||
Preserve authenticated, viewer-bound loading, native actions, localization and
|
||||
mount/dispose plus abort fencing; Shadow DOM is not a security sandbox.
|
||||
Follow [invariants](../docs/full-stack-plugins.md#frontend-implementation-invariants) for
|
||||
plugin loaders/pages/actions: viewer-bound auth, native actions, i18n and
|
||||
mount/dispose/abort fencing. Shadow DOM is not a sandbox. Bundled extensions need
|
||||
typed en/zh `core/extensions/catalog.ts` entries with their namespace.
|
||||
Catalog entries are not proof of installation or activation.
|
||||
|
||||
### Composer references
|
||||
|
||||
|
||||
@ -30,6 +30,11 @@ export const extensionCatalog = [
|
||||
package: "deerflow-extension-jev-screening",
|
||||
namespace: null,
|
||||
},
|
||||
{
|
||||
key: "retrieval",
|
||||
package: "deerflow-extension-retrieval",
|
||||
namespace: "example.retrieval",
|
||||
},
|
||||
{ key: "example", package: "deerflow-extension-example", namespace: null },
|
||||
] as const;
|
||||
|
||||
|
||||
@ -70,6 +70,11 @@ export const enUS: Translations = {
|
||||
description:
|
||||
"Explore middleware, task lifecycle, model observers, services and HTTP route contributions.",
|
||||
},
|
||||
retrieval: {
|
||||
title: "Local retrieval example",
|
||||
description:
|
||||
"Demonstrate read-only retrieval and structured tool results using fixed sample documents, without an external knowledge service.",
|
||||
},
|
||||
},
|
||||
pageUnavailable: "Extension page unavailable",
|
||||
pageUnavailableHint:
|
||||
|
||||
@ -32,6 +32,7 @@ export interface Translations {
|
||||
| "context"
|
||||
| "classify"
|
||||
| "screening"
|
||||
| "retrieval"
|
||||
| "example",
|
||||
{ title: string; description: string }
|
||||
>;
|
||||
|
||||
@ -66,6 +66,11 @@ export const zhCN: Translations = {
|
||||
description:
|
||||
"演示中间件、任务生命周期、模型观察器、服务和 HTTP 路由的扩展方式。",
|
||||
},
|
||||
retrieval: {
|
||||
title: "本地检索示例",
|
||||
description:
|
||||
"使用固定示例文档演示只读检索与结构化工具结果,不连接外部知识库。",
|
||||
},
|
||||
},
|
||||
pageUnavailable: "扩展页面不可用",
|
||||
pageUnavailableHint: "此页面未注册,或插件已停用、未能加载。",
|
||||
|
||||
@ -442,7 +442,7 @@ for (const locale of ["en-US", "zh-CN"]) {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("article")).toHaveCount(6);
|
||||
await expect(page.getByRole("article")).toHaveCount(7);
|
||||
await expect(
|
||||
page.getByRole("button", {
|
||||
name: zh ? "查看 会话书签" : "View Bookmarks",
|
||||
@ -474,6 +474,35 @@ for (const locale of ["en-US", "zh-CN"]) {
|
||||
name: zh ? "查看安装说明" : "Installation guide",
|
||||
}),
|
||||
).toHaveAttribute("href", /deerflow-extension-agent-teams#readme$/);
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: zh ? "全部扩展" : "All extensions",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByPlaceholder(
|
||||
zh ? "按名称或用途搜索扩展" : "Search extensions by name or purpose",
|
||||
)
|
||||
.fill("deerflow-extension-retrieval");
|
||||
await expect(page.getByRole("article")).toHaveCount(1);
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: zh ? "查看 本地检索示例" : "View Local retrieval example",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: zh ? "本地检索示例" : "Local retrieval example",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("link", {
|
||||
name: zh ? "查看安装说明" : "Installation guide",
|
||||
}),
|
||||
).toHaveAttribute("href", /deerflow-extension-retrieval#readme$/);
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: zh ? "全部扩展" : "All extensions",
|
||||
@ -547,8 +576,8 @@ test("catalog remains discoverable when runtime discovery fails", async ({
|
||||
await expect(
|
||||
page.getByRole("alert").filter({ hasText: "Extensions unavailable." }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("article")).toHaveCount(6);
|
||||
await expect(page.getByRole("article")).toHaveCount(7);
|
||||
await expect(
|
||||
page.getByRole("article").filter({ hasText: "Catalog extension" }),
|
||||
).toHaveCount(6);
|
||||
).toHaveCount(7);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user