mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-12 23:19:36 +00:00
* feat: add opt-in task notes and compacted history recall * fix: validate task continuity state and preserve user answers Honor explicit opt-out, preserve clarification replies and capture failure statuses, validate notebook writes, and clear branch archive references. Update the config version and audit optional LLM credentials, with regression and integration evidence. * fix: align Helm config version with task continuity schema * fix: preserve mixed task history and declare continuity policies * fix: recover malformed history and evict archives atomically
326 lines
14 KiB
Python
326 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_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()
|
|
|
|
|
|
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'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", [{"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)
|