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:
Totoro 2026-10-14 17:24:32 +08:00 committed by GitHub
parent 8f0ca58653
commit 517a8309a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 693 additions and 28 deletions

View File

@ -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)

View File

@ -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",

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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",

View File

@ -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

View File

@ -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")

View File

@ -40,6 +40,7 @@ def test_every_contract_kind_added_for_the_0_2_series_is_exported():
"BackendAction",
"BrowserModule",
"ModelTool",
"PluginToolResult",
"PluginContribution",
"SettingsField",
"ToolContext",

View File

@ -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",

View File

@ -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

View 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
View File

@ -922,7 +922,7 @@ extensions = []
[[package]]
name = "deerflow-extension-api"
version = "0.2.5"
version = "0.2.6"
source = { editable = "packages/extension-api" }
[[package]]

View File

@ -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

View 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.

View 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
```

View File

@ -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")

View 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"

View File

@ -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

View File

@ -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;

View File

@ -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:

View File

@ -32,6 +32,7 @@ export interface Translations {
| "context"
| "classify"
| "screening"
| "retrieval"
| "example",
{ title: string; description: string }
>;

View File

@ -66,6 +66,11 @@ export const zhCN: Translations = {
description:
"演示中间件、任务生命周期、模型观察器、服务和 HTTP 路由的扩展方式。",
},
retrieval: {
title: "本地检索示例",
description:
"使用固定示例文档演示只读检索与结构化工具结果,不连接外部知识库。",
},
},
pageUnavailable: "扩展页面不可用",
pageUnavailableHint: "此页面未注册,或插件已停用、未能加载。",

View File

@ -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);
});