Compare commits

..

2 Commits

Author SHA1 Message Date
Nan Gao a57d05fe0a fix runtime journal run lifecycle events (#3470) 2026-06-10 08:33:29 +08:00
Lucy Shen ae9e8bc0bf fix(sandbox): make missing sandbox.mounts host_path a loud ERROR (#3244) (#3250)
In Docker production deployments, LocalSandboxProvider runs inside the
deer-flow-gateway container, so any `sandbox.mounts[].host_path` from
config.yaml is resolved against the gateway container's filesystem — not
the host machine. When the path isn't also bind-mounted into the gateway
service, the mount was silently dropped with only a WARNING log, leaving
agents reading an empty directory in production while the same config
worked under `make dev`.

Escalate the missing-host_path branch to logger.error with explicit
guidance about Docker bind mounts and docker-compose, so the failure is
hard to miss in default log configurations. Skip behaviour is preserved
to avoid breaking existing deployments.

Also clarify the misleading `VolumeMountConfig.host_path` field
description so it documents reality for both providers:

  - LocalSandboxProvider checks host_path from inside the gateway process
    (host in `make dev`, container in `make up`).
  - AioSandboxProvider (DooD) passes host_path straight to `docker -v`
    for the sandbox container, where the host Docker daemon resolves it
    from the host machine's perspective.

config.example.yaml's `sandbox.mounts` comment gets a Note: block
pointing operators at the docker-compose bind-mount requirement so the
Docker-mode gotcha is discoverable from the canonical template.

Adds a regression test that:
  - confirms missing host_path is still skipped (no behaviour break);
  - asserts an ERROR record is emitted referencing the offending paths;
  - asserts the message contains actionable Docker/gateway/docker-compose
    keywords so future refactors can't quietly downgrade it.

Refs: https://github.com/bytedance/deer-flow/issues/3244
2026-06-09 23:16:14 +08:00
6 changed files with 102 additions and 9 deletions
@@ -4,7 +4,20 @@ from pydantic import BaseModel, ConfigDict, Field
class VolumeMountConfig(BaseModel):
"""Configuration for a volume mount."""
host_path: str = Field(..., description="Path on the host machine")
host_path: str = Field(
...,
description=(
"Source path for the mount. Resolution depends on the active provider: "
"``LocalSandboxProvider`` checks this path from the gateway process — in "
"``make dev`` that is the host machine, but in Docker deployments "
"(``make up`` / docker-compose) it is the path *inside* the "
"``deer-flow-gateway`` container, so the host directory must also be "
"bind-mounted into the gateway service for the mount to take effect. "
"``AioSandboxProvider`` (DooD) passes this value straight to ``docker -v`` "
"for the sandbox container, where it is resolved by the host Docker daemon "
"from the host machine's perspective."
),
)
container_path: str = Field(..., description="Path inside the container")
read_only: bool = Field(default=False, description="Whether the mount is read-only")
@@ -164,7 +164,18 @@ class RunJournal(BaseCallbackHandler):
metadata={"caller": caller, **(metadata or {})},
)
def on_chain_end(self, outputs: Any, *, run_id: UUID, **kwargs: Any) -> None:
def on_chain_end(
self,
outputs: Any,
*,
run_id: UUID,
parent_run_id: UUID | None = None,
**kwargs: Any,
) -> None:
# Nested chain ends fire for internal graph nodes; only the root chain
# represents the user-visible run lifecycle.
if parent_run_id is not None:
return
self._put(event_type="run.end", category="outputs", content=outputs, metadata={"status": "success"})
self._flush_sync()
@@ -147,7 +147,17 @@ class LocalSandboxProvider(SandboxProvider):
mount.container_path,
)
continue
# Ensure the host path exists before adding mapping
# Ensure the host path exists before adding mapping.
#
# ``host_path`` is resolved against the filesystem of the
# process running this provider — for ``make dev`` that is
# the host machine, but for ``make up`` it is the
# ``deer-flow-gateway`` container, so any host path that
# isn't bind-mounted into the gateway image will be missing
# here. Skipping silently makes this a high-cost-to-debug
# silent failure (sandbox skill / tool reads an empty dir
# instead of the configured mount), so escalate to ERROR
# and include actionable guidance. See #3244.
if host_path.exists():
mappings.append(
PathMapping(
@@ -157,10 +167,16 @@ class LocalSandboxProvider(SandboxProvider):
)
)
else:
logger.warning(
"Mount host_path does not exist, skipping: %s -> %s",
logger.error(
"sandbox.mounts entry %s -> %s ignored: host_path %s does not exist from the "
"perspective of the gateway process. In Docker deployments (make up / docker-compose), "
"this path must also be bind-mounted into the gateway container — add a matching "
"volume entry under services.gateway.volumes in docker/docker-compose.yaml (and use "
"the in-container path here), or run in local mode (make dev) where the gateway sees "
"the host filesystem directly.",
mount.host_path,
mount.container_path,
mount.host_path,
)
except Exception as e:
# Log but don't fail if config loading fails
@@ -612,6 +612,54 @@ class TestLocalSandboxProviderMounts:
assert [m.container_path for m in provider._path_mappings] == ["/mnt/skills"]
def test_setup_path_mappings_logs_actionable_error_for_missing_host_path(self, tmp_path, caplog):
"""Regression for #3244.
When ``sandbox.mounts[].host_path`` is absent from the gateway process's
filesystem (the typical symptom in Docker production mode: host_path is a
host machine path that is not bind-mounted into the gateway container),
the mount is still skipped — but the failure must be a hard-to-miss ERROR
log with explicit, actionable guidance about Docker bind mounts, not the
old DEBUG/WARNING that buried the silent failure.
"""
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
missing_host_path = tmp_path / "does-not-exist"
from deerflow.config.sandbox_config import SandboxConfig, VolumeMountConfig
sandbox_config = SandboxConfig(
use="deerflow.sandbox.local:LocalSandboxProvider",
mounts=[
VolumeMountConfig(host_path=str(missing_host_path), container_path="/mnt/knowledge", read_only=True),
],
)
config = SimpleNamespace(
skills=SimpleNamespace(container_path="/mnt/skills", get_skills_path=lambda: skills_dir, use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
sandbox=sandbox_config,
)
with caplog.at_level("ERROR", logger="deerflow.sandbox.local.local_sandbox_provider"):
with patch("deerflow.config.get_app_config", return_value=config):
provider = LocalSandboxProvider()
# Silent-skip behaviour is preserved (no breaking change for existing deployments).
assert [m.container_path for m in provider._path_mappings] == ["/mnt/skills"]
# The failure must be observable at ERROR level and reference the offending paths.
error_records = [r for r in caplog.records if r.levelname == "ERROR"]
assert error_records, "expected an ERROR log when host_path is missing"
message = "\n".join(r.getMessage() for r in error_records)
assert str(missing_host_path) in message
assert "/mnt/knowledge" in message
# And it must include actionable Docker guidance so users don't lose hours
# to a silent empty-mount failure in production.
lowered = message.lower()
assert "docker" in lowered
assert "gateway" in lowered
assert "docker-compose" in lowered
def test_write_file_resolves_container_paths_in_content(self, tmp_path):
"""write_file should replace container paths in file content with local paths."""
data_dir = tmp_path / "data"
+4 -3
View File
@@ -179,15 +179,16 @@ class TestLifecycleCallbacks:
assert "run.end" in types
@pytest.mark.anyio
async def test_nested_chain_no_run_start(self, journal_setup):
"""Nested chains (parent_run_id set) should NOT produce run.start."""
async def test_nested_chain_no_run_lifecycle_events(self, journal_setup):
"""Nested chains (parent_run_id set) should NOT produce root run lifecycle events."""
j, store = journal_setup
parent_id = uuid4()
j.on_chain_start({}, {}, run_id=uuid4(), parent_run_id=parent_id)
j.on_chain_end({}, run_id=uuid4())
j.on_chain_end({}, run_id=uuid4(), parent_run_id=parent_id)
await j.flush()
events = await store.list_events("t1", "r1")
assert not any(e["event_type"] == "run.start" for e in events)
assert not any(e["event_type"] == "run.end" for e in events)
class TestToolCallbacks:
+5 -1
View File
@@ -768,8 +768,12 @@ sandbox:
allow_host_bash: false
# Optional: Mount additional host directories into the sandbox.
# Each mount maps a host path to a virtual container path accessible by the agent.
# Note: with LocalSandboxProvider under `make up` (docker-compose), host_path is
# checked from inside the deer-flow-gateway container — you must also bind-mount
# the same directory into services.gateway.volumes in docker/docker-compose.yaml
# for this mount to take effect (see issue #3244).
# mounts:
# - host_path: /home/user/my-project # Absolute path on the host machine
# - host_path: /home/user/my-project # Absolute path; see note above for Docker mode
# container_path: /mnt/my-project # Virtual path inside the sandbox
# read_only: true # Whether the mount is read-only (default: false)