From 13f0a7f2636dc6bb056eeba3a3a0ed20e44a230c Mon Sep 17 00:00:00 2001 From: Nan Gao Date: Sat, 22 Aug 2026 18:57:12 -0700 Subject: [PATCH] feat(extensions): let an out-of-tree extension observe what the agent did (#4863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(extensions): let an out-of-tree extension observe what the agent did DeerFlow's extension system can contribute middleware, services and routes, but an extension cannot answer basic questions about a run without reaching into host internals. Several of the facts it would need are destroyed by the operations that produce them: * The middleware chain injects and rewrites a lot of context — date reminders, recalled memory, compaction summaries, durable-context data, image payloads, activated skill bodies. Downstream, none of it is attributable: at the model-call boundary an injected HumanMessage is indistinguishable from the user's own, and anything wanting to tell them apart has to pattern-match prompt wording, which breaks on the next copy edit. * Two runs of "the same agent" are only comparable if the chain enforced the same limits, prompts and thresholds. Recovering that from outside means reading private attributes and guessing which of them change behaviour — a guess that rots silently as middlewares gain fields. * The lead-agent factory resolves a model after runtime overrides, renders a prompt, filters tools through authorization and composes a stack, all inside one synchronous call, and none of it survives: a middleware sees its neighbours but not the prompt, the run worker sees a graph but not what went into it. * Summarization is destructive by design. N messages leave the context and one summary enters it; afterwards only the summary exists, so "which messages became this?" is not reconstructible. This adds seven neutral facilities so those facts are recorded where they are still true, and releases the contract package as 0.2.0. Message provenance Producers stamp `deerflow_content_kind` / `deerflow_producer_kind` onto the messages they inject or rewrite. Stamping is unconditional — a fact whose presence depends on whether an observer is installed is not a fact — and the keys are server-owned, so provenance cannot be forged from a request. Middleware self-description Twelve middlewares declare their own behaviour-affecting parameters through a duck-typed `release_policy_parameters()`. Long text is hashed rather than embedded: a declaration is an identity, not a copy of the prompt. Agent assembly descriptor `assemble_lead_agent()` returns the graph plus a descriptor whose fingerprint answers "did anything about this agent change between these two runs?". `make_lead_agent()` keeps its graph-only signature — it is the LangGraph Server ABI declared in langgraph.json. Tools and skills are sorted before hashing because their assembly order is incidental; middlewares are not, because stack order decides what wraps what. Host build identity is reported but excluded from the fingerprint, so a redeploy does not invalidate every agent's identity. Context compaction observation Summarization emits the content hashes of the messages it is about to remove joined to the summary that replaced them. Content is the only identity available at that seam: the summary does not become a message, and what later projects it into a request renders it bounded and escaped rather than verbatim. Neutral policy, transform and MCP-source facts Guardrail decisions are published to runtime context under a `__`-prefixed key; result-rewriting middlewares append a declared, ordered transform trail; MCP tools carry their credential-free logical origin. Extension route identity Contributed routes are session-authenticated and cannot opt out, but "logged in" and "administrator" are different questions. Extensions get a neutral projection of the caller rather than the host's auth context, and `require_admin` fails closed when identity cannot be determined. Extension-owned tables An extension that persists data owns its own MetaData and migration chain, so its tables are absent from Base.metadata and `alembic revision --autogenerate` proposes dropping them. Extensions declare a table prefix, which is rejected at registration if it would shadow a host table. The contract package stays dependency-free and imports no host code; every new Protocol method has a default so later additions remain additive. The loader's pre-1.0 rule requires an exact major.minor match, so extensions written against 0.1 are now refused at startup with an actionable install hint rather than loading into a host that implements a different surface. uv.lock records the contract package's new version, so `uv sync --locked` still resolves on a fresh checkout. * fix(backend): sort gateway service imports --- README.md | 8 +- backend/app/gateway/app.py | 37 +- backend/app/gateway/routers/threads.py | 8 +- backend/app/gateway/services.py | 81 ++- .../demo_extensions.py | 4 +- .../deerflow_extension_api/__init__.py | 56 +- .../deerflow_extension_api/assembly.py | 124 ++++ .../deerflow_extension_api/auth.py | 53 ++ .../deerflow_extension_api/compaction.py | 58 ++ .../deerflow_extension_api/contracts.py | 8 + .../deerflow_extension_api/provenance.py | 95 +++ .../deerflow_extension_api/release.py | 102 +++ backend/packages/extension-api/pyproject.toml | 2 +- .../harness/deerflow/agents/AGENTS.md | 13 +- .../deerflow/agents/assembly_descriptor.py | 510 +++++++++++++++ .../deerflow/agents/lead_agent/agent.py | 331 ++++++++-- .../deerflow/agents/middlewares/AGENTS.md | 45 ++ .../deferred_tool_filter_middleware.py | 7 + .../middlewares/durable_context_middleware.py | 7 +- .../middlewares/dynamic_context_middleware.py | 13 +- .../middlewares/loop_detection_middleware.py | 11 + .../safety_finish_reason_middleware.py | 22 + .../skill_activation_middleware.py | 9 + .../middlewares/subagent_limit_middleware.py | 6 + .../middlewares/summarization_middleware.py | 86 +++ .../system_message_coalescing_middleware.py | 10 + .../terminal_response_middleware.py | 9 + .../agents/middlewares/todo_middleware.py | 9 + .../middlewares/token_budget_middleware.py | 3 + .../tool_output_budget_middleware.py | 52 +- .../tool_result_sanitization_middleware.py | 6 +- .../agents/middlewares/tool_transform_meta.py | 27 + .../middlewares/view_image_middleware.py | 2 + .../harness/deerflow/authz/outcome.py | 52 ++ .../harness/deerflow/extensions/AGENTS.md | 78 ++- .../harness/deerflow/extensions/gateway.py | 13 +- .../harness/deerflow/extensions/loader.py | 38 ++ .../harness/deerflow/extensions/notify.py | 84 ++- .../harness/deerflow/extensions/registry.py | 36 +- .../harness/deerflow/guardrails/builtin.py | 8 + .../harness/deerflow/guardrails/middleware.py | 49 ++ .../packages/harness/deerflow/mcp/tools.py | 2 +- .../deerflow/persistence/migrations/AGENTS.md | 51 +- .../persistence/migrations/_env_filters.py | 133 +++- .../deerflow/persistence/migrations/env.py | 9 + .../harness/deerflow/runtime/runs/worker.py | 13 +- .../harness/deerflow/subagents/executor.py | 98 ++- .../harness/deerflow/tools/mcp_metadata.py | 32 +- backend/packages/harness/pyproject.toml | 2 +- backend/pyproject.toml | 7 + .../tests/test_agent_assembly_descriptor.py | 601 ++++++++++++++++++ backend/tests/test_authorization_outcome.py | 48 ++ .../tests/test_bench_checkpoint_production.py | 6 +- backend/tests/test_checkpoint_mode.py | 16 +- .../test_context_compaction_observation.py | 215 +++++++ backend/tests/test_extension_api_contracts.py | 2 +- backend/tests/test_extension_api_surface.py | 72 +++ .../tests/test_extension_dependency_sync.py | 25 + backend/tests/test_extension_loader.py | 88 ++- .../tests/test_extension_route_principal.py | 109 ++++ backend/tests/test_gateway_services.py | 57 +- backend/tests/test_guardrail_middleware.py | 106 +++ .../tests/test_lead_agent_model_resolution.py | 4 +- backend/tests/test_message_provenance.py | 277 ++++++++ .../tests/test_middleware_release_policy.py | 238 +++++++ .../tests/test_persistence_migrations_env.py | 206 ++++++ ...st_system_message_coalescing_middleware.py | 2 + .../test_tool_output_budget_middleware.py | 12 +- backend/tests/test_tool_transform_meta.py | 74 +++ backend/uv.lock | 4 +- config.example.yaml | 10 + .../deerflow_extension_example/__init__.py | 2 +- .../deerflow-extension-example/pyproject.toml | 2 +- .../tests/test_entry_point.py | 2 +- .../tests/test_plugin.py | 10 +- 75 files changed, 4559 insertions(+), 148 deletions(-) create mode 100644 backend/packages/extension-api/deerflow_extension_api/assembly.py create mode 100644 backend/packages/extension-api/deerflow_extension_api/auth.py create mode 100644 backend/packages/extension-api/deerflow_extension_api/compaction.py create mode 100644 backend/packages/extension-api/deerflow_extension_api/provenance.py create mode 100644 backend/packages/extension-api/deerflow_extension_api/release.py create mode 100644 backend/packages/harness/deerflow/agents/assembly_descriptor.py create mode 100644 backend/packages/harness/deerflow/agents/middlewares/tool_transform_meta.py create mode 100644 backend/packages/harness/deerflow/authz/outcome.py create mode 100644 backend/tests/test_agent_assembly_descriptor.py create mode 100644 backend/tests/test_authorization_outcome.py create mode 100644 backend/tests/test_context_compaction_observation.py create mode 100644 backend/tests/test_extension_api_surface.py create mode 100644 backend/tests/test_extension_route_principal.py create mode 100644 backend/tests/test_message_provenance.py create mode 100644 backend/tests/test_middleware_release_policy.py create mode 100644 backend/tests/test_tool_transform_meta.py 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"