diff --git a/README.md b/README.md index 55f871821..a8323d7fc 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 0c3dd6b1e..8d2daed44 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -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) diff --git a/backend/app/gateway/routers/threads.py b/backend/app/gateway/routers/threads.py index f94075d7d..43f09580b 100644 --- a/backend/app/gateway/routers/threads.py +++ b/backend/app/gateway/routers/threads.py @@ -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) diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py index 1a7495505..f7335f2b4 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -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 diff --git a/backend/extension_test_fixtures/demo_extensions.py b/backend/extension_test_fixtures/demo_extensions.py index 5f978bf59..106c811d0 100644 --- a/backend/extension_test_fixtures/demo_extensions.py +++ b/backend/extension_test_fixtures/demo_extensions.py @@ -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.""" diff --git a/backend/packages/extension-api/deerflow_extension_api/__init__.py b/backend/packages/extension-api/deerflow_extension_api/__init__.py index 17a1dc728..fff734d60 100644 --- a/backend/packages/extension-api/deerflow_extension_api/__init__.py +++ b/backend/packages/extension-api/deerflow_extension_api/__init__.py @@ -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", ] diff --git a/backend/packages/extension-api/deerflow_extension_api/assembly.py b/backend/packages/extension-api/deerflow_extension_api/assembly.py new file mode 100644 index 000000000..a776fa80c --- /dev/null +++ b/backend/packages/extension-api/deerflow_extension_api/assembly.py @@ -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 diff --git a/backend/packages/extension-api/deerflow_extension_api/auth.py b/backend/packages/extension-api/deerflow_extension_api/auth.py new file mode 100644 index 000000000..dda87fbb2 --- /dev/null +++ b/backend/packages/extension-api/deerflow_extension_api/auth.py @@ -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 diff --git a/backend/packages/extension-api/deerflow_extension_api/compaction.py b/backend/packages/extension-api/deerflow_extension_api/compaction.py new file mode 100644 index 000000000..3a4c233c3 --- /dev/null +++ b/backend/packages/extension-api/deerflow_extension_api/compaction.py @@ -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 diff --git a/backend/packages/extension-api/deerflow_extension_api/contracts.py b/backend/packages/extension-api/deerflow_extension_api/contracts.py index 441cb7667..09abbc56b 100644 --- a/backend/packages/extension-api/deerflow_extension_api/contracts.py +++ b/backend/packages/extension-api/deerflow_extension_api/contracts.py @@ -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 diff --git a/backend/packages/extension-api/deerflow_extension_api/provenance.py b/backend/packages/extension-api/deerflow_extension_api/provenance.py new file mode 100644 index 000000000..8a17fc207 --- /dev/null +++ b/backend/packages/extension-api/deerflow_extension_api/provenance.py @@ -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, + ) diff --git a/backend/packages/extension-api/deerflow_extension_api/release.py b/backend/packages/extension-api/deerflow_extension_api/release.py new file mode 100644 index 000000000..e12d3be53 --- /dev/null +++ b/backend/packages/extension-api/deerflow_extension_api/release.py @@ -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": ""}`` + 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 diff --git a/backend/packages/extension-api/pyproject.toml b/backend/packages/extension-api/pyproject.toml index 4a1b0652d..a9abd0dce 100644 --- a/backend/packages/extension-api/pyproject.toml +++ b/backend/packages/extension-api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "deerflow-extension-api" -version = "0.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 diff --git a/backend/packages/harness/deerflow/agents/AGENTS.md b/backend/packages/harness/deerflow/agents/AGENTS.md index 561cc279c..6f22a928e 100644 --- a/backend/packages/harness/deerflow/agents/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/AGENTS.md @@ -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 diff --git a/backend/packages/harness/deerflow/agents/assembly_descriptor.py b/backend/packages/harness/deerflow/agents/assembly_descriptor.py new file mode 100644 index 000000000..e26406e73 --- /dev/null +++ b/backend/packages/harness/deerflow/agents/assembly_descriptor.py @@ -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", +] diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index ee82892a9..3175247a2 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -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, + }, + ) diff --git a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md index 887995ed6..1579b53a5 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md @@ -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. ``) 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 (`< 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: diff --git a/backend/packages/harness/deerflow/agents/middlewares/durable_context_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/durable_context_middleware.py index 8a97a58f4..942edb180 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/durable_context_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/durable_context_middleware.py @@ -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"), }, ), ], diff --git a/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py index e28e6c3be..a69d88396 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py @@ -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"), + }, ) ) diff --git a/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py index 1d106769e..3f0efcc2f 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py @@ -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.""" diff --git a/backend/packages/harness/deerflow/agents/middlewares/safety_finish_reason_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/safety_finish_reason_middleware.py index 1a83d0764..19d25f1a6 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/safety_finish_reason_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/safety_finish_reason_middleware.py @@ -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 diff --git a/backend/packages/harness/deerflow/agents/middlewares/skill_activation_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/skill_activation_middleware.py index 57cdcaeb6..ebc8d8cb9 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/skill_activation_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/skill_activation_middleware.py @@ -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 diff --git a/backend/packages/harness/deerflow/agents/middlewares/subagent_limit_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/subagent_limit_middleware.py index 62b615d15..35ae913bf 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/subagent_limit_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/subagent_limit_middleware.py @@ -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: diff --git a/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py index e4069d321..c57be988b 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py @@ -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), diff --git a/backend/packages/harness/deerflow/agents/middlewares/system_message_coalescing_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/system_message_coalescing_middleware.py index 054153afb..50d2cdc4a 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/system_message_coalescing_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/system_message_coalescing_middleware.py @@ -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) diff --git a/backend/packages/harness/deerflow/agents/middlewares/terminal_response_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/terminal_response_middleware.py index 8c0e69320..e47b7956b 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/terminal_response_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/terminal_response_middleware.py @@ -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) diff --git a/backend/packages/harness/deerflow/agents/middlewares/todo_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/todo_middleware.py index fea19d424..3961d7213 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/todo_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/todo_middleware.py @@ -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, diff --git a/backend/packages/harness/deerflow/agents/middlewares/token_budget_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/token_budget_middleware.py index 7518ba5b7..ff4deead8 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/token_budget_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/token_budget_middleware.py @@ -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) diff --git a/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py index 0832eab9a..a29d20597 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py @@ -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) diff --git a/backend/packages/harness/deerflow/agents/middlewares/tool_result_sanitization_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/tool_result_sanitization_middleware.py index 49bcca12a..5c437aecf 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/tool_result_sanitization_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/tool_result_sanitization_middleware.py @@ -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: diff --git a/backend/packages/harness/deerflow/agents/middlewares/tool_transform_meta.py b/backend/packages/harness/deerflow/agents/middlewares/tool_transform_meta.py new file mode 100644 index 000000000..870f033ec --- /dev/null +++ b/backend/packages/harness/deerflow/agents/middlewares/tool_transform_meta.py @@ -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 +raw→visible 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)) diff --git a/backend/packages/harness/deerflow/agents/middlewares/view_image_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/view_image_middleware.py index e042fbd47..d1f1a4225 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/view_image_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/view_image_middleware.py @@ -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"), }, ) diff --git a/backend/packages/harness/deerflow/authz/outcome.py b/backend/packages/harness/deerflow/authz/outcome.py new file mode 100644 index 000000000..b07d95281 --- /dev/null +++ b/backend/packages/harness/deerflow/authz/outcome.py @@ -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) diff --git a/backend/packages/harness/deerflow/extensions/AGENTS.md b/backend/packages/harness/deerflow/extensions/AGENTS.md index 229ebe7e9..60fc2eaed 100644 --- a/backend/packages/harness/deerflow/extensions/AGENTS.md +++ b/backend/packages/harness/deerflow/extensions/AGENTS.md @@ -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` diff --git a/backend/packages/harness/deerflow/extensions/gateway.py b/backend/packages/harness/deerflow/extensions/gateway.py index 8aaec6541..027016cc3 100644 --- a/backend/packages/harness/deerflow/extensions/gateway.py +++ b/backend/packages/harness/deerflow/extensions/gateway.py @@ -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 diff --git a/backend/packages/harness/deerflow/extensions/loader.py b/backend/packages/harness/deerflow/extensions/loader.py index fef1b67f8..f27c38588 100644 --- a/backend/packages/harness/deerflow/extensions/loader.py +++ b/backend/packages/harness/deerflow/extensions/loader.py @@ -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 diff --git a/backend/packages/harness/deerflow/extensions/notify.py b/backend/packages/harness/deerflow/extensions/notify.py index b4933bb8d..f51cd7184 100644 --- a/backend/packages/harness/deerflow/extensions/notify.py +++ b/backend/packages/harness/deerflow/extensions/notify.py @@ -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, + ) diff --git a/backend/packages/harness/deerflow/extensions/registry.py b/backend/packages/harness/deerflow/extensions/registry.py index ae5882152..900053110 100644 --- a/backend/packages/harness/deerflow/extensions/registry.py +++ b/backend/packages/harness/deerflow/extensions/registry.py @@ -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), ) diff --git a/backend/packages/harness/deerflow/guardrails/builtin.py b/backend/packages/harness/deerflow/guardrails/builtin.py index c1575cc0c..8935b12f0 100644 --- a/backend/packages/harness/deerflow/guardrails/builtin.py +++ b/backend/packages/harness/deerflow/guardrails/builtin.py @@ -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")]) diff --git a/backend/packages/harness/deerflow/guardrails/middleware.py b/backend/packages/harness/deerflow/guardrails/middleware.py index a8b650695..08ce7be38 100644 --- a/backend/packages/harness/deerflow/guardrails/middleware.py +++ b/backend/packages/harness/deerflow/guardrails/middleware.py @@ -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( diff --git a/backend/packages/harness/deerflow/mcp/tools.py b/backend/packages/harness/deerflow/mcp/tools.py index 9ce63caa2..0a2af312b 100644 --- a/backend/packages/harness/deerflow/mcp/tools.py +++ b/backend/packages/harness/deerflow/mcp/tools.py @@ -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) diff --git a/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md b/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md index 2369c3723..245719cd1 100644 --- a/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md +++ b/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md @@ -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="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) diff --git a/backend/packages/harness/deerflow/persistence/migrations/_env_filters.py b/backend/packages/harness/deerflow/persistence/migrations/_env_filters.py index 35b7f398f..6551a161f 100644 --- a/backend/packages/harness/deerflow/persistence/migrations/_env_filters.py +++ b/backend/packages/harness/deerflow/persistence/migrations/_env_filters.py @@ -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 diff --git a/backend/packages/harness/deerflow/persistence/migrations/env.py b/backend/packages/harness/deerflow/persistence/migrations/env.py index 8e4f8c1d1..035150310 100644 --- a/backend/packages/harness/deerflow/persistence/migrations/env.py +++ b/backend/packages/harness/deerflow/persistence/migrations/env.py @@ -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 diff --git a/backend/packages/harness/deerflow/runtime/runs/worker.py b/backend/packages/harness/deerflow/runtime/runs/worker.py index 514b520a1..d21cf4958 100644 --- a/backend/packages/harness/deerflow/runtime/runs/worker.py +++ b/backend/packages/harness/deerflow/runtime/runs/worker.py @@ -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, diff --git a/backend/packages/harness/deerflow/subagents/executor.py b/backend/packages/harness/deerflow/subagents/executor.py index 45b88743e..8eaae8622 100644 --- a/backend/packages/harness/deerflow/subagents/executor.py +++ b/backend/packages/harness/deerflow/subagents/executor.py @@ -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)) diff --git a/backend/packages/harness/deerflow/tools/mcp_metadata.py b/backend/packages/harness/deerflow/tools/mcp_metadata.py index e3de48b66..ac1691970 100644 --- a/backend/packages/harness/deerflow/tools/mcp_metadata.py +++ b/backend/packages/harness/deerflow/tools/mcp_metadata.py @@ -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 = { diff --git a/backend/packages/harness/pyproject.toml b/backend/packages/harness/pyproject.toml index 56997177e..f32e5c288 100644 --- a/backend/packages/harness/pyproject.toml +++ b/backend/packages/harness/pyproject.toml @@ -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", diff --git a/backend/pyproject.toml b/backend/pyproject.toml index aa91c2d81..fb2ed77a3 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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", diff --git a/backend/tests/test_agent_assembly_descriptor.py b/backend/tests/test_agent_assembly_descriptor.py new file mode 100644 index 000000000..da61066e0 --- /dev/null +++ b/backend/tests/test_agent_assembly_descriptor.py @@ -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 diff --git a/backend/tests/test_authorization_outcome.py b/backend/tests/test_authorization_outcome.py new file mode 100644 index 000000000..d3fdf0e82 --- /dev/null +++ b/backend/tests/test_authorization_outcome.py @@ -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 diff --git a/backend/tests/test_bench_checkpoint_production.py b/backend/tests/test_bench_checkpoint_production.py index 05b9b101a..15a67ae04 100644 --- a/backend/tests/test_bench_checkpoint_production.py +++ b/backend/tests/test_bench_checkpoint_production.py @@ -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", diff --git a/backend/tests/test_checkpoint_mode.py b/backend/tests/test_checkpoint_mode.py index 8b04e034d..a3ee0d17c 100644 --- a/backend/tests/test_checkpoint_mode.py +++ b/backend/tests/test_checkpoint_mode.py @@ -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) diff --git a/backend/tests/test_context_compaction_observation.py b/backend/tests/test_context_compaction_observation.py new file mode 100644 index 000000000..690ae3800 --- /dev/null +++ b/backend/tests/test_context_compaction_observation.py @@ -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] == [()] diff --git a/backend/tests/test_extension_api_contracts.py b/backend/tests/test_extension_api_contracts.py index e9ece1246..268e4927b 100644 --- a/backend/tests/test_extension_api_contracts.py +++ b/backend/tests/test_extension_api_contracts.py @@ -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") diff --git a/backend/tests/test_extension_api_surface.py b/backend/tests/test_extension_api_surface.py new file mode 100644 index 000000000..0de722a72 --- /dev/null +++ b/backend/tests/test_extension_api_surface.py @@ -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}()" diff --git a/backend/tests/test_extension_dependency_sync.py b/backend/tests/test_extension_dependency_sync.py index 300eb8bc6..8444d44bc 100644 --- a/backend/tests/test_extension_dependency_sync.py +++ b/backend/tests/test_extension_dependency_sync.py @@ -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" diff --git a/backend/tests/test_extension_loader.py b/backend/tests/test_extension_loader.py index 5deea5471..0bed655f1 100644 --- a/backend/tests/test_extension_loader.py +++ b/backend/tests/test_extension_loader.py @@ -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 diff --git a/backend/tests/test_extension_route_principal.py b/backend/tests/test_extension_route_principal.py new file mode 100644 index 000000000..0c3fb57c9 --- /dev/null +++ b/backend/tests/test_extension_route_principal.py @@ -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 == () diff --git a/backend/tests/test_gateway_services.py b/backend/tests/test_gateway_services.py index 89ee164b2..062b98e86 100644 --- a/backend/tests/test_gateway_services.py +++ b/backend/tests/test_gateway_services.py @@ -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.""" diff --git a/backend/tests/test_guardrail_middleware.py b/backend/tests/test_guardrail_middleware.py index c38c3a45b..6c743ae34 100644 --- a/backend/tests/test_guardrail_middleware.py +++ b/backend/tests/test_guardrail_middleware.py @@ -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 diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py index d6b5f1fc2..3f2423e61 100644 --- a/backend/tests/test_lead_agent_model_resolution.py +++ b/backend/tests/test_lead_agent_model_resolution.py @@ -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": { diff --git a/backend/tests/test_message_provenance.py b/backend/tests/test_message_provenance.py new file mode 100644 index 000000000..2ee7e88cb --- /dev/null +++ b/backend/tests/test_message_provenance.py @@ -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: ("", "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 diff --git a/backend/tests/test_middleware_release_policy.py b/backend/tests/test_middleware_release_policy.py new file mode 100644 index 000000000..500ed8cea --- /dev/null +++ b/backend/tests/test_middleware_release_policy.py @@ -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) diff --git a/backend/tests/test_persistence_migrations_env.py b/backend/tests/test_persistence_migrations_env.py index 4ca4cd7e1..f925ef569 100644 --- a/backend/tests/test_persistence_migrations_env.py +++ b/backend/tests/test_persistence_migrations_env.py @@ -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" diff --git a/backend/tests/test_system_message_coalescing_middleware.py b/backend/tests/test_system_message_coalescing_middleware.py index 51a9c7ad5..4c1147b0a 100644 --- a/backend/tests/test_system_message_coalescing_middleware.py +++ b/backend/tests/test_system_message_coalescing_middleware.py @@ -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): diff --git a/backend/tests/test_tool_output_budget_middleware.py b/backend/tests/test_tool_output_budget_middleware.py index da6476139..0615c493c 100644 --- a/backend/tests/test_tool_output_budget_middleware.py +++ b/backend/tests/test_tool_output_budget_middleware.py @@ -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() diff --git a/backend/tests/test_tool_transform_meta.py b/backend/tests/test_tool_transform_meta.py new file mode 100644 index 000000000..71e904679 --- /dev/null +++ b/backend/tests/test_tool_transform_meta.py @@ -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 diff --git a/backend/uv.lock b/backend/uv.lock index 608dc8d1b..9418e4a28 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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]] diff --git a/config.example.yaml b/config.example.yaml index 5df01467c..b456b1f1c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 diff --git a/examples/deerflow-extension-example/deerflow_extension_example/__init__.py b/examples/deerflow-extension-example/deerflow_extension_example/__init__.py index eb14d3025..800fda7a2 100644 --- a/examples/deerflow-extension-example/deerflow_extension_example/__init__.py +++ b/examples/deerflow-extension-example/deerflow_extension_example/__init__.py @@ -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: diff --git a/examples/deerflow-extension-example/pyproject.toml b/examples/deerflow-extension-example/pyproject.toml index 0d9821664..9f9fa4919 100644 --- a/examples/deerflow-extension-example/pyproject.toml +++ b/examples/deerflow-extension-example/pyproject.toml @@ -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", diff --git a/examples/deerflow-extension-example/tests/test_entry_point.py b/examples/deerflow-extension-example/tests/test_entry_point.py index 6497a4e45..40794367f 100644 --- a/examples/deerflow-extension-example/tests/test_entry_point.py +++ b/examples/deerflow-extension-example/tests/test_entry_point.py @@ -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" diff --git a/examples/deerflow-extension-example/tests/test_plugin.py b/examples/deerflow-extension-example/tests/test_plugin.py index 555c7858f..7707d75ef 100644 --- a/examples/deerflow-extension-example/tests/test_plugin.py +++ b/examples/deerflow-extension-example/tests/test_plugin.py @@ -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"