mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-20 03:26:18 +00:00
* feat(memory): add opt-in relevance-aware retrieval ranking Add a deterministic, network-free lexical relevance strategy for DeerMem (issue #4495): memory_search ranks every fact in scope by idf-weighted token overlap combined with confidence, with optional greedy-MMR diversity against near-duplicate facts; prompt injection ranks facts against the current-turn query threaded from DynamicContextMiddleware through the new optional `query` keyword on MemoryManager.get_context/aget_context. Defaults preserve the legacy confidence-only behavior exactly; no prompt, storage-format, or vector/embedding-dependency changes. Refs #4495 Signed-off-by: pwd11 <fvdsrc@163.com> * fix(memory): bound relevance retrieval and apply review feedback Bound tokenization and index shared stems, preserve mixed CJK tokens, warm jieba, and align missing confidence with legacy injection. Cache MMR token sets and stop selection at result or injection budgets. Document retrieval-adapter precedence and add regression coverage. Refs #4495. Signed-off-by: pwd11 <fvdsrc@163.com> * fix(memory): preserve backend compatibility and normalize relevance Signed-off-by: pwd11 <fvdsrc@163.com> * fix(memory): omit absent query hints and share injection IDF Signed-off-by: pwd11 <fvdsrc@163.com> * test(memory): retain timeout mock until injection worker exits Signed-off-by: pwd11 <fvdsrc@163.com> * docs(agents): drop root guidance compaction Signed-off-by: pwd11 <fvdsrc@163.com> * fix(memory): validate token prefixes and preserve upload queries --------- Signed-off-by: pwd11 <fvdsrc@163.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
728 lines
31 KiB
Python
728 lines
31 KiB
Python
import threading
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import cast
|
|
|
|
import anyio
|
|
import pytest
|
|
|
|
from deerflow.agents.lead_agent import prompt as prompt_module
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.subagents_config import CustomSubagentConfig, SubagentsAppConfig
|
|
from deerflow.skills.types import Skill, SkillCategory
|
|
|
|
|
|
def _set_skills_cache_state(*, skills=None, active=False, version=0):
|
|
prompt_module._get_cached_skills_prompt_section.cache_clear()
|
|
with prompt_module._enabled_skills_lock:
|
|
prompt_module._enabled_skills_cache = skills
|
|
prompt_module._enabled_skills_by_config_cache.clear()
|
|
prompt_module._enabled_skills_refresh_active = active
|
|
prompt_module._enabled_skills_refresh_version = version
|
|
prompt_module._enabled_skills_refresh_event.clear()
|
|
prompt_module._enabled_skills_refresh_waiters.clear()
|
|
|
|
|
|
def test_build_self_update_section_empty_for_default_agent():
|
|
assert prompt_module._build_self_update_section(None) == ""
|
|
|
|
|
|
def test_build_self_update_section_present_for_custom_agent():
|
|
section = prompt_module._build_self_update_section("my-agent")
|
|
|
|
assert "<self_update>" in section
|
|
assert "my-agent" in section
|
|
assert "update_agent" in section
|
|
assert '"null"' in section
|
|
|
|
|
|
def test_build_custom_mounts_section_returns_empty_when_no_mounts(monkeypatch):
|
|
config = SimpleNamespace(sandbox=SimpleNamespace(mounts=[]))
|
|
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
|
|
|
|
assert prompt_module._build_custom_mounts_section() == ""
|
|
|
|
|
|
def test_build_custom_mounts_section_lists_configured_mounts(monkeypatch):
|
|
mounts = [
|
|
SimpleNamespace(container_path="/home/user/shared", read_only=False),
|
|
SimpleNamespace(container_path="/mnt/reference", read_only=True),
|
|
]
|
|
config = SimpleNamespace(sandbox=SimpleNamespace(mounts=mounts))
|
|
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
|
|
|
|
section = prompt_module._build_custom_mounts_section()
|
|
|
|
assert "**Custom Mounted Directories:**" in section
|
|
assert "`/home/user/shared`" in section
|
|
assert "read-write" in section
|
|
assert "`/mnt/reference`" in section
|
|
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(
|
|
sandbox=SimpleNamespace(mounts=mounts),
|
|
skills=SimpleNamespace(container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/skills")),
|
|
)
|
|
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 **kwargs: "")
|
|
|
|
|
|
def test_apply_prompt_template_includes_relative_path_guidance(monkeypatch):
|
|
config = SimpleNamespace(
|
|
sandbox=SimpleNamespace(mounts=[]),
|
|
skills=SimpleNamespace(container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/skills")),
|
|
)
|
|
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 **kwargs: "")
|
|
monkeypatch.setattr(prompt_module, "_get_memory_context", lambda agent_name=None, **kwargs: "")
|
|
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None, **kwargs: "")
|
|
|
|
prompt = prompt_module.apply_prompt_template()
|
|
|
|
assert "Treat `/mnt/user-data/workspace` as your default current working directory" in prompt
|
|
assert "`hello.txt`, `../uploads/data.csv`, and `../outputs/report.md`" in prompt
|
|
|
|
|
|
def test_apply_prompt_template_includes_memory_tool_guidance_only_in_tool_mode(monkeypatch):
|
|
tool_config = SimpleNamespace(
|
|
sandbox=SimpleNamespace(mounts=[]),
|
|
skills=SimpleNamespace(container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/skills")),
|
|
skill_evolution=SimpleNamespace(enabled=False),
|
|
tool_search=SimpleNamespace(enabled=False),
|
|
memory=SimpleNamespace(enabled=True, mode="tool", injection_enabled=False),
|
|
acp_agents={},
|
|
)
|
|
middleware_config = SimpleNamespace(
|
|
sandbox=SimpleNamespace(mounts=[]),
|
|
skills=tool_config.skills,
|
|
skill_evolution=SimpleNamespace(enabled=False),
|
|
tool_search=SimpleNamespace(enabled=False),
|
|
memory=SimpleNamespace(enabled=True, mode="middleware"),
|
|
acp_agents={},
|
|
)
|
|
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_or_new_user_skill_storage", lambda user_id, app_config=None: SimpleNamespace(load_skills=lambda *, enabled_only: []))
|
|
monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda **kwargs: "")
|
|
monkeypatch.setattr(prompt_module, "_build_acp_section", lambda **kwargs: "")
|
|
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None, **kwargs: "")
|
|
|
|
tool_prompt = prompt_module.apply_prompt_template(app_config=tool_config)
|
|
stateless_agent_prompt = prompt_module.apply_prompt_template(app_config=tool_config, memory_enabled=False)
|
|
middleware_prompt = prompt_module.apply_prompt_template(app_config=middleware_config)
|
|
|
|
assert "<memory_tool_system>" in tool_prompt
|
|
assert "memory_search" in tool_prompt
|
|
assert "memory_add" in tool_prompt
|
|
assert "agent facts are not injected automatically" in tool_prompt
|
|
assert "When present, the injected <memory> block contains only global user and history summaries" in tool_prompt
|
|
assert "<memory_tool_system>" not in stateless_agent_prompt
|
|
assert "<memory_tool_system>" not in middleware_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", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/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_or_new_user_skill_storage", lambda user_id, app_config=None: SimpleNamespace(load_skills=lambda *, enabled_only: []))
|
|
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None, **kwargs: "")
|
|
|
|
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", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/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, **kwargs: "")
|
|
|
|
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_apply_prompt_template_includes_subagent_total_limit(monkeypatch):
|
|
explicit_config = SimpleNamespace(
|
|
sandbox=SimpleNamespace(
|
|
use="deerflow.sandbox.local:LocalSandboxProvider",
|
|
allow_host_bash=False,
|
|
mounts=[],
|
|
),
|
|
subagents=SubagentsAppConfig(),
|
|
subagent_runtime=SimpleNamespace(max_running=4),
|
|
skills=SimpleNamespace(container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/skills")),
|
|
skill_evolution=SimpleNamespace(enabled=False),
|
|
tool_search=SimpleNamespace(enabled=False),
|
|
memory=SimpleNamespace(enabled=False, injection_enabled=True, max_injection_tokens=2000),
|
|
acp_agents={},
|
|
)
|
|
|
|
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, **kwargs: "")
|
|
|
|
prompt = prompt_module.apply_prompt_template(
|
|
subagent_enabled=True,
|
|
max_concurrent_subagents=3,
|
|
max_total_subagents=5,
|
|
app_config=explicit_config,
|
|
)
|
|
|
|
assert "MAXIMUM 3 `task` CALLS PER RESPONSE" in prompt
|
|
assert "MAXIMUM 5 `task` CALLS PER RUN" in prompt
|
|
assert "Default to direct execution" in prompt
|
|
assert "DELEGATION CHECK" in prompt
|
|
assert "expected benefit from real parallel latency" in prompt
|
|
assert "HARD LIMITS ARE NON-NEGOTIABLE" in prompt
|
|
|
|
|
|
def test_apply_prompt_template_clamps_subagent_limits_to_enforced_bounds(monkeypatch):
|
|
explicit_config = SimpleNamespace(
|
|
sandbox=SimpleNamespace(
|
|
use="deerflow.sandbox.local:LocalSandboxProvider",
|
|
allow_host_bash=False,
|
|
mounts=[],
|
|
),
|
|
subagents=SubagentsAppConfig(),
|
|
subagent_runtime=SimpleNamespace(max_running=4),
|
|
skills=SimpleNamespace(container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/skills")),
|
|
skill_evolution=SimpleNamespace(enabled=False),
|
|
tool_search=SimpleNamespace(enabled=False),
|
|
memory=SimpleNamespace(enabled=False, injection_enabled=True, max_injection_tokens=2000),
|
|
acp_agents={},
|
|
)
|
|
|
|
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, **kwargs: "")
|
|
|
|
prompt = prompt_module.apply_prompt_template(
|
|
subagent_enabled=True,
|
|
max_concurrent_subagents=99,
|
|
max_total_subagents=99,
|
|
app_config=explicit_config,
|
|
)
|
|
|
|
assert "MAXIMUM 4 `task` CALLS PER RESPONSE" in prompt
|
|
assert "MAXIMUM 50 `task` CALLS PER RUN" in prompt
|
|
|
|
|
|
def test_apply_prompt_template_prefers_startup_execution_capacity_after_reload(monkeypatch):
|
|
explicit_config = SimpleNamespace(
|
|
sandbox=SimpleNamespace(
|
|
use="deerflow.sandbox.local:LocalSandboxProvider",
|
|
allow_host_bash=False,
|
|
mounts=[],
|
|
),
|
|
subagents=SubagentsAppConfig(),
|
|
subagent_runtime=SimpleNamespace(max_running=12),
|
|
skills=SimpleNamespace(
|
|
container_path="/mnt/skills",
|
|
use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage",
|
|
get_skills_path=lambda: Path("/tmp/skills"),
|
|
),
|
|
skill_evolution=SimpleNamespace(enabled=False),
|
|
tool_search=SimpleNamespace(enabled=False),
|
|
memory=SimpleNamespace(enabled=False, injection_enabled=True, max_injection_tokens=2000),
|
|
acp_agents={},
|
|
)
|
|
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, **kwargs: "")
|
|
|
|
prompt = prompt_module.apply_prompt_template(
|
|
subagent_enabled=True,
|
|
max_concurrent_subagents=10,
|
|
app_config=explicit_config,
|
|
subagent_execution_capacity=3,
|
|
)
|
|
|
|
assert "MAXIMUM 3 `task` CALLS PER RESPONSE" in prompt
|
|
assert "MAXIMUM 10 `task` CALLS PER RESPONSE" not in prompt
|
|
|
|
|
|
def test_apply_prompt_template_single_subagent_limit_matches_middleware(monkeypatch):
|
|
"""Regression test for single-subagent mode (MIN_CONCURRENT_SUBAGENT_CALLS = 1).
|
|
|
|
Before the floor was lowered to 1, a user-configured limit of 1 was silently
|
|
bumped to 2 by both the prompt path and the middleware. This renders the real
|
|
system prompt with max_concurrent_subagents=1 and asserts the advertised
|
|
HARD LIMITS value equals the middleware-enforced max_concurrent, so the two
|
|
paths cannot drift apart on the newly-allowed value.
|
|
"""
|
|
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
|
|
|
explicit_config = SimpleNamespace(
|
|
sandbox=SimpleNamespace(
|
|
use="deerflow.sandbox.local:LocalSandboxProvider",
|
|
allow_host_bash=False,
|
|
mounts=[],
|
|
),
|
|
subagents=SubagentsAppConfig(),
|
|
skills=SimpleNamespace(container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/skills")),
|
|
skill_evolution=SimpleNamespace(enabled=False),
|
|
tool_search=SimpleNamespace(enabled=False),
|
|
memory=SimpleNamespace(enabled=False, injection_enabled=True, max_injection_tokens=2000),
|
|
acp_agents={},
|
|
)
|
|
|
|
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, **kwargs: "")
|
|
|
|
enforced = SubagentLimitMiddleware(max_concurrent=1).max_concurrent
|
|
assert enforced == 1 # 1 must pass through, not be bumped to 2
|
|
|
|
prompt = prompt_module.apply_prompt_template(
|
|
subagent_enabled=True,
|
|
max_concurrent_subagents=1,
|
|
max_total_subagents=6,
|
|
app_config=explicit_config,
|
|
)
|
|
|
|
assert f"MAXIMUM {enforced} `task` CALLS PER RESPONSE" in prompt
|
|
assert f"HARD LIMITS ARE NON-NEGOTIABLE: max {enforced} `task` calls per response" in prompt
|
|
assert "Expected benefit = specialist capability + context isolation" in prompt
|
|
assert "delegate only for material specialist or context-isolation benefit" in prompt
|
|
assert "expected benefit from real parallel latency" not in prompt
|
|
assert "material within-batch parallel savings" not in prompt
|
|
assert "Multi-batch example" 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, token_counting="tiktoken"),
|
|
)
|
|
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_context(user_id, *, agent_name=None, thread_id=None, query=None):
|
|
captured["agent_name"] = agent_name
|
|
captured["user_id"] = user_id
|
|
return "remember this"
|
|
|
|
manager = SimpleNamespace(get_context=fake_get_context)
|
|
monkeypatch.setattr("deerflow.config.memory_config.get_memory_config", fail_get_memory_config)
|
|
monkeypatch.setattr("deerflow.runtime.user_context.resolve_runtime_user_id", lambda runtime: "user-1")
|
|
monkeypatch.setattr("deerflow.agents.memory.get_memory_manager", lambda: manager)
|
|
|
|
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",
|
|
}
|
|
|
|
|
|
def test_get_memory_context_propagates_required_read_error_without_backend_config(monkeypatch):
|
|
from deerflow.agents.memory import MemoryReadError
|
|
|
|
explicit_config = SimpleNamespace(
|
|
memory=SimpleNamespace(
|
|
enabled=True,
|
|
injection_enabled=True,
|
|
backend_config={},
|
|
),
|
|
)
|
|
manager = SimpleNamespace(get_context=lambda *args, **kwargs: (_ for _ in ()).throw(MemoryReadError("down")))
|
|
monkeypatch.setattr("deerflow.agents.memory.get_memory_manager", lambda: manager)
|
|
monkeypatch.setattr("deerflow.runtime.user_context.get_effective_user_id", lambda: "user-1")
|
|
|
|
with pytest.raises(MemoryReadError, match="down"):
|
|
prompt_module._get_memory_context("agent-a", app_config=explicit_config)
|
|
|
|
|
|
def test_get_memory_context_swallows_ordinary_manager_error(monkeypatch):
|
|
from deerflow.agents.memory import MemoryManagerError
|
|
|
|
explicit_config = SimpleNamespace(
|
|
memory=SimpleNamespace(enabled=True, injection_enabled=True, backend_config={}),
|
|
)
|
|
manager = SimpleNamespace(get_context=lambda *args, **kwargs: (_ for _ in ()).throw(MemoryManagerError("down")))
|
|
monkeypatch.setattr("deerflow.agents.memory.get_memory_manager", lambda: manager)
|
|
monkeypatch.setattr("deerflow.runtime.user_context.get_effective_user_id", lambda: "user-1")
|
|
|
|
assert prompt_module._get_memory_context("agent-a", app_config=explicit_config) == ""
|
|
|
|
|
|
def test_get_memory_context_preserves_legacy_fail_closed_contract(monkeypatch):
|
|
from deerflow.agents.memory import MemoryManagerError
|
|
|
|
explicit_config = SimpleNamespace(
|
|
memory=SimpleNamespace(
|
|
enabled=True,
|
|
injection_enabled=True,
|
|
backend_config={"failure_policy": {"read": "fail_closed"}},
|
|
),
|
|
)
|
|
manager = SimpleNamespace(get_context=lambda *args, **kwargs: (_ for _ in ()).throw(MemoryManagerError("down")))
|
|
monkeypatch.setattr("deerflow.agents.memory.get_memory_manager", lambda: manager)
|
|
|
|
with pytest.raises(MemoryManagerError, match="down"):
|
|
prompt_module._get_memory_context(
|
|
"agent-a",
|
|
app_config=explicit_config,
|
|
user_id="user-1",
|
|
)
|
|
|
|
|
|
def test_get_memory_context_prefers_explicit_user_id(monkeypatch):
|
|
explicit_config = SimpleNamespace(
|
|
memory=SimpleNamespace(enabled=True, injection_enabled=True),
|
|
)
|
|
captured: dict[str, object] = {}
|
|
|
|
def fail_resolve_runtime_user_id(runtime):
|
|
raise AssertionError("explicit user_id must bypass ambient identity resolution")
|
|
|
|
def fake_get_context(user_id, *, agent_name=None, thread_id=None, query=None):
|
|
captured["agent_name"] = agent_name
|
|
captured["user_id"] = user_id
|
|
return "remember this"
|
|
|
|
monkeypatch.setattr("deerflow.runtime.user_context.resolve_runtime_user_id", fail_resolve_runtime_user_id)
|
|
monkeypatch.setattr(
|
|
"deerflow.agents.memory.get_memory_manager",
|
|
lambda: SimpleNamespace(get_context=fake_get_context),
|
|
)
|
|
|
|
context = prompt_module._get_memory_context(
|
|
"agent-a",
|
|
app_config=explicit_config,
|
|
user_id="runtime-user",
|
|
)
|
|
|
|
assert "<memory>" in context
|
|
assert captured == {
|
|
"agent_name": "agent-a",
|
|
"user_id": "runtime-user",
|
|
}
|
|
|
|
|
|
def test_refresh_skills_system_prompt_cache_async_reloads_immediately(monkeypatch, tmp_path):
|
|
def make_skill(name: str) -> Skill:
|
|
skill_dir = tmp_path / name
|
|
return Skill(
|
|
name=name,
|
|
description=f"Description for {name}",
|
|
license="MIT",
|
|
skill_dir=skill_dir,
|
|
skill_file=skill_dir / "SKILL.md",
|
|
relative_path=skill_dir.relative_to(tmp_path),
|
|
category=SkillCategory.CUSTOM,
|
|
enabled=True,
|
|
)
|
|
|
|
state = {"skills": [make_skill("first-skill")]}
|
|
monkeypatch.setattr(prompt_module, "get_or_new_skill_storage", lambda **kwargs: __import__("types").SimpleNamespace(load_skills=lambda *, enabled_only: list(state["skills"])))
|
|
_set_skills_cache_state()
|
|
|
|
try:
|
|
prompt_module.warm_enabled_skills_cache()
|
|
assert [skill.name for skill in prompt_module._get_enabled_skills()] == ["first-skill"]
|
|
|
|
state["skills"] = [make_skill("second-skill")]
|
|
anyio.run(prompt_module.refresh_skills_system_prompt_cache_async)
|
|
|
|
assert [skill.name for skill in prompt_module._get_enabled_skills()] == ["second-skill"]
|
|
finally:
|
|
_set_skills_cache_state()
|
|
|
|
|
|
def test_explicit_config_enabled_skills_are_cached_by_config_identity(monkeypatch, tmp_path):
|
|
def make_skill(name: str) -> Skill:
|
|
skill_dir = tmp_path / name
|
|
return Skill(
|
|
name=name,
|
|
description=f"Description for {name}",
|
|
license="MIT",
|
|
skill_dir=skill_dir,
|
|
skill_file=skill_dir / "SKILL.md",
|
|
relative_path=skill_dir.relative_to(tmp_path),
|
|
category=SkillCategory.CUSTOM,
|
|
enabled=True,
|
|
)
|
|
|
|
config = cast(
|
|
AppConfig,
|
|
cast(
|
|
object,
|
|
SimpleNamespace(
|
|
skills=SimpleNamespace(container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/skills")),
|
|
skill_evolution=SimpleNamespace(enabled=False),
|
|
),
|
|
),
|
|
)
|
|
load_count = 0
|
|
|
|
def fake_get_or_new_skill_storage(**kwargs):
|
|
nonlocal load_count
|
|
assert kwargs == {"app_config": config}
|
|
|
|
def load_skills(*, enabled_only):
|
|
nonlocal load_count
|
|
if enabled_only:
|
|
load_count += 1
|
|
return [make_skill("cached-skill")]
|
|
|
|
return SimpleNamespace(load_skills=load_skills)
|
|
|
|
monkeypatch.setattr(prompt_module, "get_or_new_skill_storage", fake_get_or_new_skill_storage)
|
|
monkeypatch.setattr(prompt_module, "get_or_new_user_skill_storage", lambda user_id, **kwargs: SimpleNamespace(load_skills=lambda *, enabled_only: [make_skill("cached-skill")] if kwargs.get("app_config") is config else []))
|
|
_set_skills_cache_state()
|
|
|
|
try:
|
|
first = prompt_module.get_skills_prompt_section(app_config=config)
|
|
second = prompt_module.get_skills_prompt_section(app_config=config)
|
|
|
|
assert "cached-skill" in first
|
|
assert "cached-skill" in second
|
|
assert load_count == 1
|
|
finally:
|
|
_set_skills_cache_state()
|
|
|
|
|
|
def test_clear_cache_does_not_spawn_parallel_refresh_workers(monkeypatch, tmp_path):
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
active_loads = 0
|
|
max_active_loads = 0
|
|
call_count = 0
|
|
lock = threading.Lock()
|
|
|
|
def make_skill(name: str) -> Skill:
|
|
skill_dir = tmp_path / name
|
|
return Skill(
|
|
name=name,
|
|
description=f"Description for {name}",
|
|
license="MIT",
|
|
skill_dir=skill_dir,
|
|
skill_file=skill_dir / "SKILL.md",
|
|
relative_path=skill_dir.relative_to(tmp_path),
|
|
category=SkillCategory.CUSTOM,
|
|
enabled=True,
|
|
)
|
|
|
|
def fake_load_skills(enabled_only=True):
|
|
nonlocal active_loads, max_active_loads, call_count
|
|
with lock:
|
|
active_loads += 1
|
|
max_active_loads = max(max_active_loads, active_loads)
|
|
call_count += 1
|
|
current_call = call_count
|
|
|
|
started.set()
|
|
if current_call == 1:
|
|
release.wait(timeout=5)
|
|
|
|
with lock:
|
|
active_loads -= 1
|
|
|
|
return [make_skill(f"skill-{current_call}")]
|
|
|
|
monkeypatch.setattr(prompt_module, "get_or_new_skill_storage", lambda **kwargs: __import__("types").SimpleNamespace(load_skills=lambda *, enabled_only: fake_load_skills(enabled_only=enabled_only)))
|
|
_set_skills_cache_state()
|
|
|
|
try:
|
|
prompt_module.clear_skills_system_prompt_cache()
|
|
assert started.wait(timeout=5)
|
|
|
|
prompt_module.clear_skills_system_prompt_cache()
|
|
release.set()
|
|
prompt_module.warm_enabled_skills_cache()
|
|
|
|
assert max_active_loads == 1
|
|
assert [skill.name for skill in prompt_module._get_enabled_skills()] == ["skill-2"]
|
|
finally:
|
|
release.set()
|
|
_set_skills_cache_state()
|
|
|
|
|
|
def test_warm_enabled_skills_cache_logs_on_timeout(monkeypatch, caplog):
|
|
event = threading.Event()
|
|
monkeypatch.setattr(prompt_module, "_ensure_enabled_skills_cache", lambda: event)
|
|
|
|
with caplog.at_level("WARNING"):
|
|
warmed = prompt_module.warm_enabled_skills_cache(timeout_seconds=0.01)
|
|
|
|
assert warmed is False
|
|
assert "Timed out waiting" in caplog.text
|
|
|
|
|
|
def test_system_prompt_template_contains_file_editing_workflow_rule():
|
|
"""The File Editing Workflow rule must remain in the system prompt
|
|
template so the planner picks the right tool (str_replace for edits,
|
|
write_file + append=True for long new content) and avoids mid-stream
|
|
chunk-gap timeouts on oversized single-shot writes. See issue #3189
|
|
/ PR #3195.
|
|
|
|
We deliberately do NOT assert on any specific byte / word threshold
|
|
here — that would re-introduce the docstring-lock-in pattern the
|
|
reviewers flagged. The numeric cap lives in the server-side guard
|
|
(see test_write_file_tool_size_guard.py), which is where it belongs.
|
|
"""
|
|
template = prompt_module.SYSTEM_PROMPT_TEMPLATE
|
|
# Section anchor — keeps the rule discoverable in the assembled prompt.
|
|
assert "File Editing Workflow" in template
|
|
# Behavioural anchors — if either of these disappears, the model will
|
|
# silently regress to single-shot write_file calls for long content.
|
|
assert "str_replace" in template
|
|
assert "append=True" in template
|
|
|
|
|
|
def test_system_prompt_template_requires_virtual_paths_for_output_images():
|
|
template = prompt_module.SYSTEM_PROMPT_TEMPLATE
|
|
|
|
assert "" in template
|
|
assert "Never use a bare or workspace-relative filename" in template
|
|
assert "Call `present_files` for the image before referencing it" in template
|
|
|
|
|
|
def test_system_prompt_template_preserves_placeholders():
|
|
"""Ensure the chunking-rule edit didn't drop any f-string placeholder
|
|
consumed by apply_prompt_template(). A missing placeholder would
|
|
crash prompt rendering at runtime.
|
|
"""
|
|
template = prompt_module.SYSTEM_PROMPT_TEMPLATE
|
|
for ph in (
|
|
"{agent_name}",
|
|
"{soul}",
|
|
"{self_update_section}",
|
|
"{subagent_thinking}",
|
|
"{skills_section}",
|
|
"{deferred_tools_section}",
|
|
"{subagent_section}",
|
|
"{acp_section}",
|
|
"{subagent_reminder}",
|
|
"{skill_first_reminder}",
|
|
):
|
|
assert ph in template, f"placeholder {ph} accidentally removed"
|
|
|
|
|
|
def _make_minimal_app_config():
|
|
return SimpleNamespace(
|
|
sandbox=SimpleNamespace(mounts=[]),
|
|
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 test_apply_prompt_template_legacy_path_does_not_mention_describe_skill(monkeypatch):
|
|
"""When skill_names is None (legacy path), critical_reminders must not
|
|
reference describe_skill (the tool is not registered in legacy mode)."""
|
|
config = _make_minimal_app_config()
|
|
monkeypatch.setattr("deerflow.config.get_app_config", lambda: 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, **kwargs: "")
|
|
|
|
prompt = prompt_module.apply_prompt_template(app_config=config)
|
|
|
|
# Legacy wording — tool-agnostic
|
|
assert "Always load the relevant skill" in prompt
|
|
# Must NOT reference the deferred tool
|
|
assert "describe_skill(name)" not in prompt
|
|
|
|
|
|
def test_apply_prompt_template_deferred_path_mentions_describe_skill(monkeypatch):
|
|
"""When skill_names is provided (deferred path), critical_reminders must
|
|
reference describe_skill so the LLM knows how to discover skills."""
|
|
config = _make_minimal_app_config()
|
|
monkeypatch.setattr("deerflow.config.get_app_config", lambda: 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, **kwargs: "")
|
|
|
|
prompt = prompt_module.apply_prompt_template(
|
|
app_config=config,
|
|
skill_names=frozenset({"data-analysis"}),
|
|
)
|
|
|
|
# Deferred wording — references describe_skill
|
|
assert "describe_skill(name)" in prompt
|
|
# Must NOT contain the legacy wording
|
|
assert "Always load the relevant skill" not in prompt
|