diff --git a/README.md b/README.md index 56a82bf8dd..aa1d2a097f 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/backend/packages/extension-api/deerflow_extension_api/__init__.py b/backend/packages/extension-api/deerflow_extension_api/__init__.py index 5b78058f1e..9f68b8acc0 100644 --- a/backend/packages/extension-api/deerflow_extension_api/__init__.py +++ b/backend/packages/extension-api/deerflow_extension_api/__init__.py @@ -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", diff --git a/backend/packages/extension-api/deerflow_extension_api/plugins.py b/backend/packages/extension-api/deerflow_extension_api/plugins.py index c745e0ba85..1181f7ad5c 100644 --- a/backend/packages/extension-api/deerflow_extension_api/plugins.py +++ b/backend/packages/extension-api/deerflow_extension_api/plugins.py @@ -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) diff --git a/backend/packages/extension-api/pyproject.toml b/backend/packages/extension-api/pyproject.toml index 2423dab995..04e2410206 100644 --- a/backend/packages/extension-api/pyproject.toml +++ b/backend/packages/extension-api/pyproject.toml @@ -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 diff --git a/backend/packages/harness/deerflow/extensions/AGENTS.md b/backend/packages/harness/deerflow/extensions/AGENTS.md index 11b96393e0..deb6852106 100644 --- a/backend/packages/harness/deerflow/extensions/AGENTS.md +++ b/backend/packages/harness/deerflow/extensions/AGENTS.md @@ -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, diff --git a/backend/packages/harness/deerflow/extensions/plugin_tools.py b/backend/packages/harness/deerflow/extensions/plugin_tools.py index 0e44e6877a..abbde256ba 100644 --- a/backend/packages/harness/deerflow/extensions/plugin_tools.py +++ b/backend/packages/harness/deerflow/extensions/plugin_tools.py @@ -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 diff --git a/backend/packages/harness/deerflow/extensions/registry.py b/backend/packages/harness/deerflow/extensions/registry.py index 1c77abcb2a..e123f9e34d 100644 --- a/backend/packages/harness/deerflow/extensions/registry.py +++ b/backend/packages/harness/deerflow/extensions/registry.py @@ -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) diff --git a/backend/packages/harness/pyproject.toml b/backend/packages/harness/pyproject.toml index 207cc17200..de54df2222 100644 --- a/backend/packages/harness/pyproject.toml +++ b/backend/packages/harness/pyproject.toml @@ -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", diff --git a/backend/tests/AGENTS.md b/backend/tests/AGENTS.md index b2f6d0aef5..b5a915c527 100644 --- a/backend/tests/AGENTS.md +++ b/backend/tests/AGENTS.md @@ -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 diff --git a/backend/tests/test_extension_api_contracts.py b/backend/tests/test_extension_api_contracts.py index 012530daa0..4a429a3514 100644 --- a/backend/tests/test_extension_api_contracts.py +++ b/backend/tests/test_extension_api_contracts.py @@ -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") diff --git a/backend/tests/test_extension_api_surface.py b/backend/tests/test_extension_api_surface.py index 8a7f57a830..19e6f8947f 100644 --- a/backend/tests/test_extension_api_surface.py +++ b/backend/tests/test_extension_api_surface.py @@ -40,6 +40,7 @@ def test_every_contract_kind_added_for_the_0_2_series_is_exported(): "BackendAction", "BrowserModule", "ModelTool", + "PluginToolResult", "PluginContribution", "SettingsField", "ToolContext", diff --git a/backend/tests/test_mcp_task_repository.py b/backend/tests/test_mcp_task_repository.py index 8b805a52b7..fda5f04579 100644 --- a/backend/tests/test_mcp_task_repository.py +++ b/backend/tests/test_mcp_task_repository.py @@ -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", diff --git a/backend/tests/test_plugin_tools.py b/backend/tests/test_plugin_tools.py index 6768632356..2579f57eb7 100644 --- a/backend/tests/test_plugin_tools.py +++ b/backend/tests/test_plugin_tools.py @@ -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 diff --git a/backend/tests/test_retrieval_plugin_artifacts.py b/backend/tests/test_retrieval_plugin_artifacts.py new file mode 100644 index 0000000000..dee07aad8a --- /dev/null +++ b/backend/tests/test_retrieval_plugin_artifacts.py @@ -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": []} diff --git a/backend/uv.lock b/backend/uv.lock index 7c19267e5c..bdeefd24a8 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -922,7 +922,7 @@ extensions = [] [[package]] name = "deerflow-extension-api" -version = "0.2.5" +version = "0.2.6" source = { editable = "packages/extension-api" } [[package]] diff --git a/docs/full-stack-plugins.md b/docs/full-stack-plugins.md index 5bc730766d..7b85eaf165 100644 --- a/docs/full-stack-plugins.md +++ b/docs/full-stack-plugins.md @@ -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 diff --git a/examples/deerflow-extension-retrieval/LICENSE b/examples/deerflow-extension-retrieval/LICENSE new file mode 100644 index 0000000000..9dc98a4a6b --- /dev/null +++ b/examples/deerflow-extension-retrieval/LICENSE @@ -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. diff --git a/examples/deerflow-extension-retrieval/README.md b/examples/deerflow-extension-retrieval/README.md new file mode 100644 index 0000000000..fa4ebf2ba1 --- /dev/null +++ b/examples/deerflow-extension-retrieval/README.md @@ -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 +``` diff --git a/examples/deerflow-extension-retrieval/deerflow_extension_retrieval/__init__.py b/examples/deerflow-extension-retrieval/deerflow_extension_retrieval/__init__.py new file mode 100644 index 0000000000..09fe7be4bc --- /dev/null +++ b/examples/deerflow-extension-retrieval/deerflow_extension_retrieval/__init__.py @@ -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") diff --git a/examples/deerflow-extension-retrieval/pyproject.toml b/examples/deerflow-extension-retrieval/pyproject.toml new file mode 100644 index 0000000000..a828894e6b --- /dev/null +++ b/examples/deerflow-extension-retrieval/pyproject.toml @@ -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" diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index e90df8d473..8420c7f7da 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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 diff --git a/frontend/src/core/extensions/catalog.ts b/frontend/src/core/extensions/catalog.ts index f2cc2f4d80..53f9b9e612 100644 --- a/frontend/src/core/extensions/catalog.ts +++ b/frontend/src/core/extensions/catalog.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 8a3b9d430f..d5768b08c9 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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: diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 9199a24374..e061b5c4c1 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -32,6 +32,7 @@ export interface Translations { | "context" | "classify" | "screening" + | "retrieval" | "example", { title: string; description: string } >; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 3e153fc8dd..4e0d106d9b 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -66,6 +66,11 @@ export const zhCN: Translations = { description: "演示中间件、任务生命周期、模型观察器、服务和 HTTP 路由的扩展方式。", }, + retrieval: { + title: "本地检索示例", + description: + "使用固定示例文档演示只读检索与结构化工具结果,不连接外部知识库。", + }, }, pageUnavailable: "扩展页面不可用", pageUnavailableHint: "此页面未注册,或插件已停用、未能加载。", diff --git a/frontend/tests/e2e/bookmark-plugin.spec.ts b/frontend/tests/e2e/bookmark-plugin.spec.ts index 62e8688326..e6e220ba98 100644 --- a/frontend/tests/e2e/bookmark-plugin.spec.ts +++ b/frontend/tests/e2e/bookmark-plugin.spec.ts @@ -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); });