From aae59a8ba894c74ee455891cd692f257baafdf8f Mon Sep 17 00:00:00 2001 From: Admire <64821731+LittleChenLiya@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:22:30 +0800 Subject: [PATCH] fix: surface configured sandbox mounts to agents (#1638) * fix: surface configured sandbox mounts to agents * fix: address PR review feedback --------- Co-authored-by: Willem Jiang --- backend/docs/CONFIGURATION.md | 2 + .../deerflow/agents/lead_agent/prompt.py | 26 ++++++++++- .../harness/deerflow/runtime/runs/manager.py | 4 +- .../deerflow/subagents/builtins/bash_agent.py | 1 + .../subagents/builtins/general_purpose.py | 1 + backend/tests/test_lead_agent_prompt.py | 46 +++++++++++++++++++ backend/tests/test_run_manager.py | 12 +++++ config.example.yaml | 3 ++ 8 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_lead_agent_prompt.py diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 70473d953..63ccc8d28 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -257,6 +257,8 @@ sandbox: read_only: false ``` +When you configure `sandbox.mounts`, DeerFlow exposes those `container_path` values in the agent prompt so the agent can discover and operate on mounted directories directly instead of assuming everything must live under `/mnt/user-data`. + ### Skills Configure the skills directory for specialized workflows: diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 0f73c6321..6329fd193 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -477,6 +477,28 @@ def _build_acp_section() -> str: ) +def _build_custom_mounts_section() -> str: + """Build a prompt section for explicitly configured sandbox mounts.""" + try: + from deerflow.config import get_app_config + + mounts = get_app_config().sandbox.mounts or [] + except Exception: + logger.exception("Failed to load configured sandbox mounts for the lead-agent prompt") + return "" + + if not mounts: + return "" + + lines = [] + for mount in mounts: + access = "read-only" if mount.read_only else "read-write" + lines.append(f"- Custom mount: `{mount.container_path}` - Host directory mapped into the sandbox ({access})") + + mounts_list = "\n".join(lines) + return f"\n**Custom Mounted Directories:**\n{mounts_list}\n- If the user needs files outside `/mnt/user-data`, use these absolute container paths directly when they match the requested directory" + + def apply_prompt_template(subagent_enabled: bool = False, max_concurrent_subagents: int = 3, *, agent_name: str | None = None, available_skills: set[str] | None = None) -> str: # Get memory context memory_context = _get_memory_context(agent_name) @@ -511,6 +533,8 @@ def apply_prompt_template(subagent_enabled: bool = False, max_concurrent_subagen # Build ACP agent section only if ACP agents are configured acp_section = _build_acp_section() + custom_mounts_section = _build_custom_mounts_section() + acp_and_mounts_section = "\n".join(section for section in (acp_section, custom_mounts_section) if section) # Format the prompt with dynamic skills and memory prompt = SYSTEM_PROMPT_TEMPLATE.format( @@ -522,7 +546,7 @@ def apply_prompt_template(subagent_enabled: bool = False, max_concurrent_subagen subagent_section=subagent_section, subagent_reminder=subagent_reminder, subagent_thinking=subagent_thinking, - acp_section=acp_section, + acp_section=acp_and_mounts_section, ) return prompt + f"\n{datetime.now().strftime('%Y-%m-%d, %A')}" diff --git a/backend/packages/harness/deerflow/runtime/runs/manager.py b/backend/packages/harness/deerflow/runtime/runs/manager.py index abe090372..e61a1707f 100644 --- a/backend/packages/harness/deerflow/runtime/runs/manager.py +++ b/backend/packages/harness/deerflow/runtime/runs/manager.py @@ -81,7 +81,9 @@ class RunManager: async def list_by_thread(self, thread_id: str) -> list[RunRecord]: """Return all runs for a given thread, newest first.""" async with self._lock: - return list(reversed([r for r in self._runs.values() if r.thread_id == thread_id])) + # Dict insertion order matches creation order, so reversing it gives + # us deterministic newest-first results even when timestamps tie. + return [r for r in reversed(self._runs.values()) if r.thread_id == thread_id] async def set_status(self, run_id: str, status: RunStatus, *, error: str | None = None) -> None: """Transition a run to a new status.""" diff --git a/backend/packages/harness/deerflow/subagents/builtins/bash_agent.py b/backend/packages/harness/deerflow/subagents/builtins/bash_agent.py index 9188f09ff..409594efa 100644 --- a/backend/packages/harness/deerflow/subagents/builtins/bash_agent.py +++ b/backend/packages/harness/deerflow/subagents/builtins/bash_agent.py @@ -37,6 +37,7 @@ You have access to the sandbox environment: - User uploads: `/mnt/user-data/uploads` - User workspace: `/mnt/user-data/workspace` - Output files: `/mnt/user-data/outputs` +- Deployment-configured custom mounts may also be available at other absolute container paths; use them directly when the task references those mounted directories """, tools=["bash", "ls", "read_file", "write_file", "str_replace"], # Sandbox tools only diff --git a/backend/packages/harness/deerflow/subagents/builtins/general_purpose.py b/backend/packages/harness/deerflow/subagents/builtins/general_purpose.py index de48a2fcc..45f1b9fa2 100644 --- a/backend/packages/harness/deerflow/subagents/builtins/general_purpose.py +++ b/backend/packages/harness/deerflow/subagents/builtins/general_purpose.py @@ -38,6 +38,7 @@ You have access to the same sandbox environment as the parent agent: - User uploads: `/mnt/user-data/uploads` - User workspace: `/mnt/user-data/workspace` - Output files: `/mnt/user-data/outputs` +- Deployment-configured custom mounts may also be available at other absolute container paths; use them directly when the task references those mounted directories """, tools=None, # Inherit all tools from parent diff --git a/backend/tests/test_lead_agent_prompt.py b/backend/tests/test_lead_agent_prompt.py new file mode 100644 index 000000000..ee85a2e91 --- /dev/null +++ b/backend/tests/test_lead_agent_prompt.py @@ -0,0 +1,46 @@ +from types import SimpleNamespace + +from deerflow.agents.lead_agent import prompt as prompt_module + + +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_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"), + ) + monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) + monkeypatch.setattr(prompt_module, "load_skills", lambda enabled_only=True: []) + monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda: "") + monkeypatch.setattr(prompt_module, "_build_acp_section", lambda: "") + monkeypatch.setattr(prompt_module, "_get_memory_context", lambda agent_name=None: "") + monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None: "") + + prompt = prompt_module.apply_prompt_template() + + assert "`/home/user/shared`" in prompt + assert "Custom Mounted Directories" in prompt diff --git a/backend/tests/test_run_manager.py b/backend/tests/test_run_manager.py index 1e6526d6e..2d6a0199c 100644 --- a/backend/tests/test_run_manager.py +++ b/backend/tests/test_run_manager.py @@ -86,6 +86,18 @@ async def test_list_by_thread(manager: RunManager): assert runs[1].run_id == r1.run_id +@pytest.mark.anyio +async def test_list_by_thread_is_stable_when_timestamps_tie(manager: RunManager, monkeypatch: pytest.MonkeyPatch): + """Newest-first ordering should not depend on timestamp precision.""" + monkeypatch.setattr("deerflow.runtime.runs.manager._now_iso", lambda: "2026-01-01T00:00:00+00:00") + + r1 = await manager.create("thread-1") + r2 = await manager.create("thread-1") + + runs = await manager.list_by_thread("thread-1") + assert [run.run_id for run in runs] == [r2.run_id, r1.run_id] + + @pytest.mark.anyio async def test_has_inflight(manager: RunManager): """has_inflight should be True when a run is pending or running.""" diff --git a/config.example.yaml b/config.example.yaml index ad82cdc7f..b2ccfd4cc 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -397,6 +397,9 @@ sandbox: # # - host_path: /path/on/host # # container_path: /home/user/shared # # read_only: false +# # +# # # DeerFlow will surface configured container_path values to the agent, +# # # so it can directly read/write mounted directories such as /home/user/shared # # # Optional: Environment variables to inject into the sandbox container # # Values starting with $ will be resolved from host environment variables