mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-21 15:36:48 +00:00
df95154282
* fix(tracing): propagate session_id and user_id into Langfuse traces
Adds Langfuse v4 reserved trace attributes (langfuse_session_id,
langfuse_user_id, langfuse_trace_name, langfuse_tags) to
RunnableConfig.metadata inside the run worker, so the langchain
CallbackHandler can lift them onto the root trace.
- New deerflow.tracing.metadata.build_langfuse_trace_metadata() returns
the reserved keys when Langfuse is in the enabled providers, else {}.
- worker.run_agent merges them with setdefault so caller-supplied keys
win, allowing per-request overrides from upstream metadata.
- session_id mirrors the LangGraph thread_id; user_id reads
get_effective_user_id() (falls back to "default" in no-auth mode).
- trace_name defaults to "lead-agent"; tags carry env and model name
when DEER_FLOW_ENV (or ENVIRONMENT) and a model name are present.
Closes #2930
* fix(tracing): attach Langfuse callback at graph root so metadata propagates
The first commit injected ``langfuse_session_id`` / ``langfuse_user_id`` /
``langfuse_trace_name`` / ``langfuse_tags`` into ``RunnableConfig.metadata``,
but on ``main`` the Langfuse callback is attached at *model* level
(``models/factory.py``). LangChain still threads ``parent_run_id`` through
the contextvar, so the handler sees the model as a nested observation and
``__on_llm_action`` strips the ``langfuse_*`` keys
(``keep_langfuse_trace_attributes=False``). The trace's top-level
``sessionId`` / ``userId`` therefore stayed empty in deer-flow's LangGraph
runtime — confirmed live against a real Langfuse instance.
This commit moves the callback to the **graph invocation root** so the
handler fires ``on_chain_start(parent_run_id=None)`` and runs the
``propagate_attributes`` path that actually lifts ``session_id`` /
``user_id`` onto the trace:
- ``models/factory.py``: add ``attach_tracing`` keyword (default ``True``)
so standalone callers (``MemoryUpdater``, etc.) keep their direct
model-level tracing.
- ``agents/lead_agent/agent.py``: call ``build_tracing_callbacks()`` once
inside ``_make_lead_agent`` and append the result to
``config["callbacks"]``; the four in-graph ``create_chat_model`` sites
(bootstrap, default agent, sync + async summarization) pass
``attach_tracing=False`` to avoid duplicate spans.
- ``agents/middlewares/title_middleware.py``: same ``attach_tracing=False``
for the title-generation model, since it inherits the graph's
RunnableConfig via ``_get_runnable_config``.
Test updates:
- ``tests/test_lead_agent_model_resolution.py`` and
``tests/test_title_middleware_core_logic.py``: extend the fake
``create_chat_model`` signatures / mock assertions to accept the new
``attach_tracing`` kwarg.
- ``tests/test_worker_langfuse_metadata.py``: switch the no-user fallback
test from direct ContextVar mutation to ``monkeypatch.setattr`` on
``get_effective_user_id`` to avoid pollution across the langfuse OTel
global tracer provider.
- ``tests/conftest.py``: add an autouse fixture that resets
``deerflow.config.title_config._title_config`` to its pristine default
after every test. Any test that loads the real ``config.yaml`` (via
``get_app_config()``) calls ``load_title_config_from_dict`` and mutates
the module-level singleton, which previously poisoned the
title-middleware suite when run after, e.g., the new
``test_worker_langfuse_metadata.py`` cases. The fixture is independent
of this PR's main change but unblocks the cross-file test run.
Live verification (same Langfuse instance as before):
- Drove ``worker.run_agent`` against the real ``make_lead_agent`` +
``gpt-4o-mini`` for three distinct ``user_context`` identities
(``fancy-engineer``, ``alice-pm``, ``bob-designer``).
- Each run produced one ``lead-agent`` trace whose top-level
``sessionId`` / ``userId`` / ``tags`` carry the expected values, e.g.
``session=e2e-2930-8f347c-alice-pm user=alice-pm name='lead-agent'
tags=['model:gpt-4o-mini']``.
Refs #2930.
* fix(tracing): extend root-callback + metadata injection to the embedded client
Addresses Copilot review on PR #2944.
Commit 2 disabled model-level tracing for ``TitleMiddleware`` and
``_create_summarization_middleware`` because ``_make_lead_agent`` now
attaches the tracing callbacks at the graph invocation root. But the
embedded ``DeerFlowClient`` does not call ``_make_lead_agent`` — it
calls ``_build_middlewares`` directly and never appends the tracing
handlers to its ``RunnableConfig``. So under the embedded path,
title-generation and summarization LLM calls were left untraced —
a regression introduced by this PR.
This commit mirrors the gateway worker's injection in
``DeerFlowClient.stream``:
- Append ``build_tracing_callbacks()`` to ``config["callbacks"]`` so
the Langfuse handler sees ``on_chain_start(parent_run_id=None)`` at
the graph root and runs the ``propagate_attributes`` path.
- Merge ``build_langfuse_trace_metadata(...)`` into
``config["metadata"]`` with ``setdefault`` so caller-supplied keys
still win.
- ``_ensure_agent`` now creates its main model with
``attach_tracing=False`` to avoid duplicate spans now that the
callback lives at the graph root.
Docs:
- ``backend/CLAUDE.md`` Tracing section rewritten to describe the
graph-root attachment model (replacing the inaccurate
"at model-creation time" wording).
- ``README.md`` Langfuse section now lists both injection points
(worker + client) instead of only the worker path.
Tests:
- ``tests/test_client_langfuse_metadata.py`` (new, 3 cases):
callbacks + metadata are injected when Langfuse is enabled,
caller-supplied metadata overrides win via ``setdefault``, and the
injection is inert when Langfuse is disabled.
Live verification on the real Langfuse instance:
=== user=fancy-client ===
id=cbd22847.. session=client-2930-6b9491-fancy-client user=fancy-client name='lead-agent'
=== user=alice-client ===
id=b4f6f576.. session=client-2930-6b9491-alice-client user=alice-client name='lead-agent'
Refs #2930.
* refactor(tracing): address maintainer review on PR #2944
Addresses @WillemJiang's 5 comments.
1. Duplicated metadata-injection code between worker.py and client.py
New ``deerflow.tracing.inject_langfuse_metadata(config, ...)`` helper
takes the 10-line build + merge + setdefault logic that was duplicated
in ``runtime/runs/worker.py`` and ``client.py``. Both callers now share
a single source of truth, so the two paths cannot drift.
2. Direct private-attribute mutation in conftest.py and tests
Added public ``reset_tracing_config()`` / ``reset_title_config()``
functions. ``tests/conftest.py`` and every test that previously did
``tracing_module._tracing_config = None`` or
``title_module._title_config = TitleConfig()`` now goes through the
public API. A future internal rename will surface as an ImportError
instead of a silent no-op.
3. client.py reading os.environ directly
``DeerFlowClient.__init__`` grows an optional ``environment`` parameter
so programmatic callers can pass the deployment label explicitly.
``stream()`` consults ``self._environment`` first and only falls back
to ``DEER_FLOW_ENV`` / ``ENVIRONMENT`` env vars when nothing was
passed in. Backwards compatible — env-var behaviour preserved for
callers that opt to keep using it.
4. build_tracing_callbacks() cached on hot path
Not implemented. Inspected the langfuse v4 ``langchain.CallbackHandler``
constructor: it only resolves the module-level singleton client via
``get_client()`` and initialises a few dicts (no I/O, no env parsing
at construction time). The build is essentially free. Caching would
trade a non-measurable speedup for two real risks: handler instances
carry per-run state internally (``_run_states``, ``_root_run_states``,
``last_trace_id``), and tracing config can be reloaded by env-var
changes between runs. Will revisit if profiling ever shows it as
a hot spot.
5. attach_tracing=False easy to forget at new in-graph call sites
- Module docstring at the top of ``lead_agent/agent.py`` documents
the invariant ("every in-graph ``create_chat_model`` MUST pass
``attach_tracing=False``") and enumerates the current sites.
- New regression test
``test_make_lead_agent_attaches_tracing_callbacks_at_graph_root`` in
``tests/test_lead_agent_model_resolution.py`` locks both halves of
the invariant: ``config["callbacks"]`` carries the tracing handler
after ``_make_lead_agent``, AND every ``create_chat_model`` call
captured by the test passes ``attach_tracing=False``. A future
in-graph site that forgets the flag will fail this test.
Lint clean. Full touched-suite bundle: 246 passed.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
512 lines
20 KiB
Python
512 lines
20 KiB
Python
"""Tests for lead agent runtime model resolution behavior."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from deerflow.agents.lead_agent import agent as lead_agent_module
|
|
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.loop_detection_config import LoopDetectionConfig
|
|
from deerflow.config.memory_config import MemoryConfig
|
|
from deerflow.config.model_config import ModelConfig
|
|
from deerflow.config.sandbox_config import SandboxConfig
|
|
from deerflow.config.summarization_config import SummarizationConfig
|
|
|
|
|
|
def _make_app_config(models: list[ModelConfig], loop_detection: LoopDetectionConfig | None = None) -> AppConfig:
|
|
return AppConfig(
|
|
models=models,
|
|
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"),
|
|
loop_detection=loop_detection or LoopDetectionConfig(),
|
|
)
|
|
|
|
|
|
def _make_model(name: str, *, supports_thinking: bool) -> ModelConfig:
|
|
return ModelConfig(
|
|
name=name,
|
|
display_name=name,
|
|
description=None,
|
|
use="langchain_openai:ChatOpenAI",
|
|
model=name,
|
|
supports_thinking=supports_thinking,
|
|
supports_vision=False,
|
|
)
|
|
|
|
|
|
def test_make_lead_agent_signature_matches_langgraph_server_factory_abi():
|
|
assert list(inspect.signature(lead_agent_module.make_lead_agent).parameters) == ["config"]
|
|
|
|
|
|
def test_make_lead_agent_attaches_tracing_callbacks_at_graph_root(monkeypatch):
|
|
"""Regression guard: tracing handlers must be appended to
|
|
``config["callbacks"]`` (graph invocation root), and every in-graph
|
|
``create_chat_model`` call must pass ``attach_tracing=False``.
|
|
|
|
Catches future contributors who forget the flag when adding new
|
|
in-graph model creation, which would silently produce duplicate
|
|
spans and break Langfuse session/user propagation.
|
|
"""
|
|
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])
|
|
|
|
import deerflow.tools as tools_module
|
|
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
|
|
monkeypatch.setattr(tools_module, "get_available_tools", lambda **kwargs: [])
|
|
monkeypatch.setattr(lead_agent_module, "_build_middlewares", lambda config, model_name, agent_name=None, **kwargs: [])
|
|
|
|
sentinel_handler = object()
|
|
monkeypatch.setattr(lead_agent_module, "build_tracing_callbacks", lambda: [sentinel_handler])
|
|
|
|
seen_attach_tracing: list[bool] = []
|
|
|
|
def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True):
|
|
seen_attach_tracing.append(attach_tracing)
|
|
return object()
|
|
|
|
monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model)
|
|
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
|
|
|
|
config: dict = {"configurable": {"model_name": "safe-model"}}
|
|
lead_agent_module._make_lead_agent(config, app_config=app_config)
|
|
|
|
# Handler must land on the graph invocation config so the Langfuse
|
|
# CallbackHandler fires ``on_chain_start(parent_run_id=None)`` and
|
|
# propagates ``session_id`` / ``user_id`` onto the trace.
|
|
assert sentinel_handler in (config.get("callbacks") or []), "build_tracing_callbacks output must be appended to config['callbacks']"
|
|
|
|
# Every in-graph create_chat_model call must opt out of model-level
|
|
# tracing to avoid duplicate spans.
|
|
assert seen_attach_tracing, "_make_lead_agent did not call create_chat_model"
|
|
assert all(flag is False for flag in seen_attach_tracing), f"in-graph create_chat_model must pass attach_tracing=False; got {seen_attach_tracing}"
|
|
|
|
|
|
def test_internal_make_lead_agent_uses_explicit_app_config(monkeypatch):
|
|
app_config = _make_app_config([_make_model("explicit-model", supports_thinking=False)])
|
|
|
|
import deerflow.tools as tools_module
|
|
|
|
def _raise_get_app_config():
|
|
raise AssertionError("ambient get_app_config() must not be used when app_config is explicit")
|
|
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", _raise_get_app_config)
|
|
monkeypatch.setattr(tools_module, "get_available_tools", lambda **kwargs: [])
|
|
monkeypatch.setattr(lead_agent_module, "_build_middlewares", lambda config, model_name, agent_name=None, **kwargs: [])
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True):
|
|
captured["name"] = name
|
|
captured["app_config"] = app_config
|
|
return object()
|
|
|
|
monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model)
|
|
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
|
|
|
|
result = lead_agent_module._make_lead_agent(
|
|
{"configurable": {"model_name": "explicit-model"}},
|
|
app_config=app_config,
|
|
)
|
|
|
|
assert captured == {
|
|
"name": "explicit-model",
|
|
"app_config": app_config,
|
|
}
|
|
assert result["model"] is not None
|
|
|
|
|
|
def test_make_lead_agent_uses_runtime_app_config_from_context_without_global_read(monkeypatch):
|
|
app_config = _make_app_config([_make_model("context-model", supports_thinking=False)])
|
|
|
|
import deerflow.tools as tools_module
|
|
|
|
def _raise_get_app_config():
|
|
raise AssertionError("ambient get_app_config() must not be used when runtime context already carries app_config")
|
|
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", _raise_get_app_config)
|
|
monkeypatch.setattr(tools_module, "get_available_tools", lambda **kwargs: [])
|
|
monkeypatch.setattr(lead_agent_module, "_build_middlewares", lambda config, model_name, agent_name=None, **kwargs: [])
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True):
|
|
captured["name"] = name
|
|
captured["app_config"] = app_config
|
|
return object()
|
|
|
|
monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model)
|
|
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
|
|
|
|
result = lead_agent_module.make_lead_agent(
|
|
{
|
|
"context": {
|
|
"model_name": "context-model",
|
|
"app_config": app_config,
|
|
}
|
|
}
|
|
)
|
|
|
|
assert captured == {
|
|
"name": "context-model",
|
|
"app_config": app_config,
|
|
}
|
|
assert result["model"] is not None
|
|
|
|
|
|
def test_resolve_model_name_falls_back_to_default(monkeypatch, caplog):
|
|
app_config = _make_app_config(
|
|
[
|
|
_make_model("default-model", supports_thinking=False),
|
|
_make_model("other-model", supports_thinking=True),
|
|
]
|
|
)
|
|
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
|
|
|
|
with caplog.at_level("WARNING"):
|
|
resolved = lead_agent_module._resolve_model_name("missing-model")
|
|
|
|
assert resolved == "default-model"
|
|
assert "fallback to default model 'default-model'" in caplog.text
|
|
|
|
|
|
def test_resolve_model_name_uses_default_when_none(monkeypatch):
|
|
app_config = _make_app_config(
|
|
[
|
|
_make_model("default-model", supports_thinking=False),
|
|
_make_model("other-model", supports_thinking=True),
|
|
]
|
|
)
|
|
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
|
|
|
|
resolved = lead_agent_module._resolve_model_name(None)
|
|
|
|
assert resolved == "default-model"
|
|
|
|
|
|
def test_resolve_model_name_raises_when_no_models_configured(monkeypatch):
|
|
app_config = _make_app_config([])
|
|
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="No chat models are configured",
|
|
):
|
|
lead_agent_module._resolve_model_name("missing-model")
|
|
|
|
|
|
def test_make_lead_agent_disables_thinking_when_model_does_not_support_it(monkeypatch):
|
|
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])
|
|
|
|
import deerflow.tools as tools_module
|
|
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
|
|
monkeypatch.setattr(tools_module, "get_available_tools", lambda **kwargs: [])
|
|
monkeypatch.setattr(lead_agent_module, "_build_middlewares", lambda config, model_name, agent_name=None, **kwargs: [])
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True):
|
|
captured["name"] = name
|
|
captured["thinking_enabled"] = thinking_enabled
|
|
captured["reasoning_effort"] = reasoning_effort
|
|
captured["app_config"] = app_config
|
|
return object()
|
|
|
|
monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model)
|
|
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
|
|
|
|
result = lead_agent_module.make_lead_agent(
|
|
{
|
|
"configurable": {
|
|
"model_name": "safe-model",
|
|
"thinking_enabled": True,
|
|
"is_plan_mode": False,
|
|
"subagent_enabled": False,
|
|
}
|
|
}
|
|
)
|
|
|
|
assert captured["name"] == "safe-model"
|
|
assert captured["thinking_enabled"] is False
|
|
assert captured["app_config"] is app_config
|
|
assert result["model"] is not None
|
|
|
|
|
|
def test_make_lead_agent_reads_runtime_options_from_context(monkeypatch):
|
|
app_config = _make_app_config(
|
|
[
|
|
_make_model("default-model", supports_thinking=False),
|
|
_make_model("context-model", supports_thinking=True),
|
|
]
|
|
)
|
|
|
|
import deerflow.tools as tools_module
|
|
|
|
get_available_tools = MagicMock(return_value=[])
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
|
|
monkeypatch.setattr(tools_module, "get_available_tools", get_available_tools)
|
|
monkeypatch.setattr(lead_agent_module, "_build_middlewares", lambda config, model_name, agent_name=None, **kwargs: [])
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True):
|
|
captured["name"] = name
|
|
captured["thinking_enabled"] = thinking_enabled
|
|
captured["reasoning_effort"] = reasoning_effort
|
|
captured["app_config"] = app_config
|
|
return object()
|
|
|
|
monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model)
|
|
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
|
|
|
|
result = lead_agent_module.make_lead_agent(
|
|
{
|
|
"context": {
|
|
"model_name": "context-model",
|
|
"thinking_enabled": False,
|
|
"reasoning_effort": "high",
|
|
"is_plan_mode": True,
|
|
"subagent_enabled": True,
|
|
"max_concurrent_subagents": 7,
|
|
}
|
|
}
|
|
)
|
|
|
|
assert captured == {
|
|
"name": "context-model",
|
|
"thinking_enabled": False,
|
|
"reasoning_effort": "high",
|
|
"app_config": app_config,
|
|
}
|
|
get_available_tools.assert_called_once_with(model_name="context-model", groups=None, subagent_enabled=True, app_config=app_config)
|
|
assert result["model"] is not None
|
|
|
|
|
|
def test_make_lead_agent_rejects_invalid_bootstrap_agent_name(monkeypatch):
|
|
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])
|
|
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
|
|
|
|
with pytest.raises(ValueError, match="Invalid agent name"):
|
|
lead_agent_module.make_lead_agent(
|
|
{
|
|
"configurable": {
|
|
"model_name": "safe-model",
|
|
"thinking_enabled": False,
|
|
"is_plan_mode": False,
|
|
"subagent_enabled": False,
|
|
"is_bootstrap": True,
|
|
"agent_name": "../../../tmp/evil",
|
|
}
|
|
}
|
|
)
|
|
|
|
|
|
def test_build_middlewares_uses_resolved_model_name_for_vision(monkeypatch):
|
|
app_config = _make_app_config(
|
|
[
|
|
_make_model("stale-model", supports_thinking=False),
|
|
ModelConfig(
|
|
name="vision-model",
|
|
display_name="vision-model",
|
|
description=None,
|
|
use="langchain_openai:ChatOpenAI",
|
|
model="vision-model",
|
|
supports_thinking=False,
|
|
supports_vision=True,
|
|
),
|
|
]
|
|
)
|
|
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
|
|
monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **kwargs: None)
|
|
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
|
|
|
|
middlewares = lead_agent_module._build_middlewares(
|
|
{"configurable": {"model_name": "stale-model", "is_plan_mode": False, "subagent_enabled": False}},
|
|
model_name="vision-model",
|
|
custom_middlewares=[MagicMock()],
|
|
app_config=app_config,
|
|
)
|
|
|
|
assert any(isinstance(m, lead_agent_module.ViewImageMiddleware) for m in middlewares)
|
|
# verify the custom middleware is injected correctly
|
|
assert len(middlewares) > 0 and isinstance(middlewares[-2], MagicMock)
|
|
|
|
|
|
def test_build_middlewares_passes_explicit_app_config_to_shared_factory(monkeypatch):
|
|
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])
|
|
captured: dict[str, object] = {}
|
|
|
|
def _raise_get_app_config():
|
|
raise AssertionError("ambient get_app_config() must not be used when app_config is explicit")
|
|
|
|
def _fake_build_lead_runtime_middlewares(*, app_config, lazy_init):
|
|
captured["app_config"] = app_config
|
|
captured["lazy_init"] = lazy_init
|
|
return ["base-middleware"]
|
|
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", _raise_get_app_config)
|
|
monkeypatch.setattr(
|
|
lead_agent_module,
|
|
"build_lead_runtime_middlewares",
|
|
_fake_build_lead_runtime_middlewares,
|
|
)
|
|
monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **kwargs: None)
|
|
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
|
|
monkeypatch.setattr(
|
|
lead_agent_module,
|
|
"TitleMiddleware",
|
|
lambda *, app_config: captured.setdefault("title_app_config", app_config) or "title-middleware",
|
|
)
|
|
monkeypatch.setattr(
|
|
lead_agent_module,
|
|
"MemoryMiddleware",
|
|
lambda agent_name=None, *, memory_config: captured.setdefault("memory_config", memory_config) or "memory-middleware",
|
|
)
|
|
|
|
middlewares = lead_agent_module._build_middlewares(
|
|
{"configurable": {"is_plan_mode": False, "subagent_enabled": False}},
|
|
model_name="safe-model",
|
|
app_config=app_config,
|
|
)
|
|
|
|
assert captured == {
|
|
"app_config": app_config,
|
|
"lazy_init": True,
|
|
"title_app_config": app_config,
|
|
"memory_config": app_config.memory,
|
|
}
|
|
assert middlewares[0] == "base-middleware"
|
|
|
|
|
|
def test_build_middlewares_uses_loop_detection_config(monkeypatch):
|
|
app_config = _make_app_config(
|
|
[_make_model("safe-model", supports_thinking=False)],
|
|
loop_detection=LoopDetectionConfig(
|
|
warn_threshold=7,
|
|
hard_limit=9,
|
|
window_size=30,
|
|
max_tracked_threads=40,
|
|
tool_freq_warn=50,
|
|
tool_freq_hard_limit=60,
|
|
),
|
|
)
|
|
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
|
|
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
|
|
monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda *, app_config=None: None)
|
|
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
|
|
|
|
middlewares = lead_agent_module._build_middlewares(
|
|
{"configurable": {"is_plan_mode": False, "subagent_enabled": False}},
|
|
model_name="safe-model",
|
|
app_config=app_config,
|
|
)
|
|
|
|
loop_detection = next(m for m in middlewares if isinstance(m, LoopDetectionMiddleware))
|
|
assert loop_detection.warn_threshold == 7
|
|
assert loop_detection.hard_limit == 9
|
|
assert loop_detection.window_size == 30
|
|
assert loop_detection.max_tracked_threads == 40
|
|
assert loop_detection.tool_freq_warn == 50
|
|
assert loop_detection.tool_freq_hard_limit == 60
|
|
|
|
|
|
def test_build_middlewares_omits_loop_detection_when_disabled(monkeypatch):
|
|
app_config = _make_app_config(
|
|
[_make_model("safe-model", supports_thinking=False)],
|
|
loop_detection=LoopDetectionConfig(enabled=False),
|
|
)
|
|
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
|
|
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
|
|
monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda *, app_config=None: None)
|
|
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
|
|
|
|
middlewares = lead_agent_module._build_middlewares(
|
|
{"configurable": {"is_plan_mode": False, "subagent_enabled": False}},
|
|
model_name="safe-model",
|
|
app_config=app_config,
|
|
)
|
|
|
|
assert not any(isinstance(m, LoopDetectionMiddleware) for m in middlewares)
|
|
|
|
|
|
def test_create_summarization_middleware_uses_configured_model_alias(monkeypatch):
|
|
app_config = _make_app_config([_make_model("model-masswork", supports_thinking=False)])
|
|
app_config.summarization = SummarizationConfig(enabled=True, model_name="model-masswork")
|
|
app_config.memory = MemoryConfig(enabled=False)
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
captured: dict[str, object] = {}
|
|
fake_model = MagicMock()
|
|
fake_model.with_config.return_value = fake_model
|
|
|
|
def _fake_create_chat_model(*, name=None, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True):
|
|
captured["name"] = name
|
|
captured["thinking_enabled"] = thinking_enabled
|
|
captured["reasoning_effort"] = reasoning_effort
|
|
captured["app_config"] = app_config
|
|
return fake_model
|
|
|
|
def _raise_get_app_config():
|
|
raise AssertionError("ambient get_app_config() must not be used when app_config is explicit")
|
|
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", _raise_get_app_config)
|
|
monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model)
|
|
monkeypatch.setattr(lead_agent_module, "DeerFlowSummarizationMiddleware", lambda **kwargs: kwargs)
|
|
|
|
middleware = lead_agent_module._create_summarization_middleware(app_config=app_config)
|
|
|
|
assert captured["name"] == "model-masswork"
|
|
assert captured["thinking_enabled"] is False
|
|
assert captured["app_config"] is app_config
|
|
assert middleware["model"] is fake_model
|
|
fake_model.with_config.assert_called_once_with(tags=["middleware:summarize"])
|
|
|
|
|
|
def test_create_summarization_middleware_threads_resolved_app_config_to_model(monkeypatch):
|
|
fallback_app_config = _make_app_config([_make_model("fallback-model", supports_thinking=False)])
|
|
fallback_app_config.summarization = SummarizationConfig(enabled=True, model_name="fallback-model")
|
|
fallback_app_config.memory = MemoryConfig(enabled=False)
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
captured: dict[str, object] = {}
|
|
fake_model = MagicMock()
|
|
fake_model.with_config.return_value = fake_model
|
|
|
|
def _fake_create_chat_model(*, name=None, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True):
|
|
captured["app_config"] = app_config
|
|
return fake_model
|
|
|
|
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: fallback_app_config)
|
|
monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model)
|
|
monkeypatch.setattr(lead_agent_module, "DeerFlowSummarizationMiddleware", lambda **kwargs: kwargs)
|
|
|
|
lead_agent_module._create_summarization_middleware()
|
|
|
|
assert captured["app_config"] is fallback_app_config
|
|
|
|
|
|
def test_memory_middleware_uses_explicit_memory_config_without_global_read(monkeypatch):
|
|
from deerflow.agents.middlewares import memory_middleware as memory_middleware_module
|
|
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
|
|
|
def _raise_get_memory_config():
|
|
raise AssertionError("ambient get_memory_config() must not be used when memory_config is explicit")
|
|
|
|
monkeypatch.setattr(memory_middleware_module, "get_memory_config", _raise_get_memory_config)
|
|
|
|
middleware = MemoryMiddleware(memory_config=MemoryConfig(enabled=False))
|
|
|
|
assert middleware.after_agent({"messages": []}, runtime=MagicMock(context={"thread_id": "thread-1"})) is None
|