deer-flow/backend/tests/test_middleware_release_policy.py
李靖 10bd065615
feat(agents): persist tool artifacts as resolvable handles (#4676) (#4929)
* feat(agents): persist tool artifacts as resolvable handles (#4676)

MCP and sandbox tools return file paths, URLs, and task ids that are
lost when context compaction summarizes the conversation. This adds a
per-thread artifact handle registry so the model can reference
artifacts by short deterministic handles (art_xxxxxxxx) that survive
compaction and resolve to real references at the tool-call boundary.

- ThreadState.tool_artifacts: reducer channel capped at 100 entries,
  same-handle latest-wins, survives summarization compaction
- artifact_registry: deterministic handle derivation from
  (thread_id, tool_call_id, call_index); extraction precedence
  structured_content -> file/image blocks -> conservative text scan;
  HTML-escaped model-context rendering with char budget
- ArtifactCaptureMiddleware: before_model hook captures lightweight
  metadata (never content) and tracks handle consumption
- ArtifactResolutionMiddleware: rewrites handles in tool-call args to
  real references before execution; unknown handles untouched
- DurableContextMiddleware: projects the registry into the hidden
  durable-context data block ([available]/[consumed] status)
- config: new tool_artifacts section (all switches default on)
- frontend: artifact badges on tool-call steps keyed by tool_call_id

Fixes #4676

* fix(agents): harden artifact registry after review (#4676)

Review findings on #4929:

- capture/consumption updates emitted in the same before_model call are
  now concatenated instead of dict-merged; dict-merging clobbered the
  shared tool_artifacts key and permanently dropped fresh captures
- handles embed a per-result ordinal so multiple references inside one
  tool result never collide (two paths in one text block previously
  collapsed into one handle)
- structured_content extraction pulls concrete refs from known keys
  (file/path/url/task_id/..., string or list-of-string values) instead
  of storing a truncated JSON preview, falls back to an untruncated
  whole-object data entry, and no longer skips file/image content blocks
- plain-string tool results (e.g. bash/write_file output) are scanned
  for sandbox paths / remote URLs too, gated by detect_refs_in_text
- wire max_entries through configure_tool_artifact_max_entries at
  assembly (clamped 10..1000) and gate detect_refs_in_text scans
- resolution requires both enabled and resolve_handles_in_args,
  matching its docs
- add real ToolCallRequest.override coverage and the spec 14.4
  integration cycle (capture -> inject -> resolve -> consumption)

* fix(agents): address second-round artifact registry review (#4676)

Follow-up findings on #4929:

- drop generic `output`/`outputs` from structured ref keys: prose under
  those keys no longer becomes a bogus file entry (falls back to the
  whole-object data entry; path-shaped refs are still caught by the
  free-text scan)
- strip closing quotes, brackets, and backticks from detected refs so
  markdown- and JSON-wrapped paths resolve to clean real references
- remove the process-wide cap setter: the reducer enforces only an
  absolute ceiling (1000) and ArtifactCaptureMiddleware applies the
  configured max_entries per agent when emitting updates, so agents
  with different configs in one process never share or flap a cap
- skip extraction for tool messages whose tool_call_id already has
  registry entries and memoize known-empty results (bounded FIFO), so
  steady-state capture cost drops to the new message tail instead of a
  full-history rescan per model call
- correct the inverted ordering claim: capture is position-independent
  in the wrap chain because it is a before_model hook reading state
  messages; fixed the assembly comment, AGENTS.md item 15, and the
  ordering test docstring

CI parity verified locally: ruff check + format --check, full offline
suite, blocking-io suite, collect-only, uv lock --check,
check_agent_guidance, frontend format/lint/typecheck/build/tests.

* fix(agents): sliding-window cap and URI gates for artifact registry (#4676)

Third-round review findings on #4929:

- max_entries is now a sliding retention window: fresh captures are
  always registered, and when the projection exceeds the configured
  cap the middleware appends a trailing {"op": "trim_to", "keep": N}
  directive that the reducer applies by evicting the oldest entries.
  The previous remaining-budget emission froze the registry at the cap,
  permanently dropping post-compaction artifacts — the exact failure
  mode #4676 targets — and silently disabled the round-2 rescan
  optimizations at the point threads grew longest
- _track_consumption memoizes settled tool-call args in a bounded FIFO
  alongside the existing empty-result memo, so historical AIMessage
  args are not regex-rescanned every model call
- file/image content-block URLs must be http(s) or path-shaped;
  data:/blob: URIs (MCP embedded resources carrying base64 payloads)
  can no longer enter thread state, tool args, or crowd out the render
  budget; protocol-relative //host forms are rejected as well
- structured-content ref values go through the same URI-shape gate as
  content blocks, closing the embedded-payload hole via known keys such
  as url/urls
- tool_artifacts.enabled now gates the whole middleware including
  consumption tracking via an early return in before_model

Docs updated to describe the sliding window, URI gates, enabled scope,
and both bounded memos.

* fix(agents): final evictions and pending-aware consumption for artifact registry (#4676)

Fourth-round review findings on #4929:

- the sliding window interacted with the seen_call_ids skip to
  resurrect evicted entries every round: once an entry was trimmed, its
  tool_call_id left the seen set while its ToolMessage stayed in
  context, so the next model call re-extracted it, re-registered it as
  newest, and evicted something else — a never-converging rotation.
  The empty-result memo is now a unified _processed_results memo that
  records every extraction regardless of outcome, making evictions
  final; steady state over a saturated thread is quiescent (pinned by
  a multi-round test asserting no updates and zero extractions)
- the consumption scan built its handle map from pre-capture state, so
  a call referencing a handle captured in the same before_model call
  missed, got settled as quiet, and its consumption mark was lost
  forever. _track_consumption now receives the pending capture entries
  and resolves against state + pending; a call settles as quiet only
  after every referenced handle resolved, so transient misses retry
  next round instead of permanently suppressing the mark

AGENTS.md entry 15 updated to describe both memos and the
eviction-is-final semantics.

* fix(artifacts): address occurrence identity, PII and URL review findings

* fix(artifacts): enforce resolved policy checks and bounded registry handling

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-27 11:09:41 +08:00

346 lines
14 KiB
Python

"""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
from types import SimpleNamespace
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_llm_error_handling_middleware():
from deerflow.agents.middlewares.llm_error_handling_middleware import LLMErrorHandlingMiddleware
from deerflow.config.app_config import AppConfig
from deerflow.config.sandbox_config import SandboxConfig
return LLMErrorHandlingMiddleware(app_config=AppConfig(sandbox=SandboxConfig(use="test")))
def _make_model_length_finish_reason_middleware():
from deerflow.agents.middlewares.model_length_finish_reason_middleware import ModelLengthFinishReasonMiddleware
return ModelLengthFinishReasonMiddleware()
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_durable_context_middleware():
from deerflow.agents.middlewares.durable_context_middleware import DurableContextMiddleware
return DurableContextMiddleware()
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()
def _make_dynamic_context_middleware():
from deerflow.agents.middlewares.dynamic_context_middleware import DynamicContextMiddleware
return DynamicContextMiddleware(memory_enabled=False)
def _make_subagent_date_context_middleware():
from deerflow.agents.middlewares.dynamic_context_middleware import SubagentDateContextMiddleware
return SubagentDateContextMiddleware()
# 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.agents.middlewares.llm_error_handling_middleware", "LLMErrorHandlingMiddleware", _make_llm_error_handling_middleware),
(
"deerflow.agents.middlewares.model_length_finish_reason_middleware",
"ModelLengthFinishReasonMiddleware",
_make_model_length_finish_reason_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.durable_context_middleware", "DurableContextMiddleware", _make_durable_context_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),
# The date middlewares declare the effective timezone the injected
# <current_date> follows, so differently-anchored deployments fingerprint
# differently.
("deerflow.agents.middlewares.dynamic_context_middleware", "DynamicContextMiddleware", _make_dynamic_context_middleware),
("deerflow.agents.middlewares.dynamic_context_middleware", "SubagentDateContextMiddleware", _make_subagent_date_context_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)
def _middleware_fingerprint(middleware):
from deerflow.agents.assembly_descriptor import build_assembly_descriptor
return build_assembly_descriptor(
namespace="test",
agent_name="continuity",
requested_model="static",
effective_model="static",
model_config=SimpleNamespace(),
thinking_enabled=False,
reasoning_effort=None,
rendered_base_prompt="same prompt",
tools=[],
middlewares=[middleware],
deferred_names=frozenset(),
enabled_skills=[],
effective_policies={},
).fingerprint
def _continuity_summarizer(config):
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware
return DeerFlowSummarizationMiddleware(model=_StaticChatModel(), trigger=("messages", 4), keep=("messages", 2), task_continuity_config=config)
@pytest.mark.parametrize("field,value", [("enabled", False), ("max_batches", 1), ("max_records_per_batch", 1), ("max_record_chars", 1000)])
def test_each_continuity_policy_field_changes_assembly_identity(field, value):
from deerflow.config.task_continuity_config import TaskContinuityConfig
original = _continuity_summarizer(TaskContinuityConfig(enabled=True))
changed = _continuity_summarizer(TaskContinuityConfig(**{"enabled": True, field: value}))
assert original.release_policy_parameters() != changed.release_policy_parameters()
assert _middleware_fingerprint(original) != _middleware_fingerprint(changed)
def test_disabled_continuity_retention_does_not_change_assembly_identity():
from deerflow.config.task_continuity_config import TaskContinuityConfig
omitted = _continuity_summarizer(None)
disabled = _continuity_summarizer(TaskContinuityConfig(enabled=False, max_batches=1, max_records_per_batch=1, max_record_chars=1000))
assert omitted.release_policy_parameters() == disabled.release_policy_parameters()
assert _middleware_fingerprint(omitted) == _middleware_fingerprint(disabled)
@pytest.mark.parametrize("kwargs", [{"inject_tool_artifacts": False}, {"task_continuity_enabled": True}, {"skills_container_path": "/other-skills"}, {"skill_file_read_tool_names": ["custom_read"]}])
def test_durable_context_behavior_changes_assembly_identity(kwargs):
from deerflow.agents.middlewares.durable_context_middleware import DurableContextMiddleware
original = DurableContextMiddleware()
changed = DurableContextMiddleware(**kwargs)
assert _middleware_fingerprint(original) != _middleware_fingerprint(changed)
def test_equivalent_durable_context_configuration_has_identical_identity():
from deerflow.agents.middlewares.durable_context_middleware import DurableContextMiddleware
first = DurableContextMiddleware(skills_container_path="/skills/./", skill_file_read_tool_names=["read_b", "read_a", "read_a"])
second = DurableContextMiddleware(skills_container_path="/skills", skill_file_read_tool_names=["read_a", "read_b"])
assert _middleware_fingerprint(first) == _middleware_fingerprint(second)