feat(extensions): let an out-of-tree extension observe what the agent did (#4863)

* 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
This commit is contained in:
Nan Gao 2026-08-22 18:57:12 -07:00 committed by GitHub
parent f1f4af99bb
commit 13f0a7f263
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
75 changed files with 4559 additions and 148 deletions

View File

@ -965,8 +965,12 @@ while unrelated routers continue to load. Because the host's public paths are a
prefix list that extensions cannot enter, **every contributed endpoint requires an
authenticated session** — there is currently no way for an extension to expose an
unauthenticated route, so inbound provider webhooks and public status endpoints are out of
scope for this release. Router startup/shutdown hooks, custom lifespans,
Mounts, and WebSocket routes are not accepted; lifetime resources belong in
scope for this release. Within that, an extension distinguishes an ordinary user from an
administrator through `deerflow_extension_api.auth`: `resolve_principal(request)` returns
the caller, `require_admin(request)` raises `PermissionError` for anyone else and fails
closed when identity cannot be determined. Extensions receive a projection — user id, admin
flag, internal flag, roles — never the host's auth context. Router startup/shutdown hooks,
custom lifespans, Mounts, and WebSocket routes are not accepted; lifetime resources belong in
`ExtensionService`, and WebSocket contributions require a future host-owned
authentication/Origin wrapper. Lifecycle and system-model callbacks use the Gateway's
canonical notification loop, including subagents on isolated loops.

View File

@ -3,10 +3,11 @@ import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from deerflow_extension_api import EXTENSION_PRINCIPAL_RESOLVER_KEY, ExtensionPrincipal
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.gateway.auth_disabled import warn_if_auth_disabled_enabled
from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL, warn_if_auth_disabled_enabled
from app.gateway.auth_middleware import AuthMiddleware
from app.gateway.browser_capability import ensure_browser_runtime_available
from app.gateway.config import get_gateway_config
@ -628,6 +629,40 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
# Auth: reject unauthenticated requests to non-public paths (fail-closed safety net)
app.add_middleware(AuthMiddleware)
# Give contributed routers a neutral way to ask "is this caller an admin"
# without importing app.gateway.deps, which would pin them to an
# unpublished internal layer and defeat independent distribution. The
# resolver mirrors require_admin_user's primary path (deps.py): it reads
# request.state.user, which AuthMiddleware stamps before any router runs,
# rather than the async get_current_user_from_request/get_optional_user_from_request
# accessors that exist for tests and alternative ASGI compositions. Staying
# synchronous keeps resolve_principal/require_admin usable from both sync
# and async route handlers.
def _resolve_extension_principal(request):
"""Project the host's auth context into the neutral extension shape.
Deliberately a projection, not a handle: an extension gets the
questions it may ask (who, is that an admin, and what role they
hold), not the host's AuthContext, which would pin every extension to
its internals.
"""
user = getattr(request.state, "user", None)
if user is None:
return None
system_role = getattr(user, "system_role", None)
return ExtensionPrincipal(
user_id=str(user.id),
is_admin=system_role == "admin",
is_internal=getattr(request.state, "auth_source", None) == AUTH_SOURCE_INTERNAL,
# The host's only role concept is the single system_role column
# (e.g. "admin", "user") — there is no multi-role system to
# project, so a set role becomes the one-element tuple rather
# than reading a "roles" attribute the user model never had.
roles=(system_role,) if isinstance(system_role, str) and system_role else (),
)
setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, _resolve_extension_principal)
# CSRF: Double Submit Cookie pattern for state-changing requests
app.add_middleware(CSRFMiddleware)

View File

@ -40,6 +40,7 @@ from app.gateway.services import (
build_thread_checkpoint_state_accessor,
build_thread_checkpoint_state_mutation_accessor,
reserve_checkpoint_write,
strip_server_owned_state_metadata,
)
from app.gateway.utils import sanitize_log_param
from deerflow.agents.thread_state import THREAD_STATE_REDUCER_FIELDS
@ -1284,7 +1285,12 @@ async def update_thread_state(thread_id: ThreadId, body: ThreadStateUpdateReques
as_node=mutation_node,
checkpoint_id=body.checkpoint_id,
)
values = dict(body.values or {})
# These values go straight into a checkpoint, so they need the same
# server-owned-metadata stripping the run path gets inside normalize_input.
# Without it an authenticated client can persist forged provenance and
# transform trails, which later readers are entitled to treat as facts
# about what the host itself did.
values = strip_server_owned_state_metadata(dict(body.values or {}))
writable_channels = graph_writable_channels(getattr(accessor, "graph", None))
if writable_channels is not None:
unknown_fields = sorted(set(values) - writable_channels)

View File

@ -16,6 +16,7 @@ from contextlib import asynccontextmanager
from types import SimpleNamespace
from typing import Any
from deerflow_extension_api import PROVENANCE_KEYS
from fastapi import HTTPException, Request
from langchain_core.messages import BaseMessage
from langchain_core.messages.utils import convert_to_messages
@ -34,6 +35,7 @@ from app.gateway.utils import sanitize_log_param
from app.mcp_tasks.errors import PermanentNotificationError
from deerflow.agents.middlewares.dynamic_context_middleware import _DYNAMIC_CONTEXT_REMINDER_KEY, _REMINDER_DATE_KEY
from deerflow.agents.middlewares.input_sanitization_middleware import frame_untrusted_text
from deerflow.agents.middlewares.tool_transform_meta import TOOL_TRANSFORMS_KEY
from deerflow.agents.middlewares.view_image_middleware import _IMAGE_CONTEXT_MESSAGE_MARKER_KEY
from deerflow.config.app_config import get_app_config
from deerflow.config.database_config import resolve_checkpoint_graph_cache_max
@ -105,12 +107,16 @@ _TERMINAL_RUN_STATUSES = {
_THREAD_METADATA_SETUP_TIMEOUT_SECONDS = 5.0
_SERVER_OWNED_MESSAGE_METADATA_KEYS = frozenset(
{
_DYNAMIC_CONTEXT_REMINDER_KEY,
_REMINDER_DATE_KEY,
_IMAGE_CONTEXT_MESSAGE_MARKER_KEY,
}
_SERVER_OWNED_MESSAGE_METADATA_KEYS = (
frozenset(
{
_DYNAMIC_CONTEXT_REMINDER_KEY,
_REMINDER_DATE_KEY,
_IMAGE_CONTEXT_MESSAGE_MARKER_KEY,
TOOL_TRANSFORMS_KEY,
}
)
| PROVENANCE_KEYS
)
@ -241,6 +247,46 @@ def _strip_external_message_metadata(message: Any) -> Any:
return message.model_copy(update={"additional_kwargs": additional_kwargs})
def _strip_external_metadata_from_message_like(item: Any) -> Any:
"""Strip server-owned keys from a message, in object or raw-dict form.
Callers reach the checkpoint by two different routes and the message is a
``BaseMessage`` on one and a plain dict on the other, so both shapes have
to be handled here rather than coercing coercion would change what the
caller asked to be written.
"""
if isinstance(item, BaseMessage):
return _strip_external_message_metadata(item)
if isinstance(item, dict) and isinstance(item.get("additional_kwargs"), dict):
additional_kwargs = {key: value for key, value in item["additional_kwargs"].items() if key not in _SERVER_OWNED_MESSAGE_METADATA_KEYS and key != ORIGINAL_USER_CONTENT_KEY}
if additional_kwargs == item["additional_kwargs"]:
return item
return {**item, "additional_kwargs": additional_kwargs}
return item
def strip_server_owned_state_metadata(values: Mapping[str, Any]) -> dict[str, Any]:
"""Remove server-owned message metadata from caller-supplied state values.
``normalize_input`` does this for the run path. The thread-state mutation
route writes its values straight into a checkpoint, so without the same
treatment an authenticated client can persist forged provenance and
transform trails and those keys exist precisely so a later reader can
treat them as facts about what the host did.
Every channel is walked, not just ``messages``: middleware-contributed
channels can carry messages too, and popping a key that was never there
costs nothing.
"""
stripped: dict[str, Any] = {}
for channel, value in values.items():
if isinstance(value, list):
stripped[channel] = [_strip_external_metadata_from_message_like(item) for item in value]
else:
stripped[channel] = _strip_external_metadata_from_message_like(value)
return stripped
def normalize_input(raw_input: dict[str, Any] | None, *, trusted_internal: bool = False) -> dict[str, Any]:
"""Convert LangGraph Platform input format to LangChain state dict.
@ -495,12 +541,17 @@ def resolve_agent_factory(assistant_id: str | None):
Custom agents are implemented as ``lead_agent`` + an ``agent_name``
injected into ``configurable`` or ``context`` see
:func:`build_run_config`. All ``assistant_id`` values therefore map to the
same factory; the routing happens inside ``make_lead_agent`` when it reads
same factory; the routing happens inside the assembly when it reads
``cfg["agent_name"]``.
"""
from deerflow.agents.lead_agent.agent import make_lead_agent
return make_lead_agent
The result is ``assemble_lead_agent``, which returns a
``LeadAgentAssembly(graph, descriptor)`` rather than a bare graph, so every
consumer must unwrap ``.graph``. A third-party factory that still returns a
bare graph keeps working: the unwrap sites are type-checked, not assumed.
"""
from deerflow.agents.lead_agent.agent import assemble_lead_agent
return assemble_lead_agent
# Lead-agent recursion budget bounds. The Gateway must NOT trust a
@ -728,7 +779,15 @@ def _state_accessor_graph(agent_factory: Any, assistant_id: str | None, mode: st
return cached[2]
if len(_state_accessor_graph_cache) >= _accessor_graph_cache_max(app_config):
_state_accessor_graph_cache.clear()
graph = agent_factory(config=config)
agent_result = agent_factory(config=config)
try:
from deerflow.agents.lead_agent.agent import unwrap_agent_graph
graph = unwrap_agent_graph(agent_result)
except Exception:
# A custom factory must keep working even if importing the lead
# assembly type fails.
graph = agent_result
_state_accessor_graph_cache[key] = (agent_factory, app_config, graph)
return graph

View File

@ -20,7 +20,7 @@ def install_ok(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None:
registry.middlewares(_Contributor("ok"))
@extension(api="0.1", name="stamped")
@extension(api="0.2", name="stamped")
def install_stamped(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None:
INSTALLED.append("stamped")
registry.task_lifecycle(_Contributor("stamped"))
@ -32,7 +32,7 @@ def install_future_api(registry: ExtensionRegistry, config: Mapping[str, Any]) -
registry.middlewares(_Contributor("future"))
@extension(api="0.2", name="newer-minor")
@extension(api="0.3", name="newer-minor")
def install_newer_minor_api(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None:
"""Written against a newer 0.x minor than the host provides: before 1.0,
minors carry no compatibility promise in either direction."""

View File

@ -7,6 +7,22 @@ extensions can therefore be released independently of the host.
from __future__ import annotations
from deerflow_extension_api.assembly import (
AgentAssemblyDescriptor,
AgentAssemblyObserver,
MiddlewareDescriptor,
ToolDescriptor,
)
from deerflow_extension_api.auth import (
EXTENSION_PRINCIPAL_RESOLVER_KEY,
ExtensionPrincipal,
require_admin,
resolve_principal,
)
from deerflow_extension_api.compaction import (
CompactionEvent,
ContextCompactionObserver,
)
from deerflow_extension_api.contracts import (
ExtensionInstall,
ExtensionRegistry,
@ -29,6 +45,22 @@ from deerflow_extension_api.placement import (
MiddlewarePlacement,
Placement,
)
from deerflow_extension_api.provenance import (
MESSAGE_CONTENT_KIND_KEY,
MESSAGE_PRODUCER_ENTITY_ID_KEY,
MESSAGE_PRODUCER_KIND_KEY,
PROVENANCE_KEYS,
ContentKind,
MessageProvenance,
provenance_kwargs,
read_provenance,
)
from deerflow_extension_api.release import (
ReleasePolicyProvider,
canonical_hash,
canonical_json,
collect_release_policies,
)
from deerflow_extension_api.runtime_bridge import (
EXTENSION_TASK_STORE_KEY,
task_store_from_runtime,
@ -37,22 +69,36 @@ 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.1.2"
API_VERSION = "0.2.0"
__all__ = [
"API_VERSION",
"EXTENSION_PRINCIPAL_RESOLVER_KEY",
"EXTENSION_TASK_STORE_KEY",
"MESSAGE_CONTENT_KIND_KEY",
"MESSAGE_PRODUCER_ENTITY_ID_KEY",
"MESSAGE_PRODUCER_KIND_KEY",
"PROVENANCE_KEYS",
"AgentAssemblyDescriptor",
"AgentAssemblyObserver",
"AgentBuildContext",
"AgentScope",
"CompactionEvent",
"ContentKind",
"ContextCompactionObserver",
"ExtensionData",
"ExtensionInstall",
"ExtensionPrincipal",
"ExtensionRegistry",
"ExtensionRuntimeDeps",
"ExtensionService",
"HostPolicySnapshot",
"MessageProvenance",
"MiddlewareContributor",
"MiddlewareDescriptor",
"MiddlewarePlacement",
"Placement",
"ReleasePolicyProvider",
"SystemModelCallObserver",
"SystemModelRequest",
"SystemModelResult",
@ -60,6 +106,14 @@ __all__ = [
"TaskInfo",
"TaskLifecycleContributor",
"TaskOutcome",
"ToolDescriptor",
"canonical_hash",
"canonical_json",
"collect_release_policies",
"extension",
"provenance_kwargs",
"read_provenance",
"require_admin",
"resolve_principal",
"task_store_from_runtime",
]

View File

@ -0,0 +1,124 @@
"""What an agent was assembled from, captured where it is knowable.
The lead-agent factory resolves a model (after runtime overrides), renders a
system prompt, filters a tool list through authorization, and composes a
middleware stack. All four are decided inside one synchronous call and none of
them survives to any later observation point: a middleware sees its neighbours
but not the prompt, the run worker sees the graph but not what went into it.
The factory therefore emits a descriptor alongside the graph. Its fingerprint
is what makes "did anything about this agent change between these two runs?"
answerable.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from functools import cached_property
from typing import Any, Protocol
from deerflow_extension_api.release import canonical_hash
from deerflow_extension_api.state import ExtensionData
@dataclass(frozen=True)
class ToolDescriptor:
name: str
description_hash: str
schema_hash: str
source: str
mcp_server: str | None = None
mcp_transport: str | None = None
@dataclass(frozen=True)
class MiddlewareDescriptor:
name: str
module: str
policy_parameters: dict[str, Any] = field(default_factory=dict)
#: Extension this middleware was contributed by, or ``None`` for a host
#: middleware. Contributed middlewares reach the stack inside a wrapper
#: whose class name is shared by all of them, so without this two
#: extensions' middlewares would be indistinguishable here.
extension: str | None = None
@dataclass(frozen=True)
class AgentAssemblyDescriptor:
namespace: str
agent_name: str
requested_model: str | None
effective_model: str
model_parameters: dict[str, Any]
thinking_enabled: bool
reasoning_effort: Any
base_prompt_hash: str
tools: tuple[ToolDescriptor, ...]
middlewares: tuple[MiddlewareDescriptor, ...]
deferred_tool_names: tuple[str, ...]
enabled_skills: tuple[str, ...]
effective_policies: dict[str, Any]
#: Which host build produced this assembly (package version, image digest,
#: git commit). Reported, but deliberately outside ``fingerprint`` — see
#: the note there.
build: dict[str, Any] = field(default_factory=dict)
@cached_property
def fingerprint(self) -> str:
"""Identity of everything that changes how this agent behaves.
Tools and skills are sorted: their assembly order is incidental.
Middlewares are not: stack order decides what wraps what.
Two fields are reported but deliberately excluded:
* ``build`` the fingerprint answers "did this agent's assembly
change", which is a finer question than "did the host binary
change". Folding the build in would change every agent's fingerprint
on every redeploy, making the fine question unanswerable; leaving it
out keeps both answerable, because a consumer can still compare
``build`` directly.
* ``requested_model`` only ``effective_model`` reaches the provider,
so a request that resolves to the same effective model is not a
behavioural difference.
"""
return canonical_hash(
{
"namespace": self.namespace,
"agent_name": self.agent_name,
"effective_model": self.effective_model,
"model_parameters": self.model_parameters,
"thinking_enabled": self.thinking_enabled,
"reasoning_effort": self.reasoning_effort,
"base_prompt_hash": self.base_prompt_hash,
"tools": sorted(
[
{
"name": tool.name,
"description_hash": tool.description_hash,
"schema_hash": tool.schema_hash,
"source": tool.source,
"mcp_server": tool.mcp_server,
"mcp_transport": tool.mcp_transport,
}
for tool in self.tools
],
key=lambda entry: entry["name"],
),
"middlewares": [{"name": m.name, "module": m.module, "extension": m.extension, "policy_parameters": m.policy_parameters} for m in self.middlewares],
"deferred_tool_names": sorted(self.deferred_tool_names),
"enabled_skills": sorted(self.enabled_skills),
"effective_policies": self.effective_policies,
}
)
class AgentAssemblyObserver(Protocol):
def on_agent_assembled(self, app_store: ExtensionData, descriptor: AgentAssemblyDescriptor) -> None:
"""Called synchronously at the end of agent construction.
Synchronous because construction is: there is no loop to await on, and
the descriptor must be captured before the graph is handed out.
Implementations must be cheap and must not raise.
"""
return None

View File

@ -0,0 +1,53 @@
"""The caller's identity, for contributed routes.
Contributed routers are constructed during install(), long before any request
exists, so identity cannot be handed to them at registration time. The host
instead installs a resolver on ``app.state`` and this module reads it back.
``request`` is duck-typed rather than annotated as a Starlette Request: this
package must not depend on a web framework.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
EXTENSION_PRINCIPAL_RESOLVER_KEY = "deerflow_extension_principal_resolver"
@dataclass(frozen=True)
class ExtensionPrincipal:
user_id: str
is_admin: bool = False
is_internal: bool = False
roles: tuple[str, ...] = field(default_factory=tuple)
def resolve_principal(request: object) -> ExtensionPrincipal | None:
"""Return the caller's principal, or ``None`` when it cannot be determined."""
app = getattr(request, "app", None)
state = getattr(app, "state", None)
resolver = getattr(state, EXTENSION_PRINCIPAL_RESOLVER_KEY, None)
if not callable(resolver):
return None
try:
principal = resolver(request)
except Exception as exc: # noqa: BLE001 - an unanswerable identity is not an error to propagate
logger.warning("extension principal resolver failed: %s", type(exc).__name__)
return None
return principal if isinstance(principal, ExtensionPrincipal) else None
def require_admin(request: object) -> ExtensionPrincipal:
"""Return the principal when it is an admin, else raise ``PermissionError``.
Fails closed on an absent or failing resolver: an authorization question the
host cannot answer must never resolve to "allowed".
"""
principal = resolve_principal(request)
if principal is None or not principal.is_admin:
raise PermissionError("this endpoint requires an administrator account")
return principal

View File

@ -0,0 +1,58 @@
"""The transform that replaces many messages with one.
Summarization is destructive by design: N messages leave the context and one
summary enters it. Afterwards only the summary exists, so nothing downstream can
answer "which messages became this?" the mapping is gone. It has to be emitted
where it is still true.
Fields are keyed on canonical content hashes (``deerflow_extension_api.canonical_hash``),
not a producer-stamped identity key: nothing in the host currently mints a stable
per-message identity for the messages a compaction consumes or the summary it
produces, so an identity-keyed field would ship permanently empty in every event.
Every message has content, so hashing it is always available.
The exact recipe both sides must use: ``canonical_hash(message.content)`` the
message's ``content`` attribute passed directly, never pre-stringified. DeerFlow
messages are routinely multimodal (``list[dict]`` content), and ``str()`` on a
dict renders insertion order, so two logically identical messages would hash
differently if stringified first. ``canonical_hash`` already normalizes through
``canonical_json`` (sorted keys, deterministic separators); passing it anything
else throws that normalization away.
Trap for consumers: ``DurableContextMiddleware`` is the only place the produced summary
text later reaches a message (the ``durable_context_data`` block), but
``_render_durable_context_data`` renders a *bounded, HTML-escaped* projection of
``summary_text`` for the model prompt, not the summary itself. Hashing that rendering
will not equal ``output_content_hash``. A consumer must join a compaction to what came
after it through this event alone, never by re-hashing a later projection of the same
text.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
from deerflow_extension_api.state import ExtensionData
@dataclass(frozen=True)
class CompactionEvent:
"""What a compaction consumed and produced, captured while both still exist."""
transform_kind: str
transform_version: str
source_content_hashes: tuple[str, ...]
output_content_hash: str
compacted_message_count: int
kept_message_count: int
class ContextCompactionObserver(Protocol):
async def on_context_compacted(
self,
app_store: ExtensionData,
task_store: ExtensionData,
event: CompactionEvent,
) -> None:
return None

View File

@ -17,6 +17,8 @@ from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, runtime_check
from deerflow_extension_api.state import ExtensionData
if TYPE_CHECKING: # pragma: no cover - typing only
from deerflow_extension_api.assembly import AgentAssemblyObserver
from deerflow_extension_api.compaction import ContextCompactionObserver
from deerflow_extension_api.placement import AgentBuildContext, MiddlewarePlacement
F = TypeVar("F", bound=Callable[..., Any])
@ -194,6 +196,12 @@ class ExtensionRegistry(Protocol):
def system_model_observer(self, observer: SystemModelCallObserver) -> None:
return None
def agent_assembly_observer(self, observer: AgentAssemblyObserver) -> None:
return None
def context_compaction_observer(self, observer: ContextCompactionObserver) -> None:
return None
def service(self, service: ExtensionService) -> None:
return None

View File

@ -0,0 +1,95 @@
"""Who produced a message, declared by the producer.
DeerFlow's middleware chain injects and rewrites messages: a date reminder, a
recalled-memory block, a compaction summary, a durable-context data block, an
image payload, an activated skill body. By the time any of those reach the
model-call boundary, the component that produced them is no longer recoverable
from the message itself an observer would have to infer it from wording,
which breaks the moment a prompt is reworded.
The producing middleware therefore stamps the fact. Keys live here, in the
contract package, rather than in the host: an extension pinned to this contract
version must be able to rely on the facility existing, and only a shared
declaration makes that checkable.
Values are plain strings, not enum members, so an unknown producer from a newer
host degrades to an unrecognised string rather than an import error.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
MESSAGE_CONTENT_KIND_KEY = "deerflow_content_kind"
MESSAGE_PRODUCER_KIND_KEY = "deerflow_producer_kind"
MESSAGE_PRODUCER_ENTITY_ID_KEY = "deerflow_producer_entity_id"
#: Every key this contract owns. The host treats all of them as server-owned and
#: strips caller-supplied values from untrusted input.
PROVENANCE_KEYS: frozenset[str] = frozenset(
{
MESSAGE_CONTENT_KIND_KEY,
MESSAGE_PRODUCER_KIND_KEY,
MESSAGE_PRODUCER_ENTITY_ID_KEY,
}
)
class ContentKind(StrEnum):
"""What a stamped message *is*, independent of which component made it."""
MIDDLEWARE_INJECTION = "middleware_injection"
MEMORY = "memory"
DURABLE_CONTEXT = "durable_context"
SKILL_BODY = "skill_body"
IMAGE_PAYLOAD = "image_payload"
@dataclass(frozen=True)
class MessageProvenance:
content_kind: str
producer_kind: str
producer_entity_id: str | None = None
def provenance_kwargs(
content_kind: str,
producer_kind: str,
*,
producer_entity_id: str | None = None,
) -> dict[str, str]:
"""Build the ``additional_kwargs`` fragment a producer merges into its message.
Optional fields are omitted rather than written as ``None`` so a stamped
message carries no keys whose value says nothing.
"""
kwargs = {
MESSAGE_CONTENT_KIND_KEY: str(content_kind),
MESSAGE_PRODUCER_KIND_KEY: str(producer_kind),
}
if producer_entity_id is not None:
kwargs[MESSAGE_PRODUCER_ENTITY_ID_KEY] = str(producer_entity_id)
return kwargs
def read_provenance(message: object) -> MessageProvenance | None:
"""Return the stamp, or ``None`` when absent or malformed.
Both required fields must be present strings; a partial or wrongly-typed
stamp is treated as absent rather than as a half-truth an observer would
then record as fact.
"""
kwargs = getattr(message, "additional_kwargs", None)
if not isinstance(kwargs, dict):
return None
content_kind = kwargs.get(MESSAGE_CONTENT_KIND_KEY)
producer_kind = kwargs.get(MESSAGE_PRODUCER_KIND_KEY)
if not isinstance(content_kind, str) or not isinstance(producer_kind, str):
return None
entity_id = kwargs.get(MESSAGE_PRODUCER_ENTITY_ID_KEY)
return MessageProvenance(
content_kind=content_kind,
producer_kind=producer_kind,
producer_entity_id=entity_id if isinstance(entity_id, str) else None,
)

View File

@ -0,0 +1,102 @@
"""Behaviour-affecting parameters, declared by the component that owns them.
Two runs of "the same agent" are only the same if the middleware chain enforced
the same limits, prompts, and thresholds. Reconstructing that from outside means
reading private attributes and guessing which of them change behaviour a
guess that silently rots as middlewares gain fields.
Each middleware declares its own instead. The declaration is the contract; the
attributes behind it are free to change.
``canonical_json`` is here rather than in the host because a hash is only
comparable if both sides compute it identically, and one of those sides is an
extension released on a different schedule.
"""
from __future__ import annotations
import hashlib
import json
import logging
from collections.abc import Sequence
from typing import Protocol, runtime_checkable
logger = logging.getLogger(__name__)
@runtime_checkable
class ReleasePolicyProvider(Protocol):
def release_policy_parameters(self) -> dict[str, object]:
"""Return this component's behaviour-affecting parameters.
Values must be JSON-serialisable. Hash long text rather than embedding
it: a declaration is an identity, not a copy of the prompt.
"""
return None
def canonical_json(value: object) -> str:
"""Deterministic JSON: sorted keys, no insignificant whitespace.
Raises ``TypeError`` on an unserialisable value rather than coercing it to
``repr``, which would make two structurally different declarations collide
on the same address-dependent string.
"""
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def canonical_hash(value: object) -> str:
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
def _unwrap_release_policy_source(middleware: object) -> object:
"""Return the object that owns the behaviour, not an isolation wrapper.
Extension contributions can reach the stack inside an isolation wrapper
whose dynamically generated subclass shares one class name across every
contributed middleware in the process describing the wrapper would
collapse them all into one indistinguishable, empty declaration.
Duck-typed on ``inner`` rather than importing the wrapper type: this
package must stay host-independent, and any future wrapper of the same
shape is handled for free.
"""
described = middleware
for _ in range(4):
inner = getattr(described, "inner", None)
if inner is None or inner is described:
break
described = inner
return described
def collect_release_policies(middlewares: Sequence[object]) -> dict[str, dict[str, object]]:
"""Gather every declaration in an assembled stack, keyed by class name.
A middleware whose declaration raises is recorded as ``{"error": "<Type>"}``
rather than dropped: an assembly that failed to describe itself is a
different fact from one that had nothing to say.
Two instances of the same class get distinct keys (``Name``, ``Name#2``,
...) rather than the second silently overwriting the first: a stack that
legitimately runs the same middleware twice must not lose one instance's
declaration.
"""
policies: dict[str, dict[str, object]] = {}
seen_counts: dict[str, int] = {}
for middleware in middlewares:
described = _unwrap_release_policy_source(middleware)
declare = getattr(described, "release_policy_parameters", None)
if not callable(declare):
continue
name = type(described).__name__
seen_counts[name] = seen_counts.get(name, 0) + 1
key = name if seen_counts[name] == 1 else f"{name}#{seen_counts[name]}"
try:
declared = declare()
except Exception as exc: # noqa: BLE001 - a broken declaration must not abort assembly
logger.warning("middleware %s failed to declare release policy: %s", name, type(exc).__name__)
policies[key] = {"error": type(exc).__name__}
continue
policies[key] = declared if isinstance(declared, dict) else {"error": "NonMappingDeclaration"}
return policies

View File

@ -1,6 +1,6 @@
[project]
name = "deerflow-extension-api"
version = "0.1.2"
version = "0.2.0"
description = "Public contracts for DeerFlow extensions"
requires-python = ">=3.12"
# Keep the contract package import-light and independent from the host. Public

View File

@ -1,7 +1,18 @@
### Agent System
**Lead Agent** (`packages/harness/deerflow/agents/lead_agent/agent.py`):
- Entry point: `make_lead_agent(config: RunnableConfig)` registered in `langgraph.json`
- Entry point: `make_lead_agent(config: RunnableConfig)` registered in `langgraph.json`.
Its signature and bare-graph return type are a published ABI: LangGraph Server calls it
directly, so neither may change.
- `assemble_lead_agent(config, *, app_config=None) -> LeadAgentAssembly(graph, descriptor)`
is the richer entry point the Gateway uses; `make_lead_agent` is a thin wrapper returning
`.graph`. The descriptor is built by
`deerflow/agents/assembly_descriptor.py::build_assembly_descriptor()` and captures what
only the factory knows — the model resolved after runtime overrides, the rendered prompt
hash, the tool list left by authorization, and the composed middleware stack in order.
Consumers of a factory result must unwrap `.graph` defensively (see
`runtime/runs/worker.py::_agent_graph`), because a third-party factory still returns a
bare graph.
- Dynamic model selection via `create_chat_model()` with thinking/vision support
- Tools loaded via `get_available_tools()` - combines sandbox, built-in, MCP, community, and subagent tools
- System prompt generated by `apply_prompt_template()` with skills, memory, and subagent instructions

View File

@ -0,0 +1,510 @@
"""Projection of an assembled agent into a comparable descriptor.
The factory knows things nothing downstream can recover: which model survived
the runtime overrides, what the rendered prompt actually said, which tools
authorization left in place, and the order the middleware stack ended up in.
This module turns that transient knowledge into
:class:`~deerflow_extension_api.assembly.AgentAssemblyDescriptor`.
Two rules shape the projection:
* **Declared beats probed.** A middleware that implements
``release_policy_parameters()`` owns its own behaviour identity; probing
private attributes is the fallback for the ones that do not, and is marked as
such so a reader can tell a contract from a guess.
* **Hash, do not copy.** Prompts, tool descriptions, and argument schemas are
reduced to hashes. A descriptor is an identity, not a second copy of the
agent's payload.
"""
from __future__ import annotations
import logging
import os
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from deerflow_extension_api import (
AgentAssemblyDescriptor,
MiddlewareDescriptor,
ToolDescriptor,
canonical_hash,
collect_release_policies,
)
from deerflow.sandbox.env_policy import is_blocked_env_name
from deerflow.tools.mcp_metadata import get_mcp_source, is_mcp_tool
logger = logging.getLogger(__name__)
# Fields on a model profile that are pure identity/presentation metadata: they
# never reach the provider constructor (see ``create_chat_model``'s own
# exclude set) and renaming/re-describing a model must not look like a
# behaviour change. ``use`` is surfaced separately as ``provider``.
_MODEL_METADATA_FIELDS = frozenset(
{
"name",
"display_name",
"description",
"use",
"context_window",
"pricing",
}
)
_MIDDLEWARE_PUBLIC_FIELDS = (
"max_concurrent",
"max_total",
"warn_threshold",
"hard_limit",
"window_size",
"max_tracked_threads",
"tool_freq_warn",
"tool_freq_hard_limit",
"trigger",
"keep",
"trim_tokens_to_summarize",
"fail_closed",
"passport",
"_tool_freq_overrides",
"_top_k",
"_deferred",
"_catalog_hash",
)
_MIDDLEWARE_HASHED_TEXT_FIELDS = (
"summary_prompt",
"system_prompt",
"tool_description",
)
_MODEL_IDENTITY_FIELDS = (
"model",
"model_name",
"deployment_name",
)
_PROVIDER_PARAMETER_FIELDS = (
"_allowed",
"_denied",
"_default_role",
"_resource_type",
"_action",
)
_DETECTOR_PARAMETER_FIELDS = (
"_finish_reasons",
"_stop_reasons",
)
def _plain_value(value: object) -> object | None:
"""Reduce ``value`` to JSON-shaped data, or ``None`` when it cannot be.
``None`` means "not describable", which is deliberately indistinguishable
from a real ``None``: both are equally uninformative for identity, and
inventing a marker would make two undescribable values look different.
"""
if value is None or isinstance(value, bool | int | float | str):
return value
if isinstance(value, (list, tuple, set, frozenset)):
children = [_plain_value(child) for child in value]
if any(child is None and original is not None for child, original in zip(children, value, strict=True)):
return None
return sorted(children, key=str) if isinstance(value, (set, frozenset)) else children
if isinstance(value, dict):
result: dict[str, object] = {}
for key, child in value.items():
if not isinstance(key, str):
continue
plain = _plain_value(child)
if plain is not None or child is None:
result[key] = plain
return result
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
try:
return _plain_value(model_dump(mode="python"))
except Exception:
return None
return None
def _stable_type_name(value: object) -> str:
value_type = type(value)
return f"{value_type.__module__}.{value_type.__qualname__}"
def describe_model_identity(value: object) -> dict[str, str]:
"""Name a chat model without serialising it.
Chat-model objects carry credentials and clients, so they are never plain
data. What identifies them for comparison is the class plus the configured
model name, unwrapped through any ``bound`` runnable wrapper the middleware
stack layered on top.
"""
original = value
if isinstance(value, str):
return {"class": "builtins.str", "name": value}
resolved = value
seen: set[int] = set()
for _ in range(8):
if not hasattr(resolved, "bound") or id(resolved) in seen:
break
seen.add(id(resolved))
bound = getattr(resolved, "bound")
if bound is None or bound is resolved:
break
resolved = bound
identity = {"class": _stable_type_name(resolved)}
for candidate in (resolved, original):
for field_name in _MODEL_IDENTITY_FIELDS:
field_value = getattr(candidate, field_name, None)
if isinstance(field_value, str) and field_value:
identity["name"] = field_value
break
if "name" in identity:
break
return identity
def _provider_identity(value: object) -> dict[str, object]:
identity: dict[str, object] = {
"class": _stable_type_name(value),
"name": str(getattr(value, "name", type(value).__name__)),
}
parameters: dict[str, object] = {}
for field_name in _PROVIDER_PARAMETER_FIELDS:
if not hasattr(value, field_name):
continue
raw_value = getattr(value, field_name)
plain = _plain_value(raw_value)
if plain is not None or raw_value is None:
parameters[field_name.removeprefix("_")] = plain
if parameters:
identity["parameters"] = parameters
nested = getattr(value, "_provider", None)
if nested is not None and nested is not value:
identity["provider"] = _provider_identity(nested)
return identity
def _tool_schema(tool: object) -> dict[str, object]:
get_input_schema = getattr(tool, "get_input_schema", None)
if callable(get_input_schema):
try:
schema_model = get_input_schema()
schema = schema_model.model_json_schema()
if isinstance(schema, dict):
return schema
except Exception:
pass
args_schema = getattr(tool, "args_schema", None)
model_json_schema = getattr(args_schema, "model_json_schema", None)
if callable(model_json_schema):
try:
schema = model_json_schema()
if isinstance(schema, dict):
return schema
except Exception:
pass
return {}
def _tool_source(tool: object) -> str:
if is_mcp_tool(tool):
source = get_mcp_source(tool)
return f"mcp:{source['server_name']}" if source is not None else "mcp:unknown"
metadata = getattr(tool, "metadata", None)
if isinstance(metadata, dict):
declared = metadata.get("deerflow_tool_source")
if isinstance(declared, str) and declared:
return declared
callable_object = getattr(tool, "func", None) or getattr(tool, "coroutine", None)
module = getattr(callable_object, "__module__", "") or ""
if module.startswith("deerflow.tools.builtins") or module.startswith("deerflow.agents.memory"):
return "builtin"
if "skill" in module:
return "skill"
return "community" if module else "builtin"
def describe_tool(tool: object) -> ToolDescriptor:
"""Project one bound tool into its identity."""
source = get_mcp_source(tool) if is_mcp_tool(tool) else None
return ToolDescriptor(
name=str(getattr(tool, "name", type(tool).__name__)),
description_hash=canonical_hash(str(getattr(tool, "description", "") or "")),
schema_hash=canonical_hash(_plain_value(_tool_schema(tool))),
source=_tool_source(tool),
mcp_server=source["server_name"] if source is not None else None,
mcp_transport=source["transport"] if source is not None else None,
)
def _probe_middleware_parameters(middleware: object) -> dict[str, object]:
"""Best-effort identity for a middleware that declares none of its own."""
parameters: dict[str, object] = {}
for field_name in _MIDDLEWARE_PUBLIC_FIELDS:
if not hasattr(middleware, field_name):
continue
raw_value = getattr(middleware, field_name)
plain = _plain_value(raw_value)
if plain is not None or raw_value is None:
parameters[field_name.removeprefix("_")] = plain
for field_name in _MIDDLEWARE_HASHED_TEXT_FIELDS:
raw_value = getattr(middleware, field_name, None)
if isinstance(raw_value, str):
parameters[f"{field_name}_hash"] = canonical_hash(raw_value)
model = getattr(middleware, "model", None)
if model is not None:
parameters["model"] = describe_model_identity(model)
provider = getattr(middleware, "provider", None)
if provider is not None:
parameters["provider"] = _provider_identity(provider)
routing_index = getattr(middleware, "_routing_index", None)
plain_routing_index = _plain_value(routing_index)
if plain_routing_index is not None:
parameters["routing_index_hash"] = canonical_hash(plain_routing_index)
detectors = getattr(middleware, "_detectors", None)
if isinstance(detectors, (list, tuple)):
detector_descriptors: list[dict[str, object]] = []
for detector in detectors:
descriptor: dict[str, object] = {
"class": _stable_type_name(detector),
"name": str(getattr(detector, "name", type(detector).__name__)),
}
detector_parameters: dict[str, object] = {}
for field_name in _DETECTOR_PARAMETER_FIELDS:
if not hasattr(detector, field_name):
continue
raw_value = getattr(detector, field_name)
plain = _plain_value(raw_value)
if plain is not None or raw_value is None:
detector_parameters[field_name.removeprefix("_")] = plain
if detector_parameters:
descriptor["parameters"] = detector_parameters
detector_descriptors.append(descriptor)
parameters["detectors"] = detector_descriptors
config = getattr(middleware, "_config", None)
plain_config = _plain_value(config)
if isinstance(plain_config, dict):
parameters["config"] = plain_config
return parameters
def _unwrap_middleware(middleware: object) -> tuple[object, str | None]:
"""Return the middleware that owns the behaviour, plus its extension.
Extension contributions reach the stack inside an isolation wrapper whose
dynamically generated subclass is named after the wrapper, not the
contribution so every contributed middleware in the process shares one
class name and one (empty) probe result. Describing the wrapper would
collapse them all into a single indistinguishable descriptor and hide any
policy change inside them.
Duck-typed on ``inner``/``source`` rather than importing the wrapper type:
``deerflow.extensions`` sits below this layer, so importing it here would
point the dependency backwards, and any future wrapper of the same shape
is handled for free.
"""
described = middleware
extension: str | None = None
for _ in range(4):
inner = getattr(described, "inner", None)
if inner is None or inner is described:
break
source = getattr(described, "source", None)
if not isinstance(source, str) or not source:
source = getattr(described, "name", None)
if isinstance(source, str) and source and extension is None:
extension = source
described = inner
return described, extension
def describe_middleware(middleware: object) -> MiddlewareDescriptor:
"""Project one middleware, preferring its own declaration over probing."""
described, extension = _unwrap_middleware(middleware)
name = type(described).__name__
declared = collect_release_policies([described])
if name in declared:
parameters = _plain_value(declared[name])
if not isinstance(parameters, dict):
logger.warning("%s declared a release policy that is not plain data; recording it as unserialisable", name)
parameters = {"error": "UnserialisableDeclaration"}
else:
parameters = {"probed": True, **_probe_middleware_parameters(described)}
return MiddlewareDescriptor(
name=name,
module=type(described).__module__,
policy_parameters=parameters,
extension=extension,
)
def _build_identity() -> dict[str, str]:
"""Which build produced this assembly, when the deployment says so.
Reported on its own descriptor field rather than inside
``effective_policies`` because the latter is hashed into the fingerprint:
a redeploy that changes nothing about an agent must not change that
agent's fingerprint.
"""
try:
package_version = version("deerflow-harness")
except PackageNotFoundError:
package_version = "unknown"
return {
"package_version": package_version,
"image_digest": os.environ.get("DEER_FLOW_IMAGE_DIGEST", "unknown"),
"git_commit": os.environ.get("DEER_FLOW_GIT_COMMIT", "unknown"),
}
def _effective_model_fields(model_config: object) -> dict[str, object]:
"""All fields the model profile actually carries, declared or extra.
``ModelConfig`` is ``extra="allow"``, so a provider kwarg a user sets
(``temperature``, ``max_tokens``, anything else) lives only as an extra
field a fixed allowlist would never see it. ``model_dump`` is the
profile's own account of its fields, extras included, so it is preferred
over probing named attributes. Falls back to plain instance attributes
for a non-pydantic profile (e.g. the ``SimpleNamespace`` a subagent
builds when its model name has no entry in the config table).
"""
model_dump = getattr(model_config, "model_dump", None)
if callable(model_dump):
try:
dumped = model_dump(mode="python")
except Exception:
dumped = None
if isinstance(dumped, dict):
return dumped
namespace = getattr(model_config, "__dict__", None)
return dict(namespace) if isinstance(namespace, dict) else {}
def _model_parameters(model_config: object, model_overrides: dict[str, object] | None = None) -> dict[str, object]:
"""The behaviour-affecting half of a model profile, as actually constructed.
Built from the profile's own effective fields plus any per-caller
overrides actually applied on top (e.g. a custom agent's ``model_settings``
sampling overrides, or a request's runtime overrides) — mirroring what
``create_chat_model`` layers onto the constructor rather than a fixed
allowlist, so a changed ``temperature``/``max_tokens``/arbitrary provider
kwarg is always visible here.
Credential-shaped field names (``api_key`` and anything else
``is_blocked_env_name`` flags) are never projected, and values that
cannot be reduced to plain JSON-shaped data are silently dropped rather
than raising (see ``_plain_value``).
``thinking_enabled`` and ``reasoning_effort`` are descriptor fields in their
own right, so they are deliberately not duplicated here.
"""
use = getattr(model_config, "use", None)
result: dict[str, object] = {"provider": str(use) if use else None}
effective = _effective_model_fields(model_config)
if model_overrides:
effective = {**effective, **{key: value for key, value in model_overrides.items() if value is not None}}
for field_name, value in effective.items():
if not isinstance(field_name, str) or field_name in _MODEL_METADATA_FIELDS:
continue
if is_blocked_env_name(field_name):
continue
plain = _plain_value(value)
if plain is not None or value is None:
result[field_name] = plain
return result
def _skill_content_hash(skill: object) -> str | None:
"""Digest of a skill's ``SKILL.md`` body.
``SkillActivationMiddleware`` injects this body as current-turn context
(see ``skill_activation_middleware._read_skill_content``), so it is what
actually drives the agent's behaviour when the skill is used — not just
the name/description/allowed-tools already projected above. ``Skill``
does not cache its content as a field (the middleware re-reads it from
disk at activation time), so this does the same rather than inventing a
cached-content field the rest of the system does not have. A skill whose
file has gone missing or become unreadable is recorded as undescribable
(``None``) rather than raising assembly must not fail because a skill
file disappeared out from under it.
"""
skill_file = getattr(skill, "skill_file", None)
if not isinstance(skill_file, Path):
return None
try:
content = skill_file.read_text(encoding="utf-8")
except OSError:
return None
return canonical_hash(content)
def build_assembly_descriptor(
*,
namespace: str,
agent_name: str,
requested_model: str | None,
effective_model: str,
model_config: object,
model_overrides: dict[str, object] | None = None,
thinking_enabled: bool,
reasoning_effort: object,
rendered_base_prompt: str,
prompt_template_id: str = "deerflow-lead-agent-v1",
tools: list[object],
middlewares: list[object],
deferred_names: frozenset[str],
enabled_skills: list[object],
effective_policies: dict[str, object],
) -> AgentAssemblyDescriptor:
"""Describe one finished assembly.
``tools`` is the list bound to the graph; middleware-owned tools are folded
in because the model sees them exactly the same way. The skill catalog is
hashed rather than listed field-by-field so editing a skill's body changes
the fingerprint while the descriptor stays small.
"""
skill_catalog = [
{
"name": str(getattr(skill, "name", "")),
"description": str(getattr(skill, "description", "")),
"allowed_tools": sorted(str(item) for item in (getattr(skill, "allowed_tools", None) or ())),
"content_hash": _skill_content_hash(skill),
"secrets_autonomous": bool(getattr(skill, "secrets_autonomous", True)),
"required_secrets": sorted(f"{getattr(requirement, 'name', '')}:{bool(getattr(requirement, 'optional', False))}" for requirement in (getattr(skill, "required_secrets", None) or ())),
}
for skill in enabled_skills
]
assembled_tools = list(tools)
for middleware in middlewares:
middleware_tools = getattr(middleware, "tools", None)
if isinstance(middleware_tools, (list, tuple)):
assembled_tools.extend(middleware_tools)
resolved_policies = dict(effective_policies)
resolved_policies["prompt_template_id"] = prompt_template_id
resolved_policies["skill_catalog_hash"] = canonical_hash(sorted(skill_catalog, key=lambda item: item["name"]))
return AgentAssemblyDescriptor(
namespace=namespace,
agent_name=agent_name,
requested_model=requested_model,
effective_model=effective_model,
model_parameters=_model_parameters(model_config, model_overrides),
thinking_enabled=thinking_enabled,
reasoning_effort=_plain_value(reasoning_effort),
base_prompt_hash=canonical_hash(rendered_base_prompt),
tools=tuple(describe_tool(tool) for tool in assembled_tools),
middlewares=tuple(describe_middleware(middleware) for middleware in middlewares),
deferred_tool_names=tuple(sorted(str(name) for name in deferred_names)),
enabled_skills=tuple(entry["name"] for entry in skill_catalog),
effective_policies=resolved_policies,
build=_build_identity(),
)
__all__ = [
"build_assembly_descriptor",
"describe_middleware",
"describe_model_identity",
"describe_tool",
]

View File

@ -27,6 +27,7 @@ from __future__ import annotations
import logging
import secrets
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from langchain.agents import create_agent
@ -82,11 +83,81 @@ _NON_INTERACTIVE_DISABLED_TOOL_NAMES = frozenset({"ask_clarification"})
_WEBHOOK_CHANNELS: frozenset[str] = frozenset({"github"})
@dataclass(frozen=True)
class LeadAgentAssembly:
"""The compiled graph plus what it was assembled from.
``descriptor`` is typed loosely on purpose: this module is imported during
LangGraph Server startup and must not pull the extension contract package
into that import path.
"""
graph: Any
descriptor: Any
def unwrap_agent_graph(agent_result: Any) -> Any:
"""Unwrap a lead assembly, leaving any other factory result untouched.
The Gateway factory returns ``LeadAgentAssembly(graph, descriptor)``, but a
third-party or test factory may still return a bare graph. Type-checking
the result rather than duck-typing ``.graph`` keeps both contracts valid.
Lives beside the dataclass so "what counts as an assembly, and which
attribute holds the graph" is answered in one place. Callers that must
survive this module failing to import (the runtime worker, the Gateway's
state accessor both of which have to keep serving custom factories that
never produce an assembly) guard the import and fall back to the result
unchanged.
"""
return agent_result.graph if isinstance(agent_result, LeadAgentAssembly) else agent_result
def _default_max_total_subagents(app_config: object) -> int:
subagents_config = getattr(app_config, "subagents", None)
return getattr(subagents_config, "max_total_per_run", DEFAULT_MAX_TOTAL_SUBAGENTS_PER_RUN)
def _subagent_release_policy(
app_config: AppConfig,
*,
enabled: bool,
max_concurrent: int,
max_total: int,
) -> dict[str, object]:
"""Delegation limits as the run will actually enforce them.
The per-type turn/timeout caps are read here rather than left implicit
because a subagent config edit changes what the lead agent can spend
without changing anything visible in the lead's own configuration.
"""
policy: dict[str, object] = {
"enabled": enabled,
"max_concurrent": max_concurrent,
"max_total": max_total,
"type_allowlist": [],
"runtime_limits": {},
}
if not enabled:
return policy
from deerflow.subagents import get_available_subagent_names, get_subagent_config
type_allowlist = sorted(set(get_available_subagent_names(app_config=app_config)))
runtime_limits: dict[str, object] = {}
for name in type_allowlist:
subagent_config = get_subagent_config(name, app_config=app_config)
if subagent_config is None:
continue
runtime_limits[name] = {
"max_turns": subagent_config.max_turns,
"timeout_seconds": subagent_config.timeout_seconds,
}
policy["type_allowlist"] = type_allowlist
policy["runtime_limits"] = runtime_limits
return policy
def _resolve_runtime_option(cfg: dict, key: str, agent_value, default):
"""Resolve a runtime option with ``request > agent config > default`` precedence.
@ -658,8 +729,23 @@ def _load_enabled_available_skills(available_skills: set[str] | None, *, app_con
def make_lead_agent(config: RunnableConfig):
"""LangGraph graph factory; keep the signature compatible with LangGraph Server."""
return assemble_lead_agent(config).graph
def assemble_lead_agent(
config: RunnableConfig,
*,
app_config: AppConfig | None = None,
) -> LeadAgentAssembly:
"""Return the compiled lead graph together with its assembly descriptor.
Gateway workers use this explicit assembly result so what the agent was
built from does not have to be recovered from LangGraph private runtime
keys or mutable graph attributes. ``make_lead_agent`` remains the
graph-only LangGraph Server ABI declared in ``langgraph.json``.
"""
runtime_config = _get_runtime_config(config)
runtime_app_config = runtime_config.get("app_config")
runtime_app_config = app_config or runtime_config.get("app_config")
if not isinstance(runtime_app_config, AppConfig):
runtime_app_config = get_app_config()
# Mode selection precedence, pinned by test_checkpoint_mode.py:
@ -684,10 +770,84 @@ def make_lead_agent(config: RunnableConfig):
# configurable key must not recompile the channel table either).
freeze_checkpoint_snapshot_frequency(runtime_app_config.database.checkpoint_delta.snapshot_frequency)
inject_checkpoint_mode(config, mode)
return _make_lead_agent(config, app_config=runtime_app_config)
return _assemble_lead_agent(config, app_config=runtime_app_config)
def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
"""Internal graph-only entry point.
Kept as a graph-returning wrapper because callers inside the harness (and
the model-resolution tests) want the compiled graph without the mode
freeze that :func:`assemble_lead_agent` performs.
"""
return _assemble_lead_agent(config, app_config=app_config).graph
def _complete_assembly(
*,
config: RunnableConfig,
graph: Any,
namespace: str,
agent_name: str,
requested_model: str | None,
effective_model: str,
model_config: object,
model_overrides: dict[str, object] | None = None,
thinking_enabled: bool,
reasoning_effort: object,
rendered_base_prompt: str,
tools: list[object],
middlewares: list[object],
deferred_names: frozenset[str],
enabled_skills: list[object],
effective_policies: dict[str, object],
) -> LeadAgentAssembly:
"""Describe the finished graph and hand the description to observers.
The recursion limit is folded in here rather than at either call site: it
is a per-invocation budget the Gateway clamps, so it belongs to the
assembly even though nothing inside the factory chose it.
Building the descriptor hashes every tool's description and JSON schema
and probes every middleware real work on every assembly. Skipped
entirely when no observer is registered to receive it, mirroring
``notify_agent_assembled``'s own zero-observer fast path.
"""
from deerflow.extensions import get_agent_build_extensions
resolved_extensions = get_agent_build_extensions()
if not resolved_extensions.has_agent_assembly_observers:
return LeadAgentAssembly(graph=graph, descriptor=None)
from deerflow.agents.assembly_descriptor import build_assembly_descriptor
from deerflow.extensions.notify import notify_agent_assembled
resolved_policies = dict(effective_policies)
resolved_policies.setdefault(
"recursion_limit",
config.get("recursion_limit", "framework-default"),
)
descriptor = build_assembly_descriptor(
namespace=namespace,
agent_name=agent_name,
requested_model=requested_model,
effective_model=effective_model,
model_config=model_config,
model_overrides=model_overrides,
thinking_enabled=thinking_enabled,
reasoning_effort=reasoning_effort,
rendered_base_prompt=rendered_base_prompt,
tools=tools,
middlewares=middlewares,
deferred_names=deferred_names,
enabled_skills=enabled_skills,
effective_policies=resolved_policies,
)
notify_agent_assembled(descriptor, resolved_extensions)
return LeadAgentAssembly(graph=graph, descriptor=descriptor)
def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> LeadAgentAssembly:
# Lazy import to avoid circular dependency
from deerflow.tools import get_available_tools
from deerflow.tools.builtins import setup_agent, update_agent
@ -834,35 +994,66 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
setup,
top_k=resolved_app_config.tool_search.auto_promote_top_k,
)
return create_agent(
middlewares = build_middlewares(
config,
model_name=model_name,
agent_name=agent_name,
available_skills=set(_BOOTSTRAP_SKILL_NAMES),
app_config=resolved_app_config,
deferred_setup=setup,
mcp_routing_middleware=mcp_routing_middleware,
user_id=resolved_user_id,
authorization_provider=_authz_provider,
)
system_prompt = apply_prompt_template(
subagent_enabled=subagent_enabled,
max_concurrent_subagents=max_concurrent_subagents,
max_total_subagents=max_total_subagents,
available_skills=set(_BOOTSTRAP_SKILL_NAMES),
app_config=resolved_app_config,
deferred_names=setup.deferred_names,
user_id=resolved_user_id,
skill_names=skill_setup.skill_names or None,
)
graph = create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False),
tools=final_tools,
middleware=normalize_middleware_state_schemas(
build_middlewares(
config,
model_name=model_name,
agent_name=agent_name,
available_skills=set(_BOOTSTRAP_SKILL_NAMES),
app_config=resolved_app_config,
deferred_setup=setup,
mcp_routing_middleware=mcp_routing_middleware,
user_id=resolved_user_id,
authorization_provider=_authz_provider,
),
mode,
),
system_prompt=apply_prompt_template(
subagent_enabled=subagent_enabled,
max_concurrent_subagents=max_concurrent_subagents,
max_total_subagents=max_total_subagents,
available_skills=set(_BOOTSTRAP_SKILL_NAMES),
app_config=resolved_app_config,
deferred_names=setup.deferred_names,
user_id=resolved_user_id,
skill_names=skill_setup.skill_names or None,
),
middleware=normalize_middleware_state_schemas(middlewares, mode),
system_prompt=system_prompt,
state_schema=get_thread_state_schema(mode),
)
return _complete_assembly(
config=config,
graph=graph,
namespace="deerflow",
agent_name="bootstrap",
requested_model=requested_model_name or agent_model_name,
effective_model=model_name,
model_config=model_config,
thinking_enabled=thinking_enabled,
reasoning_effort=None,
rendered_base_prompt=system_prompt,
tools=final_tools,
middlewares=middlewares,
deferred_names=setup.deferred_names,
enabled_skills=bootstrap_skills,
effective_policies={
"bootstrap": True,
"non_interactive": non_interactive,
"plan_mode": is_plan_mode,
"subagents": _subagent_release_policy(
resolved_app_config,
enabled=subagent_enabled,
max_concurrent=max_concurrent_subagents,
max_total=max_total_subagents,
),
"deferred_tools": {
"enabled": resolved_app_config.tool_search.enabled,
"catalog_hash": setup.catalog_hash,
},
"deferred_skills": skill_search_enabled,
},
)
# Custom agents can update their own SOUL.md / config via update_agent.
# The default agent (no agent_name) does not see this tool.
@ -916,34 +1107,66 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
top_k=resolved_app_config.tool_search.auto_promote_top_k,
)
mcp_routing_hints_section = get_mcp_routing_hints_prompt_section(authorized_tools, deferred_names=setup.deferred_names)
return create_agent(
middlewares = build_middlewares(
config,
model_name=model_name,
agent_name=agent_name,
available_skills=available_skills,
app_config=resolved_app_config,
deferred_setup=setup,
mcp_routing_middleware=mcp_routing_middleware,
user_id=resolved_user_id,
authorization_provider=_authz_provider,
)
system_prompt = apply_prompt_template(
subagent_enabled=subagent_enabled,
max_concurrent_subagents=max_concurrent_subagents,
max_total_subagents=max_total_subagents,
agent_name=agent_name,
available_skills=available_skills,
app_config=resolved_app_config,
deferred_names=setup.deferred_names,
mcp_routing_hints_section=mcp_routing_hints_section,
user_id=resolved_user_id,
skill_names=skill_setup.skill_names or None,
)
graph = create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config, attach_tracing=False, model_overrides=agent_model_overrides),
tools=final_tools,
middleware=normalize_middleware_state_schemas(
build_middlewares(
config,
model_name=model_name,
agent_name=agent_name,
available_skills=available_skills,
app_config=resolved_app_config,
deferred_setup=setup,
mcp_routing_middleware=mcp_routing_middleware,
user_id=resolved_user_id,
authorization_provider=_authz_provider,
),
mode,
),
system_prompt=apply_prompt_template(
subagent_enabled=subagent_enabled,
max_concurrent_subagents=max_concurrent_subagents,
max_total_subagents=max_total_subagents,
agent_name=agent_name,
available_skills=available_skills,
app_config=resolved_app_config,
deferred_names=setup.deferred_names,
mcp_routing_hints_section=mcp_routing_hints_section,
user_id=resolved_user_id,
skill_names=skill_setup.skill_names or None,
),
middleware=normalize_middleware_state_schemas(middlewares, mode),
system_prompt=system_prompt,
state_schema=get_thread_state_schema(mode),
)
return _complete_assembly(
config=config,
graph=graph,
namespace="deerflow",
agent_name=agent_name or "lead-agent",
requested_model=requested_model_name or agent_model_name,
effective_model=model_name,
model_config=model_config,
model_overrides=agent_model_overrides,
thinking_enabled=thinking_enabled,
reasoning_effort=reasoning_effort,
rendered_base_prompt=system_prompt,
tools=final_tools,
middlewares=middlewares,
deferred_names=setup.deferred_names,
enabled_skills=enabled_skills,
effective_policies={
"bootstrap": False,
"non_interactive": non_interactive,
"plan_mode": is_plan_mode,
"subagents": _subagent_release_policy(
resolved_app_config,
enabled=subagent_enabled,
max_concurrent=max_concurrent_subagents,
max_total=max_total_subagents,
),
"deferred_tools": {
"enabled": resolved_app_config.tool_search.enabled,
"catalog_hash": setup.catalog_hash,
},
"deferred_skills": skill_search_enabled,
},
)

View File

@ -2,17 +2,62 @@
Lead-agent middlewares are assembled in strict order across three functions: the shared base in `packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py` (`_build_runtime_middlewares`, exposed via `build_lead_runtime_middlewares`), then the lead-only middlewares appended in `packages/harness/deerflow/agents/lead_agent/agent.py` (`build_middlewares`). Items marked *(optional)* are appended only when their config/runtime condition holds, so the live chain length varies.
**Message provenance.** A middleware that injects or rewrites a message stamps
`additional_kwargs` with the neutral provenance keys from
`deerflow_extension_api.provenance` (`deerflow_content_kind`,
`deerflow_producer_kind`, and optionally `deerflow_producer_entity_id`) via
`provenance_kwargs()`. The producer is not recoverable downstream — by the
model-call boundary the message is indistinguishable from any other — so the
fact is recorded where it is known. Stamping is unconditional: a fact whose
presence depends on whether an observer is installed is not a fact. All three
keys are in `_SERVER_OWNED_MESSAGE_METADATA_KEYS`, so a caller cannot forge
provenance on inbound messages. Currently stamped by: DynamicContext (reminder + memory),
DurableContext (contract + data), SystemMessageCoalescing, ViewImage,
SkillActivation. Summarization, Title, and Memory are deliberately absent:
Summarization's and Title's own model calls are already attributed through
system-model-call observation (`SystemOperationKind.SUMMARIZATION` /
`.TITLE`), and the summary text they produce only ever enters a request via
`DurableContextMiddleware`'s already-stamped `durable_context_data` block —
there is no separate message of theirs to stamp. Memory only *reads*
messages to queue them for extraction; the recalled-memory content that
actually re-enters context is DynamicContext's `dynamic_context_memory`
stamp, not anything Memory itself produces.
**Middleware self-description.** A middleware whose configuration changes agent
behaviour implements `release_policy_parameters() -> dict[str, object]`
(`deerflow_extension_api.release.ReleasePolicyProvider`, duck-typed — no base
class). Values must be JSON-serialisable; long text is hashed with
`canonical_hash` rather than embedded, because a declaration is an identity and
not a copy of the prompt. `collect_release_policies()` gathers them from an
assembled stack. Adding a behaviour-affecting field to a middleware means adding
it to that middleware's declaration in the same change.
**Shared runtime base** (`build_lead_runtime_middlewares`; subagents reuse most of this via `build_subagent_runtime_middlewares`):
1. **InputSanitizationMiddleware** - First, so it is the outermost `wrap_model_call` wrapper; every inner middleware (including LLM retries) sees sanitized messages. `additional_kwargs.original_user_content` is server-owned provenance: Gateway strips caller-supplied values for non-internal run requests, trusted IM calls may carry the string they captured before adding transport/file context, and the middleware replaces any non-string value before wrapping. Uploads and sanitization retain first-writer-wins only for validated strings.
2. **ToolOutputBudgetMiddleware** - Caps tool output size (per app config) before it re-enters the model context. Oversized results are externalized to `tool_output.storage_subdir` (default `.tool-results`, shared constant `TOOL_RESULTS_DIRNAME`) under the thread outputs dir with a typed synopsis + `read_file` reference left in context; those files are process feedback, so the workspace-changes scanner excludes that directory and run delivery verification never counts them as produced artifacts
3. **ToolResultSanitizationMiddleware** - Neutralizes framework/injection tags (e.g. `<system-reminder>`) and boundary markers in *remote-content* tool results (`web_fetch`/`web_search`/`image_search`/`web_capture`) so attacker-controlled fetched pages cannot forge trusted framework context. Mirrors `InputSanitizationMiddleware`'s user-input guardrail for the other untrusted-content entry point; sits inner of `ToolOutputBudgetMiddleware` (neutralizes the raw output, then the budget truncates). Local tool output (bash/read_file) is left untouched. Scope is a name-based allowlist, so MCP remote-content tools registered under other names (e.g. `fetch_url`) are not yet covered — a metadata-tagging follow-up is tracked in the middleware source
Result-rewriting middlewares between the raw callable boundary and the
model-visible result append a declared entry to
`additional_kwargs["deerflow_tool_transforms"]` via
`agents/middlewares/tool_transform_meta.py::append_tool_transform`. The trail is
ordered by application — the last entry produced the final visible bytes — so an
observer classifies raw→visible transforms from facts rather than by sniffing
output wording.
4. **ThreadDataMiddleware** - Creates per-thread directories under the user's isolation scope (`backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/{workspace,uploads,outputs}`); resolves identity via `resolve_runtime_user_id(runtime)`, including Gateway runtime context and standalone LangGraph Server auth, then falls back to the request ContextVar / `"default"`
5. **UploadsMiddleware** - Tracks and injects newly uploaded files into conversation (lead agent only); upload existence checks use the same runtime-resolved user bucket as thread-data creation
6. **SandboxMiddleware** - Acquires sandbox, stores `sandbox_id` in state
7. **DanglingToolCallMiddleware** - Injects placeholder ToolMessages for AIMessage tool_calls that lack responses (e.g., user interruption), preserving raw provider tool-call payloads in `additional_kwargs["tool_calls"]`; malformed tool-call names and arguments are sanitized in the model-bound request so strict OpenAI-compatible providers do not reject the next request
8. **LLMErrorHandlingMiddleware** - Normalizes provider/model invocation failures into recoverable assistant-facing errors before later stages run
9. **Authorization / GuardrailMiddleware** - Up to two independent pre-tool-call gates run here. When `authorization.enabled`, the `AuthorizationProvider` instance already used for Layer 1 capability filtering is wrapped by `GuardrailAuthorizationAdapter` and reused for Layer 2 execution checks. A generated `tool_search` bypasses the adapter's second provider call only when the current build has a concrete deferred setup; its catalog was already filtered by Layer 1, and an ordinary same-named tool without that deferred setup receives no exemption. When `guardrails.enabled`, the explicitly configured `GuardrailProvider` is appended after authorization and still evaluates every call, including `tool_search`. Authorization therefore runs outermost and can deny before an external guardrail call; both use the existing middleware's fail-closed, audit, sync/async, and error-`ToolMessage` behavior. See the authorization RFC and [docs/GUARDRAILS.md](../../../../../docs/GUARDRAILS.md).
Every guardrail decision path publishes a neutral
`deerflow.authz.outcome.AuthorizationOutcome` into the per-run runtime context,
keyed by `tool_call_id` under the `__`-prefixed
`__authorization_outcome` key (so `build_run_config` strips caller-supplied
forgeries). Consumers pop it; the publisher and the consumer share only that
contract module.
10. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution. Command classification is **defense-in-depth and audit, not a security boundary** — the sandbox itself is the isolation boundary. Command substitution is judged by *position*, not by the presence of `$(`: a substitution in **command position** (`$(curl url)`, `` `curl url` ``, the word after a `|`/`&&`/`;`, or any `eval`/`source` argument) executes fetched or interpreted content and is blocked, while **value position** (`x=$(curl url)`, `echo $(curl url)`, an argument, a `for` word list) only captures output and passes (#4611). `_HIGH_RISK_COMMAND_POSITION_PATTERNS` is therefore matched anchored against each split sub-command, never against the whole compound string, and `_split_compound_command(split_pipes=True)` supplies those sub-commands; rules that span a pipe (`| sh`, `base64 -d | ...`) still rely on `_classify_command`'s whole-command Pass 1. `_COMMAND_POSITION_PREFIX` extends the anchor over leading variable assignments and exec wrappers (`FOO=1 $(curl url)`, `env`/`command`/`builtin`/`exec`/`nohup`/`time`/`sudo`/`doas`), which are still command position; its assignment branch requires whitespace before the substitution, which is exactly what keeps `x=$(curl url)` in value position. Two execution contexts are deliberately **position-blind** and matched against the whole command in Pass 1, because they execute what they receive wherever they appear (including as an argument to something else, e.g. `xargs sh -c "$(curl url)"`): an `eval`/`source` argument, and an interpreter's **code-string flag** — `-c` (shells, `python`), `-e` (`perl`/`ruby`/`node`), `-p` (`perl`/`node`), `-r` (`php`) — plus the here-string (`<<<`) that reaches the same place through stdin. All three substitution spellings (`$(cmd`, `<(cmd`, `` `cmd ``) share one `_RISKY_SUBSTITUTION` opener so a rule cannot cover one spelling and miss another. An unquoted newline splits like `;`, because it separates statements the same way: leaving it joined let `echo hi\n$(curl url)` evade the anchored rules that its `;` spelling triggers. A heredoc body is data rather than statements, so `_split_compound_command` records headers (`<<EOF`, `<<-EOF`, `<<'EOF'`) and consumes their bodies verbatim at the newline that starts them — otherwise a body line beginning with `$(curl url)` would be promoted to a command position the shell never creates. Two things that look like headers must not open one, or a body that never terminates swallows every following statement: `<<<` is a here-string (both a lookahead and a lookbehind are needed, or the trailing `<<` of `<<< "text"` reads as a heredoc with delimiter `text`), and a `<<` inside `$(( ... ))` / `(( ... ))` is a bit shift, so arithmetic depth is tracked alongside the quote flags. That is a heuristic, not shell parsing: it exists only to avoid manufacturing command positions *and* to avoid destroying real ones. An unterminated body consumes the rest of the string; an unclosed `((` only disables heredoc detection, so newlines keep splitting and the failure direction stays towards seeing more command positions rather than fewer. Known, deliberate gaps: process substitution outside `eval`/`source` (`. <(curl u)`) is not detected — closing it would require real shell parsing, which is out of scope for this layer. Two-step forms (`x=$(curl u); eval "$x"`) are inherent rather than incidental: any rule that allows output capture allows the first statement, and connecting it to the later `eval` needs dataflow analysis, not pattern matching. There is currently no config gate: the middleware is appended unconditionally in `_build_runtime_middlewares`, so it applies to both the lead agent and subagents.
11. **ReadBeforeWriteMiddleware** - *(optional, if `read_before_write.enabled`, default on)* Outermost write gate (issue #3857): `read_file` stamps a content hash onto its ToolMessage; `write_file` (append/overwrite-existing) and `str_replace` are blocked unless the newest mark for that path matches the file's current hash. Sits outside ToolProgressMiddleware and ToolErrorHandlingMiddleware so a blocked write returns immediately without consuming a ToolProgress slot. Blocked results call `normalize_tool_result` directly to stamp `deerflow_tool_meta` (`recoverable_by_model=True`) before returning, keeping the result well-formed for any outer consumer. Marks live on messages, so summarization dropping the read result invalidates the gate automatically; writes never refresh marks, forcing a re-read between consecutive edits. Gate check + tool execution are serialized per (thread, path) so same-turn parallel writes cannot reuse one stale mark; on sandboxes whose `read_file` reports failures as `"Error: ..."` strings instead of raising (AIO/E2B), uninspectable targets fail open (creation proceeds, no mark stamped)
12. **ToolProgressMiddleware** - *(optional, if `tool_progress.enabled`)* State-machine-based stagnation guard (RFC #3177). Outer wrapper around ToolErrorHandlingMiddleware so its `wrap_tool_call` receives results already stamped with `deerflow_tool_meta`. Tracks per-(thread, tool) consecutive "no-new-info" calls across three error categories: (a) `recoverable_by_model=True` (no_results, not_found, permission, Jaccard-duplicate success): ACTIVE → WARNED (terminal — hint re-injected on each subsequent problem); (b) `recoverable_by_model=False, action≠stop` (rate_limited, transient): ACTIVE → WARNED → BLOCKED after `warn_escalation_count` more problems; (c) `recoverable_by_model=False, action=stop` (auth, config, internal): immediately BLOCKED on first occurrence. **Division of labor with LoopDetectionMiddleware:** ToolProgressMiddleware is a result-quality guard — fires after tool execution and blocks specific tools that stop producing new information; LoopDetectionMiddleware is a call-pattern guard — fires after the model responds and hard-stops the whole turn when the model repeatedly issues identical tool_calls. Both can inject HumanMessage hints in the same model call without conflict; neither reads the other's internal state.

View File

@ -39,6 +39,13 @@ class DeferredToolFilterMiddleware(AgentMiddleware[AgentState]):
self._deferred = deferred_names
self._catalog_hash = catalog_hash
def release_policy_parameters(self) -> dict[str, object]:
return {
"deferred_names": sorted(self._deferred),
"catalog_hash": self._catalog_hash,
"promotion_scope": "graph_state_catalog_hash",
}
def _promoted(self, state) -> set[str]:
promoted = (state or {}).get("promoted")
if promoted and promoted.get("catalog_hash") == self._catalog_hash:

View File

@ -14,6 +14,7 @@ from collections.abc import Awaitable, Callable, Collection
from html import escape
from typing import override
from deerflow_extension_api import ContentKind, provenance_kwargs
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
@ -258,12 +259,16 @@ class DurableContextMiddleware(AgentMiddleware[AgentState]):
messages = _insert_after_leading_system_messages(
list(request.messages),
[
SystemMessage(content=_AUTHORITY_CONTRACT),
SystemMessage(
content=_AUTHORITY_CONTRACT,
additional_kwargs=provenance_kwargs(ContentKind.MIDDLEWARE_INJECTION, "durable_context"),
),
HumanMessage(
content=data_block,
additional_kwargs={
"hide_from_ui": True,
_DURABLE_CONTEXT_DATA_KEY: True,
**provenance_kwargs(ContentKind.DURABLE_CONTEXT, "durable_context_data"),
},
),
],

View File

@ -36,6 +36,7 @@ import uuid
from datetime import datetime
from typing import TYPE_CHECKING, override
from deerflow_extension_api import ContentKind, provenance_kwargs
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import HumanMessage, SystemMessage
from langgraph.runtime import Runtime
@ -281,7 +282,11 @@ class DynamicContextMiddleware(AgentMiddleware):
stable_id = original.id or str(uuid.uuid4())
messages: list[SystemMessage | HumanMessage] = []
reminder_kwargs = {"hide_from_ui": True, _DYNAMIC_CONTEXT_REMINDER_KEY: True}
reminder_kwargs = {
"hide_from_ui": True,
_DYNAMIC_CONTEXT_REMINDER_KEY: True,
**provenance_kwargs(ContentKind.MIDDLEWARE_INJECTION, "dynamic_context"),
}
if reminder_date is not None:
reminder_kwargs[_REMINDER_DATE_KEY] = reminder_date
messages.append(
@ -297,7 +302,11 @@ class DynamicContextMiddleware(AgentMiddleware):
HumanMessage(
content=memory_content,
id=f"{stable_id}__memory",
additional_kwargs={"hide_from_ui": True, _DYNAMIC_CONTEXT_REMINDER_KEY: True},
additional_kwargs={
"hide_from_ui": True,
_DYNAMIC_CONTEXT_REMINDER_KEY: True,
**provenance_kwargs(ContentKind.MEMORY, "dynamic_context_memory"),
},
)
)

View File

@ -284,6 +284,17 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
# still drops it.
self._stop_reason: BoundedDict[str, str] = BoundedDict(1000)
def release_policy_parameters(self) -> dict[str, object]:
return {
"warn_threshold": self.warn_threshold,
"hard_limit": self.hard_limit,
"window_size": self.window_size,
"max_tracked_threads": self.max_tracked_threads,
"tool_freq_warn": self.tool_freq_warn,
"tool_freq_hard_limit": self.tool_freq_hard_limit,
"tool_freq_overrides": self._tool_freq_overrides,
}
@classmethod
def from_config(cls, config: LoopDetectionConfig) -> LoopDetectionMiddleware:
"""Construct from a Pydantic-validated config, trusting its validation."""

View File

@ -105,6 +105,28 @@ class SafetyFinishReasonMiddleware(AgentMiddleware[AgentState]):
# Copy so caller mutations after construction don't leak into us.
self._detectors: list[SafetyTerminationDetector] = list(detectors) if detectors else default_detectors()
def release_policy_parameters(self) -> dict[str, object]:
detectors: list[dict[str, object]] = []
for detector in self._detectors:
detector_type = type(detector)
parameters: dict[str, object] = {}
for field_name in ("_finish_reasons", "_stop_reasons"):
value = getattr(detector, field_name, None)
if value is not None:
# frozenset is not JSON-serialisable; project to a sorted list.
parameters[field_name.removeprefix("_")] = sorted(value)
descriptor: dict[str, object] = {
"class": f"{detector_type.__module__}.{detector_type.__qualname__}",
"name": str(getattr(detector, "name", detector_type.__name__)),
}
if parameters:
descriptor["parameters"] = parameters
detectors.append(descriptor)
return {
"action": "suppress_tool_calls",
"detectors": detectors,
}
@classmethod
def from_config(cls, config: SafetyFinishReasonConfig) -> SafetyFinishReasonMiddleware:
"""Construct from validated Pydantic config, honouring the

View File

@ -13,6 +13,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, override
from deerflow_extension_api import ContentKind, provenance_kwargs
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelRequest, ModelResponse
from langchain_core.messages import AIMessage, HumanMessage
@ -107,6 +108,13 @@ class SkillActivationMiddleware(AgentMiddleware):
self._user_id = user_id
self._slash_source_owner_token = slash_source_owner_token
def release_policy_parameters(self) -> dict[str, object]:
return {
# None means "any enabled, runtime-allowed skill may be activated";
# a concrete list narrows that to a fixed set.
"available_skills": sorted(self._available_skills) if self._available_skills is not None else None,
}
def _storage(self) -> SkillStorage:
if self._user_id is not None:
return get_or_new_user_skill_storage(self._user_id, app_config=self._app_config)
@ -552,6 +560,7 @@ Follow this skill before choosing a general workflow. Load supporting resources
additional_kwargs = {
"hide_from_ui": True,
_SLASH_SKILL_ACTIVATION_KEY: True,
**provenance_kwargs(ContentKind.SKILL_BODY, "skill_activation"),
}
if target.id:
additional_kwargs[_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY] = target.id

View File

@ -114,6 +114,12 @@ class SubagentLimitMiddleware(AgentMiddleware[AgentState]):
self.max_concurrent = _clamp_subagent_limit(max_concurrent)
self.max_total = _clamp_total_subagent_limit(max_total)
def release_policy_parameters(self) -> dict[str, object]:
return {
"max_concurrent": self.max_concurrent,
"max_total": self.max_total,
}
def _truncate_task_calls(self, state: AgentState, runtime: Runtime | None = None) -> dict | None:
messages = state.get("messages", [])
if not messages:

View File

@ -7,6 +7,7 @@ import logging
from dataclasses import dataclass
from typing import Any, Protocol, override, runtime_checkable
from deerflow_extension_api import CompactionEvent, canonical_hash
from langchain.agents import AgentState
from langchain.agents.middleware import SummarizationMiddleware
from langchain_core.messages import AnyMessage, HumanMessage, RemoveMessage, get_buffer_string, trim_messages
@ -17,11 +18,14 @@ from langgraph.runtime import Runtime
from deerflow.agents.middlewares.dynamic_context_middleware import is_dynamic_context_reminder
from deerflow.config.app_config import get_app_config
from deerflow.extensions.notify import notify_context_compacted
from deerflow.models import create_chat_model
from deerflow.utils.messages import is_real_user_message
logger = logging.getLogger(__name__)
_SUMMARY_TRIGGER_MESSAGE_NAME = "summary"
_COMPACTION_TRANSFORM_KIND = "summarization"
_COMPACTION_TRANSFORM_VERSION = "1"
_UNSET = object()
# Valid non-generated summaries for the empty / too-long-to-summarize edges; these
# short-circuit model invocation (and must not be treated as generation failures).
@ -151,6 +155,28 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
# not escape the fail-open boundary).
self._model_cache: dict[str | None, Any] = {}
def release_policy_parameters(self) -> dict[str, object]:
"""Return the effective compaction policy used for release identity."""
def plain_size(value: object) -> object:
if isinstance(value, tuple):
return [plain_size(child) for child in value]
if isinstance(value, list):
return [plain_size(child) for child in value]
return value
return {
"trigger": plain_size(self.trigger),
"keep": plain_size(self.keep),
"trim_tokens_to_summarize": self.trim_tokens_to_summarize,
"summary_prompt_hash": canonical_hash(self.summary_prompt),
# self.model is a chat-model object and is not JSON-serialisable; the
# anchor model name is the identity that actually drives compaction
# behaviour (token counting/profile inspection and, absent an
# explicit configured summary model, generation itself).
"summary_model": self._anchor_model_name,
}
def _tag_nostream(self, model: Any) -> Any:
"""Return a copy of ``model`` carrying TAG_NOSTREAM without clobbering tags.
@ -542,6 +568,52 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
return None
return messages_to_summarize, preserved_messages, previous_summary, total_tokens
def _freeze_compaction_sources(self, messages_to_summarize: list[AnyMessage]) -> tuple[str, ...]:
"""Hash each about-to-be-removed message's content before the summary call.
Returns empty when no ``ContextCompactionObserver`` is registered. The
hashing is an O(context-size) canonical-JSON pass, and
``notify_context_compacted`` would discard the event anyway; an install
with no observer must not pay for one, the same rule ``_complete_assembly``
follows for descriptor construction. The check cannot live only in the
notify call by then the work is already done.
Once the summary call returns, ``messages_to_summarize`` is gone from state
only the produced summary remains. The mapping from "these messages" to "this
summary" exists only in this stack frame, so it must be captured now rather
than reconstructed later.
Hashes ``message.content`` directly, never ``str(message.content)``:
DeerFlow messages are routinely multimodal (``list[dict]`` content, e.g.
``view_image_middleware``'s injected image payloads), and ``str()`` on a
dict renders insertion order, so pre-stringifying would make two
logically identical messages hash differently. ``canonical_hash`` exists
precisely to normalize that away (sorted keys via ``canonical_json``);
stringifying first throws the normalization away before it runs.
"""
extensions = getattr(self, "_extensions", None)
if extensions is None or not extensions.context_compaction_observers:
return ()
return tuple(canonical_hash(message.content) for message in messages_to_summarize)
def _record_compaction(
self,
source_content_hashes: tuple[str, ...],
*,
summary: str,
compacted_message_count: int,
kept_message_count: int,
) -> None:
event = CompactionEvent(
transform_kind=_COMPACTION_TRANSFORM_KIND,
transform_version=_COMPACTION_TRANSFORM_VERSION,
source_content_hashes=source_content_hashes,
output_content_hash=canonical_hash(summary),
compacted_message_count=compacted_message_count,
kept_message_count=kept_message_count,
)
notify_context_compacted(event, extensions=self._extensions)
def compact_state(
self,
state: AgentState,
@ -562,6 +634,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
if prepared is None:
return None
messages_to_summarize, preserved_messages, previous_summary, total_tokens = prepared
source_content_hashes = self._freeze_compaction_sources(messages_to_summarize)
summary = self._summarize_with(messages_to_summarize, previous_summary=previous_summary)
if summary is None:
if raise_on_failure:
@ -572,6 +645,12 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
# duplicate that work on the next attempt. Messages are still removed after
# this returns (in _maybe_summarize), so hooks run before they are gone.
self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
self._record_compaction(
source_content_hashes,
summary=summary,
compacted_message_count=len(messages_to_summarize),
kept_message_count=len(preserved_messages),
)
return ContextCompactionResult(
summary_text=summary,
messages_to_summarize=tuple(messages_to_summarize),
@ -594,6 +673,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
messages_to_summarize, preserved_messages, previous_summary, total_tokens = prepared
from deerflow_extension_api import task_store_from_runtime
source_content_hashes = self._freeze_compaction_sources(messages_to_summarize)
summary = await self._asummarize_with(
messages_to_summarize,
previous_summary=previous_summary,
@ -605,6 +685,12 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
return None
# Fire hooks only once a replacement summary exists (see compact_state).
self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
self._record_compaction(
source_content_hashes,
summary=summary,
compacted_message_count=len(messages_to_summarize),
kept_message_count=len(preserved_messages),
)
return ContextCompactionResult(
summary_text=summary,
messages_to_summarize=tuple(messages_to_summarize),

View File

@ -30,6 +30,7 @@ every backend benefits from a single fix instead of per-provider patches.
from collections.abc import Awaitable, Callable
from typing import override
from deerflow_extension_api import ContentKind, provenance_kwargs
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
@ -106,6 +107,7 @@ def _coalesce_request(request: ModelRequest) -> ModelRequest | None:
merged_kwargs: dict = {}
for p in parts:
merged_kwargs.update(p.additional_kwargs or {})
merged_kwargs.update(provenance_kwargs(ContentKind.MIDDLEWARE_INJECTION, "system_coalescing"))
merged = SystemMessage(
content="\n\n".join(_flatten_content(p.content) for p in parts),
id=first.id,
@ -126,6 +128,14 @@ class SystemMessageCoalescingMiddleware(AgentMiddleware[AgentState]):
(memory builder, journal, summarization, dynamic-context detection).
"""
def release_policy_parameters(self) -> dict[str, object]:
# No constructor parameters: the merge strategy and reminder-dedup rule
# below are the middleware's entire behaviour, so they are the policy.
return {
"strategy": "merge_leading_system_message",
"dynamic_context_reminder_dedup": "keep_last",
}
@staticmethod
def _maybe_coalesce(request: ModelRequest) -> ModelRequest:
coalesced = _coalesce_request(request)

View File

@ -80,6 +80,15 @@ class TerminalResponseMiddleware(AgentMiddleware[AgentState]):
self._retry_counts: BoundedDict[tuple[str, str], int] = BoundedDict(1000)
self._pending_prompts: BoundedDict[tuple[str, str], bool] = BoundedDict(1000)
def release_policy_parameters(self) -> dict[str, object]:
from deerflow_extension_api import canonical_hash
return {
"post_tool_empty_retry_limit": 1,
"recovery_prompt_hash": canonical_hash(_RECOVERY_PROMPT),
"fallback_content_hash": canonical_hash(_FALLBACK_CONTENT),
}
@staticmethod
def _key(runtime: Runtime) -> tuple[str, str]:
context = getattr(runtime, "context", None)

View File

@ -112,6 +112,15 @@ class TodoMiddleware(TodoListMiddleware):
state_schema = ThreadState
def release_policy_parameters(self) -> dict[str, object]:
from deerflow_extension_api import canonical_hash
return {
"system_prompt_hash": canonical_hash(self.system_prompt),
"tool_description_hash": canonical_hash(self.tool_description),
"state_channel": "todos",
}
@override
def before_model(
self,

View File

@ -77,6 +77,9 @@ class TokenBudgetMiddleware(AgentMiddleware[AgentState]):
# after the run returns; bounded so abandoned runs cannot leak.
self._stop_reason: BoundedDict[str, str] = BoundedDict(1000)
def release_policy_parameters(self) -> dict[str, object]:
return {"config": self._config.model_dump(mode="python")}
@classmethod
def from_config(cls, config: TokenBudgetConfig) -> TokenBudgetMiddleware:
return cls(config=config)

View File

@ -25,6 +25,7 @@ from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command
from deerflow.agents.middlewares.tool_output_synopsis import render_tool_output_preview
from deerflow.agents.middlewares.tool_transform_meta import append_tool_transform
from deerflow.config.tool_output_config import ToolOutputConfig
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
@ -338,8 +339,12 @@ def _budget_content(
outputs_path: str | None,
config: ToolOutputConfig,
sandbox: Sandbox | None = None,
) -> str | None:
"""Apply budget to *content*. Returns ``None`` if no change needed."""
) -> tuple[str, str] | None:
"""Apply budget to *content* and name the applied transform.
Returns ``(replacement, transform_kind)`` ``"externalized"`` or
``"truncated"`` or ``None`` if no change was needed.
"""
threshold = config.tool_overrides.get(tool_name, config.externalize_min_chars)
if threshold <= 0 and config.fallback_max_chars <= 0:
return None
@ -397,12 +402,15 @@ def _budget_content(
len(content),
virtual_path,
)
return _build_preview(
content,
tool_name=tool_name,
virtual_path=virtual_path,
head_chars=config.preview_head_chars,
tail_chars=config.preview_tail_chars,
return (
_build_preview(
content,
tool_name=tool_name,
virtual_path=virtual_path,
head_chars=config.preview_head_chars,
tail_chars=config.preview_tail_chars,
),
"externalized",
)
if config.fallback_max_chars > 0 and len(content) > config.fallback_max_chars:
@ -412,12 +420,15 @@ def _budget_content(
len(content),
config.fallback_max_chars,
)
return _build_fallback(
content,
tool_name=tool_name,
max_chars=config.fallback_max_chars,
head_chars=config.fallback_head_chars,
tail_chars=config.fallback_tail_chars,
return (
_build_fallback(
content,
tool_name=tool_name,
max_chars=config.fallback_max_chars,
head_chars=config.fallback_head_chars,
tail_chars=config.fallback_tail_chars,
),
"truncated",
)
return None
@ -443,7 +454,7 @@ def _patch_tool_message(
if text is None:
return msg
replacement = _budget_content(
budgeted = _budget_content(
text,
tool_name=tool_name,
tool_call_id=msg.tool_call_id or "",
@ -451,14 +462,16 @@ def _patch_tool_message(
config=config,
sandbox=sandbox,
)
if replacement is None:
if budgeted is None:
return msg
replacement, transform_kind = budgeted
update: dict[str, Any] = {"content": replacement}
if getattr(msg, "response_metadata", None):
update["response_metadata"] = dict(msg.response_metadata)
if getattr(msg, "additional_kwargs", None):
update["additional_kwargs"] = dict(msg.additional_kwargs)
new_kwargs = dict(getattr(msg, "additional_kwargs", None) or {})
append_tool_transform(new_kwargs, transform_kind, by="ToolOutputBudgetMiddleware")
update["additional_kwargs"] = new_kwargs
return msg.model_copy(update=update)
@ -577,6 +590,9 @@ class ToolOutputBudgetMiddleware(AgentMiddleware[AgentState]):
super().__init__()
self._config = config if config is not None else _default_config()
def release_policy_parameters(self) -> dict[str, object]:
return {"config": self._config.model_dump(mode="python")}
@classmethod
def from_app_config(cls, app_config: Any) -> ToolOutputBudgetMiddleware:
tool_output = getattr(app_config, "tool_output", None)

View File

@ -34,6 +34,8 @@ from langchain_core.messages import ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command
from deerflow.agents.middlewares.tool_transform_meta import append_tool_transform
logger = logging.getLogger(__name__)
# Tool names whose results are attacker-influenceable remote content. The
@ -98,7 +100,9 @@ def _sanitize_tool_message(message: ToolMessage) -> ToolMessage:
new_content = _neutralize_content(message.content)
if new_content == message.content:
return message
return message.model_copy(update={"content": new_content})
new_kwargs = dict(message.additional_kwargs or {})
append_tool_transform(new_kwargs, "sanitized", by="ToolResultSanitizationMiddleware")
return message.model_copy(update={"content": new_content, "additional_kwargs": new_kwargs})
def _sanitize_result(result: ToolMessage | Command) -> ToolMessage | Command:

View File

@ -0,0 +1,27 @@
"""Structured transform-trail metadata for tool results.
Middlewares that rewrite a ToolMessage between the raw callable boundary and
the model-visible result append a declared entry here, so observers classify
rawvisible transforms from facts instead of sniffing output wording.
Entries are additive and ordered by application: the last entry produced the
final visible bytes.
"""
from __future__ import annotations
TOOL_TRANSFORMS_KEY = "deerflow_tool_transforms"
def append_tool_transform(additional_kwargs: dict, kind: str, *, by: str, version: str = "1") -> None:
trail = additional_kwargs.get(TOOL_TRANSFORMS_KEY)
if not isinstance(trail, list):
trail = []
additional_kwargs[TOOL_TRANSFORMS_KEY] = [*trail, {"kind": kind, "by": by, "version": version}]
def read_tool_transforms(message: object) -> tuple[dict[str, str], ...]:
kwargs = getattr(message, "additional_kwargs", None)
trail = kwargs.get(TOOL_TRANSFORMS_KEY) if isinstance(kwargs, dict) else None
if not isinstance(trail, list):
return ()
return tuple(entry for entry in trail if isinstance(entry, dict) and isinstance(entry.get("kind"), str))

View File

@ -7,6 +7,7 @@ from pathlib import Path
from typing import override
from uuid import uuid4
from deerflow_extension_api import ContentKind, provenance_kwargs
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage, ToolMessage
from langgraph.runtime import Runtime
@ -231,6 +232,7 @@ class ViewImageMiddleware(AgentMiddleware[ViewImageMiddlewareState]):
additional_kwargs={
"hide_from_ui": True,
_IMAGE_CONTEXT_MESSAGE_MARKER_KEY: True,
**provenance_kwargs(ContentKind.IMAGE_PAYLOAD, "view_image"),
},
)

View File

@ -0,0 +1,52 @@
"""Neutral Guardrail->observer authorization outcome contract.
GuardrailMiddleware writes an AuthorizationOutcome into the per-run runtime
context; an observer pops it to record which policy actually decided a given
tool call. Neither side imports the other -- both depend only on this
contract. The context key is ``__``-prefixed so Gateway build_run_config
strips any caller-supplied forgery, matching ``__run_journal`` /
``__active_skill_secrets``.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
AUTHORIZATION_OUTCOME_CONTEXT_KEY = "__authorization_outcome"
#: A run with no observer never pops entries (``pop_authorization_outcome`` has
#: no production caller yet), so an authorization-enabled deployment would
#: otherwise grow this store for the life of the run, one entry per tool call.
#: Capping it bounds that growth to a fixed footprint; the oldest entries are
#: evicted first since a stale decision is the least likely to still be wanted.
_MAX_TRACKED_OUTCOMES = 500
@dataclass(frozen=True)
class AuthorizationOutcome:
decision: Literal["allowed", "denied"]
policy_id: str
policy_version: str
reason_codes: tuple[str, ...] = ()
def put_authorization_outcome(context: object, tool_call_id: object, outcome: AuthorizationOutcome) -> None:
if not isinstance(context, dict) or not tool_call_id:
return
store = context.get(AUTHORIZATION_OUTCOME_CONTEXT_KEY)
if not isinstance(store, dict):
store = {}
context[AUTHORIZATION_OUTCOME_CONTEXT_KEY] = store
store[tool_call_id] = outcome
while len(store) > _MAX_TRACKED_OUTCOMES:
store.pop(next(iter(store)))
def pop_authorization_outcome(context: object, tool_call_id: object) -> AuthorizationOutcome | None:
if not isinstance(context, dict) or not tool_call_id:
return None
store = context.get(AUTHORIZATION_OUTCOME_CONTEXT_KEY)
if not isinstance(store, dict):
return None
return store.pop(tool_call_id, None)

View File

@ -137,9 +137,9 @@ entry, the manager owns the controlled locked sync.
The public package is `packages/extension-api/` and must never import `deerflow` or carry
framework dependencies. Extensions declare any FastAPI, LangChain, or LangGraph imports
themselves. Its registry contract exposes five contribution kinds: middleware
contributors, task-lifecycle contributors, system-model-call observers, Gateway-lifetime
services, and eager routers. Middleware contributions declare lead/subagent scope, stable
themselves. Its registry contract exposes seven contribution kinds: middleware
contributors, task-lifecycle contributors, system-model-call observers, agent-assembly
observers, context-compaction observers, Gateway-lifetime services, and eager routers. Middleware contributions declare lead/subagent scope, stable
order, and a semantic placement (`MODEL_LOGICAL`, `MODEL_PHYSICAL`, `TOOL_VISIBLE`,
`TOOL_RAW`, or `STANDARD`) rather than a fragile list index. `extensions/stack.py` is the
single final composition point; do not inject inside
@ -154,6 +154,42 @@ which is `assert_ordering` / composition time, already inside the middleware bui
Defer by deferring the *call*; do not fake a resolved value with a lazy container
subclass, which reports one answer when iterated and another when measured.
**Agent assembly observation.** `assemble_lead_agent()` returns
`LeadAgentAssembly(graph, descriptor)`; `make_lead_agent()` remains the
graph-only LangGraph Server ABI declared in `langgraph.json` and must keep that
signature. The descriptor
(`deerflow_extension_api.assembly.AgentAssemblyDescriptor`) captures the
resolved model, rendered prompt hash, authorization-filtered tool list,
composed middleware stack with each middleware's declared policy, deferred tool
names, enabled skills, and effective policies — all of which are decided inside
the factory and are unrecoverable afterwards. Its `fingerprint` sorts tools and
skills (assembly order is incidental) but preserves middleware order (stack
order decides what wraps what). It also excludes `build` and `requested_model`:
the fingerprint answers "did this agent's assembly change", so folding in the
host build would move every agent's fingerprint on every redeploy and make that
finer question unanswerable — `build` stays a reported field a consumer can
compare directly. Registered `AgentAssemblyObserver`s are notified
synchronously at the end of construction; failures are contained per observer.
Gateway `resolve_agent_factory()` now returns `assemble_lead_agent`, so every
consumer must unwrap `.graph` — a third-party factory returning a bare graph
stays supported.
`SubagentExecutor` publishes the same descriptor kind for each delegated agent
on `self.assembly_descriptor`. The projection itself lives in
`deerflow/agents/assembly_descriptor.py`: a middleware that implements
`release_policy_parameters()` owns its own identity, and probing private
attributes is the marked fallback for the ones that do not.
Because `IsolatedMiddleware`'s cached subclasses all carry the wrapper's own
class name and module, and the wrapper forwards no `release_policy_parameters`,
describing a contributed middleware directly would collapse every extension's
contribution into one identical descriptor and hide policy changes inside them.
`describe_middleware()` therefore unwraps to `.inner` and records `.source` as
the descriptor's `extension` field, which participates in the fingerprint. It
duck-types on those attributes rather than importing `extensions/isolation.py`:
`extensions/` sits below `agents/`, so importing it there would point the
dependency backwards.
Contributed middlewares are wrapped by `IsolatedMiddleware`: extension failures emit
diagnostics and fail open without repeating a downstream model/tool side effect. The
wrapper mirrors lifecycle hooks, tools, transformers, and state schema implemented by
@ -215,6 +251,26 @@ subagent's isolated loop, while synchronous system callbacks submit fire-and-for
there. Shutdown stops accepting detached observations before the memory shutdown flush and
resets the loop only after in-flight run/subagent drain ordering is complete.
`ContextCompactionObserver` reports the one moment a lossy context transform can still be
described: `DeerFlowSummarizationMiddleware.compact_state()` / `acompact_state()` hash each
about-to-be-removed message's content before the summary model call, then — once a summary
is produced and the pre-compaction hooks have run — build a `CompactionEvent` (transform
kind/version, source content hashes, the produced summary's content hash, and the
compacted/kept message counts) and call `notify_context_compacted()`. Once
`_maybe_summarize`/`_amaybe_summarize` remove the source messages from state, that mapping
cannot be reconstructed, so the event is the only record of it. The event is keyed on
`canonical_hash(message.content)` directly — never a stringified copy, which would defeat
`canonical_hash`'s key-order normalization for multimodal (`list[dict]`) content — rather
than a producer-stamped identity key: nothing currently mints a stable per-message identity
for compaction's source messages or its summary, so an identity-keyed field would ship
permanently empty. `notify_context_compacted()` is a
synchronous, fire-and-forget entry point — both the sync and async compaction paths call it
without an `await` — that dispatches to the same registered extension-notification loop
system-model-call cancellation uses, reusing `_notify_each`'s per-observer fail-open
containment. There is no live task to attach at that call site, so observers receive a
detached task store, the same fallback `notify_system_model_call` uses when its caller
supplies none.
Gateway services start in registration order after the persistence engine and session
factory are ready. Each receives the same `ExtensionRuntimeDeps` snapshot containing the
app store, projected host policy, and session factory. Start failures are attributed and
@ -256,6 +312,22 @@ later routers from mounting. Do not introduce a framework-bound `RouterContribut
contract: the public registry accepts `Sequence[Any]`
to keep extension-api dependency-free.
Contributed routes are session-authenticated and cannot opt out. Within that, an extension
distinguishes an ordinary user from an administrator through `deerflow_extension_api.auth`:
`resolve_principal(request)` returns the caller, `require_admin(request)` raises
`PermissionError` for anyone else and fails closed when identity cannot be determined.
Extensions receive a projection — user id, admin flag, internal flag, roles — never the
host's auth context. The host installs the resolver on `app.state` (keyed by
`EXTENSION_PRINCIPAL_RESOLVER_KEY`) in `app.gateway.app.create_app()`, after
`AuthMiddleware` is added and before contributed routers are mounted; `resolve_principal`
reads it back at call time, since the router objects a contribution builds during
`install()` exist long before any request (or its identity) does. The host's projection
reads `request.state.user` synchronously (the same field `AuthMiddleware` stamps and
`require_admin_user` in `app/gateway/deps.py` reads as its primary path) rather than the
async, exception-based accessors that exist there for tests and alternative ASGI
compositions — keeping the resolver synchronous keeps it usable from both sync and async
route handlers.
The memory kind reaches those observers through a different shape, and the difference is
deliberate rather than an oversight to be "aligned" away. DeerMem must stay vendorable and
cannot import the extension API, so it reports through the `MemoryCallbacks.on_memory_llm_result`

View File

@ -1,4 +1,15 @@
"""Gateway-side plumbing for app-scoped extension contributions."""
"""Gateway-side plumbing for app-scoped extension contributions.
Every contributed router mounted here runs behind the host's ``AuthMiddleware``
(added earlier in ``create_app()``) and cannot enter a host-reserved or
auth-exempt prefix, so every request reaching a contributed route is already
session-authenticated. "Logged in" and "administrator" are still different
questions, though, and a contributed route asks the second one through
``deerflow_extension_api.auth``: ``resolve_principal(request)`` /
``require_admin(request)`` read a resolver the host installs on ``app.state``,
handing the router a neutral projection of identity rather than the host's
own auth context.
"""
from __future__ import annotations

View File

@ -17,6 +17,7 @@ from deerflow_extension_api import API_VERSION
from pydantic import BaseModel, ConfigDict, Field
from deerflow.extensions.registry import ExtensionRegistry, LoadedExtensions
from deerflow.persistence.migrations._env_filters import register_extension_table_prefix
from deerflow.reflection import resolve_variable
logger = logging.getLogger(__name__)
@ -50,6 +51,22 @@ class ExtensionSpec(BaseModel):
default=False,
description="When true, a load failure aborts startup instead of being skipped",
)
table_prefix: str | None = Field(
default=None,
min_length=1,
description=(
"Table-name prefix this extension owns, if it persists data under its own "
"MetaData and migration chain. Registered with "
"deerflow.persistence.migrations._env_filters so alembic revision --autogenerate "
"excludes those tables instead of reflecting them from a live database and "
"proposing to drop them. Registered from two processes: here for the Gateway, "
"and from migrations/env.py -- reading this declaration, never importing the "
"extension -- for alembic, which never starts a Gateway. Omit the key to "
"declare no prefix; an empty string is rejected here rather than treated "
"as absent, so that one declaration cannot mean 'no prefix' to one of "
"those two processes and 'a prefix matching every table' to the other."
),
)
@dataclass(frozen=True)
@ -142,6 +159,27 @@ def load_extensions(specs: Sequence[ExtensionSpec]) -> tuple[LoadedExtensions, l
loaded_sources: list[str] = []
for spec in specs:
if spec.table_prefix:
# Registered unconditionally -- even for a disabled or later-failing
# spec -- because the tables it names may already exist in the
# database from a previous run. Excluding them from alembic's view
# is the safe direction; the risk this guards against is
# autogenerate proposing to drop them, not registering one prefix
# too many.
#
# A prefix that collides with a host table name is not a
# per-extension failure `required: false` can shrug off: it
# corrupts the shared alembic filter for that host table for the
# life of the process, regardless of whether this extension ever
# loads. It always aborts startup.
try:
register_extension_table_prefix(spec.table_prefix)
except ValueError as exc:
message = str(exc)
diagnostics.append(Diagnostic.error(spec.use, message))
logger.error("Extension %s: %s", spec.use, message)
raise ExtensionLoadError(message) from exc
if not spec.enabled:
continue

View File

@ -10,6 +10,7 @@ from typing import Any
from deerflow_extension_api import (
EXTENSION_TASK_STORE_KEY,
CompactionEvent,
ExtensionData,
SystemModelRequest,
SystemModelResult,
@ -57,10 +58,53 @@ def _host_is_cancelling() -> bool:
first increments the task's cancellation counter, so it is what tells the
two apart.
"""
task = asyncio.current_task()
try:
task = asyncio.current_task()
except RuntimeError:
# Synchronous hook sites (agent assembly) can run with no loop at all,
# and "no loop" means there is no host task being cancelled.
return False
return task is not None and task.cancelling() > 0
def notify_agent_assembled(descriptor: object, extensions: object | None = None) -> None:
"""Fan a completed assembly out to observers, in registration order.
Synchronous: agent construction is synchronous and there is no loop to
dispatch onto. Failures are contained per observer a broken observer must
not prevent an agent from being built.
"""
resolved = extensions
if resolved is None:
from deerflow.extensions import get_agent_build_extensions
resolved = get_agent_build_extensions()
observers = getattr(resolved, "agent_assembly_observers", ())
if not observers:
return
app_store = getattr(resolved, "app_store", None)
for source, observer in observers:
try:
observer.on_agent_assembled(app_store, descriptor)
except asyncio.CancelledError:
if _host_is_cancelling():
raise
# Same rule as the awaited hooks: an observer raising it on its own
# must not skip its successors, and must not turn graph
# construction into a deferred interrupt.
logger.exception(
"Extension %s: on_agent_assembled raised CancelledError for %s",
source,
type(descriptor).__name__,
)
except Exception:
logger.exception(
"Extension %s: on_agent_assembled failed for %s",
source,
type(descriptor).__name__,
)
async def _notify_each(
contributors: tuple[tuple[str, Any], ...],
hook: str,
@ -416,3 +460,41 @@ def dispatch_system_model_observation(
finally:
if not submitted:
coro.close()
def notify_context_compacted(event: CompactionEvent, extensions: LoadedExtensions | None = None) -> None:
"""Fan a completed compaction out to observers, fire-and-forget.
The compaction seam sits in the summarization middleware's ``before_model`` /
``abefore_model`` hooks; the sync half has no loop to await onto, and the async
half must not block the model-call turn on observer latency. Both therefore call
this synchronous entry point, which dispatches to the registered extension-notify
loop the same non-blocking way a synchronous system-model-call cancellation does,
reusing the same fail-open cancellation containment inside ``_notify_each``.
There is no live task for this hook to attach observers to (unlike lifecycle or
system-model-call notification, which run from an awaited call site holding the
real task store), so observers receive a detached store the same fallback
``notify_system_model_call`` uses when its caller has none.
"""
resolved = extensions
if resolved is None:
from deerflow.extensions import get_agent_build_extensions
resolved = get_agent_build_extensions()
observers = resolved.context_compaction_observers
if not observers:
return
app_store = resolved.app_store
task_store = ExtensionData("detached")
what = f"compaction ({event.transform_kind})"
dispatch_system_model_observation(
_notify_each(
observers,
"on_context_compacted",
lambda observer: observer.on_context_compacted(app_store, task_store, event),
what,
None,
),
what,
)

View File

@ -13,6 +13,8 @@ from dataclasses import dataclass
from typing import Any
from deerflow_extension_api import (
AgentAssemblyObserver,
ContextCompactionObserver,
ExtensionData,
ExtensionService,
MiddlewareContributor,
@ -36,6 +38,16 @@ class LoadedExtensions:
middleware_contributors: tuple[tuple[str, MiddlewareContributor], ...] = ()
task_lifecycle: tuple[tuple[str, TaskLifecycleContributor], ...] = ()
system_model_observers: tuple[tuple[str, SystemModelCallObserver], ...] = ()
agent_assembly_observers: tuple[tuple[str, AgentAssemblyObserver], ...] = ()
# No has_context_compaction_observers precomputed flag: unlike agent-assembly
# description (a synchronous, per-graph-build cost worth short-circuiting
# ahead of time), the compaction hook sites test this tuple's own truthiness
# directly, so a redundant flag would just be another thing to keep in sync.
# Note "sites", plural: notify_context_compacted is the last of them, and a
# check there cannot cover work already done by the time it is called --
# _freeze_compaction_sources runs an O(context-size) hashing pass one frame
# earlier and has to make the same test itself.
context_compaction_observers: tuple[tuple[str, ContextCompactionObserver], ...] = ()
services: tuple[tuple[str, ExtensionService], ...] = ()
routers: tuple[tuple[str, Any], ...] = ()
@ -44,6 +56,7 @@ class LoadedExtensions:
has_middleware_contributors: bool = False
has_task_lifecycle: bool = False
has_system_model_observers: bool = False
has_agent_assembly_observers: bool = False
needs_task_store: bool = False
@ -60,6 +73,8 @@ class ExtensionRegistry(ExtensionRegistryContract):
self._middlewares: list[_Entry] = []
self._task_lifecycle: list[_Entry] = []
self._system_model_observers: list[_Entry] = []
self._agent_assembly_observers: list[_Entry] = []
self._context_compaction_observers: list[_Entry] = []
self._services: list[_Entry] = []
self._routers: list[_Entry] = []
self._current_source: str | None = None
@ -88,6 +103,12 @@ class ExtensionRegistry(ExtensionRegistryContract):
def system_model_observer(self, observer: SystemModelCallObserver) -> None:
self._system_model_observers.append((self._source(), observer))
def agent_assembly_observer(self, observer: AgentAssemblyObserver) -> None:
self._agent_assembly_observers.append((self._source(), observer))
def context_compaction_observer(self, observer: ContextCompactionObserver) -> None:
self._context_compaction_observers.append((self._source(), observer))
def service(self, service: ExtensionService) -> None:
self._services.append((self._source(), service))
@ -112,22 +133,26 @@ class ExtensionRegistry(ExtensionRegistryContract):
self._middlewares,
self._task_lifecycle,
self._system_model_observers,
self._agent_assembly_observers,
self._context_compaction_observers,
self._services,
self._routers,
):
bucket[:] = [entry for entry in bucket if entry[0] != source]
def mark(self) -> tuple[int, int, int, int, int]:
def mark(self) -> tuple[int, int, int, int, int, int, int]:
"""Snapshot bucket lengths so one install() can be undone positionally."""
return (
len(self._middlewares),
len(self._task_lifecycle),
len(self._system_model_observers),
len(self._agent_assembly_observers),
len(self._context_compaction_observers),
len(self._services),
len(self._routers),
)
def rollback_to(self, mark: tuple[int, int, int, int, int]) -> None:
def rollback_to(self, mark: tuple[int, int, int, int, int, int, int]) -> None:
"""Undo every registration made since ``mark``.
Positional rather than source-keyed: two specs may legitimately share
@ -139,6 +164,8 @@ class ExtensionRegistry(ExtensionRegistryContract):
self._middlewares,
self._task_lifecycle,
self._system_model_observers,
self._agent_assembly_observers,
self._context_compaction_observers,
self._services,
self._routers,
),
@ -153,12 +180,15 @@ class ExtensionRegistry(ExtensionRegistryContract):
middleware_contributors=tuple(self._middlewares),
task_lifecycle=tuple(self._task_lifecycle),
system_model_observers=tuple(self._system_model_observers),
agent_assembly_observers=tuple(self._agent_assembly_observers),
context_compaction_observers=tuple(self._context_compaction_observers),
services=tuple(self._services),
routers=tuple(self._routers),
has_middleware_contributors=bool(self._middlewares),
has_task_lifecycle=bool(self._task_lifecycle),
has_system_model_observers=bool(self._system_model_observers),
needs_task_store=bool(self._middlewares or self._task_lifecycle or self._system_model_observers),
has_agent_assembly_observers=bool(self._agent_assembly_observers),
needs_task_store=bool(self._middlewares or self._task_lifecycle or self._system_model_observers or self._context_compaction_observers),
)

View File

@ -7,6 +7,8 @@ class AllowlistProvider:
"""Simple allowlist/denylist provider. No external dependencies."""
name = "allowlist"
policy_id = "deerflow.guardrails.allowlist"
policy_version = "1.0.0"
def __init__(self, *, allowed_tools: list[str] | None = None, denied_tools: list[str] | None = None):
# Distinguish "no allowlist configured" (None -> allow all) from an
@ -16,6 +18,12 @@ class AllowlistProvider:
self._allowed = set(allowed_tools) if allowed_tools is not None else None
self._denied = set(denied_tools) if denied_tools else set()
def release_policy_parameters(self) -> dict[str, object]:
return {
"allowed_tools": None if self._allowed is None else sorted(self._allowed),
"denied_tools": sorted(self._denied),
}
def evaluate(self, request: GuardrailRequest) -> GuardrailDecision:
if self._allowed is not None and request.tool_name not in self._allowed:
return GuardrailDecision(allow=False, reasons=[GuardrailReason(code="oap.tool_not_allowed", message=f"tool '{request.tool_name}' not in allowlist")])

View File

@ -12,6 +12,7 @@ from langgraph.errors import GraphBubbleUp
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command
from deerflow.authz.outcome import AuthorizationOutcome, put_authorization_outcome
from deerflow.authz.principal import normalize_authz_attributes
from deerflow.guardrails.provider import GuardrailDecision, GuardrailProvider, GuardrailReason, GuardrailRequest
from deerflow.runtime.events.catalog import MIDDLEWARE_GUARDRAIL_TAG
@ -35,6 +36,37 @@ class GuardrailMiddleware(AgentMiddleware[AgentState]):
self.fail_closed = fail_closed
self.passport = passport
def _resolve_policy_identity(self) -> tuple[str, str]:
"""Return ``(policy_id, policy_version)`` without the provider's full declaration.
Deliberately does not call ``self.provider.release_policy_parameters()``:
that also computes ``provider_parameters`` (e.g. sorting allow/deny
lists), which is wasted work on the per-tool-call authorization-outcome
path that only ever wants these two identity strings.
"""
policy_id = getattr(self.provider, "policy_id", None)
if not isinstance(policy_id, str) or not policy_id:
policy_id = str(getattr(self.provider, "name", type(self.provider).__name__))
policy_version = getattr(self.provider, "policy_version", None)
if not isinstance(policy_version, str) or not policy_version:
policy_version = str(getattr(self.provider, "version", "unknown"))
return policy_id, policy_version
def release_policy_parameters(self) -> dict[str, object]:
provider_parameters: dict[str, object] = {}
release_parameters = getattr(self.provider, "release_policy_parameters", None)
if callable(release_parameters):
declared = release_parameters()
if isinstance(declared, dict):
provider_parameters = declared
policy_id, policy_version = self._resolve_policy_identity()
return {
"fail_closed": self.fail_closed,
"passport": self.passport,
"policy": {"id": policy_id, "version": policy_version},
"provider_parameters": provider_parameters,
}
@staticmethod
def _resolve_context(request: ToolCallRequest) -> dict:
runtime = getattr(request, "runtime", None)
@ -72,6 +104,17 @@ class GuardrailMiddleware(AgentMiddleware[AgentState]):
status="error",
)
def _build_authorization_outcome(self, decision: GuardrailDecision) -> AuthorizationOutcome:
resolved_policy_id, policy_version = self._resolve_policy_identity()
policy_id = decision.policy_id or resolved_policy_id
reason_codes = tuple(reason.code for reason in decision.reasons if reason.code)
return AuthorizationOutcome(
decision="allowed" if decision.allow else "denied",
policy_id=policy_id,
policy_version=policy_version,
reason_codes=reason_codes,
)
def _record_guardrail_event(
self,
context: dict,
@ -146,6 +189,7 @@ class GuardrailMiddleware(AgentMiddleware[AgentState]):
action="deny_tool_call",
provider_error=True,
)
put_authorization_outcome(context, request.tool_call.get("id"), self._build_authorization_outcome(decision))
return self._build_denied_message(request, decision)
else:
decision = GuardrailDecision(allow=True, reasons=[GuardrailReason(code="oap.evaluator_error", message="guardrail provider error (fail-open)")])
@ -156,7 +200,9 @@ class GuardrailMiddleware(AgentMiddleware[AgentState]):
action="allow_tool_call_after_provider_error",
provider_error=True,
)
put_authorization_outcome(context, request.tool_call.get("id"), self._build_authorization_outcome(decision))
return handler(request)
put_authorization_outcome(context, request.tool_call.get("id"), self._build_authorization_outcome(decision))
if not decision.allow:
logger.warning("Guardrail denied: tool=%s policy=%s code=%s", gr.tool_name, decision.policy_id, decision.reasons[0].code if decision.reasons else "unknown")
self._record_guardrail_event(
@ -193,6 +239,7 @@ class GuardrailMiddleware(AgentMiddleware[AgentState]):
action="deny_tool_call",
provider_error=True,
)
put_authorization_outcome(context, request.tool_call.get("id"), self._build_authorization_outcome(decision))
return self._build_denied_message(request, decision)
else:
decision = GuardrailDecision(allow=True, reasons=[GuardrailReason(code="oap.evaluator_error", message="guardrail provider error (fail-open)")])
@ -203,7 +250,9 @@ class GuardrailMiddleware(AgentMiddleware[AgentState]):
action="allow_tool_call_after_provider_error",
provider_error=True,
)
put_authorization_outcome(context, request.tool_call.get("id"), self._build_authorization_outcome(decision))
return await handler(request)
put_authorization_outcome(context, request.tool_call.get("id"), self._build_authorization_outcome(decision))
if not decision.allow:
logger.warning("Guardrail denied: tool=%s policy=%s code=%s", gr.tool_name, decision.policy_id, decision.reasons[0].code if decision.reasons else "unknown")
self._record_guardrail_event(

View File

@ -881,7 +881,7 @@ async def get_mcp_tools() -> list[BaseTool]:
_VALID_MCP_TOOL_NAME.pattern,
)
continue
tag_mcp_tool(tool)
tag_mcp_tool(tool, server_name=source_name, transport=transport)
prefix = f"{source_name}_"
original_name = tool.name[len(prefix) :] if tool_name_prefix and tool.name.startswith(prefix) else tool.name
routing = resolve_effective_mcp_routing(server_cfg, original_name)

View File

@ -24,9 +24,55 @@ cd backend && make migrate-rev MSG="add foo column to runs"
```
This invokes `alembic revision --autogenerate` against the live ORM models. Review the generated file under `migrations/versions/` and switch raw `op.add_column` / `op.drop_column` calls to the idempotent helpers from `_helpers.py` before committing. There is no `make migrate` / `make migrate-stamp` target on purpose — the only execution path is Gateway startup, which keeps operational mistakes off the table.
**Extension-owned tables.** An extension that persists data owns its schema
end to end and must not register models against `deerflow.persistence.base.Base`
— doing so makes the host's empty-DB `create_all` create the extension's tables
on installs that never enabled it. The convention is:
- one `MetaData` instance private to the extension;
- every table sharing one prefix, declared via the `plugins:` record's
`ExtensionSpec.table_prefix` field, so `alembic revision --autogenerate`
ignores them instead of reflecting them, finding them absent from
`Base.metadata`, and proposing `drop_table`. Registration happens in two
places on purpose, because two different processes read the filter:
`extensions/loader.py::load_extensions` covers the Gateway, and
`register_configured_extension_table_prefixes()` — called from
`migrations/env.py` — covers the alembic process, which never starts a
Gateway and would otherwise see an empty prefix set exactly where
`include_object` consumes it. The alembic side reads the declaration out of
`config.yaml` and never imports extension code: a migration process must not
execute third-party code. The Gateway side registers unconditionally, even
for a disabled or later-failing spec, because the tables it names may
already exist in the database from a previous run. Because those two readers
cannot both be right about an empty prefix — the Gateway's truthiness test
reads it as "no prefix", a literal reader as one matching every table —
`ExtensionSpec.table_prefix` carries `min_length=1`, and the alembic-side
reader (which parses raw YAML, so pydantic never runs there) skips anything
that model would reject rather than raising: an operator does not expect to
hear about a malformed `config.yaml` from alembic, and Gateway startup runs
that same module through `bootstrap_schema`;
Scope, because it is narrower than it first appears: **`make migrate-rev` is
already safe without this.** `scripts/_autogen_revision.py` builds a
throwaway SQLite from the migration chain and diffs against that, so no
extension table — and no LangGraph table — is ever reflected. The exposed
path is running `alembic revision --autogenerate` directly from the
migrations directory, where `alembic.ini` points `sqlalchemy.url` at a real
`./data/deerflow.db`. That is the same path `LANGGRAPH_OWNED_TABLES` covers,
which is why that exclusion exists even though the throwaway-DB script
landed in the same commit;
- an independent alembic chain with its own
`version_table="<prefix>alembic_version"`, run from `ExtensionService.start()`
against `ExtensionRuntimeDeps.session_factory`'s bind — which is sequenced
after the host's own bootstrap by construction, since services start once
persistence is ready;
- a Postgres advisory lock around that upgrade, mirroring `bootstrap_schema`,
so concurrent Gateway instances serialise.
**Where things live**:
- `migrations/env.py` — alembic env, delegates filter to `_env_filters.py`, sets `render_as_batch=True` for SQLite ALTER support
- `migrations/_env_filters.py::include_object` — drops LangGraph checkpointer tables from alembic's view
- `migrations/_env_filters.py::include_object` — drops LangGraph checkpointer tables and any registered extension-owned tables (`EXTENSION_TABLE_PREFIXES`) from alembic's view
- `migrations/_env_filters.py::register_configured_extension_table_prefixes` — populates that set inside the alembic process, reading `plugins[*].table_prefix` from `config.yaml` and never importing extension code; called at import from `migrations/env.py`, because `load_extensions()` only ever runs in the Gateway
- `migrations/_helpers.py``safe_add_column` / `safe_drop_column`
- `migrations/versions/0001_baseline.py` — chain root, matches the schema `create_all` produces from `Base.metadata`
- `migrations/versions/0002_runs_token_usage.py` — fixes issue #3682
@ -38,4 +84,5 @@ This invokes `alembic revision --autogenerate` against the live ORM models. Revi
- `migrations/versions/0012_mcp_task_results.py` — adds bounded result preview/truncation/artifact fields for ordinary task drivers
- `migrations/versions/0013_mcp_task_notifications.py` — adds durable Agent-run notification snapshots, delivery leases, idempotency fields, and the separate bounded-retry attempt counter
- `persistence/bootstrap.py``bootstrap_schema(engine, backend=...)`, the three-branch decision + locking
- Tests: `tests/test_persistence_bootstrap.py` (branches), `tests/test_persistence_bootstrap_concurrency.py` (concurrency), `tests/test_persistence_bootstrap_regression.py` (issue #3682), `tests/test_persistence_migrations_env.py` (filter), `tests/blocking_io/test_persistence_bootstrap.py` (asyncio.to_thread anchor), `tests/test_migration_0004_run_ownership_dedupe.py` + `tests/test_migration_0007_scheduled_run_active_dedupe.py` (dedupe-before-unique-index pre-steps)
- `extensions/loader.py::load_extensions` — registers each spec's `table_prefix` with `register_extension_table_prefix()`
- Tests: `tests/test_persistence_bootstrap.py` (branches), `tests/test_persistence_bootstrap_concurrency.py` (concurrency), `tests/test_persistence_bootstrap_regression.py` (issue #3682), `tests/test_persistence_migrations_env.py` (filter, including extension-owned tables), `tests/test_extension_loader.py::TestTablePrefixRegistration` (spec-to-filter wiring), `tests/blocking_io/test_persistence_bootstrap.py` (asyncio.to_thread anchor), `tests/test_migration_0004_run_ownership_dedupe.py` + `tests/test_migration_0007_scheduled_run_active_dedupe.py` (dedupe-before-unique-index pre-steps)

View File

@ -4,6 +4,22 @@ LangGraph checkpointer tables live in the same database but are owned by
LangGraph. Without this filter, ``alembic revision --autogenerate`` would
reflect them and emit spurious ``drop_table`` ops every revision.
Extensions that persist data follow the same shape: they own their own
``MetaData`` and their own migration chain, so their tables share the
database but are absent from ``Base.metadata``. ``register_extension_table_prefix``
lets an extension declare the prefix its tables share so this filter excludes
them too.
Which path this actually guards, since it is narrower than it looks:
``make migrate-rev`` is already safe by construction ``_autogen_revision.py``
builds a throwaway SQLite from the migration chain and diffs against that, so
neither LangGraph's tables nor an extension's are ever reflected. What is not
safe is running ``alembic revision --autogenerate`` directly from this
directory, where ``alembic.ini`` points ``sqlalchemy.url`` at a real
``./data/deerflow.db``. That is the path both exclusions cover, and it is why
``LANGGRAPH_OWNED_TABLES`` exists despite the throwaway-DB script landing in
the same commit.
Kept in its own module (instead of inlined in ``env.py``) so it can be
unit-tested without dragging in alembic's import-time machinery.
"""
@ -21,16 +37,125 @@ LANGGRAPH_OWNED_TABLES: frozenset[str] = frozenset(
)
#: Table-name prefixes owned by loaded extensions. An extension brings its own
#: MetaData and its own migration chain, so its tables are absent from
#: Base.metadata; without this, autogenerate would reflect them and propose
#: dropping them.
EXTENSION_TABLE_PREFIXES: set[str] = set()
def _host_table_names() -> frozenset[str]:
"""Every table name the host itself owns.
Imported on demand rather than at module scope: this module is
deliberately kept import-light (see module docstring) so it stays
unit-testable without dragging in the ORM's import graph unless a prefix
is actually being registered.
"""
import deerflow.persistence.models # noqa: F401 - registers ORM tables onto Base.metadata
from deerflow.persistence.base import Base
return frozenset(Base.metadata.tables.keys())
def register_extension_table_prefix(prefix: str) -> None:
"""Declare a table-name prefix alembic must not propose DDL for.
Fails loudly when the prefix would also match a host-owned table name
(``str.startswith``, the same test ``_is_extension_owned`` uses below): a
prefix like ``"run"`` would silently exclude the host's own ``runs`` and
``run_events`` tables from autogenerate, and that has to be caught here.
An earlier design reasoned about a malicious prefix and left it at that;
this guards against the far more likely case of an honest typo, which is
exactly the failure mode this whole facility exists to prevent autogenerate
from producing silently.
"""
if not prefix:
raise ValueError("extension table prefix must be a non-empty string")
colliding = sorted(name for name in _host_table_names() if name.startswith(prefix))
if colliding:
raise ValueError(f"extension table_prefix {prefix!r} would hide host-owned table(s) {colliding!r} from alembic autogenerate; choose a prefix that is not a prefix of any host table name")
EXTENSION_TABLE_PREFIXES.add(prefix)
def register_configured_extension_table_prefixes(config_path: str | None = None) -> tuple[str, ...]:
"""Register the prefixes declared by ``plugins:`` in ``config.yaml``.
Alembic runs in its own process. ``make migrate-rev`` spawns
``scripts/_autogen_revision.py``, and a direct ``alembic revision`` runs
from the migrations directory neither starts a Gateway, so neither calls
``load_extensions()``. Registering only from there would leave
``EXTENSION_TABLE_PREFIXES`` empty in the one process that reads it, and
the filter below would silently degrade to its LangGraph-only behaviour.
Only the declaration is read. Extension code is never imported: a
migration process must not execute third-party code, and the prefix is a
plain string sitting in config. That is also why this parses the YAML
directly instead of going through ``AppConfig`` an unrelated validation
error elsewhere in the file should not stop someone generating a revision.
Entries whose ``table_prefix`` is absent, null, empty, or not a string are
skipped rather than rejected, matching what the Gateway does with the same
declaration: ``ExtensionSpec`` rejects them at config-load time, so this
process would only be adding a second, worse-placed verdict.
Returns the prefixes it registered, for the caller to log or assert on.
"""
import yaml
from deerflow.config.app_config import AppConfig
try:
path = AppConfig.resolve_config_path(config_path)
except FileNotFoundError:
# A clean checkout has no config.yaml, so there is nothing declared and
# nothing to exclude. Not an error.
return ()
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError) as exc:
# Refusing here is the point of the whole filter: if the declarations
# cannot be read, the exclusion cannot be guaranteed, and a silently
# unfiltered autogenerate is exactly the outcome this prevents.
raise RuntimeError(f"could not read extension table prefixes from {path}: {exc}") from exc
registered: list[str] = []
for entry in raw.get("plugins") or []:
if not isinstance(entry, dict):
continue
prefix = entry.get("table_prefix")
if not isinstance(prefix, str) or not prefix:
# Absent, null, empty, or the wrong type: nothing to exclude. This reader
# parses raw YAML precisely so it never imports the extension, which
# also means ``ExtensionSpec`` never validates what it sees -- so it
# must skip whatever that model would reject (``min_length=1`` there)
# instead of adjudicating it. Raising here would put the verdict in
# the wrong process twice over: alembic is not where an operator
# expects to hear that config.yaml is malformed, and Gateway startup
# runs this same module through ``bootstrap_schema``, so a raise
# would take the Gateway down with a message about migrations.
continue
register_extension_table_prefix(prefix)
registered.append(prefix)
return tuple(registered)
def _is_extension_owned(name: object) -> bool:
return isinstance(name, str) and any(name.startswith(prefix) for prefix in EXTENSION_TABLE_PREFIXES)
def include_object(object_, name, type_, reflected, compare_to): # noqa: ARG001
"""Returns False for any LangGraph-owned table or for an index/constraint
whose parent table is LangGraph-owned. Returns True otherwise.
"""Returns False for any LangGraph-owned or extension-owned table, or for an
index/constraint whose parent table is one of those. Returns True otherwise.
Signature matches alembic's ``include_object`` callable contract:
``(object, name, type_, reflected, compare_to)``.
"""
if type_ == "table" and name in LANGGRAPH_OWNED_TABLES:
if type_ == "table" and (name in LANGGRAPH_OWNED_TABLES or _is_extension_owned(name)):
return False
parent_table = getattr(object_, "table", None)
if parent_table is not None and getattr(parent_table, "name", None) in LANGGRAPH_OWNED_TABLES:
parent_name = getattr(parent_table, "name", None) if parent_table is not None else None
if parent_name is not None and (parent_name in LANGGRAPH_OWNED_TABLES or _is_extension_owned(parent_name)):
return False
return True

View File

@ -25,6 +25,7 @@ from deerflow.persistence.base import Base
from deerflow.persistence.migrations._env_filters import (
LANGGRAPH_OWNED_TABLES,
include_object,
register_configured_extension_table_prefixes,
)
# Re-export under the module namespace for any consumer that addresses them
@ -44,6 +45,14 @@ config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# This process never starts a Gateway, so ``load_extensions()`` has not run and
# ``EXTENSION_TABLE_PREFIXES`` would be empty in the one place ``include_object``
# reads it. Read the declarations straight from config instead; extension code
# is never imported here.
_extension_prefixes = register_configured_extension_table_prefixes()
if _extension_prefixes:
logging.getLogger(__name__).info("alembic: excluding extension-owned tables with prefixes %s", ", ".join(sorted(_extension_prefixes)))
target_metadata = Base.metadata

View File

@ -485,6 +485,17 @@ def _agent_factory_supports_app_config(agent_factory: Any) -> bool:
return _compute_agent_factory_supports_app_config(agent_factory)
def _agent_graph(agent_result: Any) -> Any:
"""Unwrap the lead assembly, leaving any other factory result untouched."""
try:
from deerflow.agents.lead_agent.agent import unwrap_agent_graph
except Exception:
# A custom factory must keep working even if importing the lead
# assembly type fails.
return agent_result
return unwrap_agent_graph(agent_result)
class _SubagentEventBuffer:
"""Buffer subagent ``task_*`` step events and flush them in one locked batch (#3779).
@ -872,7 +883,7 @@ async def run_agent(
from deerflow.extensions import bind_agent_build_extensions
with bind_agent_build_extensions(extensions):
agent = agent_factory(**agent_factory_kwargs)
agent = _agent_graph(agent_factory(**agent_factory_kwargs))
accessor = CheckpointStateAccessor.bind(
agent,

View File

@ -543,6 +543,13 @@ class SubagentExecutor:
# not just the first — because the v2 contract advertises more than one
# cap reason.
self._stop_reason_middlewares: list[Any] = []
# What this subagent was assembled from, published to extension
# observers at the end of ``_create_agent``. The prompt and skill set
# are captured while ``_build_initial_state`` renders them because
# neither is recoverable from the compiled graph afterwards.
self.assembly_descriptor: Any | None = None
self._assembled_system_prompt = self.config.system_prompt or ""
self._assembled_skills: list[Any] = []
logger.info(f"[trace={self.trace_id}] SubagentExecutor initialized: {config.name} with {len(self.tools)} tools")
@ -604,14 +611,97 @@ class SubagentExecutor:
# system_prompt is included in initial state messages (see _build_initial_state)
# to avoid multiple SystemMessages which some LLM APIs don't support.
return create_agent(
bound_tools = list(tools if tools is not None else self.tools)
agent = create_agent(
model=model,
tools=tools if tools is not None else self.tools,
tools=bound_tools,
middleware=middlewares,
system_prompt=None,
state_schema=ThreadState,
checkpointer=False,
)
self._describe_assembly(
app_config=app_config,
tools=bound_tools,
middlewares=middlewares,
deferred_setup=deferred_setup,
extensions=extensions if extensions is not None else self.extensions,
)
return agent
def _describe_assembly(
self,
*,
app_config: Any,
tools: list[Any],
middlewares: list[Any],
deferred_setup: "DeferredToolSetup | None",
extensions: Any | None,
) -> None:
"""Record and publish what this subagent was assembled from.
Fail-open: a subagent that cannot describe itself must still run.
Building the descriptor hashes every tool's description and JSON
schema and probes every middleware, so it is skipped entirely when no
observer is registered to receive it.
"""
if not getattr(extensions, "has_agent_assembly_observers", False):
return
from types import SimpleNamespace
from deerflow.agents.assembly_descriptor import build_assembly_descriptor
from deerflow.extensions.notify import notify_agent_assembled
try:
get_model_config = getattr(app_config, "get_model_config", None)
model_config = get_model_config(self.model_name) if callable(get_model_config) else None
if model_config is None:
# A name the profile table does not know still has an identity;
# a missing profile must not blank out the whole descriptor.
model_config = SimpleNamespace(
model=self.model_name,
use="unknown",
supports_thinking=False,
supports_reasoning_effort=False,
supports_vision=False,
)
deferred_names = deferred_setup.deferred_names if deferred_setup is not None else frozenset()
descriptor = build_assembly_descriptor(
namespace="deerflow",
agent_name=self.config.name,
requested_model=(self.config.model if self.config.model != "inherit" else self.parent_model),
effective_model=self.model_name,
model_config=model_config,
thinking_enabled=False,
reasoning_effort=None,
rendered_base_prompt=self._assembled_system_prompt,
prompt_template_id="deerflow-subagent-v1",
tools=tools,
middlewares=middlewares,
deferred_names=deferred_names,
enabled_skills=self._assembled_skills,
effective_policies={
"max_turns": self.config.max_turns,
"timeout_seconds": self.config.timeout_seconds,
"tool_allowlist": self.config.tools,
"tool_denylist": self.config.disallowed_tools,
"deferred_tools": {
"enabled": bool(deferred_names),
"catalog_hash": (deferred_setup.catalog_hash if deferred_setup is not None else None),
},
},
)
except Exception:
logger.warning(
"[trace=%s] Could not describe subagent %s assembly",
self.trace_id,
self.config.name,
exc_info=True,
)
return
self.assembly_descriptor = descriptor
notify_agent_assembled(descriptor, extensions)
def _consume_guard_stop_reason(self) -> str | None:
"""Pop and return the guard-cap stop reason set during the last run.
@ -685,6 +775,7 @@ class SubagentExecutor:
# loaded through read_file. Their allowed-tools declarations are applied
# dynamically by SkillToolPolicyMiddleware, not eagerly here.
skills = await self._load_skills()
self._assembled_skills = list(skills)
self._available_skill_names = {skill.name for skill in skills}
resolved_app_config = self.app_config or get_app_config()
@ -773,7 +864,8 @@ class SubagentExecutor:
messages: list[Any] = []
if system_parts:
messages.append(SystemMessage(content="\n\n".join(system_parts)))
self._assembled_system_prompt = "\n\n".join(system_parts)
messages.append(SystemMessage(content=self._assembled_system_prompt))
# Then the actual task
messages.append(HumanMessage(content=task))

View File

@ -20,11 +20,23 @@ from langchain.tools import BaseTool
MCP_TOOL_METADATA_KEY = "deerflow_mcp"
MCP_TOOL_ROUTING_METADATA_KEY = "deerflow_mcp_routing"
MCP_TOOL_SOURCE_METADATA_KEY = "deerflow_mcp_source"
def tag_mcp_tool(tool: BaseTool) -> BaseTool:
def tag_mcp_tool(
tool: BaseTool,
*,
server_name: str | None = None,
transport: str | None = None,
) -> BaseTool:
"""Mark ``tool`` as MCP-sourced. Mutates in place and returns it for chaining."""
tool.metadata = {**(tool.metadata or {}), MCP_TOOL_METADATA_KEY: True}
metadata: dict[str, Any] = {**(tool.metadata or {}), MCP_TOOL_METADATA_KEY: True}
if server_name:
metadata[MCP_TOOL_SOURCE_METADATA_KEY] = {
"server_name": server_name,
"transport": transport or "unknown",
}
tool.metadata = metadata
return tool
@ -33,6 +45,22 @@ def is_mcp_tool(tool: BaseTool) -> bool:
return (getattr(tool, "metadata", None) or {}).get(MCP_TOOL_METADATA_KEY) is True
def get_mcp_source(tool: BaseTool) -> dict[str, str] | None:
"""Return only the credential-free logical MCP source metadata."""
source = (getattr(tool, "metadata", None) or {}).get(MCP_TOOL_SOURCE_METADATA_KEY)
if not isinstance(source, Mapping):
return None
server_name = source.get("server_name")
transport = source.get("transport")
if not isinstance(server_name, str) or not server_name:
return None
return {
"server_name": server_name,
"transport": transport if isinstance(transport, str) and transport else "unknown",
}
def tag_mcp_routing(tool: BaseTool, routing: Mapping[str, Any]) -> BaseTool:
"""Attach serialized MCP routing metadata to ``tool``."""
tool.metadata = {

View File

@ -11,7 +11,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.1.2",
"deerflow-extension-api==0.2.0",
"dotenv>=0.9.9",
"exa-py>=1.0.0",
"httpx>=0.28.0",

View File

@ -6,6 +6,13 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"deerflow-harness",
# Direct dependency on purpose, even though deerflow-harness already pulls
# it in: app/gateway/app.py and app/gateway/services.py import the public
# contract package themselves (the extension principal resolver, and the
# provenance key set the state route strips). Those are the app layer's own
# imports of a package whose whole point is a stable public surface, so the
# app declares them rather than relying on the harness to keep supplying it.
"deerflow-extension-api",
"fastapi>=0.115.0",
"httpx>=0.28.0",
"python-multipart>=0.0.31",

View File

@ -0,0 +1,601 @@
"""What the agent was actually assembled from, captured at build time.
Everything here is knowable only inside the factory: the resolved model after
runtime overrides, the rendered prompt, the tool list after authorization
filtering, the composed middleware stack. None of it survives to any later
observation point.
"""
from pathlib import Path
from deerflow_extension_api import AgentAssemblyDescriptor, MiddlewareDescriptor, ToolDescriptor
def test_fingerprint_is_stable_for_identical_assemblies():
def make():
return AgentAssemblyDescriptor(
namespace="lead",
agent_name="lead-agent",
requested_model=None,
effective_model="gpt-x",
model_parameters={"temperature": 0},
thinking_enabled=False,
reasoning_effort=None,
base_prompt_hash="abc",
tools=(ToolDescriptor(name="bash", description_hash="d", schema_hash="s", source="builtin"),),
middlewares=(MiddlewareDescriptor(name="M", module="m", policy_parameters={"limit": 1}),),
deferred_tool_names=(),
enabled_skills=(),
effective_policies={"recursion_limit": 100},
)
assert make().fingerprint == make().fingerprint
def test_fingerprint_changes_when_a_middleware_policy_changes():
from dataclasses import replace
base = AgentAssemblyDescriptor(
namespace="lead",
agent_name="lead-agent",
requested_model=None,
effective_model="gpt-x",
model_parameters={},
thinking_enabled=False,
reasoning_effort=None,
base_prompt_hash="abc",
tools=(),
middlewares=(MiddlewareDescriptor(name="M", module="m", policy_parameters={"limit": 1}),),
deferred_tool_names=(),
enabled_skills=(),
effective_policies={},
)
changed = replace(base, middlewares=(MiddlewareDescriptor(name="M", module="m", policy_parameters={"limit": 2}),))
assert base.fingerprint != changed.fingerprint
def test_fingerprint_ignores_tool_ordering():
"""Tool order is an assembly detail, not a behavioural difference."""
from dataclasses import replace
a = ToolDescriptor(name="a", description_hash="1", schema_hash="1", source="builtin")
b = ToolDescriptor(name="b", description_hash="2", schema_hash="2", source="builtin")
base = AgentAssemblyDescriptor(
namespace="lead",
agent_name="lead-agent",
requested_model=None,
effective_model="gpt-x",
model_parameters={},
thinking_enabled=False,
reasoning_effort=None,
base_prompt_hash="abc",
tools=(a, b),
middlewares=(),
deferred_tool_names=(),
enabled_skills=(),
effective_policies={},
)
assert base.fingerprint == replace(base, tools=(b, a)).fingerprint
def test_middleware_order_does_affect_the_fingerprint():
"""Stack order determines what wraps what, so it is behavioural."""
from dataclasses import replace
m1 = MiddlewareDescriptor(name="A", module="m", policy_parameters={})
m2 = MiddlewareDescriptor(name="B", module="m", policy_parameters={})
base = AgentAssemblyDescriptor(
namespace="lead",
agent_name="lead-agent",
requested_model=None,
effective_model="gpt-x",
model_parameters={},
thinking_enabled=False,
reasoning_effort=None,
base_prompt_hash="abc",
tools=(),
middlewares=(m1, m2),
deferred_tool_names=(),
enabled_skills=(),
effective_policies={},
)
assert base.fingerprint != replace(base, middlewares=(m2, m1)).fingerprint
class TestLeadAgentAssembly:
def test_make_lead_agent_still_returns_a_bare_graph(self):
"""langgraph.json declares this factory; its ABI must not move."""
import inspect
from deerflow.agents.lead_agent.agent import make_lead_agent
signature = inspect.signature(make_lead_agent)
assert list(signature.parameters) == ["config"]
@staticmethod
def _isolate_from_the_ambient_config(monkeypatch):
"""Assemble against a config this test owns, not the machine's.
``assemble_lead_agent`` falls back to ``get_app_config()``, so without
this the test passes only where a developer happens to have a usable
``config.yaml``. CI checks out ``config.example.yaml``, whose ``models:``
entries are all commented out, and assembly raises "No chat models are
configured" before it can produce anything to assert on.
"""
from deerflow.agents.lead_agent import agent as lead_agent_module
from deerflow.config.app_config import AppConfig
from deerflow.config.model_config import ModelConfig
from deerflow.config.sandbox_config import SandboxConfig
app_config = AppConfig(
models=[
ModelConfig(
name="assembly-test-model",
display_name="assembly-test-model",
description=None,
use="langchain_openai:ChatOpenAI",
model="assembly-test-model",
supports_thinking=False,
supports_vision=False,
)
],
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"),
)
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
monkeypatch.setattr(
lead_agent_module,
"create_chat_model",
lambda **kwargs: object(),
)
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
return app_config
@staticmethod
def _extensions_with_an_agent_assembly_observer(observer=None):
"""A minimal LoadedExtensions carrying one agent-assembly observer.
Building the descriptor is real work (hashing every tool's description
and schema, probing every middleware), so it only happens when an
observer is actually registered to receive it.
"""
from deerflow.extensions.registry import ExtensionRegistry
class _NoOpObserver:
def on_agent_assembled(self, app_store, descriptor):
return None
registry = ExtensionRegistry()
with registry.attributed_to("test"):
registry.agent_assembly_observer(observer or _NoOpObserver())
return registry.build()
def test_assemble_returns_both_the_graph_and_a_descriptor(self, monkeypatch):
from deerflow.agents.lead_agent.agent import LeadAgentAssembly, assemble_lead_agent
from deerflow.extensions import bind_agent_build_extensions
self._isolate_from_the_ambient_config(monkeypatch)
with bind_agent_build_extensions(self._extensions_with_an_agent_assembly_observer()):
assembly = assemble_lead_agent({"configurable": {"thread_id": "t-1"}})
assert isinstance(assembly, LeadAgentAssembly)
assert assembly.graph is not None
assert assembly.descriptor.effective_model
assert assembly.descriptor.fingerprint
def test_observers_receive_the_descriptor(self, monkeypatch):
from deerflow.agents.lead_agent.agent import assemble_lead_agent
from deerflow.extensions import bind_agent_build_extensions
seen = []
class Observer:
def on_agent_assembled(self, app_store, descriptor):
seen.append(descriptor)
monkeypatch.setattr(
"deerflow.extensions.notify.notify_agent_assembled",
lambda descriptor, extensions=None: Observer().on_agent_assembled(None, descriptor),
)
self._isolate_from_the_ambient_config(monkeypatch)
with bind_agent_build_extensions(self._extensions_with_an_agent_assembly_observer()):
assemble_lead_agent({"configurable": {"thread_id": "t-2"}})
assert len(seen) == 1
def test_no_descriptor_is_built_without_a_registered_observer(self, monkeypatch):
"""The zero-observer fast path must skip the expensive build entirely,
not just skip notifying mirroring notify_agent_assembled's own
zero-observer short-circuit."""
from deerflow.agents.lead_agent.agent import assemble_lead_agent
def _fail(*args, **kwargs):
raise AssertionError("build_assembly_descriptor must not run without an observer")
monkeypatch.setattr("deerflow.agents.assembly_descriptor.build_assembly_descriptor", _fail)
self._isolate_from_the_ambient_config(monkeypatch)
assembly = assemble_lead_agent({"configurable": {"thread_id": "t-3"}})
assert assembly.descriptor is None
class TestFactoryConsumersUnwrapTheGraph:
"""A missed unwrap fails at request time, not at import time."""
def test_worker_unwraps_the_assembly(self):
from deerflow.agents.lead_agent.agent import LeadAgentAssembly
from deerflow.runtime.runs.worker import _agent_graph
graph = object()
assert _agent_graph(LeadAgentAssembly(graph=graph, descriptor=object())) is graph
def test_worker_leaves_a_third_party_bare_graph_alone(self):
from deerflow.runtime.runs.worker import _agent_graph
graph = object()
assert _agent_graph(graph) is graph
class TestAssemblyObserverHost:
def test_registration_survives_rollback_of_a_later_install(self):
from deerflow.extensions.registry import ExtensionRegistry
class Observer:
def on_agent_assembled(self, app_store, descriptor):
return None
registry = ExtensionRegistry()
keeper = Observer()
with registry.attributed_to("keeper"):
registry.agent_assembly_observer(keeper)
mark = registry.mark()
with registry.attributed_to("doomed"):
registry.agent_assembly_observer(Observer())
registry.rollback_to(mark)
loaded = registry.build()
assert loaded.agent_assembly_observers == (("keeper", keeper),)
assert loaded.has_agent_assembly_observers is True
# The descriptor is app-scoped; it does not create a task store need.
assert loaded.needs_task_store is False
def test_a_broken_observer_does_not_stop_its_successors(self, caplog):
from deerflow.extensions.notify import notify_agent_assembled
from deerflow.extensions.registry import ExtensionRegistry
seen = []
class Broken:
def on_agent_assembled(self, app_store, descriptor):
raise RuntimeError("boom")
class Working:
def on_agent_assembled(self, app_store, descriptor):
seen.append(descriptor)
registry = ExtensionRegistry()
with registry.attributed_to("broken"):
registry.agent_assembly_observer(Broken())
with registry.attributed_to("working"):
registry.agent_assembly_observer(Working())
loaded = registry.build()
notify_agent_assembled("descriptor", loaded)
assert seen == ["descriptor"]
class TestBuildIdentityIsOutsideTheFingerprint:
"""A redeploy that changed nothing must not look like an assembly change."""
def _descriptor(self, build):
return AgentAssemblyDescriptor(
namespace="lead",
agent_name="lead-agent",
requested_model=None,
effective_model="gpt-x",
model_parameters={},
thinking_enabled=False,
reasoning_effort=None,
base_prompt_hash="abc",
tools=(),
middlewares=(),
deferred_tool_names=(),
enabled_skills=(),
effective_policies={},
build=build,
)
def test_two_builds_of_the_same_assembly_share_a_fingerprint(self):
before = self._descriptor({"package_version": "1.0.0", "git_commit": "aaaa", "image_digest": "sha256:aaa"})
after = self._descriptor({"package_version": "1.0.1", "git_commit": "bbbb", "image_digest": "sha256:bbb"})
assert before.fingerprint == after.fingerprint
def test_the_build_itself_stays_comparable(self):
"""The coarser question must remain answerable, just separately."""
before = self._descriptor({"git_commit": "aaaa"})
after = self._descriptor({"git_commit": "bbbb"})
assert before.build != after.build
def test_the_builder_reports_a_build_without_hashing_it(self):
from deerflow.agents.assembly_descriptor import build_assembly_descriptor
def make():
return build_assembly_descriptor(
namespace="deerflow",
agent_name="lead-agent",
requested_model=None,
effective_model="gpt-x",
model_config=None,
thinking_enabled=False,
reasoning_effort=None,
rendered_base_prompt="prompt",
tools=[],
middlewares=[],
deferred_names=frozenset(),
enabled_skills=[],
effective_policies={},
)
descriptor = make()
assert descriptor.build["package_version"]
assert "build" not in descriptor.effective_policies
assert descriptor.fingerprint == make().fingerprint
class TestWrappedExtensionMiddlewaresStayDistinguishable:
"""Contributed middlewares all share the isolation wrapper's class name."""
@staticmethod
def _wrap(inner, source):
from deerflow.extensions.isolation import IsolatedMiddleware
return IsolatedMiddleware(inner, source, lambda diagnostic: None)
@staticmethod
def _inner(name, *, policy=None):
from langchain.agents.middleware import AgentMiddleware
namespace = {}
if policy is not None:
namespace["release_policy_parameters"] = lambda self: dict(policy)
return type(name, (AgentMiddleware,), namespace)()
def test_two_extensions_middlewares_do_not_collapse_into_one_descriptor(self):
from deerflow.agents.assembly_descriptor import describe_middleware
first = describe_middleware(self._wrap(self._inner("AlphaMiddleware"), "ext-a"))
second = describe_middleware(self._wrap(self._inner("BetaMiddleware"), "ext-b"))
assert first.name == "AlphaMiddleware"
assert second.name == "BetaMiddleware"
assert first.extension == "ext-a"
assert second.extension == "ext-b"
assert first != second
def test_a_wrapped_declaration_reaches_the_descriptor(self):
from deerflow.agents.assembly_descriptor import describe_middleware
descriptor = describe_middleware(self._wrap(self._inner("DeclaringMiddleware", policy={"limit": 7}), "ext-a"))
assert descriptor.policy_parameters == {"limit": 7}
assert "probed" not in descriptor.policy_parameters
def test_a_policy_change_inside_a_wrapped_middleware_moves_the_fingerprint(self):
from dataclasses import replace
from deerflow.agents.assembly_descriptor import describe_middleware
def descriptor_for(limit):
return AgentAssemblyDescriptor(
namespace="lead",
agent_name="lead-agent",
requested_model=None,
effective_model="gpt-x",
model_parameters={},
thinking_enabled=False,
reasoning_effort=None,
base_prompt_hash="abc",
tools=(),
middlewares=(describe_middleware(self._wrap(self._inner("DeclaringMiddleware", policy={"limit": limit}), "ext-a")),),
deferred_tool_names=(),
enabled_skills=(),
effective_policies={},
)
assert descriptor_for(1).fingerprint != descriptor_for(2).fingerprint
# And the same policy from a different extension is a different agent.
base = descriptor_for(1)
other = replace(base, middlewares=(replace(base.middlewares[0], extension="ext-b"),))
assert base.fingerprint != other.fingerprint
def test_an_unwrapped_host_middleware_reports_no_extension(self):
from deerflow.agents.assembly_descriptor import describe_middleware
descriptor = describe_middleware(self._inner("HostMiddleware", policy={"limit": 1}))
assert descriptor.extension is None
assert descriptor.name == "HostMiddleware"
class TestModelParametersProjectEffectiveSettings:
"""Provider kwargs a user actually sets must move the fingerprint.
``ModelConfig`` is ``extra="allow"``, so ``temperature``/``max_tokens``/
anything else a deployer sets live only as extra fields; a fixed allowlist
never saw them. And the *effective* per-agent override (issue #4336's
``model_settings``) must reach the descriptor too, not just the static
profile.
"""
@staticmethod
def _build(model_config, *, model_overrides=None):
from deerflow.agents.assembly_descriptor import build_assembly_descriptor
return build_assembly_descriptor(
namespace="deerflow",
agent_name="lead-agent",
requested_model=None,
effective_model="gpt-x",
model_config=model_config,
model_overrides=model_overrides,
thinking_enabled=False,
reasoning_effort=None,
rendered_base_prompt="prompt",
tools=[],
middlewares=[],
deferred_names=frozenset(),
enabled_skills=[],
effective_policies={},
)
@staticmethod
def _model_config(**extra):
from deerflow.config.model_config import ModelConfig
return ModelConfig(
name="assembly-test-model",
display_name=None,
description=None,
use="langchain_openai:ChatOpenAI",
model="gpt-x",
**extra,
)
def test_changing_temperature_changes_the_fingerprint(self):
cold = self._build(self._model_config(temperature=0.1))
hot = self._build(self._model_config(temperature=0.9))
assert cold.fingerprint != hot.fingerprint
assert cold.model_parameters["temperature"] == 0.1
def test_changing_a_per_agent_model_setting_changes_the_fingerprint(self):
"""The *effective* override, not just the static profile, must count."""
model_config = self._model_config()
without_override = self._build(model_config)
with_override = self._build(model_config, model_overrides={"temperature": 0.7})
assert without_override.fingerprint != with_override.fingerprint
assert with_override.model_parameters["temperature"] == 0.7
def test_a_none_valued_override_does_not_clobber_the_profile(self):
model_config = self._model_config(temperature=0.3)
profile_only = self._build(model_config)
with_noop_override = self._build(model_config, model_overrides={"temperature": None})
assert profile_only.fingerprint == with_noop_override.fingerprint
def test_api_key_is_never_projected_and_never_moves_the_fingerprint(self):
quiet = self._build(self._model_config(api_key="sk-aaaaaaaaaaaa"))
loud = self._build(self._model_config(api_key="sk-bbbbbbbbbbbb"))
assert "api_key" not in quiet.model_parameters
assert quiet.fingerprint == loud.fingerprint
def test_an_override_named_like_a_credential_is_also_excluded(self):
model_config = self._model_config()
descriptor = self._build(model_config, model_overrides={"api_key": "sk-should-not-appear"})
assert "api_key" not in descriptor.model_parameters
class TestCustomAgentModelSettingsReachTheDescriptor:
"""End-to-end: a custom agent's ``model_settings`` must move the fingerprint.
``agent.py`` computes ``agent_model_overrides`` from ``agent_config.model_settings``
and passes it into ``create_chat_model``; this checks it also reaches
``_complete_assembly`` -> ``build_assembly_descriptor`` for the default
(non-bootstrap) assembly branch. Composes with (rather than subclasses)
``TestLeadAgentAssembly``'s isolation helpers so this class's own tests are
the only ones that run under it.
"""
def test_temperature_override_on_a_custom_agent_changes_the_fingerprint(self, monkeypatch):
from deerflow.agents.lead_agent import agent as lead_agent_module
from deerflow.agents.lead_agent.agent import assemble_lead_agent
from deerflow.config.agents_config import AgentConfig, AgentModelSettings
from deerflow.extensions import bind_agent_build_extensions
TestLeadAgentAssembly._isolate_from_the_ambient_config(monkeypatch)
def assemble(temperature):
agent_config = AgentConfig(name="custom", model_settings=AgentModelSettings(temperature=temperature))
monkeypatch.setattr(lead_agent_module, "load_agent_config", lambda name, user_id=None: agent_config)
with bind_agent_build_extensions(TestLeadAgentAssembly._extensions_with_an_agent_assembly_observer()):
return assemble_lead_agent({"configurable": {"thread_id": "t-model-settings", "agent_name": "custom"}})
low = assemble(0.1)
high = assemble(0.9)
assert low.descriptor.fingerprint != high.descriptor.fingerprint
assert high.descriptor.model_parameters["temperature"] == 0.9
def test_bootstrap_assembly_does_not_invent_model_overrides(self, monkeypatch):
"""The bootstrap branch has no ``agent_config``, so no overrides exist to project."""
from deerflow.agents.lead_agent.agent import assemble_lead_agent
from deerflow.extensions import bind_agent_build_extensions
TestLeadAgentAssembly._isolate_from_the_ambient_config(monkeypatch)
with bind_agent_build_extensions(TestLeadAgentAssembly._extensions_with_an_agent_assembly_observer()):
assembly = assemble_lead_agent({"configurable": {"thread_id": "t-bootstrap", "is_bootstrap": True}})
assert "temperature" not in assembly.descriptor.model_parameters
class TestSkillCatalogHashesContent:
"""Editing SKILL.md changes what ``SkillActivationMiddleware`` injects into
the turn, so it must change the fingerprint even though name/description/
allowed-tools are untouched."""
@staticmethod
def _skill(skill_dir: Path, *, required_secrets=(), secrets_autonomous=True):
from deerflow.skills.types import Skill, SkillCategory
skill_file = skill_dir / "SKILL.md"
return Skill(
name="my-skill",
description="A test skill",
license=None,
skill_dir=skill_dir,
skill_file=skill_file,
relative_path=Path(skill_dir.name),
category=SkillCategory.CUSTOM,
required_secrets=required_secrets,
secrets_autonomous=secrets_autonomous,
)
@staticmethod
def _build(enabled_skills):
from deerflow.agents.assembly_descriptor import build_assembly_descriptor
return build_assembly_descriptor(
namespace="deerflow",
agent_name="lead-agent",
requested_model=None,
effective_model="gpt-x",
model_config=None,
thinking_enabled=False,
reasoning_effort=None,
rendered_base_prompt="prompt",
tools=[],
middlewares=[],
deferred_names=frozenset(),
enabled_skills=enabled_skills,
effective_policies={},
)
def test_changing_only_skill_md_content_changes_the_fingerprint(self, tmp_path):
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
skill = self._skill(skill_dir)
(skill_dir / "SKILL.md").write_text("---\nname: my-skill\n---\nOriginal instructions.\n", encoding="utf-8")
before = self._build([skill])
(skill_dir / "SKILL.md").write_text("---\nname: my-skill\n---\nCompletely different instructions.\n", encoding="utf-8")
after = self._build([skill])
assert before.fingerprint != after.fingerprint
assert before.enabled_skills == after.enabled_skills # the catalog's visible identity is unchanged
def test_required_secrets_flag_changes_the_fingerprint(self):
from deerflow.skills.types import SecretRequirement
no_secrets = self._skill(Path("/nonexistent/skill-a"))
with_secret = self._skill(Path("/nonexistent/skill-b"), required_secrets=(SecretRequirement(name="API_KEY"),))
assert self._build([no_secrets]).fingerprint != self._build([with_secret]).fingerprint
def test_a_missing_skill_file_is_undescribable_not_fatal(self, tmp_path):
skill = self._skill(tmp_path / "missing-skill")
descriptor = self._build([skill])
assert descriptor.fingerprint

View File

@ -0,0 +1,48 @@
"""The neutral Guardrail -> observer authorization handoff."""
from deerflow.authz.outcome import (
_MAX_TRACKED_OUTCOMES,
AUTHORIZATION_OUTCOME_CONTEXT_KEY,
AuthorizationOutcome,
pop_authorization_outcome,
put_authorization_outcome,
)
OUTCOME = AuthorizationOutcome(decision="denied", policy_id="p", policy_version="1.0.0", reason_codes=("x",))
def test_round_trip_is_keyed_by_tool_call_id():
context: dict = {}
put_authorization_outcome(context, "call-1", OUTCOME)
assert pop_authorization_outcome(context, "call-1") == OUTCOME
def test_pop_consumes_so_a_later_call_cannot_inherit_a_stale_decision():
context: dict = {}
put_authorization_outcome(context, "call-1", OUTCOME)
pop_authorization_outcome(context, "call-1")
assert pop_authorization_outcome(context, "call-1") is None
def test_context_key_is_double_underscore_prefixed_so_gateway_strips_forgeries():
assert AUTHORIZATION_OUTCOME_CONTEXT_KEY.startswith("__")
def test_missing_context_or_tool_call_id_is_a_no_op_rather_than_an_error():
put_authorization_outcome(None, "call-1", OUTCOME)
put_authorization_outcome({}, None, OUTCOME)
assert pop_authorization_outcome(None, "call-1") is None
assert pop_authorization_outcome({}, None) is None
def test_the_store_evicts_the_oldest_entry_once_it_is_full():
"""No production caller pops entries yet, so an unbounded store would grow
for the life of a run. Capping it keeps that growth to a fixed footprint."""
context: dict = {}
for i in range(_MAX_TRACKED_OUTCOMES + 1):
put_authorization_outcome(context, f"call-{i}", OUTCOME)
store = context[AUTHORIZATION_OUTCOME_CONTEXT_KEY]
assert len(store) == _MAX_TRACKED_OUTCOMES
assert "call-0" not in store
assert f"call-{_MAX_TRACKED_OUTCOMES}" in store

View File

@ -207,11 +207,13 @@ def test_run_case_fails_when_warm_cache_is_not_hit(tmp_path, monkeypatch) -> Non
gateway_services._state_accessor_graph_cache.clear()
# Bypass the cache entirely: every accessor resolution rebuilds the graph,
# so warm reads trip the contract assertion.
# so warm reads trip the contract assertion. The factory returns a
# LeadAgentAssembly, so this stub unwraps it exactly as the real accessor
# does — the point here is the missing cache, not a different return shape.
monkeypatch.setattr(
gateway_services,
"_state_accessor_graph",
lambda agent_factory, assistant_id, mode, snapshot_frequency, config: agent_factory(config=config),
lambda agent_factory, assistant_id, mode, snapshot_frequency, config: agent_factory(config=config).graph,
)
case = bench.ProductionCase(
mode="full",

View File

@ -173,7 +173,7 @@ def test_yaml_mode_change_is_rejected_when_graph_is_reconstructed(tmp_path, monk
write_config("full")
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setattr(checkpoint_mode, "_frozen_checkpoint_channel_mode", None)
monkeypatch.setattr(lead_agent, "_make_lead_agent", lambda config, *, app_config: object())
monkeypatch.setattr(lead_agent, "_assemble_lead_agent", lambda config, *, app_config: SimpleNamespace(graph=object()))
reset_app_config()
try:
lead_agent.make_lead_agent({"configurable": {}})
@ -215,7 +215,7 @@ def test_yaml_snapshot_frequency_change_is_rejected_when_graph_is_reconstructed(
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setattr(checkpoint_mode, "_frozen_checkpoint_channel_mode", None)
monkeypatch.setattr(checkpoint_mode, "_frozen_checkpoint_snapshot_frequency", None)
monkeypatch.setattr(lead_agent, "_make_lead_agent", lambda config, *, app_config: object())
monkeypatch.setattr(lead_agent, "_assemble_lead_agent", lambda config, *, app_config: SimpleNamespace(graph=object()))
reset_app_config()
try:
lead_agent.make_lead_agent({"configurable": {}})
@ -316,8 +316,8 @@ def test_direct_langgraph_request_cannot_select_delta_in_full_process(
monkeypatch.setattr(lead_agent, "get_app_config", lambda: app_config)
monkeypatch.setattr(
lead_agent,
"_make_lead_agent",
lambda config, *, app_config: object(),
"_assemble_lead_agent",
lambda config, *, app_config: SimpleNamespace(graph=object()),
)
lead_agent.make_lead_agent(config)
@ -343,8 +343,8 @@ def test_make_lead_agent_freezes_delta_snapshot_frequency_from_app_config(
monkeypatch.setattr(lead_agent, "get_app_config", lambda: app_config)
monkeypatch.setattr(
lead_agent,
"_make_lead_agent",
lambda config, *, app_config: object(),
"_assemble_lead_agent",
lambda config, *, app_config: SimpleNamespace(graph=object()),
)
lead_agent.make_lead_agent({"configurable": {}})
@ -379,8 +379,8 @@ def test_gateway_runtime_app_config_can_supply_its_frozen_internal_mode(
)
monkeypatch.setattr(
lead_agent,
"_make_lead_agent",
lambda config, *, app_config: object(),
"_assemble_lead_agent",
lambda config, *, app_config: SimpleNamespace(graph=object()),
)
lead_agent.make_lead_agent(config)

View File

@ -0,0 +1,215 @@
"""Compaction destroys the mapping it is observed by.
Summarization replaces N messages with one summary. After the fact, only the
summary survives, so 'which messages became this summary' is not reconstructible
from state it has to be emitted at the moment of the transform.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from deerflow_extension_api import CompactionEvent, canonical_hash
def test_event_records_both_ends_of_the_transform():
event = CompactionEvent(
transform_kind="summarization",
transform_version="1",
source_content_hashes=("h1", "h2"),
output_content_hash="h3",
compacted_message_count=2,
kept_message_count=4,
)
assert event.source_content_hashes == ("h1", "h2")
assert event.output_content_hash == "h3"
def test_source_hashes_are_a_tuple_so_the_event_cannot_be_mutated_after_emission():
event = CompactionEvent(
transform_kind="summarization",
transform_version="1",
source_content_hashes=("h1",),
output_content_hash="h3",
compacted_message_count=1,
kept_message_count=1,
)
with pytest.raises(AttributeError):
event.output_content_hash = "other"
_UNOBSERVED = object()
def _observed_extensions(observer=None):
"""A real ``LoadedExtensions`` carrying one compaction observer.
``replace`` on the ambient set rather than a hand-built stub: the
middleware reads other fields off ``_extensions`` too (the system-model
call path), so a namespace carrying only the observer tuple would pass
these tests while diverging from what the middleware is handed in
production.
"""
from dataclasses import replace
from deerflow.extensions import get_agent_build_extensions
return replace(get_agent_build_extensions(), context_compaction_observers=(("test-source", observer or (lambda event, context=None: None)),))
def test_source_hashes_are_computed_on_content_directly_not_a_stringified_copy():
"""Regression: hashing ``str(message.content)`` would defeat canonical_hash's
key-order normalization for multimodal (``list[dict]``) content, which
``view_image_middleware`` and other producers routinely inject. Two
logically identical messages whose dict content differs only in key
insertion order must hash the same.
"""
from langchain_core.messages import HumanMessage
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware
a = HumanMessage(content=[{"type": "text", "text": "hi"}, {"b": 1, "a": 2}])
b = HumanMessage(content=[{"type": "text", "text": "hi"}, {"a": 2, "b": 1}])
middleware = DeerFlowSummarizationMiddleware(model=MagicMock(), extensions=_observed_extensions())
hashes = middleware._freeze_compaction_sources([a, b])
assert hashes[0] == hashes[1]
assert hashes[0] == canonical_hash(a.content)
# str() on a dict renders insertion order, so the pre-stringified form
# this guards against would not have matched.
assert str(a.content) != str(b.content)
# --- Driving a real compaction --------------------------------------------
#
# Mirrors tests/test_summarization_middleware.py's `_messages` / `_middleware` /
# `_runtime` fixture helpers rather than inventing a second way to drive the
# middleware: a static model, `token_counter=len`, and a runtime carrying a
# plain `context` mapping.
def _messages() -> list:
from langchain_core.messages import AIMessage, HumanMessage
return [
HumanMessage(content="user-1"),
AIMessage(content="assistant-1"),
HumanMessage(content="user-2"),
AIMessage(content="assistant-2"),
]
def _runtime(thread_id: str | None = "thread-1") -> SimpleNamespace:
context = {}
if thread_id is not None:
context["thread_id"] = thread_id
return SimpleNamespace(context=context)
def _middleware(*, trigger=("messages", 4), keep=("messages", 2), extensions=_UNOBSERVED):
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware
model = MagicMock()
model.invoke.return_value = SimpleNamespace(text="compressed summary")
model.ainvoke = AsyncMock(return_value=SimpleNamespace(text="compressed summary"))
model.with_config.return_value = model
return DeerFlowSummarizationMiddleware(
model=model,
trigger=trigger,
keep=keep,
token_counter=len,
extensions=_observed_extensions() if extensions is _UNOBSERVED else extensions,
)
class TestSummarizationEmitsTheEvent:
@pytest.mark.asyncio
async def test_a_compaction_notifies_observers_once(self, monkeypatch):
from deerflow.agents.middlewares import summarization_middleware
events = []
monkeypatch.setattr(
summarization_middleware,
"notify_context_compacted",
lambda event, extensions=None: events.append(event),
)
middleware = _middleware()
result = await middleware.abefore_model({"messages": _messages()}, _runtime())
assert result is not None
assert len(events) == 1
event = events[0]
assert event.transform_kind == "summarization"
assert event.compacted_message_count == 2
assert event.kept_message_count == 2
assert event.source_content_hashes == (
canonical_hash("user-1"),
canonical_hash("assistant-1"),
)
assert event.output_content_hash == canonical_hash("compressed summary")
@pytest.mark.asyncio
async def test_no_event_is_emitted_when_the_trigger_does_not_fire(self, monkeypatch):
from deerflow.agents.middlewares import summarization_middleware
events = []
monkeypatch.setattr(
summarization_middleware,
"notify_context_compacted",
lambda event, extensions=None: events.append(event),
)
# A trigger threshold far above the message count never fires, so
# compaction never runs and the record half is never reached.
middleware = _middleware(trigger=("messages", 100))
result = await middleware.abefore_model({"messages": _messages()}, _runtime())
assert result is None
assert events == []
class TestAnInstallWithNoObserverPaysNothing:
"""Hashing the sources is an O(context-size) canonical-JSON pass.
Every install runs this middleware; almost none of them register a
compaction observer. The check cannot live in ``notify_context_compacted``
by the time it is called the hashing has already happened so the freeze
site has to make it itself.
"""
def test_the_sources_are_not_hashed_when_nothing_observes(self):
from dataclasses import replace
from deerflow.extensions import get_agent_build_extensions
unobserved = replace(get_agent_build_extensions(), context_compaction_observers=())
middleware = _middleware(extensions=unobserved)
assert middleware._freeze_compaction_sources(_messages()) == ()
def test_the_sources_are_hashed_when_an_observer_is_registered(self):
middleware = _middleware(extensions=_observed_extensions())
assert middleware._freeze_compaction_sources(_messages()) == tuple(canonical_hash(m.content) for m in _messages())
@pytest.mark.asyncio
async def test_the_compaction_itself_still_happens_unobserved(self, monkeypatch):
"""The skip must cost the run nothing but the hashes."""
from dataclasses import replace
from deerflow.agents.middlewares import summarization_middleware
from deerflow.extensions import get_agent_build_extensions
events = []
monkeypatch.setattr(summarization_middleware, "notify_context_compacted", lambda event, extensions=None: events.append(event))
unobserved = replace(get_agent_build_extensions(), context_compaction_observers=())
result = await _middleware(extensions=unobserved).abefore_model({"messages": _messages()}, _runtime())
assert result is not None, "compaction must still run; only the observation bookkeeping is skipped"
# The middleware still calls notify (which would itself no-op on the
# empty observer tuple); what it must not do is compute the hashes.
assert [e.source_content_hashes for e in events] == [()]

View File

@ -304,7 +304,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.1.2"
assert API_VERSION == "0.2.0"
assert API_VERSION == version("deerflow-extension-api")

View File

@ -0,0 +1,72 @@
"""The contract package's public surface must import cleanly and stay complete.
A typo in a re-exported name breaks `import deerflow_extension_api` for every
extension, which is a silent-until-startup failure for third parties.
"""
import importlib
def test_public_surface_imports_and_matches_all():
module = importlib.import_module("deerflow_extension_api")
for name in module.__all__:
assert hasattr(module, name), f"__all__ advertises {name!r} but it is not exported"
def test_runtime_deps_is_exported_under_its_documented_name():
from deerflow_extension_api import ExtensionRuntimeDeps
assert ExtensionRuntimeDeps.__name__ == "ExtensionRuntimeDeps"
def test_api_version_matches_the_packaging_metadata():
"""A contract version that disagrees with the wheel's version makes pip
resolution and the ``@extension`` marker disagree about the same host."""
import tomllib
from pathlib import Path
from deerflow_extension_api import API_VERSION
pyproject = Path(__file__).resolve().parents[1] / "packages" / "extension-api" / "pyproject.toml"
declared = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["version"]
assert declared == API_VERSION
def test_every_contract_kind_added_for_the_0_2_series_is_exported():
import deerflow_extension_api as api
for name in (
"AgentAssemblyDescriptor",
"AgentAssemblyObserver",
"CompactionEvent",
"ContentKind",
"ContextCompactionObserver",
"ExtensionPrincipal",
"MessageProvenance",
"MiddlewareDescriptor",
"PROVENANCE_KEYS",
"ReleasePolicyProvider",
"ToolDescriptor",
"canonical_hash",
"collect_release_policies",
"provenance_kwargs",
"read_provenance",
"require_admin",
"resolve_principal",
):
assert name in api.__all__, f"{name} must be part of the public contract"
def test_registry_protocol_declares_every_registration_method():
from deerflow_extension_api import ExtensionRegistry
for method in (
"middlewares",
"task_lifecycle",
"system_model_observer",
"agent_assembly_observer",
"context_compaction_observer",
"service",
"routers",
):
assert hasattr(ExtensionRegistry, method), f"ExtensionRegistry must declare {method}()"

View File

@ -29,6 +29,31 @@ def test_extensions_dependency_group_is_part_of_the_default_sync() -> None:
assert set(project["tool"]["uv"]["default-groups"]) == {"dev", "extensions"}
def test_the_app_layer_declares_the_contract_package_it_imports_directly() -> None:
"""The app imports ``deerflow_extension_api`` itself, so it declares it.
Only ``deerflow-harness`` guarantees the package transitively. That is the
harness's own dependency to change, and the app's imports would break with
it the same argument the ``starlette`` entry in ``pyproject.toml`` spells
out for a package FastAPI happens to pull in.
"""
import ast
importers = sorted(
str(path.relative_to(BACKEND_ROOT))
for path in (BACKEND_ROOT / "app").rglob("*.py")
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8")))
if (isinstance(node, ast.ImportFrom) and (node.module or "").split(".")[0] == "deerflow_extension_api") or (isinstance(node, ast.Import) and any(alias.name.split(".")[0] == "deerflow_extension_api" for alias in node.names))
)
if not importers:
pytest.skip("app no longer imports the contract package directly")
project = tomllib.loads((BACKEND_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
declared = {re.split(r"[<>=!\[ ]", entry, maxsplit=1)[0] for entry in project["project"]["dependencies"]}
assert "deerflow-extension-api" in declared, f"imported directly by {importers} but only guaranteed transitively"
def test_backend_make_targets_never_mutate_the_extension_lock() -> None:
makefile = BACKEND_ROOT / "Makefile"

View File

@ -288,7 +288,7 @@ def test_compatible_string_subclass_api_marker_can_load(monkeypatch):
monkeypatch.setattr(
demo_extensions.install_ok,
"__deerflow_api__",
_HostileString("0.1.0"),
_HostileString("0.2.0"),
raising=False,
)
@ -409,3 +409,89 @@ def test_host_registry_satisfies_the_public_contract():
from deerflow.extensions.registry import ExtensionRegistry as HostRegistry
assert isinstance(HostRegistry(), ContractRegistry)
class TestTablePrefixRegistration:
"""A spec's ``table_prefix`` must reach alembic's exclusion filter.
``EXTENSION_TABLE_PREFIXES`` is module-level mutable state shared with
``_env_filters``, so every test here restores it -- a test that registers
a prefix and leaves it registered would poison every later test in the
process, including the filter's own suite.
"""
def setup_method(self):
from deerflow.persistence.migrations import _env_filters
self._saved = set(_env_filters.EXTENSION_TABLE_PREFIXES)
def teardown_method(self):
from deerflow.persistence.migrations import _env_filters
_env_filters.EXTENSION_TABLE_PREFIXES.clear()
_env_filters.EXTENSION_TABLE_PREFIXES.update(self._saved)
def test_a_declared_prefix_is_registered_with_the_migration_filter(self):
from deerflow.persistence.migrations._env_filters import include_object
spec = ExtensionSpec(use=f"{_FIXTURE}:install_ok", table_prefix="ext_")
load_extensions([spec])
assert include_object(None, "ext_events", "table", True, None) is False
def test_no_declared_prefix_registers_nothing(self):
from deerflow.persistence.migrations import _env_filters
spec = ExtensionSpec(use=f"{_FIXTURE}:install_ok")
load_extensions([spec])
assert _env_filters.EXTENSION_TABLE_PREFIXES == self._saved
def test_a_disabled_specs_prefix_is_still_registered(self):
"""Tables from a previously-enabled run may still be in the database;
disabling the extension must not make autogenerate reflect them."""
from deerflow.persistence.migrations._env_filters import include_object
spec = ExtensionSpec(use=f"{_FIXTURE}:install_ok", enabled=False, table_prefix="ext_")
load_extensions([spec])
assert include_object(None, "ext_events", "table", True, None) is False
def test_a_failing_specs_prefix_is_still_registered(self):
"""A broken install() this run doesn't retroactively delete tables a
prior successful run already created."""
from deerflow.persistence.migrations._env_filters import include_object
spec = ExtensionSpec(use=f"{_FIXTURE}:install_partial_then_raise", table_prefix="ext_")
load_extensions([spec])
assert include_object(None, "ext_events", "table", True, None) is False
def test_a_prefix_that_collides_with_a_host_table_aborts_loading(self):
"""A typo such as table_prefix: "run" would silently stop alembic from
managing the host's own `runs` table. That must abort startup loudly
rather than degrade the whole host's autogenerate coverage, regardless
of `required` -- the corruption is not scoped to this one extension."""
spec = ExtensionSpec(use=f"{_FIXTURE}:install_ok", required=False, table_prefix="run")
with pytest.raises(ExtensionLoadError, match="runs"):
load_extensions([spec])
def test_an_empty_prefix_is_rejected_at_config_load(self):
"""Omit the key to declare no prefix; "" is not a way to spell that.
The declaration is read by two processes that cannot both be right
about an empty string: the loader's ``if spec.table_prefix:`` would
treat it as "no prefix", while a reader taking it literally has a
prefix that matches every table name. Rejecting it here means the
question is never asked twice and it is asked in the process an
operator is actually looking at when the Gateway refuses to start.
"""
from pydantic import ValidationError
with pytest.raises(ValidationError, match="table_prefix"):
ExtensionSpec(use=f"{_FIXTURE}:install_ok", table_prefix="")
def test_omitting_the_key_remains_the_way_to_declare_no_prefix(self):
assert ExtensionSpec(use=f"{_FIXTURE}:install_ok").table_prefix is None
assert ExtensionSpec(use=f"{_FIXTURE}:install_ok", table_prefix=None).table_prefix is None

View File

@ -0,0 +1,109 @@
"""Contributed routers need the caller's identity without importing app.*.
Every extension route is session-authenticated (contributed routers cannot
enter host-reserved or auth-exempt prefixes), but "logged in" and "admin" are
different questions and an extension must be able to ask the second one.
"""
from types import SimpleNamespace
import pytest
from deerflow_extension_api import (
EXTENSION_PRINCIPAL_RESOLVER_KEY,
ExtensionPrincipal,
require_admin,
resolve_principal,
)
ADMIN = ExtensionPrincipal(user_id="u1", is_admin=True, is_internal=False)
PLAIN = ExtensionPrincipal(user_id="u2", is_admin=False, is_internal=False)
def _request(principal):
state = SimpleNamespace(**{EXTENSION_PRINCIPAL_RESOLVER_KEY: (lambda request: principal)})
return SimpleNamespace(app=SimpleNamespace(state=state))
def test_resolve_returns_the_hosts_principal():
assert resolve_principal(_request(ADMIN)) == ADMIN
def test_resolve_returns_none_when_the_host_installed_no_resolver():
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace()))
assert resolve_principal(request) is None
def test_resolve_returns_none_rather_than_raising_when_the_resolver_fails():
def boom(request):
raise RuntimeError("nope")
state = SimpleNamespace(**{EXTENSION_PRINCIPAL_RESOLVER_KEY: boom})
request = SimpleNamespace(app=SimpleNamespace(state=state))
assert resolve_principal(request) is None
def test_require_admin_accepts_an_admin():
assert require_admin(_request(ADMIN)) == ADMIN
def test_require_admin_rejects_a_plain_user():
with pytest.raises(PermissionError):
require_admin(_request(PLAIN))
def test_require_admin_fails_closed_with_no_resolver():
"""An unanswerable authorization question must not resolve to 'allowed'."""
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace()))
with pytest.raises(PermissionError):
require_admin(request)
@pytest.fixture
def _stub_app_config(monkeypatch):
"""Keep ``create_app()`` independent of a real ``config.yaml``.
The repo-root ``config.yaml`` is gitignored; a checkout that configures
plugins there would otherwise make this test load them for real and leak
a populated extension registry into the process-global singleton other
tests read through (see ``tests/test_extension_app_loading.py`` for the
same pattern).
"""
import app.gateway.app as app_module
from deerflow.config.app_config import AppConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.extensions import reset_loaded_extensions, reset_runtime_diagnostics
config = AppConfig(sandbox=SandboxConfig(use="test"))
monkeypatch.setattr(app_module, "get_app_config", lambda: config)
reset_loaded_extensions()
reset_runtime_diagnostics()
yield
reset_runtime_diagnostics()
reset_loaded_extensions()
def test_host_installs_a_resolver_on_app_state(_stub_app_config):
from app.gateway.app import create_app
app = create_app()
assert callable(getattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, None))
def test_the_installed_resolver_projects_system_role_into_roles(_stub_app_config):
"""The host's only role concept is the single system_role column; the
projection must actually populate ``roles`` from it rather than reading a
"roles" attribute the user model never had (which would always resolve to
an empty tuple, silently breaking the documented contract)."""
from app.gateway.app import create_app
app = create_app()
resolver = getattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY)
admin_request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(id="u1", system_role="admin"), auth_source=None))
assert resolver(admin_request).roles == ("admin",)
plain_request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(id="u2", system_role="user"), auth_source=None))
assert resolver(plain_request).roles == ("user",)
no_role_request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(id="u3", system_role=None), auth_source=None))
assert resolver(no_role_request).roles == ()

View File

@ -646,15 +646,15 @@ def test_build_run_config_context_custom_agent_injects_agent_name():
assert config["configurable"]["agent_name"] == "finalis"
def test_resolve_agent_factory_returns_make_lead_agent():
"""resolve_agent_factory always returns make_lead_agent regardless of assistant_id."""
def test_resolve_agent_factory_returns_the_explicit_lead_assembly_factory():
"""Gateway workers receive the graph and its assembly descriptor together."""
from app.gateway.services import resolve_agent_factory
from deerflow.agents.lead_agent.agent import make_lead_agent
from deerflow.agents.lead_agent.agent import assemble_lead_agent
assert resolve_agent_factory(None) is make_lead_agent
assert resolve_agent_factory("lead_agent") is make_lead_agent
assert resolve_agent_factory("finalis") is make_lead_agent
assert resolve_agent_factory("custom-agent-123") is make_lead_agent
assert resolve_agent_factory(None) is assemble_lead_agent
assert resolve_agent_factory("lead_agent") is assemble_lead_agent
assert resolve_agent_factory("finalis") is assemble_lead_agent
assert resolve_agent_factory("custom-agent-123") is assemble_lead_agent
@pytest.mark.parametrize(
@ -727,6 +727,49 @@ def test_build_checkpoint_state_accessor_uses_frozen_mode_and_binds_runtime_pers
assert config["configurable"]["checkpoint_id"] == checkpoint_id
def test_build_checkpoint_state_accessor_accepts_lead_agent_assembly_factory(_stub_app_config):
"""Checkpoint reads accept the descriptor-carrying Gateway factory result."""
from types import SimpleNamespace
from unittest.mock import patch
from app.gateway.services import build_checkpoint_state_accessor
from deerflow.agents.lead_agent.agent import LeadAgentAssembly
from deerflow.config.app_config import get_app_config
class FakeGraph:
checkpointer = None
store = None
graph = FakeGraph()
assembly = LeadAgentAssembly(graph=graph, descriptor=object())
def fake_factory(*, config):
return assembly
checkpointer = object()
store = object()
ctx = SimpleNamespace(
checkpointer=checkpointer,
store=store,
checkpoint_channel_mode="full",
app_config=get_app_config(),
)
request = SimpleNamespace(state=SimpleNamespace(checkpoint_channel_mode="full"))
with (
patch("app.gateway.services.get_run_context", return_value=ctx),
patch("app.gateway.services.resolve_agent_factory", return_value=fake_factory),
):
accessor, _config = build_checkpoint_state_accessor(
request,
thread_id="thread-with-assembly-factory",
)
assert accessor.graph is graph
assert graph.checkpointer is checkpointer
assert graph.store is store
def test_state_accessor_graph_cache_keys_on_snapshot_frequency():
"""The accessor-graph cache must not serve a graph compiled at a different
delta snapshot cadence."""

View File

@ -8,6 +8,7 @@ from unittest.mock import MagicMock
import pytest
from langgraph.errors import GraphBubbleUp
from deerflow.authz.outcome import pop_authorization_outcome
from deerflow.guardrails.builtin import AllowlistProvider
from deerflow.guardrails.middleware import GuardrailMiddleware
from deerflow.guardrails.provider import GuardrailDecision, GuardrailReason, GuardrailRequest
@ -83,6 +84,28 @@ class _ExplodingProvider:
class TestAllowlistProvider:
def test_release_policy_contract_has_stable_identity_and_effective_rules(self):
provider = AllowlistProvider(
allowed_tools=["web_search", "read_file"],
denied_tools=["bash"],
)
middleware = GuardrailMiddleware(provider, fail_closed=True, passport="ops-policy")
policy = middleware.release_policy_parameters()
assert policy == {
"fail_closed": True,
"passport": "ops-policy",
"policy": {
"id": "deerflow.guardrails.allowlist",
"version": "1.0.0",
},
"provider_parameters": {
"allowed_tools": ["read_file", "web_search"],
"denied_tools": ["bash"],
},
}
def test_no_restrictions_allows_all(self):
provider = AllowlistProvider()
req = GuardrailRequest(tool_name="bash", tool_input={})
@ -703,3 +726,86 @@ class TestGuardrailsConfig:
assert config.enabled is True
finally:
reset_guardrails_config()
class TestGuardrailWritesAuthorizationOutcome:
def test_allow_writes_allowed_outcome_with_policy_identity(self):
mw = GuardrailMiddleware(_AllowAllProvider())
req = _make_tool_call_request(call_id="c1")
mw.wrap_tool_call(req, MagicMock())
outcome = pop_authorization_outcome(req.runtime.context, "c1")
assert outcome is not None
assert outcome.decision == "allowed"
assert outcome.reason_codes == ("oap.allowed",)
assert outcome.policy_id # non-empty resolved identity
def test_deny_writes_denied_outcome_with_real_policy_id(self):
mw = GuardrailMiddleware(_DenyAllProvider())
req = _make_tool_call_request(call_id="c2")
mw.wrap_tool_call(req, MagicMock())
outcome = pop_authorization_outcome(req.runtime.context, "c2")
assert outcome is not None
assert outcome.decision == "denied"
assert outcome.policy_id == "test.deny.v1"
assert "oap.denied" in outcome.reason_codes
def test_fail_closed_provider_error_writes_denied_outcome(self):
mw = GuardrailMiddleware(_ExplodingProvider(), fail_closed=True)
req = _make_tool_call_request(call_id="c3")
mw.wrap_tool_call(req, MagicMock())
outcome = pop_authorization_outcome(req.runtime.context, "c3")
assert outcome is not None and outcome.decision == "denied"
assert "oap.evaluator_error" in outcome.reason_codes
def test_async_allow_writes_allowed_outcome(self):
mw = GuardrailMiddleware(_AllowAllProvider())
req = _make_tool_call_request(call_id="c4")
async def handler(_req):
return MagicMock()
asyncio.run(mw.awrap_tool_call(req, handler))
outcome = pop_authorization_outcome(req.runtime.context, "c4")
assert outcome is not None and outcome.decision == "allowed"
def test_recording_the_outcome_does_not_recompute_the_providers_full_declaration(self):
"""Per-tool-call bookkeeping must not pay for provider_parameters.
``release_policy_parameters()`` (the middleware's own public method) is
the expensive path: for AllowlistProvider it sorts the allow/deny sets.
Building an AuthorizationOutcome only needs policy id/version, so it
must resolve those directly rather than going through the provider's
full declaration and discarding everything else.
"""
calls = 0
class _CountingProvider(AllowlistProvider):
def release_policy_parameters(self) -> dict[str, object]:
nonlocal calls
calls += 1
return super().release_policy_parameters()
mw = GuardrailMiddleware(_CountingProvider(allowed_tools=["bash"]))
req = _make_tool_call_request(call_id="c5", name="bash")
mw.wrap_tool_call(req, MagicMock())
assert calls == 0
assert mw.release_policy_parameters()["provider_parameters"] == {"allowed_tools": ["bash"], "denied_tools": []}
assert calls == 1
def test_the_outcome_store_is_bounded_so_an_unpopped_run_cannot_grow_forever(self):
"""No production caller pops outcomes today, so the store must self-limit."""
from deerflow.authz.outcome import _MAX_TRACKED_OUTCOMES
mw = GuardrailMiddleware(_AllowAllProvider())
# Seeded non-empty: _FakeRuntime's ``context or {}`` fallback would
# otherwise hand each call an unrelated fresh dict instead of this one.
context: dict = {"seed": True}
for i in range(_MAX_TRACKED_OUTCOMES + 10):
req = _make_tool_call_request(call_id=f"call-{i}", context=context)
mw.wrap_tool_call(req, MagicMock())
store = context["__authorization_outcome"]
assert len(store) == _MAX_TRACKED_OUTCOMES
assert "call-0" not in store
assert f"call-{_MAX_TRACKED_OUTCOMES + 9}" in store

View File

@ -351,9 +351,9 @@ def test_public_make_lead_agent_does_not_take_mode_from_runtime_context(monkeypa
def _capture(config, *, app_config):
captured["config"] = config
captured["app_config"] = app_config
return object()
return lead_agent_module.LeadAgentAssembly(graph=object(), descriptor=object())
monkeypatch.setattr(lead_agent_module, "_make_lead_agent", _capture)
monkeypatch.setattr(lead_agent_module, "_assemble_lead_agent", _capture)
config = {
"configurable": {"model_name": "full-model"},
"context": {

View File

@ -0,0 +1,277 @@
"""Neutral message-provenance metadata.
The host stamps which component produced an injected or rewritten message.
An observer cannot reconstruct this after the fact: by the time a message
reaches the model-call boundary, its producer is no longer recoverable.
"""
from deerflow_extension_api import (
MESSAGE_CONTENT_KIND_KEY,
MESSAGE_PRODUCER_ENTITY_ID_KEY,
MESSAGE_PRODUCER_KIND_KEY,
PROVENANCE_KEYS,
ContentKind,
provenance_kwargs,
read_provenance,
)
from langchain_core.messages import HumanMessage, SystemMessage
def test_kwargs_round_trip_through_a_message():
message = SystemMessage(
content="reminder",
additional_kwargs=provenance_kwargs(ContentKind.MIDDLEWARE_INJECTION, "dynamic_context"),
)
provenance = read_provenance(message)
assert provenance is not None
assert provenance.content_kind == "middleware_injection"
assert provenance.producer_kind == "dynamic_context"
assert provenance.producer_entity_id is None
def test_optional_fields_are_omitted_rather_than_written_as_none():
kwargs = provenance_kwargs(ContentKind.MEMORY, "dynamic_context_memory")
assert MESSAGE_PRODUCER_ENTITY_ID_KEY not in kwargs
def test_optional_fields_round_trip_when_supplied():
message = HumanMessage(
content="a durable-context data block",
additional_kwargs=provenance_kwargs(
ContentKind.DURABLE_CONTEXT,
"durable_context_data",
producer_entity_id="run-7",
),
)
provenance = read_provenance(message)
assert provenance.producer_entity_id == "run-7"
def test_read_returns_none_for_an_unstamped_message():
assert read_provenance(HumanMessage(content="hi")) is None
def test_read_returns_none_when_the_required_pair_is_incomplete():
message = HumanMessage(content="hi", additional_kwargs={MESSAGE_CONTENT_KIND_KEY: "memory"})
assert read_provenance(message) is None
def test_read_ignores_non_string_values_rather_than_raising():
message = HumanMessage(
content="hi",
additional_kwargs={MESSAGE_CONTENT_KIND_KEY: 1, MESSAGE_PRODUCER_KIND_KEY: "x"},
)
assert read_provenance(message) is None
def test_every_key_is_declared_in_the_exported_set():
assert PROVENANCE_KEYS == {
MESSAGE_CONTENT_KIND_KEY,
MESSAGE_PRODUCER_KIND_KEY,
MESSAGE_PRODUCER_ENTITY_ID_KEY,
}
def test_gateway_treats_every_provenance_key_as_server_owned():
"""A caller must not be able to forge provenance on an inbound message."""
from app.gateway.services import _SERVER_OWNED_MESSAGE_METADATA_KEYS
assert PROVENANCE_KEYS <= _SERVER_OWNED_MESSAGE_METADATA_KEYS
class TestDynamicContextStamping:
"""The date reminder and the recalled-memory block are distinct producers."""
def _inject(self):
from langchain_core.messages import HumanMessage
from deerflow.agents.middlewares.dynamic_context_middleware import DynamicContextMiddleware
middleware = DynamicContextMiddleware()
return middleware._inject({"messages": [HumanMessage(content="hello", id="u1")]})
def test_the_date_reminder_is_stamped_as_a_middleware_injection(self):
messages = self._inject()["messages"]
reminders = [m for m in messages if read_provenance(m) and read_provenance(m).content_kind == "middleware_injection"]
assert reminders, "expected the date reminder to carry provenance"
assert read_provenance(reminders[0]).producer_kind == "dynamic_context"
def test_the_users_own_message_is_never_stamped(self):
messages = self._inject()["messages"]
user_messages = [m for m in messages if m.content == "hello"]
assert user_messages
assert all(read_provenance(m) is None for m in user_messages)
class TestDynamicContextMemoryStamping:
"""The recalled-memory block is a distinct producer from the date reminder."""
def test_the_memory_block_is_stamped_as_memory(self, monkeypatch):
from langchain_core.messages import HumanMessage
from deerflow.agents.middlewares import dynamic_context_middleware as module
monkeypatch.setattr(module.DynamicContextMiddleware, "_build_full_reminder", lambda self, runtime=None: ("<system-reminder></system-reminder>", "some recalled memory"))
middleware = module.DynamicContextMiddleware()
result = middleware._inject({"messages": [HumanMessage(content="hello", id="u1")]})
memory_messages = [m for m in result["messages"] if str(m.id or "").endswith("__memory")]
assert memory_messages, "expected a memory block message"
provenance = read_provenance(memory_messages[0])
assert provenance is not None
assert provenance.content_kind == "memory"
assert provenance.producer_kind == "dynamic_context_memory"
class TestDurableContextStamping:
"""The authority contract and the data block are distinct producers."""
def _inject(self, *, summary_text: str = "a compacted summary"):
from types import SimpleNamespace
from langchain.agents.middleware.types import ModelRequest
from deerflow.agents.middlewares.durable_context_middleware import DurableContextMiddleware
middleware = DurableContextMiddleware()
request = ModelRequest(
model=SimpleNamespace(),
messages=[],
state={"summary_text": summary_text, "delegations": [], "skill_context": []},
)
return middleware._inject(request)
def test_the_authority_contract_is_stamped_as_a_middleware_injection(self):
from langchain_core.messages import SystemMessage
result = self._inject()
system_messages = [m for m in result.messages if isinstance(m, SystemMessage)]
assert system_messages, "expected the authority-contract SystemMessage"
provenance = read_provenance(system_messages[0])
assert provenance is not None
assert provenance.content_kind == "middleware_injection"
assert provenance.producer_kind == "durable_context"
def test_the_data_block_is_stamped_as_durable_context(self):
result = self._inject()
data_messages = [m for m in result.messages if "durable_context_data" in (m.additional_kwargs or {})]
assert data_messages, "expected the durable-context data block"
provenance = read_provenance(data_messages[0])
assert provenance is not None
assert provenance.content_kind == "durable_context"
assert provenance.producer_kind == "durable_context_data"
class TestSystemMessageCoalescingStamping:
"""The coalesced leading SystemMessage is stamped as a middleware injection."""
def test_the_coalesced_system_message_is_stamped(self):
from types import SimpleNamespace
from langchain.agents.middleware.types import ModelRequest
from langchain_core.messages import SystemMessage
from deerflow.agents.middlewares.system_message_coalescing_middleware import _coalesce_request
request = ModelRequest(
model=SimpleNamespace(),
messages=[SystemMessage(content="extra system block")],
system_message=SystemMessage(content="base system prompt"),
)
coalesced = _coalesce_request(request)
assert coalesced is not None
provenance = read_provenance(coalesced.system_message)
assert provenance is not None
assert provenance.content_kind == "middleware_injection"
assert provenance.producer_kind == "system_coalescing"
class TestViewImageStamping:
"""The hidden image-details message is stamped as an image payload."""
def test_the_image_context_message_is_stamped(self):
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
message = ViewImageMiddleware._create_image_context_message(["some image content"])
provenance = read_provenance(message)
assert provenance is not None
assert provenance.content_kind == "image_payload"
assert provenance.producer_kind == "view_image"
class TestSkillActivationStamping:
"""The hidden slash-skill activation reminder is stamped as a skill body."""
def test_the_activation_message_is_stamped(self):
from langchain_core.messages import HumanMessage
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
target = HumanMessage(content="/some-skill do the thing", id="u1")
message = SkillActivationMiddleware._make_activation_message(target, "activation reminder text")
provenance = read_provenance(message)
assert provenance is not None
assert provenance.content_kind == "skill_body"
assert provenance.producer_kind == "skill_activation"
class TestStateWritesCannotForgeServerOwnedMetadata:
"""The run path strips these inside ``normalize_input``.
``POST /threads/{id}/state`` writes its values straight into a checkpoint,
so without the same treatment an authenticated client can persist forged
provenance and transform trails and these keys exist precisely so a later
reader can treat them as facts about what the host did. Membership of the
key in a frozenset proves nothing on its own; these drive the stripper.
"""
@staticmethod
def _forged() -> dict:
from deerflow.agents.middlewares.tool_transform_meta import TOOL_TRANSFORMS_KEY
return {
MESSAGE_CONTENT_KIND_KEY: "memory",
MESSAGE_PRODUCER_KIND_KEY: "dynamic_context_memory",
TOOL_TRANSFORMS_KEY: [{"kind": "sanitized", "by": "ToolResultSanitizationMiddleware", "version": "1"}],
"hide_from_ui": True,
}
def test_a_forged_message_object_is_stripped(self):
from langchain_core.messages import HumanMessage
from app.gateway.services import strip_server_owned_state_metadata
values = {"messages": [HumanMessage(content="looks recalled", additional_kwargs=self._forged())]}
cleaned = strip_server_owned_state_metadata(values)["messages"][0]
assert not (PROVENANCE_KEYS & set(cleaned.additional_kwargs))
assert "deerflow_tool_transforms" not in cleaned.additional_kwargs
# Caller-owned keys must survive — this strips forgeries, not payload.
assert cleaned.additional_kwargs["hide_from_ui"] is True
assert cleaned.content == "looks recalled"
def test_a_forged_raw_dict_is_stripped(self):
"""The route forwards whatever the caller sent; it is not always coerced."""
from app.gateway.services import strip_server_owned_state_metadata
values = {"messages": [{"type": "human", "content": "looks recalled", "additional_kwargs": self._forged()}]}
cleaned = strip_server_owned_state_metadata(values)["messages"][0]
assert not (PROVENANCE_KEYS & set(cleaned["additional_kwargs"]))
assert "deerflow_tool_transforms" not in cleaned["additional_kwargs"]
assert cleaned["additional_kwargs"]["hide_from_ui"] is True
def test_unrelated_channels_pass_through_unchanged(self):
from app.gateway.services import strip_server_owned_state_metadata
values = {"title": "a thread", "todos": [{"content": "x", "status": "pending"}]}
assert strip_server_owned_state_metadata(values) == values
def test_the_state_route_actually_calls_the_stripper(self):
"""A stripper nothing calls is the same defect in a new place."""
import ast
from pathlib import Path
route = Path(__file__).resolve().parents[1] / "app/gateway/routers/threads.py"
called = {node.func.id for node in ast.walk(ast.parse(route.read_text(encoding="utf-8"))) if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)}
assert "strip_server_owned_state_metadata" in called

View File

@ -0,0 +1,238 @@
"""Middlewares describe their own behaviour-affecting parameters.
Two runs that used different limits are different runs. Reconstructing that
from outside means reading private attributes and guessing which ones matter;
each middleware declares it instead.
"""
import importlib
import pytest
from deerflow_extension_api import ReleasePolicyProvider, canonical_hash, canonical_json, collect_release_policies
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage
from langchain_core.outputs import ChatGeneration, ChatResult
def test_canonical_json_is_key_order_independent():
assert canonical_json({"b": 1, "a": 2}) == canonical_json({"a": 2, "b": 1})
def test_canonical_json_is_stable_across_processes_for_nested_values():
assert canonical_json({"a": [1, {"d": 4, "c": 3}]}) == '{"a":[1,{"c":3,"d":4}]}'
def test_canonical_hash_differs_when_a_value_differs():
assert canonical_hash({"limit": 5}) != canonical_hash({"limit": 6})
def test_canonical_json_rejects_unserialisable_values_loudly():
with pytest.raises(TypeError):
canonical_json({"f": object()})
def test_collect_skips_middlewares_that_declare_nothing():
class Silent:
pass
class Declaring:
def release_policy_parameters(self):
return {"limit": 3}
assert collect_release_policies([Silent(), Declaring()]) == {"Declaring": {"limit": 3}}
def test_collect_survives_a_middleware_whose_declaration_raises():
class Broken:
def release_policy_parameters(self):
raise RuntimeError("boom")
class Fine:
def release_policy_parameters(self):
return {"ok": True}
result = collect_release_policies([Broken(), Fine()])
assert result["Fine"] == {"ok": True}
assert result["Broken"] == {"error": "RuntimeError"}
def test_collect_survives_two_middlewares_of_the_same_class():
"""A second instance of the same class must not overwrite the first."""
class Declaring:
def __init__(self, limit):
self._limit = limit
def release_policy_parameters(self):
return {"limit": self._limit}
result = collect_release_policies([Declaring(1), Declaring(2)])
assert result == {"Declaring": {"limit": 1}, "Declaring#2": {"limit": 2}}
def test_collect_unwraps_an_isolation_style_wrapper():
"""A contributed middleware reaches the stack behind a duck-typed ``.inner``
wrapper; describing the wrapper instead of the real middleware would
collapse every extension contribution into one shared, empty entry."""
class Wrapped:
def release_policy_parameters(self):
return {"limit": 3}
class Wrapper:
def __init__(self, inner):
self.inner = inner
assert collect_release_policies([Wrapper(Wrapped())]) == {"Wrapped": {"limit": 3}}
def test_protocol_is_runtime_checkable():
class Declaring:
def release_policy_parameters(self):
return {}
assert isinstance(Declaring(), ReleasePolicyProvider)
class _StaticChatModel(BaseChatModel):
"""Minimal real ``BaseChatModel`` that never calls a provider.
Mirrors the construction-time stand-in already used by
``test_summarization_middleware.py``'s ``_StaticChatModel``: summarization
middleware construction needs a model object, but no API key or network
access, so a real (non-string) ``BaseChatModel`` subclass sidesteps
``langchain``'s ``init_chat_model`` entirely.
"""
text: str = "ok"
@property
def _llm_type(self) -> str:
return "static-test-chat-model"
def bind_tools(self, tools, **kwargs):
return self
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
return ChatResult(generations=[ChatGeneration(message=AIMessage(content=self.text))])
def _make_loop_detection_middleware():
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
return LoopDetectionMiddleware()
def _make_subagent_limit_middleware():
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
return SubagentLimitMiddleware(max_concurrent=2, max_total=6)
def _make_terminal_response_middleware():
from deerflow.agents.middlewares.terminal_response_middleware import TerminalResponseMiddleware
return TerminalResponseMiddleware()
def _make_todo_middleware():
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
return TodoMiddleware()
def _make_token_budget_middleware():
from deerflow.agents.middlewares.token_budget_middleware import TokenBudgetMiddleware
from deerflow.config.token_budget_config import TokenBudgetConfig
return TokenBudgetMiddleware(config=TokenBudgetConfig())
def _make_deferred_tool_filter_middleware():
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
return DeferredToolFilterMiddleware(deferred_names=frozenset({"tool_b", "tool_a"}), catalog_hash="catalog-1")
def _make_safety_finish_reason_middleware():
from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware
return SafetyFinishReasonMiddleware()
def _make_summarization_middleware():
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware
return DeerFlowSummarizationMiddleware(
model=_StaticChatModel(),
trigger=("messages", 4),
keep=("messages", 2),
token_counter=len,
)
def _make_tool_output_budget_middleware():
from deerflow.agents.middlewares.tool_output_budget_middleware import ToolOutputBudgetMiddleware
return ToolOutputBudgetMiddleware()
def _make_skill_activation_middleware():
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
return SkillActivationMiddleware(available_skills={"skill-b", "skill-a"}, slash_source_owner_token="test-owner-token")
def _make_system_message_coalescing_middleware():
from deerflow.agents.middlewares.system_message_coalescing_middleware import SystemMessageCoalescingMiddleware
return SystemMessageCoalescingMiddleware()
# Single source of truth for "which middlewares declare a release policy" so
# the existence check and the construct-call-hash check below can never drift
# apart into two separately-maintained middleware lists. Every entry here is
# constructible with the minimum arguments needed for a valid instance; if a
# future addition genuinely cannot be constructed in a unit test, keep its
# entry and mark it with `pytest.param(..., marks=pytest.mark.skip(reason=...))`
# instead of dropping it — a documented gap beats an invisible one.
_MIDDLEWARE_DECLARATIONS = [
("deerflow.agents.middlewares.loop_detection_middleware", "LoopDetectionMiddleware", _make_loop_detection_middleware),
("deerflow.agents.middlewares.subagent_limit_middleware", "SubagentLimitMiddleware", _make_subagent_limit_middleware),
("deerflow.agents.middlewares.terminal_response_middleware", "TerminalResponseMiddleware", _make_terminal_response_middleware),
# DeerFlow's own subclass, not the LangChain base class re-exported into
# this module under the same import path (TodoListMiddleware).
("deerflow.agents.middlewares.todo_middleware", "TodoMiddleware", _make_todo_middleware),
("deerflow.agents.middlewares.token_budget_middleware", "TokenBudgetMiddleware", _make_token_budget_middleware),
("deerflow.agents.middlewares.deferred_tool_filter_middleware", "DeferredToolFilterMiddleware", _make_deferred_tool_filter_middleware),
("deerflow.agents.middlewares.safety_finish_reason_middleware", "SafetyFinishReasonMiddleware", _make_safety_finish_reason_middleware),
("deerflow.agents.middlewares.summarization_middleware", "DeerFlowSummarizationMiddleware", _make_summarization_middleware),
("deerflow.agents.middlewares.tool_output_budget_middleware", "ToolOutputBudgetMiddleware", _make_tool_output_budget_middleware),
("deerflow.agents.middlewares.skill_activation_middleware", "SkillActivationMiddleware", _make_skill_activation_middleware),
("deerflow.agents.middlewares.system_message_coalescing_middleware", "SystemMessageCoalescingMiddleware", _make_system_message_coalescing_middleware),
]
@pytest.mark.parametrize("import_path,class_name,make_instance", _MIDDLEWARE_DECLARATIONS)
def test_middleware_declares_release_policy_parameters(import_path, class_name, make_instance):
cls = getattr(importlib.import_module(import_path), class_name)
assert hasattr(cls, "release_policy_parameters"), f"{class_name} must declare its behaviour policy"
@pytest.mark.parametrize("import_path,class_name,make_instance", _MIDDLEWARE_DECLARATIONS)
def test_middleware_release_policy_parameters_are_canonically_serialisable(import_path, class_name, make_instance):
"""A declaration that cannot be hashed is not usable as release identity.
Unlike ``test_middleware_declares_release_policy_parameters`` above (which
only checks the method exists), this constructs a real instance and calls
it for real. A set-typed or model-typed field added to any declaration
later would raise ``TypeError`` here a bare ``hasattr`` check would stay
green while the identity mechanism this slice exists to provide breaks
silently.
"""
cls = getattr(importlib.import_module(import_path), class_name)
middleware = make_instance()
assert isinstance(middleware, cls)
params = middleware.release_policy_parameters()
assert isinstance(params, dict)
canonical_hash(params)

View File

@ -89,3 +89,209 @@ def test_env_module_wires_busy_timeout_for_sqlite() -> None:
"env.py must set busy_timeout on its alembic-spawned engine; without it, cross-process bootstrap on SQLite fails fast instead of waiting for the file lock"
)
assert 'listens_for(connectable.sync_engine, "connect")' in src, "busy_timeout must be wired via an event listener so EVERY connection alembic opens gets the PRAGMA, not just one initial probe"
class TestExtensionOwnedTables:
"""Extension tables share the database but not alembic's view of it.
An extension owns its own MetaData and its own migration chain, so
autogenerate reflecting them from a live database would find them absent
from Base.metadata and propose dropping them.
Note the scope: `make migrate-rev` is already safe, because
`_autogen_revision.py` diffs against a throwaway SQLite built from the
migration chain, where no extension table exists. The exposed path is a
direct `alembic revision --autogenerate` from the migrations directory,
whose `alembic.ini` points at a real `./data/deerflow.db` the same path
`LANGGRAPH_OWNED_TABLES` covers.
"""
def setup_method(self):
from deerflow.persistence.migrations import _env_filters
self._saved = set(_env_filters.EXTENSION_TABLE_PREFIXES)
def teardown_method(self):
from deerflow.persistence.migrations import _env_filters
_env_filters.EXTENSION_TABLE_PREFIXES.clear()
_env_filters.EXTENSION_TABLE_PREFIXES.update(self._saved)
def test_a_registered_prefix_is_excluded(self):
from deerflow.persistence.migrations._env_filters import include_object, register_extension_table_prefix
register_extension_table_prefix("ext_")
assert include_object(None, "ext_events", "table", True, None) is False
def test_an_unregistered_table_is_still_included(self):
from deerflow.persistence.migrations._env_filters import include_object
assert include_object(None, "runs", "table", True, None) is True
def test_an_index_on_an_excluded_table_is_excluded_too(self):
from types import SimpleNamespace
from deerflow.persistence.migrations._env_filters import include_object, register_extension_table_prefix
register_extension_table_prefix("ext_")
index = SimpleNamespace(table=SimpleNamespace(name="ext_events"))
assert include_object(index, "ix_ext_events_seq", "index", True, None) is False
def test_a_constraint_on_an_excluded_table_is_excluded_too(self):
# A filter that drops the table but keeps its constraints still emits
# broken DDL (e.g. a dangling unique_constraint against a table
# alembic no longer believes exists).
from types import SimpleNamespace
from deerflow.persistence.migrations._env_filters import include_object, register_extension_table_prefix
register_extension_table_prefix("ext_")
constraint = SimpleNamespace(table=SimpleNamespace(name="ext_events"))
assert include_object(constraint, "uq_ext_events_seq", "unique_constraint", True, None) is False
def test_registration_rejects_an_empty_prefix(self):
import pytest
from deerflow.persistence.migrations._env_filters import register_extension_table_prefix
with pytest.raises(ValueError):
register_extension_table_prefix("")
def test_langgraph_exclusion_is_unaffected(self):
from deerflow.persistence.migrations._env_filters import include_object
assert include_object(None, "checkpoints", "table", True, None) is False
def test_registration_rejects_a_prefix_that_would_hide_a_host_table(self):
"""A typo like table_prefix: "run" would silently stop alembic from
managing the host's own `runs` / `run_events` tables. This must fail
loudly at registration time rather than degrade autogenerate silently."""
import pytest
from deerflow.persistence.migrations._env_filters import register_extension_table_prefix
with pytest.raises(ValueError, match="runs"):
register_extension_table_prefix("run")
def test_registration_accepts_a_prefix_that_matches_no_host_table(self):
from deerflow.persistence.migrations._env_filters import EXTENSION_TABLE_PREFIXES, register_extension_table_prefix
register_extension_table_prefix("acme_ext_")
assert "acme_ext_" in EXTENSION_TABLE_PREFIXES
_PREFIX_PROBE = """
import json, sys
from alembic.autogenerate import compare_metadata
from alembic.migration import MigrationContext
from sqlalchemy import Column, Integer, MetaData, String, Table, create_engine
import deerflow.persistence.models # noqa: F401 - populate Base.metadata
from deerflow.persistence.base import Base
from deerflow.persistence.migrations._env_filters import (
include_object,
register_configured_extension_table_prefixes,
)
db_path = sys.argv[1]
# An extension-owned table in the shape an extension's own chain leaves it:
# present in the database, absent from the host's Base.metadata.
foreign = MetaData()
Table("acme_events", foreign, Column("id", Integer, primary_key=True), Column("body", String(16)))
engine = create_engine("sqlite:///" + db_path)
foreign.create_all(engine)
registered = register_configured_extension_table_prefixes()
with engine.connect() as conn:
ctx = MigrationContext.configure(conn, opts={"include_object": include_object, "compare_type": False})
diffs = compare_metadata(ctx, Base.metadata)
dropped = [op[1].name for op in diffs if isinstance(op, tuple) and op and op[0] == "remove_table"]
print(json.dumps({"registered": list(registered), "dropped": dropped}))
"""
class TestPrefixesReachTheAlembicProcess:
"""The filter is only as good as whatever populates it.
Every other test here registers a prefix in-process and then asserts
``include_object``. Those pass for a real reason the filter works and
still missed that the sole caller of ``register_extension_table_prefix``
was ``load_extensions()``, which runs in the Gateway and never in the
alembic process, leaving the set empty exactly where alembic reads it.
These cover the wiring rather than the filter.
"""
def _probe(self, tmp_path, config_yaml: str) -> dict:
import json
import os
import subprocess
import sys
from pathlib import Path
(tmp_path / "config.yaml").write_text(config_yaml, encoding="utf-8")
script = tmp_path / "probe.py"
script.write_text(_PREFIX_PROBE, encoding="utf-8")
backend = Path(__file__).resolve().parents[1]
proc = subprocess.run(
[sys.executable, str(script), str(tmp_path / "probe.db")],
cwd=str(backend),
env={**os.environ, "DEER_FLOW_CONFIG_PATH": str(tmp_path / "config.yaml"), "PYTHONPATH": str(backend)},
capture_output=True,
text=True,
timeout=180,
)
assert proc.returncode == 0, f"probe failed:\n{proc.stdout}\n{proc.stderr}"
return json.loads(proc.stdout.strip().splitlines()[-1])
def test_a_declared_prefix_survives_into_a_fresh_process(self, tmp_path):
"""No Gateway has run here, so nothing called load_extensions()."""
result = self._probe(tmp_path, "plugins:\n - use: acme_ext:install\n table_prefix: acme_\n")
assert result["registered"] == ["acme_"]
assert "acme_events" not in result["dropped"], "autogenerate proposed dropping an extension-owned table"
def test_an_undeclared_table_is_still_proposed_for_dropping(self, tmp_path):
"""The control. Without it the test above could pass on a filter that excludes everything."""
result = self._probe(tmp_path, "plugins: []\n")
assert result["registered"] == []
assert "acme_events" in result["dropped"], "expected an undeclared foreign table to be reflected and dropped"
def test_an_empty_prefix_does_not_take_the_alembic_process_down(self, tmp_path):
"""One declaration must not mean two different things in two processes.
This reader parses raw YAML so it never imports the extension, which
also means ``ExtensionSpec`` never validates what it sees. Rejecting an
empty prefix here would surface a config-schema error from the wrong
process twice over: an operator does not expect to hear about a
malformed ``config.yaml`` from alembic, and Gateway startup runs this
same module through ``bootstrap_schema`` so a raise would take the
Gateway down with a message about migrations. ``ExtensionSpec`` owns
that verdict (see the paired test below).
"""
result = self._probe(tmp_path, 'plugins:\n - use: acme_ext:install\n table_prefix: ""\n')
assert result["registered"] == []
# And it degrades to the no-prefix behaviour rather than to
# "" matching every table name.
assert "acme_events" in result["dropped"]
def test_a_non_string_prefix_does_not_take_the_alembic_process_down(self, tmp_path):
result = self._probe(tmp_path, "plugins:\n - use: acme_ext:install\n table_prefix: 7\n")
assert result["registered"] == []
def test_env_module_registers_configured_prefixes(self):
"""``env.py`` cannot be imported outside alembic, so pin the call in its source."""
import ast
from pathlib import Path
env_py = Path(__file__).resolve().parents[1] / "packages/harness/deerflow/persistence/migrations/env.py"
called = {node.func.id for node in ast.walk(ast.parse(env_py.read_text(encoding="utf-8"))) if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)}
assert "register_configured_extension_table_prefixes" in called, "env.py must populate the prefix set; include_object reads it in that process"

View File

@ -196,6 +196,8 @@ class TestCoalesceRequest:
"source": "prompt",
"hide_from_ui": True,
"dynamic_context_reminder": True,
"deerflow_content_kind": "middleware_injection",
"deerflow_producer_kind": "system_coalescing",
}
def test_merged_kwargs_later_parts_override(self):

View File

@ -1417,7 +1417,8 @@ class TestBudgetContentSandboxDispatch:
sandbox=sb,
)
assert result is not None
assert "Full remote_executor output saved to /mnt/user-data/outputs/" in result
assert "Full remote_executor output saved to /mnt/user-data/outputs/" in result[0]
assert result[1] == "externalized"
# Mounted path must NOT touch the sandbox.
assert sb.commands == []
assert sb.writes == []
@ -1445,7 +1446,8 @@ class TestBudgetContentSandboxDispatch:
sandbox=sb,
)
assert result is not None
assert "Full remote_executor output saved to /mnt/user-data/outputs/" in result
assert "Full remote_executor output saved to /mnt/user-data/outputs/" in result[0]
assert result[1] == "externalized"
# Non-mounted path MUST write into the sandbox.
assert sb.writes and sb.writes[0][1] == "x" * 500
# And MUST NOT touch the host.
@ -1474,7 +1476,8 @@ class TestBudgetContentSandboxDispatch:
sandbox=None,
)
assert result is not None
assert "Persistent storage unavailable" in result
assert "Persistent storage unavailable" in result[0]
assert result[1] == "truncated"
class TestResolveSandbox:
@ -1586,6 +1589,7 @@ class TestBudgetContentNoSandboxNoProviderCall:
sandbox=None,
)
assert result is not None
assert "Full remote_executor output saved to /mnt/user-data/outputs/" in result
assert "Full remote_executor output saved to /mnt/user-data/outputs/" in result[0]
assert result[1] == "externalized"
assert called["n"] == 0
assert (tmp_path / ".tool-results").is_dir()

View File

@ -0,0 +1,74 @@
"""Declared transform trail for tool results.
Middlewares between the raw callable and the model-visible result append an
entry, so a consumer classifies raw -> visible transforms from facts instead of
sniffing output wording.
"""
from langchain_core.messages import ToolMessage
from deerflow.agents.middlewares.tool_transform_meta import (
TOOL_TRANSFORMS_KEY,
append_tool_transform,
read_tool_transforms,
)
def test_entries_are_ordered_by_application():
kwargs: dict = {}
append_tool_transform(kwargs, "sanitized", by="ToolResultSanitizationMiddleware")
append_tool_transform(kwargs, "truncated", by="ToolOutputBudgetMiddleware")
assert [entry["kind"] for entry in kwargs[TOOL_TRANSFORMS_KEY]] == ["sanitized", "truncated"]
def test_read_returns_empty_for_an_untagged_message():
assert read_tool_transforms(ToolMessage(content="x", tool_call_id="1")) == ()
def test_read_ignores_a_malformed_trail_rather_than_raising():
message = ToolMessage(content="x", tool_call_id="1", additional_kwargs={TOOL_TRANSFORMS_KEY: "not-a-list"})
assert read_tool_transforms(message) == ()
def test_read_drops_entries_without_a_string_kind():
message = ToolMessage(
content="x",
tool_call_id="1",
additional_kwargs={TOOL_TRANSFORMS_KEY: [{"kind": "ok", "by": "m"}, {"by": "m"}, "junk"]},
)
assert read_tool_transforms(message) == ({"kind": "ok", "by": "m"},)
def test_mcp_source_projection_is_credential_free_and_defaults_transport():
from langchain_core.tools import tool as make_tool
from deerflow.tools.mcp_metadata import get_mcp_source, tag_mcp_tool
@make_tool
def probe(x: str) -> str:
"""probe"""
return x
tag_mcp_tool(probe, server_name="files", transport=None)
assert get_mcp_source(probe) == {"server_name": "files", "transport": "unknown"}
def test_gateway_treats_the_transform_trail_as_server_owned():
"""A caller must not be able to forge a transform trail on an inbound message."""
from app.gateway.services import _SERVER_OWNED_MESSAGE_METADATA_KEYS
assert TOOL_TRANSFORMS_KEY in _SERVER_OWNED_MESSAGE_METADATA_KEYS
def test_mcp_source_is_absent_when_no_server_name_is_supplied():
from langchain_core.tools import tool as make_tool
from deerflow.tools.mcp_metadata import get_mcp_source, tag_mcp_tool
@make_tool
def probe(x: str) -> str:
"""probe"""
return x
tag_mcp_tool(probe)
assert get_mcp_source(probe) is None

4
backend/uv.lock generated
View File

@ -807,6 +807,7 @@ version = "2.1.0"
source = { virtual = "." }
dependencies = [
{ name = "bcrypt" },
{ name = "deerflow-extension-api" },
{ name = "deerflow-harness" },
{ name = "dingtalk-stream" },
{ name = "e2b-code-interpreter" },
@ -867,6 +868,7 @@ dev = [
requires-dist = [
{ name = "bcrypt", specifier = ">=4.0.0" },
{ name = "coincurve", marker = "extra == 'buzz'", specifier = ">=20.0.0" },
{ name = "deerflow-extension-api", editable = "packages/extension-api" },
{ name = "deerflow-harness", editable = "packages/harness" },
{ name = "deerflow-harness", extras = ["browser"], marker = "extra == 'browser'", editable = "packages/harness" },
{ name = "deerflow-harness", extras = ["memory-zh"], marker = "extra == 'memory-zh'", editable = "packages/harness" },
@ -910,7 +912,7 @@ extensions = []
[[package]]
name = "deerflow-extension-api"
version = "0.1.2"
version = "0.2.0"
source = { editable = "packages/extension-api" }
[[package]]

View File

@ -2619,5 +2619,15 @@ authorization:
# enabled: true # false skips import and registration
# required: false # true makes load failure abort Gateway startup
# # (install --required opts in)
# table_prefix: example_ext_ # optional: table-name prefix this extension
# # owns under its own MetaData/migration chain.
# # Keeps `alembic revision --autogenerate`, run
# # directly against a live database, from seeing
# # those tables and proposing to drop them.
# # (`make migrate-rev` diffs a throwaway SQLite
# # built from the migration chain, so it never
# # sees them in the first place.)
# # Rejected at startup if empty, or if it would
# # also match a host-owned table name.
# config:
# label: example # extension-private values, if any

View File

@ -18,7 +18,7 @@ from deerflow_extension_example.plugin import (
__all__ = ["install"]
@extension(api="0.1.2", name="example")
@extension(api="0.2.0", name="example")
def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None:
"""Register one example of each supported contribution kind."""
if config.get("enabled", True) is False:

View File

@ -5,7 +5,7 @@ description = "A compact standalone example covering every DeerFlow extension co
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"deerflow-extension-api>=0.1.2,<0.2",
"deerflow-extension-api>=0.2,<0.3",
"fastapi>=0.115.0,<1",
"langchain>=1.3,<2",
"langgraph>=1.2.9,<1.3",

View File

@ -7,5 +7,5 @@ def test_installed_distribution_exposes_deerflow_extension_entry_point() -> None
assert [(entry_point.name, entry_point.value) for entry_point in entry_points] == [("example", "deerflow_extension_example:install")]
install = entry_points[0].load()
assert install.__deerflow_api__ == "0.1.2"
assert install.__deerflow_api__ == "0.2.0"
assert install.__deerflow_name__ == "example"

View File

@ -29,6 +29,8 @@ class FakeRegistry:
self.middleware_contributors: list[Any] = []
self.task_lifecycle_contributors: list[Any] = []
self.system_model_observers: list[Any] = []
self.agent_assembly_observers: list[Any] = []
self.context_compaction_observers: list[Any] = []
self.services: list[Any] = []
self.contributed_routers: list[Any] = []
@ -41,6 +43,12 @@ class FakeRegistry:
def system_model_observer(self, observer: Any) -> None:
self.system_model_observers.append(observer)
def agent_assembly_observer(self, observer: Any) -> None:
self.agent_assembly_observers.append(observer)
def context_compaction_observer(self, observer: Any) -> None:
self.context_compaction_observers.append(observer)
def service(self, service: Any) -> None:
self.services.append(service)
@ -70,7 +78,7 @@ def test_install_registers_all_five_contribution_kinds() -> None:
assert len(registry.services) == 1
assert len(registry.contributed_routers) == 1
assert [route.path for route in registry.contributed_routers[0].routes] == ["/api/extension-example/stats"]
assert install.__deerflow_api__ == "0.1.2"
assert install.__deerflow_api__ == "0.2.0"
assert install.__deerflow_name__ == "example"