mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(agent): drop script guidance from the lead prompt when no bash tool is bound
The workspace section told the agent how to write scripts and commands
("prefer relative paths", "avoid hardcoding /mnt/user-data/... inside
generated scripts") even when no tool could run them. With the default
LocalSandboxProvider host bash is off and the bash tool is removed, so
agents wrote helper scripts nothing could execute, then stalled or
computed by hand.
apply_prompt_template() takes bash_available (default True, so other
callers keep today's text). The lead agent (both assembly branches) and
the embedded client pass whether their authorized tools, bound or
deferred, include bash. Without it:
- the two script bullets become one line: no bash tool is bound, work
out results directly and write them with write_file;
- the subagent section no longer shows direct bash examples, and marks
the bash subagent unavailable, since it inherits the lead's tool groups;
- the ACP hint no longer suggests `bash cp`.
* fix(goal): show the goal evaluator the user's answer to a Human Input Card
The web UI sends a Human Input Card answer as a hidden human message
(hide_from_ui with a human_input_response) and shows it on the card.
format_visible_conversation() skipped every hidden message, so the
evaluator saw the question but never the answer. Following the user's
answer then read as the assistant guessing, and the goal stood down with
needs_user_input although the user had answered.
A hidden human message whose human_input_response passes
read_human_input_response() now adds a line
"User (Human Input Card answer): <value>" from the structured value; the
question is already on the card's own line. Other hidden messages, such
as goal continuations, stay out. Over the 12,000-char cap the latest
answer the cut drops is kept at the top next to the latest request, and
is never taken for the request itself. Without card answers the cut is
unchanged.
* fix(agent): keep the subagent examples and bash hint in line with the bound tools
Review follow-up for the lead prompt without bash.
- The last delegation example follows the tools. With a bash subagent on
offer it is unchanged. A lead with bash and no bash subagent keeps
"Run a routine test, build, or git command directly." without the Bash
subagent sentence. A lead without bash gets a file-work example instead.
- The unavailable-bash description no longer points at the sandbox
provider. Since #2253 it renders only when the bash subagent is
registered and the lead binds no bash, where the sandbox is not the cause.
---------
Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
807 lines
36 KiB
Python
807 lines
36 KiB
Python
import threading
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import cast
|
|
|
|
import anyio
|
|
import pytest
|
|
|
|
from deerflow.agents.interaction_policy import RunInteractionMode, RunInteractionPolicy
|
|
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_uses_non_interactive_clarification_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")),
|
|
skill_evolution=SimpleNamespace(enabled=False),
|
|
tool_search=SimpleNamespace(enabled=False),
|
|
memory=SimpleNamespace(enabled=False, mode="middleware", injection_enabled=False),
|
|
acp_agents={},
|
|
)
|
|
policy = RunInteractionPolicy(RunInteractionMode.SCHEDULED)
|
|
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None, **kwargs: "")
|
|
monkeypatch.setattr(prompt_module, "get_skills_prompt_section", lambda *args, **kwargs: "")
|
|
monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda **kwargs: "")
|
|
monkeypatch.setattr(prompt_module, "_build_acp_section", lambda **kwargs: "")
|
|
monkeypatch.setattr(prompt_module, "_build_custom_mounts_section", lambda **kwargs: "")
|
|
monkeypatch.setattr(prompt_module, "_build_memory_tool_section", lambda **kwargs: "")
|
|
|
|
prompt = prompt_module.apply_prompt_template(app_config=config, interaction_policy=policy)
|
|
|
|
assert "There is no human available to answer a synchronous question" in prompt
|
|
assert "MUST call ask_clarification" not in prompt
|
|
assert "Do not wait for a human response" in prompt
|
|
|
|
|
|
def test_apply_prompt_template_preserves_interactive_clarification_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")),
|
|
skill_evolution=SimpleNamespace(enabled=False),
|
|
tool_search=SimpleNamespace(enabled=False),
|
|
memory=SimpleNamespace(enabled=False, mode="middleware", injection_enabled=False),
|
|
acp_agents={},
|
|
)
|
|
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None, **kwargs: "")
|
|
monkeypatch.setattr(prompt_module, "get_skills_prompt_section", lambda *args, **kwargs: "")
|
|
monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda **kwargs: "")
|
|
monkeypatch.setattr(prompt_module, "_build_acp_section", lambda **kwargs: "")
|
|
monkeypatch.setattr(prompt_module, "_build_custom_mounts_section", lambda **kwargs: "")
|
|
monkeypatch.setattr(prompt_module, "_build_memory_tool_section", lambda **kwargs: "")
|
|
|
|
prompt = prompt_module.apply_prompt_template(app_config=config)
|
|
|
|
assert "**WORKFLOW PRIORITY: CLARIFY → PLAN → ACT**" in prompt
|
|
assert "DO NOT call any other tool in the same turn as ask_clarification" in prompt
|
|
assert "❌ DO NOT make assumptions when information is missing - ALWAYS ask" in prompt
|
|
assert '**Example:**\nUser: "Deploy the application"' in prompt
|
|
assert 'User: "staging"\nYou: "Deploying to staging..." [proceed]' 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_uses_configured_subagent_total_when_unset(monkeypatch):
|
|
explicit_config = SimpleNamespace(
|
|
sandbox=SimpleNamespace(
|
|
use="deerflow.sandbox.local:LocalSandboxProvider",
|
|
allow_host_bash=False,
|
|
mounts=[],
|
|
),
|
|
subagents=SubagentsAppConfig(max_total_per_run=7),
|
|
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_total_subagents=None,
|
|
app_config=explicit_config,
|
|
)
|
|
|
|
assert "MAXIMUM 7 `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}",
|
|
"{workspace_scripts_guidance}",
|
|
):
|
|
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
|