mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* 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>
346 lines
14 KiB
Python
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)
|