mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-21 07:26:50 +00:00
refactor: thread app_config through lead and subagent task path (#2666)
* refactor: thread app config through lead prompt * fix: honor explicit app config across runtime paths * style: format subagent executor tests * fix: thread resolved app config and guard subagents-only fallback Address two PR review findings: 1. _create_summarization_middleware passed the original (possibly None) app_config into create_chat_model, forcing the model factory back to ambient get_app_config() and risking config drift between the middleware's resolved view and the model's view. Pass the resolved AppConfig instance through end-to-end. 2. get_available_subagent_names accepted Any-typed config and forwarded it to is_host_bash_allowed, which reads ``.sandbox``. A SubagentsAppConfig (also accepted upstream as a sum-type input) has no ``.sandbox`` attribute and would be silently treated as "no sandbox configured", incorrectly disabling the bash subagent. Guard on hasattr and fall back to ambient lookup otherwise. Adds regression tests for both paths. * chore: simplify hasattr guard and tighten regression tests - Collapse if/else into ternary in get_available_subagent_names; hasattr(None, ...) is False so the explicit None check was redundant. - Drop comments that narrate the change rather than explain non-obvious WHY (test names already convey intent). - Replace stringly-typed sentinel "no-arg" in regression test with direct args tuple comparison. --------- Co-authored-by: greatmengqi <chenmengqi.0376@bytedance.com>
This commit is contained in:
@@ -697,3 +697,33 @@ def test_get_available_tools_includes_invoke_acp_agent_when_agents_configured(mo
|
||||
assert "invoke_acp_agent" in [tool.name for tool in tools]
|
||||
|
||||
load_acp_config_from_dict({})
|
||||
|
||||
|
||||
def test_get_available_tools_uses_explicit_app_config_for_acp_agents(monkeypatch):
|
||||
explicit_agents = {"codex": ACPAgentConfig(command="codex-acp", description="Codex CLI")}
|
||||
explicit_config = SimpleNamespace(
|
||||
tools=[],
|
||||
models=[],
|
||||
tool_search=SimpleNamespace(enabled=False),
|
||||
skill_evolution=SimpleNamespace(enabled=False),
|
||||
get_model_config=lambda name: None,
|
||||
acp_agents=explicit_agents,
|
||||
)
|
||||
sentinel_tool = SimpleNamespace(name="invoke_acp_agent")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fail_get_acp_agents():
|
||||
raise AssertionError("ambient get_acp_agents() must not be used when app_config is explicit")
|
||||
|
||||
def fake_build_invoke_acp_agent_tool(agents):
|
||||
captured["agents"] = agents
|
||||
return sentinel_tool
|
||||
|
||||
monkeypatch.setattr("deerflow.tools.tools.is_host_bash_allowed", lambda config=None: True)
|
||||
monkeypatch.setattr("deerflow.config.acp_config.get_acp_agents", fail_get_acp_agents)
|
||||
monkeypatch.setattr("deerflow.tools.builtins.invoke_acp_agent_tool.build_invoke_acp_agent_tool", fake_build_invoke_acp_agent_tool)
|
||||
|
||||
tools = get_available_tools(include_mcp=False, subagent_enabled=False, app_config=explicit_config)
|
||||
|
||||
assert captured["agents"] is explicit_agents
|
||||
assert "invoke_acp_agent" in [tool.name for tool in tools]
|
||||
|
||||
@@ -72,6 +72,44 @@ def test_internal_make_lead_agent_uses_explicit_app_config(monkeypatch):
|
||||
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):
|
||||
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(
|
||||
[
|
||||
@@ -276,6 +314,16 @@ def test_build_middlewares_passes_explicit_app_config_to_shared_factory(monkeypa
|
||||
)
|
||||
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}},
|
||||
@@ -286,17 +334,16 @@ def test_build_middlewares_passes_explicit_app_config_to_shared_factory(monkeypa
|
||||
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_create_summarization_middleware_uses_configured_model_alias(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
lead_agent_module,
|
||||
"get_summarization_config",
|
||||
lambda: SummarizationConfig(enabled=True, model_name="model-masswork"),
|
||||
)
|
||||
monkeypatch.setattr(lead_agent_module, "get_memory_config", lambda: MemoryConfig(enabled=False))
|
||||
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
|
||||
|
||||
@@ -311,13 +358,55 @@ def test_create_summarization_middleware_uses_configured_model_alias(monkeypatch
|
||||
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=_make_app_config([_make_model("model-masswork", supports_thinking=False)]))
|
||||
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 not None
|
||||
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):
|
||||
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
|
||||
|
||||
@@ -4,6 +4,7 @@ from types import SimpleNamespace
|
||||
import anyio
|
||||
|
||||
from deerflow.agents.lead_agent import prompt as prompt_module
|
||||
from deerflow.config.subagents_config import CustomSubagentConfig, SubagentsAppConfig
|
||||
from deerflow.skills.types import Skill
|
||||
|
||||
|
||||
@@ -40,6 +41,21 @@ def test_build_custom_mounts_section_lists_configured_mounts(monkeypatch):
|
||||
assert "read-only" in section
|
||||
|
||||
|
||||
def test_build_custom_mounts_section_uses_explicit_app_config_without_global_read(monkeypatch):
|
||||
mounts = [SimpleNamespace(container_path="/home/user/shared", read_only=False)]
|
||||
config = SimpleNamespace(sandbox=SimpleNamespace(mounts=mounts))
|
||||
|
||||
def fail_get_app_config():
|
||||
raise AssertionError("ambient get_app_config() must not be used when app_config is explicit")
|
||||
|
||||
monkeypatch.setattr("deerflow.config.get_app_config", fail_get_app_config)
|
||||
|
||||
section = prompt_module._build_custom_mounts_section(app_config=config)
|
||||
|
||||
assert "`/home/user/shared`" in section
|
||||
assert "read-write" in section
|
||||
|
||||
|
||||
def test_apply_prompt_template_includes_custom_mounts(monkeypatch):
|
||||
mounts = [SimpleNamespace(container_path="/home/user/shared", read_only=False)]
|
||||
config = SimpleNamespace(
|
||||
@@ -49,8 +65,8 @@ def test_apply_prompt_template_includes_custom_mounts(monkeypatch):
|
||||
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
|
||||
monkeypatch.setattr(prompt_module, "_get_enabled_skills", lambda: [])
|
||||
monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "_build_acp_section", lambda: "")
|
||||
monkeypatch.setattr(prompt_module, "_get_memory_context", lambda agent_name=None: "")
|
||||
monkeypatch.setattr(prompt_module, "_build_acp_section", lambda **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "_get_memory_context", lambda agent_name=None, **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None: "")
|
||||
|
||||
prompt = prompt_module.apply_prompt_template()
|
||||
@@ -67,8 +83,8 @@ def test_apply_prompt_template_includes_relative_path_guidance(monkeypatch):
|
||||
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
|
||||
monkeypatch.setattr(prompt_module, "_get_enabled_skills", lambda: [])
|
||||
monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "_build_acp_section", lambda: "")
|
||||
monkeypatch.setattr(prompt_module, "_get_memory_context", lambda agent_name=None: "")
|
||||
monkeypatch.setattr(prompt_module, "_build_acp_section", lambda **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "_get_memory_context", lambda agent_name=None, **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None: "")
|
||||
|
||||
prompt = prompt_module.apply_prompt_template()
|
||||
@@ -77,6 +93,123 @@ def test_apply_prompt_template_includes_relative_path_guidance(monkeypatch):
|
||||
assert "`hello.txt`, `../uploads/data.csv`, and `../outputs/report.md`" in prompt
|
||||
|
||||
|
||||
def test_apply_prompt_template_threads_explicit_app_config_without_global_config(monkeypatch):
|
||||
mounts = [SimpleNamespace(container_path="/home/user/shared", read_only=False)]
|
||||
explicit_config = SimpleNamespace(
|
||||
sandbox=SimpleNamespace(mounts=mounts),
|
||||
skills=SimpleNamespace(container_path="/mnt/explicit-skills"),
|
||||
skill_evolution=SimpleNamespace(enabled=False),
|
||||
tool_search=SimpleNamespace(enabled=False),
|
||||
memory=SimpleNamespace(enabled=False, injection_enabled=True, max_injection_tokens=2000),
|
||||
acp_agents={},
|
||||
)
|
||||
|
||||
def fail_get_app_config():
|
||||
raise AssertionError("ambient get_app_config() must not be used when app_config is explicit")
|
||||
|
||||
def fail_get_memory_config():
|
||||
raise AssertionError("ambient get_memory_config() must not be used when app_config is explicit")
|
||||
|
||||
monkeypatch.setattr("deerflow.config.get_app_config", fail_get_app_config)
|
||||
monkeypatch.setattr("deerflow.config.memory_config.get_memory_config", fail_get_memory_config)
|
||||
monkeypatch.setattr(prompt_module, "get_or_new_skill_storage", lambda app_config=None: SimpleNamespace(load_skills=lambda enabled_only=True: []))
|
||||
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None: "")
|
||||
|
||||
prompt = prompt_module.apply_prompt_template(app_config=explicit_config)
|
||||
|
||||
assert "`/home/user/shared`" in prompt
|
||||
assert "Custom Mounted Directories" in prompt
|
||||
|
||||
|
||||
def test_apply_prompt_template_threads_explicit_app_config_to_subagents_without_global_config(monkeypatch):
|
||||
explicit_config = SimpleNamespace(
|
||||
sandbox=SimpleNamespace(
|
||||
use="deerflow.sandbox.local:LocalSandboxProvider",
|
||||
allow_host_bash=False,
|
||||
mounts=[],
|
||||
),
|
||||
subagents=SubagentsAppConfig(
|
||||
custom_agents={
|
||||
"researcher": CustomSubagentConfig(
|
||||
description="Research agent\nwith details",
|
||||
system_prompt="You research.",
|
||||
)
|
||||
}
|
||||
),
|
||||
skills=SimpleNamespace(container_path="/mnt/skills"),
|
||||
skill_evolution=SimpleNamespace(enabled=False),
|
||||
tool_search=SimpleNamespace(enabled=False),
|
||||
memory=SimpleNamespace(enabled=False, injection_enabled=True, max_injection_tokens=2000),
|
||||
acp_agents={},
|
||||
)
|
||||
|
||||
def fail_get_app_config():
|
||||
raise AssertionError("ambient get_app_config() must not be used when app_config is explicit")
|
||||
|
||||
def fail_get_subagents_app_config():
|
||||
raise AssertionError("ambient get_subagents_app_config() must not be used when app_config is explicit")
|
||||
|
||||
monkeypatch.setattr("deerflow.config.get_app_config", fail_get_app_config)
|
||||
monkeypatch.setattr("deerflow.config.subagents_config.get_subagents_app_config", fail_get_subagents_app_config)
|
||||
monkeypatch.setattr(prompt_module, "get_or_new_skill_storage", lambda app_config=None: SimpleNamespace(load_skills=lambda enabled_only=True: []))
|
||||
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None: "")
|
||||
|
||||
prompt = prompt_module.apply_prompt_template(subagent_enabled=True, app_config=explicit_config)
|
||||
|
||||
assert "**researcher**: Research agent" in prompt
|
||||
assert "**bash**" not in prompt
|
||||
|
||||
|
||||
def test_build_acp_section_uses_explicit_app_config_without_global_config(monkeypatch):
|
||||
explicit_config = SimpleNamespace(acp_agents={"codex": object()})
|
||||
|
||||
def fail_get_acp_agents():
|
||||
raise AssertionError("ambient get_acp_agents() must not be used when app_config is explicit")
|
||||
|
||||
monkeypatch.setattr("deerflow.config.acp_config.get_acp_agents", fail_get_acp_agents)
|
||||
|
||||
section = prompt_module._build_acp_section(app_config=explicit_config)
|
||||
|
||||
assert "ACP Agent Tasks" in section
|
||||
assert "/mnt/acp-workspace/" in section
|
||||
|
||||
|
||||
def test_get_memory_context_uses_explicit_app_config_without_global_config(monkeypatch):
|
||||
explicit_config = SimpleNamespace(
|
||||
memory=SimpleNamespace(enabled=True, injection_enabled=True, max_injection_tokens=1234),
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fail_get_memory_config():
|
||||
raise AssertionError("ambient get_memory_config() must not be used when app_config is explicit")
|
||||
|
||||
def fake_get_memory_data(agent_name=None, *, user_id=None):
|
||||
captured["agent_name"] = agent_name
|
||||
captured["user_id"] = user_id
|
||||
return {"facts": []}
|
||||
|
||||
def fake_format_memory_for_injection(memory_data, *, max_tokens):
|
||||
captured["memory_data"] = memory_data
|
||||
captured["max_tokens"] = max_tokens
|
||||
return "remember this"
|
||||
|
||||
monkeypatch.setattr("deerflow.config.memory_config.get_memory_config", fail_get_memory_config)
|
||||
monkeypatch.setattr("deerflow.runtime.user_context.get_effective_user_id", lambda: "user-1")
|
||||
monkeypatch.setattr("deerflow.agents.memory.get_memory_data", fake_get_memory_data)
|
||||
monkeypatch.setattr("deerflow.agents.memory.format_memory_for_injection", fake_format_memory_for_injection)
|
||||
|
||||
context = prompt_module._get_memory_context("agent-a", app_config=explicit_config)
|
||||
|
||||
assert "<memory>" in context
|
||||
assert "remember this" in context
|
||||
assert captured == {
|
||||
"agent_name": "agent-a",
|
||||
"user_id": "user-1",
|
||||
"memory_data": {"facts": []},
|
||||
"max_tokens": 1234,
|
||||
}
|
||||
|
||||
|
||||
def test_refresh_skills_system_prompt_cache_async_reloads_immediately(monkeypatch, tmp_path):
|
||||
def make_skill(name: str) -> Skill:
|
||||
skill_dir = tmp_path / name
|
||||
|
||||
@@ -106,7 +106,11 @@ def test_get_skills_prompt_section_uses_explicit_config_for_enabled_skills(monke
|
||||
skill_evolution=SimpleNamespace(enabled=False),
|
||||
)
|
||||
|
||||
def fail_get_app_config():
|
||||
raise AssertionError("ambient get_app_config() must not be used when app_config is explicit")
|
||||
|
||||
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: [_make_skill("global-skill")])
|
||||
monkeypatch.setattr("deerflow.config.get_app_config", fail_get_app_config)
|
||||
monkeypatch.setattr(
|
||||
"deerflow.agents.lead_agent.prompt.get_or_new_skill_storage",
|
||||
lambda app_config=None, **kwargs: __import__("types").SimpleNamespace(load_skills=lambda *, enabled_only: [_make_skill("explicit-skill")] if app_config is explicit_config else []),
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, call
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.runtime.runs.worker import _agent_factory_supports_app_config, _build_runtime_context, _rollback_to_pre_run_checkpoint
|
||||
from deerflow.runtime.runs.manager import RunManager
|
||||
from deerflow.runtime.runs.schemas import RunStatus
|
||||
from deerflow.runtime.runs.worker import RunContext, _agent_factory_supports_app_config, _build_runtime_context, _install_runtime_context, _rollback_to_pre_run_checkpoint, run_agent
|
||||
|
||||
|
||||
class FakeCheckpointer:
|
||||
@@ -12,6 +16,73 @@ class FakeCheckpointer:
|
||||
self.aput_writes = AsyncMock()
|
||||
|
||||
|
||||
def test_build_runtime_context_includes_app_config_when_present():
|
||||
app_config = object()
|
||||
|
||||
context = _build_runtime_context("thread-1", "run-1", None, app_config)
|
||||
|
||||
assert context["thread_id"] == "thread-1"
|
||||
assert context["run_id"] == "run-1"
|
||||
assert context["app_config"] is app_config
|
||||
|
||||
|
||||
def test_install_runtime_context_preserves_existing_thread_id_and_threads_app_config():
|
||||
app_config = object()
|
||||
config = {"context": {"thread_id": "caller-thread"}}
|
||||
|
||||
_install_runtime_context(
|
||||
config,
|
||||
{
|
||||
"thread_id": "record-thread",
|
||||
"run_id": "run-1",
|
||||
"app_config": app_config,
|
||||
},
|
||||
)
|
||||
|
||||
assert config["context"]["thread_id"] == "caller-thread"
|
||||
assert config["context"]["run_id"] == "run-1"
|
||||
assert config["context"]["app_config"] is app_config
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_agent_threads_explicit_app_config_into_config_only_factory():
|
||||
run_manager = RunManager()
|
||||
record = await run_manager.create("thread-1")
|
||||
bridge = SimpleNamespace(
|
||||
publish=AsyncMock(),
|
||||
publish_end=AsyncMock(),
|
||||
cleanup=AsyncMock(),
|
||||
)
|
||||
app_config = object()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class DummyAgent:
|
||||
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
||||
captured["astream_context"] = config["context"]
|
||||
yield {"messages": []}
|
||||
|
||||
def factory(*, config):
|
||||
captured["factory_context"] = config["context"]
|
||||
return DummyAgent()
|
||||
|
||||
await run_agent(
|
||||
bridge,
|
||||
run_manager,
|
||||
record,
|
||||
ctx=RunContext(checkpointer=None, app_config=app_config),
|
||||
agent_factory=factory,
|
||||
graph_input={},
|
||||
config={},
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert captured["factory_context"]["app_config"] is app_config
|
||||
assert captured["astream_context"]["app_config"] is app_config
|
||||
assert run_manager.get(record.run_id).status == RunStatus.success
|
||||
bridge.publish_end.assert_awaited_once_with(record.run_id)
|
||||
bridge.cleanup.assert_awaited_once_with(record.run_id, delay=60)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_rollback_restores_snapshot_without_deleting_thread():
|
||||
checkpointer = FakeCheckpointer(put_result={"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": "restored-1"}})
|
||||
|
||||
@@ -204,7 +204,7 @@ class TestAgentConstruction:
|
||||
|
||||
SubagentExecutor = classes["SubagentExecutor"]
|
||||
|
||||
app_config = object()
|
||||
app_config = SimpleNamespace(models=[SimpleNamespace(name="default-model")])
|
||||
model = object()
|
||||
middlewares = [object()]
|
||||
agent = object()
|
||||
@@ -266,6 +266,43 @@ class TestAgentConstruction:
|
||||
assert captured["agent"]["tools"] == []
|
||||
assert captured["agent"]["system_prompt"] == base_config.system_prompt
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_load_skill_messages_uses_explicit_app_config_for_skill_storage(
|
||||
self,
|
||||
classes,
|
||||
base_config,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
):
|
||||
"""Explicit app_config must be threaded into subagent skill storage lookup."""
|
||||
SubagentExecutor = classes["SubagentExecutor"]
|
||||
|
||||
app_config = SimpleNamespace(models=[SimpleNamespace(name="default-model")])
|
||||
skill_dir = tmp_path / "demo-skill"
|
||||
skill_dir.mkdir()
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
skill_file.write_text("Use demo skill", encoding="utf-8")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_get_or_new_skill_storage(*, app_config=None):
|
||||
captured["app_config"] = app_config
|
||||
return SimpleNamespace(load_skills=lambda *, enabled_only: [SimpleNamespace(name="demo-skill", skill_file=skill_file)])
|
||||
|
||||
monkeypatch.setattr("deerflow.skills.storage.get_or_new_skill_storage", fake_get_or_new_skill_storage)
|
||||
|
||||
executor = SubagentExecutor(
|
||||
config=base_config,
|
||||
tools=[],
|
||||
app_config=app_config,
|
||||
thread_id="test-thread",
|
||||
)
|
||||
|
||||
messages = await executor._load_skill_messages()
|
||||
|
||||
assert captured["app_config"] is app_config
|
||||
assert len(messages) == 1
|
||||
assert "Use demo skill" in messages[0].content
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Async Execution Path Tests
|
||||
|
||||
@@ -9,6 +9,8 @@ Covers:
|
||||
- Skills filter passthrough in task_tool config assembly
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.config.subagents_config import (
|
||||
@@ -343,12 +345,54 @@ class TestRegistryCustomAgentLookup:
|
||||
assert config.timeout_seconds == 600
|
||||
assert config.model == "inherit"
|
||||
|
||||
def test_custom_agent_found_from_explicit_app_config_without_global_config(self, monkeypatch):
|
||||
from deerflow.subagents.registry import get_subagent_config
|
||||
|
||||
def fail_get_subagents_app_config():
|
||||
raise AssertionError("ambient get_subagents_app_config() must not be used when app_config is explicit")
|
||||
|
||||
monkeypatch.setattr("deerflow.config.subagents_config.get_subagents_app_config", fail_get_subagents_app_config)
|
||||
|
||||
app_config = SimpleNamespace(
|
||||
subagents=SubagentsAppConfig(
|
||||
custom_agents={
|
||||
"analysis": CustomSubagentConfig(
|
||||
description="Data analysis specialist",
|
||||
system_prompt="You are a data analysis subagent.",
|
||||
skills=["data-analysis"],
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
config = get_subagent_config("analysis", app_config=app_config)
|
||||
|
||||
assert config is not None
|
||||
assert config.name == "analysis"
|
||||
assert config.skills == ["data-analysis"]
|
||||
|
||||
def test_custom_agent_not_found(self):
|
||||
from deerflow.subagents.registry import get_subagent_config
|
||||
|
||||
_reset_subagents_config()
|
||||
assert get_subagent_config("nonexistent") is None
|
||||
|
||||
def test_get_available_subagent_names_falls_back_when_subagents_app_config_lacks_sandbox(self, monkeypatch):
|
||||
from deerflow.subagents import registry as registry_module
|
||||
from deerflow.subagents.registry import get_available_subagent_names
|
||||
|
||||
captured: dict[str, tuple] = {}
|
||||
|
||||
def fake_is_host_bash_allowed(*args, **kwargs):
|
||||
captured["args"] = args
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(registry_module, "is_host_bash_allowed", fake_is_host_bash_allowed)
|
||||
|
||||
get_available_subagent_names(app_config=SubagentsAppConfig())
|
||||
|
||||
assert captured["args"] == ()
|
||||
|
||||
def test_builtin_takes_priority_over_custom(self):
|
||||
"""If a custom agent has the same name as a builtin, builtin wins."""
|
||||
from deerflow.subagents.builtins import BUILTIN_SUBAGENTS
|
||||
|
||||
@@ -24,8 +24,11 @@ class FakeSubagentStatus(Enum):
|
||||
TIMED_OUT = "timed_out"
|
||||
|
||||
|
||||
def _make_runtime() -> SimpleNamespace:
|
||||
def _make_runtime(*, app_config=None) -> SimpleNamespace:
|
||||
# Minimal ToolRuntime-like object; task_tool only reads these three attributes.
|
||||
context = {"thread_id": "thread-1"}
|
||||
if app_config is not None:
|
||||
context["app_config"] = app_config
|
||||
return SimpleNamespace(
|
||||
state={
|
||||
"sandbox": {"sandbox_id": "local"},
|
||||
@@ -35,14 +38,14 @@ def _make_runtime() -> SimpleNamespace:
|
||||
"outputs_path": "/tmp/outputs",
|
||||
},
|
||||
},
|
||||
context={"thread_id": "thread-1"},
|
||||
context=context,
|
||||
config={"metadata": {"model_name": "ark-model", "trace_id": "trace-1"}},
|
||||
)
|
||||
|
||||
|
||||
def _make_subagent_config() -> SubagentConfig:
|
||||
def _make_subagent_config(name: str = "general-purpose") -> SubagentConfig:
|
||||
return SubagentConfig(
|
||||
name="general-purpose",
|
||||
name=name,
|
||||
description="General helper",
|
||||
system_prompt="Base system prompt",
|
||||
max_turns=50,
|
||||
@@ -112,6 +115,68 @@ def test_task_tool_rejects_bash_subagent_when_host_bash_disabled(monkeypatch):
|
||||
assert result.startswith("Error: Bash subagent is disabled")
|
||||
|
||||
|
||||
def test_task_tool_threads_runtime_app_config_to_subagent_dependencies(monkeypatch):
|
||||
app_config = object()
|
||||
config = _make_subagent_config(name="bash")
|
||||
runtime = _make_runtime(app_config=app_config)
|
||||
events = []
|
||||
captured = {}
|
||||
|
||||
class DummyExecutor:
|
||||
def __init__(self, **kwargs):
|
||||
captured["executor_kwargs"] = kwargs
|
||||
|
||||
def execute_async(self, prompt, task_id=None):
|
||||
captured["prompt"] = prompt
|
||||
return task_id or "generated-task-id"
|
||||
|
||||
def fake_get_available_subagent_names(*, app_config):
|
||||
captured["names_app_config"] = app_config
|
||||
return ["bash"]
|
||||
|
||||
def fake_get_subagent_config(name, *, app_config):
|
||||
captured["config_lookup"] = (name, app_config)
|
||||
return config
|
||||
|
||||
def fake_is_host_bash_allowed(config):
|
||||
captured["bash_gate_app_config"] = config
|
||||
return True
|
||||
|
||||
def fake_get_available_tools(**kwargs):
|
||||
captured["tools_kwargs"] = kwargs
|
||||
return ["tool-a"]
|
||||
|
||||
monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus)
|
||||
monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor)
|
||||
monkeypatch.setattr(task_tool_module, "get_available_subagent_names", fake_get_available_subagent_names)
|
||||
monkeypatch.setattr(task_tool_module, "get_subagent_config", fake_get_subagent_config)
|
||||
monkeypatch.setattr(task_tool_module, "is_host_bash_allowed", fake_is_host_bash_allowed)
|
||||
monkeypatch.setattr(
|
||||
task_tool_module,
|
||||
"get_background_task_result",
|
||||
lambda _: _make_result(FakeSubagentStatus.COMPLETED, result="done"),
|
||||
)
|
||||
monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append)
|
||||
monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep)
|
||||
monkeypatch.setattr("deerflow.tools.get_available_tools", fake_get_available_tools)
|
||||
|
||||
output = _run_task_tool(
|
||||
runtime=runtime,
|
||||
description="运行命令",
|
||||
prompt="inspect files",
|
||||
subagent_type="bash",
|
||||
tool_call_id="tc-explicit-config",
|
||||
)
|
||||
|
||||
assert output == "Task Succeeded. Result: done"
|
||||
assert captured["names_app_config"] is app_config
|
||||
assert captured["config_lookup"] == ("bash", app_config)
|
||||
assert captured["bash_gate_app_config"] is app_config
|
||||
assert captured["tools_kwargs"]["app_config"] is app_config
|
||||
assert captured["executor_kwargs"]["app_config"] is app_config
|
||||
assert captured["executor_kwargs"]["tools"] == ["tool-a"]
|
||||
|
||||
|
||||
def test_task_tool_emits_running_and_completed_events(monkeypatch):
|
||||
config = _make_subagent_config()
|
||||
runtime = _make_runtime()
|
||||
@@ -421,7 +486,8 @@ def test_task_tool_runtime_none_passes_groups_none(monkeypatch):
|
||||
monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append)
|
||||
monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep)
|
||||
monkeypatch.setattr("deerflow.tools.get_available_tools", get_available_tools)
|
||||
monkeypatch.setattr(task_tool_module, "get_app_config", lambda: SimpleNamespace(models=[SimpleNamespace(name="default-model")]))
|
||||
fallback_app_config = SimpleNamespace(models=[SimpleNamespace(name="default-model")])
|
||||
monkeypatch.setattr(task_tool_module, "get_app_config", lambda: fallback_app_config)
|
||||
|
||||
output = _run_task_tool(
|
||||
runtime=None,
|
||||
@@ -433,7 +499,12 @@ def test_task_tool_runtime_none_passes_groups_none(monkeypatch):
|
||||
|
||||
assert output == "Task Succeeded. Result: ok"
|
||||
# runtime is None -> metadata is empty dict -> groups=None, model falls back to app default.
|
||||
get_available_tools.assert_called_once_with(model_name="default-model", groups=None, subagent_enabled=False)
|
||||
get_available_tools.assert_called_once_with(
|
||||
model_name="default-model",
|
||||
groups=None,
|
||||
subagent_enabled=False,
|
||||
app_config=fallback_app_config,
|
||||
)
|
||||
|
||||
config = _make_subagent_config()
|
||||
events = []
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Core behavior tests for TitleMiddleware."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
@@ -98,6 +99,34 @@ class TestTitleMiddlewareCoreLogic:
|
||||
"tags": ["middleware:title"],
|
||||
}
|
||||
|
||||
def test_generate_title_uses_explicit_app_config_without_global_config(self, monkeypatch):
|
||||
title_config = TitleConfig(enabled=True, model_name="title-model", max_chars=20)
|
||||
app_config = SimpleNamespace(title=title_config)
|
||||
middleware = TitleMiddleware(app_config=app_config)
|
||||
model = MagicMock()
|
||||
model.ainvoke = AsyncMock(return_value=AIMessage(content="显式标题"))
|
||||
|
||||
def fail_get_title_config():
|
||||
raise AssertionError("ambient get_title_config() must not be used when app_config is explicit")
|
||||
|
||||
monkeypatch.setattr(title_middleware_module, "get_title_config", fail_get_title_config)
|
||||
monkeypatch.setattr(title_middleware_module, "create_chat_model", MagicMock(return_value=model))
|
||||
|
||||
state = {
|
||||
"messages": [
|
||||
HumanMessage(content="请帮我写一个标题"),
|
||||
AIMessage(content="好的"),
|
||||
]
|
||||
}
|
||||
result = asyncio.run(middleware._agenerate_title_result(state))
|
||||
|
||||
assert result == {"title": "显式标题"}
|
||||
title_middleware_module.create_chat_model.assert_called_once_with(
|
||||
name="title-model",
|
||||
thinking_enabled=False,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
def test_generate_title_normalizes_structured_message_content(self, monkeypatch):
|
||||
_set_test_title_config(max_chars=20)
|
||||
middleware = TitleMiddleware()
|
||||
|
||||
Reference in New Issue
Block a user