mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 14:38:38 +00:00
* feat(extensions): let an out-of-tree extension observe what the agent did
DeerFlow's extension system can contribute middleware, services and routes,
but an extension cannot answer basic questions about a run without reaching
into host internals. Several of the facts it would need are destroyed by the
operations that produce them:
* The middleware chain injects and rewrites a lot of context — date
reminders, recalled memory, compaction summaries, durable-context data,
image payloads, activated skill bodies. Downstream, none of it is
attributable: at the model-call boundary an injected HumanMessage is
indistinguishable from the user's own, and anything wanting to tell them
apart has to pattern-match prompt wording, which breaks on the next copy
edit.
* Two runs of "the same agent" are only comparable if the chain enforced the
same limits, prompts and thresholds. Recovering that from outside means
reading private attributes and guessing which of them change behaviour — a
guess that rots silently as middlewares gain fields.
* The lead-agent factory resolves a model after runtime overrides, renders a
prompt, filters tools through authorization and composes a stack, all
inside one synchronous call, and none of it survives: a middleware sees its
neighbours but not the prompt, the run worker sees a graph but not what
went into it.
* Summarization is destructive by design. N messages leave the context and
one summary enters it; afterwards only the summary exists, so "which
messages became this?" is not reconstructible.
This adds seven neutral facilities so those facts are recorded where they are
still true, and releases the contract package as 0.2.0.
Message provenance
Producers stamp `deerflow_content_kind` / `deerflow_producer_kind` onto the
messages they inject or rewrite. Stamping is unconditional — a fact whose
presence depends on whether an observer is installed is not a fact — and the
keys are server-owned, so provenance cannot be forged from a request.
Middleware self-description
Twelve middlewares declare their own behaviour-affecting parameters through
a duck-typed `release_policy_parameters()`. Long text is hashed rather than
embedded: a declaration is an identity, not a copy of the prompt.
Agent assembly descriptor
`assemble_lead_agent()` returns the graph plus a descriptor whose fingerprint
answers "did anything about this agent change between these two runs?".
`make_lead_agent()` keeps its graph-only signature — it is the LangGraph
Server ABI declared in langgraph.json. Tools and skills are sorted before
hashing because their assembly order is incidental; middlewares are not,
because stack order decides what wraps what. Host build identity is reported
but excluded from the fingerprint, so a redeploy does not invalidate every
agent's identity.
Context compaction observation
Summarization emits the content hashes of the messages it is about to remove
joined to the summary that replaced them. Content is the only identity
available at that seam: the summary does not become a message, and what later
projects it into a request renders it bounded and escaped rather than
verbatim.
Neutral policy, transform and MCP-source facts
Guardrail decisions are published to runtime context under a `__`-prefixed
key; result-rewriting middlewares append a declared, ordered transform trail;
MCP tools carry their credential-free logical origin.
Extension route identity
Contributed routes are session-authenticated and cannot opt out, but
"logged in" and "administrator" are different questions. Extensions get a
neutral projection of the caller rather than the host's auth context, and
`require_admin` fails closed when identity cannot be determined.
Extension-owned tables
An extension that persists data owns its own MetaData and migration chain, so
its tables are absent from Base.metadata and `alembic revision --autogenerate`
proposes dropping them. Extensions declare a table prefix, which is rejected
at registration if it would shadow a host table.
The contract package stays dependency-free and imports no host code; every new
Protocol method has a default so later additions remain additive. The loader's
pre-1.0 rule requires an exact major.minor match, so extensions written against
0.1 are now refused at startup with an actionable install hint rather than
loading into a host that implements a different surface.
uv.lock records the contract package's new version, so `uv sync --locked` still
resolves on a fresh checkout.
* fix(backend): sort gateway service imports
321 lines
11 KiB
Python
321 lines
11 KiB
Python
"""Tests for the extension contract surface.
|
|
|
|
The contracts carry two compatibility promises that are easy to break by
|
|
accident and impossible to catch at runtime later: every Protocol method has a
|
|
default implementation, and every optional dataclass field has a default. Both
|
|
are asserted here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import dataclasses
|
|
import importlib.resources
|
|
import inspect
|
|
|
|
import pytest
|
|
from deerflow_extension_api import (
|
|
API_VERSION,
|
|
AgentBuildContext,
|
|
AgentScope,
|
|
ExtensionData,
|
|
ExtensionInstall,
|
|
ExtensionRegistry,
|
|
ExtensionRuntimeDeps,
|
|
ExtensionService,
|
|
HostPolicySnapshot,
|
|
MiddlewareContributor,
|
|
MiddlewarePlacement,
|
|
Placement,
|
|
SystemModelCallObserver,
|
|
SystemModelRequest,
|
|
SystemModelResult,
|
|
SystemOperationKind,
|
|
TaskInfo,
|
|
TaskLifecycleContributor,
|
|
TaskOutcome,
|
|
extension,
|
|
)
|
|
from deerflow_extension_api.runtime_bridge import (
|
|
EXTENSION_TASK_STORE_KEY,
|
|
task_store_from_runtime,
|
|
)
|
|
|
|
|
|
def test_placement_members_cover_both_axes():
|
|
assert Placement.MODEL_LOGICAL.value == "model_logical"
|
|
assert Placement.MODEL_PHYSICAL.value == "model_physical"
|
|
assert Placement.TOOL_VISIBLE.value == "tool_visible"
|
|
assert Placement.TOOL_RAW.value == "tool_raw"
|
|
assert Placement.STANDARD.value == "standard"
|
|
|
|
|
|
def test_agent_scope_both_is_union():
|
|
assert AgentScope.BOTH == AgentScope.LEAD | AgentScope.SUBAGENT
|
|
assert AgentScope.LEAD in AgentScope.BOTH
|
|
|
|
|
|
def test_middleware_placement_defaults():
|
|
p = MiddlewarePlacement(middleware=object(), placement=Placement.STANDARD)
|
|
assert p.scope is AgentScope.BOTH
|
|
assert p.order == 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"cls",
|
|
[
|
|
HostPolicySnapshot,
|
|
ExtensionRuntimeDeps,
|
|
AgentBuildContext,
|
|
TaskInfo,
|
|
SystemModelRequest,
|
|
SystemModelResult,
|
|
MiddlewarePlacement,
|
|
],
|
|
)
|
|
def test_every_dataclass_is_frozen(cls):
|
|
assert dataclasses.is_dataclass(cls)
|
|
assert cls.__dataclass_params__.frozen, f"{cls.__name__} must be frozen"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"cls",
|
|
[HostPolicySnapshot, ExtensionRuntimeDeps, TaskInfo, SystemModelRequest, SystemModelResult],
|
|
)
|
|
def test_additive_dataclasses_are_constructible_with_required_fields_only(cls):
|
|
"""Fields added later must carry defaults, or old extensions break on upgrade.
|
|
|
|
HostPolicySnapshot and the two system-call snapshots are host-constructed
|
|
and fully optional. TaskInfo has a required identity core and optional
|
|
remainder. AgentBuildContext gets its own dedicated test below because its
|
|
scope is legitimately required.
|
|
"""
|
|
if cls is TaskInfo:
|
|
info = cls(task_id="t", run_id="r", thread_id="th", kind="lead")
|
|
assert info.parent_task_id is None
|
|
assert info.agent_name is None
|
|
assert info.resumed is False
|
|
else:
|
|
assert cls() is not None
|
|
|
|
|
|
def test_agent_build_context_optional_fields_keep_their_defaults():
|
|
"""AgentBuildContext has one required field (scope); the rest must default.
|
|
|
|
Unlike the fully-optional dataclasses above, scope is legitimately
|
|
required, so this is not folded into the parametrized test above — it
|
|
would misrepresent the required/optional split this suite is meant to
|
|
document.
|
|
"""
|
|
ctx = AgentBuildContext(scope=AgentScope.LEAD)
|
|
assert ctx.agent_name is None
|
|
assert ctx.model_name is None
|
|
assert isinstance(ctx.policy, HostPolicySnapshot)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"protocol",
|
|
[
|
|
ExtensionRegistry,
|
|
ExtensionService,
|
|
MiddlewareContributor,
|
|
TaskLifecycleContributor,
|
|
SystemModelCallObserver,
|
|
],
|
|
)
|
|
def test_every_protocol_method_has_a_default_implementation(protocol):
|
|
"""Adding a method to a Protocol is only additive when it has a default.
|
|
|
|
Without this, shipping a new contract method breaks every already-released
|
|
extension that does not implement it.
|
|
"""
|
|
checked = 0
|
|
for name, member in vars(protocol).items():
|
|
if name.startswith("_") or not inspect.isfunction(member):
|
|
continue
|
|
checked += 1
|
|
body = inspect.getsource(member).split("\n", 1)[1]
|
|
assert "return" in body, f"{protocol.__name__}.{name} has no default implementation. Adding a contract method is only additive when it returns a default; otherwise every already-released extension breaks on upgrade."
|
|
assert checked > 0, f"{protocol.__name__} declared no methods to check"
|
|
|
|
|
|
def test_contributor_defaults_return_empty():
|
|
class _Bare:
|
|
pass
|
|
|
|
bare = _Bare()
|
|
assert MiddlewareContributor.contribute_middlewares(bare, ExtensionData("app"), AgentBuildContext(scope=AgentScope.LEAD)) == ()
|
|
|
|
|
|
def test_task_lifecycle_contract_is_public_and_defaults_to_noop():
|
|
class _Bare:
|
|
pass
|
|
|
|
app_store = ExtensionData("app")
|
|
task_store = ExtensionData("task-1")
|
|
info = TaskInfo(
|
|
task_id="task-1",
|
|
run_id="run-1",
|
|
thread_id="thread-1",
|
|
kind="lead",
|
|
)
|
|
|
|
assert TaskOutcome.COMPLETED.value == "completed"
|
|
assert asyncio.run(TaskLifecycleContributor.on_task_start(_Bare(), app_store, task_store, info)) is None
|
|
assert asyncio.run(TaskLifecycleContributor.on_task_stop(_Bare(), app_store, task_store, info, TaskOutcome.COMPLETED)) is None
|
|
|
|
|
|
def test_system_model_observer_contract_reports_success_and_failure_shapes():
|
|
class _Bare:
|
|
pass
|
|
|
|
app_store = ExtensionData("app")
|
|
task_store = ExtensionData("task-1")
|
|
request = SystemModelRequest(messages=("prompt",), model_name="system-model")
|
|
success = SystemModelResult(response="answer", duration_ms=1.5)
|
|
failure = SystemModelResult(error=RuntimeError("provider failed"), duration_ms=2.0)
|
|
|
|
assert SystemOperationKind.GOAL.value == "goal"
|
|
assert asyncio.run(SystemModelCallObserver.on_system_model_call(_Bare(), app_store, task_store, SystemOperationKind.GOAL, request, success)) is None
|
|
assert asyncio.run(SystemModelCallObserver.on_system_model_call(_Bare(), app_store, task_store, SystemOperationKind.GOAL, request, failure)) is None
|
|
|
|
|
|
def test_system_model_request_normalizes_messages_into_an_immutable_sequence():
|
|
"""``messages`` is a snapshot of a message sequence, never a per-character view.
|
|
|
|
Title and summarization pass a single prompt string, so a bare ``str`` must not
|
|
reach observers as a ``Sequence`` whose items are characters. A live ``list`` from
|
|
a call site must also be copied: the snapshot is documented as read-only, and the
|
|
caller keeps mutating its own list after the observation is dispatched.
|
|
"""
|
|
assert SystemModelRequest(messages="one prompt").messages == ("one prompt",)
|
|
|
|
live: list[str] = ["first"]
|
|
request = SystemModelRequest(messages=live)
|
|
live.append("second")
|
|
assert request.messages == ("first",)
|
|
|
|
assert SystemModelRequest().messages == ()
|
|
assert SystemModelRequest(messages=("already", "a", "tuple")).messages == ("already", "a", "tuple")
|
|
|
|
|
|
def test_gateway_contribution_points_are_part_of_the_public_surface():
|
|
import deerflow_extension_api
|
|
|
|
for name in (
|
|
"ExtensionRuntimeDeps",
|
|
"ExtensionService",
|
|
):
|
|
assert name in deerflow_extension_api.__all__
|
|
assert hasattr(deerflow_extension_api, name)
|
|
assert callable(ExtensionRegistry.service)
|
|
assert callable(ExtensionRegistry.routers)
|
|
assert not hasattr(deerflow_extension_api, "RouterContributor")
|
|
|
|
|
|
def test_task_store_from_runtime_reads_the_host_key():
|
|
class _Runtime:
|
|
def __init__(self, context):
|
|
self.context = context
|
|
|
|
store = ExtensionData("task-1")
|
|
assert task_store_from_runtime(_Runtime({EXTENSION_TASK_STORE_KEY: store})) is store
|
|
|
|
|
|
def test_task_store_from_runtime_returns_none_on_missing_or_wrong_shape():
|
|
class _Runtime:
|
|
def __init__(self, context):
|
|
self.context = context
|
|
|
|
assert task_store_from_runtime(None) is None
|
|
assert task_store_from_runtime(_Runtime({})) is None
|
|
assert task_store_from_runtime(_Runtime("not-a-mapping")) is None
|
|
assert task_store_from_runtime(_Runtime({EXTENSION_TASK_STORE_KEY: "wrong type"})) is None
|
|
|
|
|
|
def test_extension_decorator_stamps_api_requirement():
|
|
@extension(api="0.1", name="demo")
|
|
def install(registry, config):
|
|
return None
|
|
|
|
assert install.__deerflow_api__ == "0.1"
|
|
assert install.__deerflow_name__ == "demo"
|
|
|
|
|
|
def test_task_outcome_members():
|
|
assert {outcome.value for outcome in TaskOutcome} == {"completed", "aborted", "failed"}
|
|
|
|
|
|
def test_system_operation_kind_members():
|
|
assert {kind.value for kind in SystemOperationKind} == {"goal", "memory", "title", "summarization"}
|
|
|
|
|
|
def test_registry_and_install_alias_are_part_of_the_public_surface():
|
|
"""Independent extensions annotate install(registry, config) against the
|
|
contract package alone — importing the host's concrete registry would pin
|
|
them to the harness release cadence and advertise host-only machinery."""
|
|
import typing
|
|
|
|
import deerflow_extension_api
|
|
|
|
assert "ExtensionRegistry" in deerflow_extension_api.__all__
|
|
assert "ExtensionInstall" in deerflow_extension_api.__all__
|
|
parameters, return_type = typing.get_args(ExtensionInstall)
|
|
assert parameters[0] is ExtensionRegistry, "install()'s first argument must be the public registry contract"
|
|
|
|
|
|
def test_distribution_marks_the_contract_package_as_typed():
|
|
marker = importlib.resources.files("deerflow_extension_api").joinpath("py.typed")
|
|
assert marker.is_file()
|
|
|
|
|
|
def test_contract_package_keeps_runtime_dependencies_empty():
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
pyproject = Path(__file__).parent.parent / "packages" / "extension-api" / "pyproject.toml"
|
|
|
|
assert tomllib.loads(pyproject.read_text())["project"]["dependencies"] == []
|
|
|
|
|
|
def test_harness_pins_the_contract_package_exactly():
|
|
"""The version contract (extension-system design): the host pins the
|
|
contract package exactly, extensions use ranges. A range here would let an
|
|
older harness resolve a newer 1.x contract package — API_VERSION would
|
|
then come from the upgraded package and newer extensions would look
|
|
supported against a host whose registry/placements/hook pipeline still
|
|
implements the older contract. The pin makes pip reject that skew at
|
|
install time."""
|
|
import tomllib
|
|
from importlib.metadata import version
|
|
from pathlib import Path
|
|
|
|
from packaging.requirements import Requirement
|
|
|
|
pyproject = Path(__file__).parent.parent / "packages" / "harness" / "pyproject.toml"
|
|
dependencies = tomllib.loads(pyproject.read_text())["project"]["dependencies"]
|
|
requirement = next(Requirement(dep) for dep in dependencies if Requirement(dep).name == "deerflow-extension-api")
|
|
|
|
expected = f"=={version('deerflow-extension-api')}"
|
|
assert str(requirement.specifier) == expected, f"the host must pin deerflow-extension-api exactly ({expected}); a range lets pip resolve a contract newer than the host implements"
|
|
|
|
|
|
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.0"
|
|
assert API_VERSION == version("deerflow-extension-api")
|
|
|
|
|
|
def test_extension_service_contract_is_public_and_defaults_to_noop():
|
|
class _Bare:
|
|
pass
|
|
|
|
deps = ExtensionRuntimeDeps()
|
|
|
|
assert deps.app_store is None
|
|
assert deps.session_factory is None
|
|
assert asyncio.run(ExtensionService.start(_Bare(), deps)) is None
|
|
assert asyncio.run(ExtensionService.stop(_Bare())) is None
|