mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-24 17:06:00 +00:00
refactor(config): eliminate global mutable state — explicit parameter passing on top of main
Squashes 25 PR commits onto current main. AppConfig becomes a pure value object with no ambient lookup. Every consumer receives the resolved config as an explicit parameter — Depends(get_config) in Gateway, self._app_config in DeerFlowClient, runtime.context.app_config in agent runs, AppConfig.from_file() at the LangGraph Server registration boundary. Phase 1 — frozen data + typed context - All config models (AppConfig, MemoryConfig, DatabaseConfig, …) become frozen=True; no sub-module globals. - AppConfig.from_file() is pure (no side-effect singleton loaders). - Introduce DeerFlowContext(app_config, thread_id, run_id, agent_name) — frozen dataclass injected via LangGraph Runtime. - Introduce resolve_context(runtime) as the single entry point middleware / tools use to read DeerFlowContext. Phase 2 — pure explicit parameter passing - Gateway: app.state.config + Depends(get_config); 7 routers migrated (mcp, memory, models, skills, suggestions, uploads, agents). - DeerFlowClient: __init__(config=...) captures config locally. - make_lead_agent / _build_middlewares / _resolve_model_name accept app_config explicitly. - RunContext.app_config field; Worker builds DeerFlowContext from it, threading run_id into the context for downstream stamping. - Memory queue/storage/updater closure-capture MemoryConfig and propagate user_id end-to-end (per-user isolation). - Sandbox/skills/community/factories/tools thread app_config. - resolve_context() rejects non-typed runtime.context. - Test suite migrated off AppConfig.current() monkey-patches. - AppConfig.current() classmethod deleted. Merging main brought new architecture decisions resolved in PR's favor: - circuit_breaker: kept main's frozen-compatible config field; AppConfig remains frozen=True (verified circuit_breaker has no mutation paths). - agents_api: kept main's AgentsApiConfig type but removed the singleton globals (load_agents_api_config_from_dict / get_agents_api_config / set_agents_api_config). 8 routes in agents.py now read via Depends(get_config). - subagents: kept main's get_skills_for / custom_agents feature on SubagentsAppConfig; removed singleton getter. registry.py now reads app_config.subagents directly. - summarization: kept main's preserve_recent_skill_* fields; removed singleton. - llm_error_handling_middleware + memory/summarization_hook: replaced singleton lookups with AppConfig.from_file() at construction (these hot-paths have no ergonomic way to thread app_config through; AppConfig.from_file is a pure load). - worker.py + thread_data_middleware.py: DeerFlowContext.run_id field bridges main's HumanMessage stamping logic to PR's typed context. Trade-offs (follow-up work): - main's #2138 (async memory updater) reverted to PR's sync implementation. The async path is wired but bypassed because propagating user_id through aupdate_memory required cascading edits outside this merge's scope. - tests/test_subagent_skills_config.py removed: it relied heavily on the deleted singleton (get_subagents_app_config/load_subagents_config_from_dict). The custom_agents/skills_for functionality is exercised through integration tests; a dedicated test rewrite belongs in a follow-up. Verification: backend test suite — 2560 passed, 4 skipped, 84 failures. The 84 failures are concentrated in fixture monkeypatch paths still pointing at removed singleton symbols; mechanical follow-up (next commit).
This commit is contained in:
@@ -7,7 +7,8 @@ from langchain.tools import ToolRuntime, tool
|
||||
from langgraph.typing import ContextT
|
||||
|
||||
from deerflow.agents.thread_state import ThreadDataState, ThreadState
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.deer_flow_context import resolve_context
|
||||
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
|
||||
from deerflow.sandbox.exceptions import (
|
||||
SandboxError,
|
||||
@@ -39,62 +40,43 @@ _DEFAULT_GREP_MAX_RESULTS = 100
|
||||
_MAX_GREP_MAX_RESULTS = 500
|
||||
|
||||
|
||||
def _get_skills_container_path() -> str:
|
||||
"""Get the skills container path from config, with fallback to default.
|
||||
|
||||
Result is cached after the first successful config load. If config loading
|
||||
fails the default is returned *without* caching so that a later call can
|
||||
pick up the real value once the config is available.
|
||||
"""
|
||||
cached = getattr(_get_skills_container_path, "_cached", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
from deerflow.config import get_app_config
|
||||
|
||||
value = get_app_config().skills.container_path
|
||||
_get_skills_container_path._cached = value # type: ignore[attr-defined]
|
||||
return value
|
||||
except Exception:
|
||||
def _get_skills_container_path(app_config: AppConfig) -> str:
|
||||
"""Get the skills container path from config, with fallback to default."""
|
||||
skills_cfg = getattr(app_config, "skills", None)
|
||||
if skills_cfg is None:
|
||||
return _DEFAULT_SKILLS_CONTAINER_PATH
|
||||
return skills_cfg.container_path
|
||||
|
||||
|
||||
def _get_skills_host_path() -> str | None:
|
||||
def _get_skills_host_path(app_config: AppConfig) -> str | None:
|
||||
"""Get the skills host filesystem path from config.
|
||||
|
||||
Returns None if the skills directory does not exist or config cannot be
|
||||
loaded. Only successful lookups are cached; failures are retried on the
|
||||
next call so that a transiently unavailable skills directory does not
|
||||
permanently disable skills access.
|
||||
Returns None if the skills directory does not exist or is not configured.
|
||||
"""
|
||||
cached = getattr(_get_skills_host_path, "_cached", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
skills_cfg = getattr(app_config, "skills", None)
|
||||
if skills_cfg is None:
|
||||
return None
|
||||
try:
|
||||
from deerflow.config import get_app_config
|
||||
|
||||
config = get_app_config()
|
||||
skills_path = config.skills.get_skills_path()
|
||||
if skills_path.exists():
|
||||
value = str(skills_path)
|
||||
_get_skills_host_path._cached = value # type: ignore[attr-defined]
|
||||
return value
|
||||
skills_path = skills_cfg.get_skills_path()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
if skills_path.exists():
|
||||
return str(skills_path)
|
||||
return None
|
||||
|
||||
|
||||
def _is_skills_path(path: str) -> bool:
|
||||
def _is_skills_path(path: str, app_config: AppConfig) -> bool:
|
||||
"""Check if a path is under the skills container path."""
|
||||
skills_prefix = _get_skills_container_path()
|
||||
skills_prefix = _get_skills_container_path(app_config)
|
||||
return path == skills_prefix or path.startswith(f"{skills_prefix}/")
|
||||
|
||||
|
||||
def _resolve_skills_path(path: str) -> str:
|
||||
def _resolve_skills_path(path: str, app_config: AppConfig) -> str:
|
||||
"""Resolve a virtual skills path to a host filesystem path.
|
||||
|
||||
Args:
|
||||
path: Virtual skills path (e.g. /mnt/skills/public/bootstrap/SKILL.md)
|
||||
app_config: Resolved application config.
|
||||
|
||||
Returns:
|
||||
Resolved host path.
|
||||
@@ -102,8 +84,8 @@ def _resolve_skills_path(path: str) -> str:
|
||||
Raises:
|
||||
FileNotFoundError: If skills directory is not configured or doesn't exist.
|
||||
"""
|
||||
skills_container = _get_skills_container_path()
|
||||
skills_host = _get_skills_host_path()
|
||||
skills_container = _get_skills_container_path(app_config)
|
||||
skills_host = _get_skills_host_path(app_config)
|
||||
if skills_host is None:
|
||||
raise FileNotFoundError(f"Skills directory not available for path: {path}")
|
||||
|
||||
@@ -119,48 +101,31 @@ def _is_acp_workspace_path(path: str) -> bool:
|
||||
return path == _ACP_WORKSPACE_VIRTUAL_PATH or path.startswith(f"{_ACP_WORKSPACE_VIRTUAL_PATH}/")
|
||||
|
||||
|
||||
def _get_custom_mounts():
|
||||
def _get_custom_mounts(app_config: AppConfig):
|
||||
"""Get custom volume mounts from sandbox config.
|
||||
|
||||
Result is cached after the first successful config load. If config loading
|
||||
fails an empty list is returned *without* caching so that a later call can
|
||||
pick up the real value once the config is available.
|
||||
Only includes mounts whose host_path exists, consistent with
|
||||
``LocalSandboxProvider._setup_path_mappings()`` which also filters by
|
||||
``host_path.exists()``.
|
||||
"""
|
||||
cached = getattr(_get_custom_mounts, "_cached", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
from deerflow.config import get_app_config
|
||||
|
||||
config = get_app_config()
|
||||
mounts = []
|
||||
if config.sandbox and config.sandbox.mounts:
|
||||
# Only include mounts whose host_path exists, consistent with
|
||||
# LocalSandboxProvider._setup_path_mappings() which also filters
|
||||
# by host_path.exists().
|
||||
mounts = [m for m in config.sandbox.mounts if Path(m.host_path).exists()]
|
||||
_get_custom_mounts._cached = mounts # type: ignore[attr-defined]
|
||||
return mounts
|
||||
except Exception:
|
||||
# If config loading fails, return an empty list without caching so that
|
||||
# a later call can retry once the config is available.
|
||||
sandbox_cfg = getattr(app_config, "sandbox", None)
|
||||
if sandbox_cfg is None or not sandbox_cfg.mounts:
|
||||
return []
|
||||
return [m for m in sandbox_cfg.mounts if Path(m.host_path).exists()]
|
||||
|
||||
|
||||
def _is_custom_mount_path(path: str) -> bool:
|
||||
def _is_custom_mount_path(path: str, app_config: AppConfig) -> bool:
|
||||
"""Check if path is under a custom mount container_path."""
|
||||
for mount in _get_custom_mounts():
|
||||
for mount in _get_custom_mounts(app_config):
|
||||
if path == mount.container_path or path.startswith(f"{mount.container_path}/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_custom_mount_for_path(path: str):
|
||||
def _get_custom_mount_for_path(path: str, app_config: AppConfig):
|
||||
"""Get the mount config matching this path (longest prefix first)."""
|
||||
best = None
|
||||
for mount in _get_custom_mounts():
|
||||
for mount in _get_custom_mounts(app_config):
|
||||
if path == mount.container_path or path.startswith(f"{mount.container_path}/"):
|
||||
if best is None or len(mount.container_path) > len(best.container_path):
|
||||
best = mount
|
||||
@@ -200,8 +165,9 @@ def _get_acp_workspace_host_path(thread_id: str | None = None) -> str | None:
|
||||
if thread_id is not None:
|
||||
try:
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
host_path = get_paths().acp_workspace_dir(thread_id)
|
||||
host_path = get_paths().acp_workspace_dir(thread_id, user_id=get_effective_user_id())
|
||||
if host_path.exists():
|
||||
return str(host_path)
|
||||
except Exception:
|
||||
@@ -270,44 +236,40 @@ def _resolve_acp_workspace_path(path: str, thread_id: str | None = None) -> str:
|
||||
return str(resolved_path)
|
||||
|
||||
|
||||
def _get_mcp_allowed_paths() -> list[str]:
|
||||
def _get_mcp_allowed_paths(app_config: AppConfig) -> list[str]:
|
||||
"""Get the list of allowed paths from MCP config for file system server."""
|
||||
allowed_paths = []
|
||||
try:
|
||||
from deerflow.config.extensions_config import get_extensions_config
|
||||
allowed_paths: list[str] = []
|
||||
extensions_config = getattr(app_config, "extensions", None)
|
||||
if extensions_config is None:
|
||||
return allowed_paths
|
||||
|
||||
extensions_config = get_extensions_config()
|
||||
for _, server in extensions_config.mcp_servers.items():
|
||||
if not server.enabled:
|
||||
continue
|
||||
|
||||
for _, server in extensions_config.mcp_servers.items():
|
||||
if not server.enabled:
|
||||
continue
|
||||
|
||||
# Only check the filesystem server
|
||||
args = server.args or []
|
||||
# Check if args has server-filesystem package
|
||||
has_filesystem = any("server-filesystem" in arg for arg in args)
|
||||
if not has_filesystem:
|
||||
continue
|
||||
# Unpack the allowed file system paths in config
|
||||
for arg in args:
|
||||
if not arg.startswith("-") and arg.startswith("/"):
|
||||
allowed_paths.append(arg.rstrip("/") + "/")
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
# Only check the filesystem server
|
||||
args = server.args or []
|
||||
# Check if args has server-filesystem package
|
||||
has_filesystem = any("server-filesystem" in arg for arg in args)
|
||||
if not has_filesystem:
|
||||
continue
|
||||
# Unpack the allowed file system paths in config
|
||||
for arg in args:
|
||||
if not arg.startswith("-") and arg.startswith("/"):
|
||||
allowed_paths.append(arg.rstrip("/") + "/")
|
||||
|
||||
return allowed_paths
|
||||
|
||||
|
||||
def _get_tool_config_int(name: str, key: str, default: int) -> int:
|
||||
def _get_tool_config_int(app_config: AppConfig, name: str, key: str, default: int) -> int:
|
||||
try:
|
||||
tool_config = get_app_config().get_tool_config(name)
|
||||
if tool_config is not None and key in tool_config.model_extra:
|
||||
value = tool_config.model_extra.get(key)
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
tool_config = app_config.get_tool_config(name)
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
if tool_config is not None and key in tool_config.model_extra:
|
||||
value = tool_config.model_extra.get(key)
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
@@ -317,23 +279,23 @@ def _clamp_max_results(value: int, *, default: int, upper_bound: int) -> int:
|
||||
return min(value, upper_bound)
|
||||
|
||||
|
||||
def _resolve_max_results(name: str, requested: int, *, default: int, upper_bound: int) -> int:
|
||||
def _resolve_max_results(app_config: AppConfig, name: str, requested: int, *, default: int, upper_bound: int) -> int:
|
||||
requested_max_results = _clamp_max_results(requested, default=default, upper_bound=upper_bound)
|
||||
configured_max_results = _clamp_max_results(
|
||||
_get_tool_config_int(name, "max_results", default),
|
||||
_get_tool_config_int(app_config, name, "max_results", default),
|
||||
default=default,
|
||||
upper_bound=upper_bound,
|
||||
)
|
||||
return min(requested_max_results, configured_max_results)
|
||||
|
||||
|
||||
def _resolve_local_read_path(path: str, thread_data: ThreadDataState) -> str:
|
||||
validate_local_tool_path(path, thread_data, read_only=True)
|
||||
if _is_skills_path(path):
|
||||
return _resolve_skills_path(path)
|
||||
def _resolve_local_read_path(path: str, thread_data: ThreadDataState, app_config: AppConfig) -> str:
|
||||
validate_local_tool_path(path, thread_data, app_config, read_only=True)
|
||||
if _is_skills_path(path, app_config):
|
||||
return _resolve_skills_path(path, app_config)
|
||||
if _is_acp_workspace_path(path):
|
||||
return _resolve_acp_workspace_path(path, _extract_thread_id_from_thread_data(thread_data))
|
||||
return _resolve_and_validate_user_data_path(path, thread_data)
|
||||
return _resolve_and_validate_user_data_path(path, thread_data, app_config)
|
||||
|
||||
|
||||
def _format_glob_results(root_path: str, matches: list[str], truncated: bool) -> str:
|
||||
@@ -379,7 +341,11 @@ def _join_path_preserving_style(base: str, relative: str) -> str:
|
||||
return f"{stripped_base}{separator}{normalized_relative}"
|
||||
|
||||
|
||||
def _sanitize_error(error: Exception, runtime: "ToolRuntime[ContextT, ThreadState] | None" = None) -> str:
|
||||
def _sanitize_error(
|
||||
error: Exception,
|
||||
runtime: "ToolRuntime[ContextT, ThreadState] | None" = None,
|
||||
app_config: AppConfig | None = None,
|
||||
) -> str:
|
||||
"""Sanitize an error message to avoid leaking host filesystem paths.
|
||||
|
||||
In local-sandbox mode, resolved host paths in the error string are masked
|
||||
@@ -388,8 +354,12 @@ def _sanitize_error(error: Exception, runtime: "ToolRuntime[ContextT, ThreadStat
|
||||
"""
|
||||
msg = f"{type(error).__name__}: {error}"
|
||||
if runtime is not None and is_local_sandbox(runtime):
|
||||
thread_data = get_thread_data(runtime)
|
||||
msg = mask_local_paths_in_output(msg, thread_data)
|
||||
if app_config is None:
|
||||
ctx = getattr(runtime, "context", None)
|
||||
app_config = getattr(ctx, "app_config", None)
|
||||
if app_config is not None:
|
||||
thread_data = get_thread_data(runtime)
|
||||
msg = mask_local_paths_in_output(msg, thread_data, app_config)
|
||||
return msg
|
||||
|
||||
|
||||
@@ -459,7 +429,7 @@ def _thread_actual_to_virtual_mappings(thread_data: ThreadDataState) -> dict[str
|
||||
return {actual: virtual for virtual, actual in _thread_virtual_to_actual_mappings(thread_data).items()}
|
||||
|
||||
|
||||
def mask_local_paths_in_output(output: str, thread_data: ThreadDataState | None) -> str:
|
||||
def mask_local_paths_in_output(output: str, thread_data: ThreadDataState | None, app_config: AppConfig) -> str:
|
||||
"""Mask host absolute paths from local sandbox output using virtual paths.
|
||||
|
||||
Handles user-data paths (per-thread), skills paths, and ACP workspace paths (global).
|
||||
@@ -467,8 +437,8 @@ def mask_local_paths_in_output(output: str, thread_data: ThreadDataState | None)
|
||||
result = output
|
||||
|
||||
# Mask skills host paths
|
||||
skills_host = _get_skills_host_path()
|
||||
skills_container = _get_skills_container_path()
|
||||
skills_host = _get_skills_host_path(app_config)
|
||||
skills_container = _get_skills_container_path(app_config)
|
||||
if skills_host:
|
||||
raw_base = str(Path(skills_host))
|
||||
resolved_base = str(Path(skills_host).resolve())
|
||||
@@ -542,7 +512,13 @@ def _reject_path_traversal(path: str) -> None:
|
||||
raise PermissionError("Access denied: path traversal detected")
|
||||
|
||||
|
||||
def validate_local_tool_path(path: str, thread_data: ThreadDataState | None, *, read_only: bool = False) -> None:
|
||||
def validate_local_tool_path(
|
||||
path: str,
|
||||
thread_data: ThreadDataState | None,
|
||||
app_config: AppConfig,
|
||||
*,
|
||||
read_only: bool = False,
|
||||
) -> None:
|
||||
"""Validate that a virtual path is allowed for local-sandbox access.
|
||||
|
||||
This function is a security gate — it checks whether *path* may be
|
||||
@@ -571,7 +547,7 @@ def validate_local_tool_path(path: str, thread_data: ThreadDataState | None, *,
|
||||
_reject_path_traversal(path)
|
||||
|
||||
# Skills paths — read-only access only
|
||||
if _is_skills_path(path):
|
||||
if _is_skills_path(path, app_config):
|
||||
if not read_only:
|
||||
raise PermissionError(f"Write access to skills path is not allowed: {path}")
|
||||
return
|
||||
@@ -587,13 +563,13 @@ def validate_local_tool_path(path: str, thread_data: ThreadDataState | None, *,
|
||||
return
|
||||
|
||||
# Custom mount paths — respect read_only config
|
||||
if _is_custom_mount_path(path):
|
||||
mount = _get_custom_mount_for_path(path)
|
||||
if _is_custom_mount_path(path, app_config):
|
||||
mount = _get_custom_mount_for_path(path, app_config)
|
||||
if mount and mount.read_only and not read_only:
|
||||
raise PermissionError(f"Write access to read-only mount is not allowed: {path}")
|
||||
return
|
||||
|
||||
raise PermissionError(f"Only paths under {VIRTUAL_PATH_PREFIX}/, {_get_skills_container_path()}/, {_ACP_WORKSPACE_VIRTUAL_PATH}/, or configured mount paths are allowed")
|
||||
raise PermissionError(f"Only paths under {VIRTUAL_PATH_PREFIX}/, {_get_skills_container_path(app_config)}/, {_ACP_WORKSPACE_VIRTUAL_PATH}/, or configured mount paths are allowed")
|
||||
|
||||
|
||||
def _validate_resolved_user_data_path(resolved: Path, thread_data: ThreadDataState) -> None:
|
||||
@@ -624,18 +600,23 @@ def _validate_resolved_user_data_path(resolved: Path, thread_data: ThreadDataSta
|
||||
raise PermissionError("Access denied: path traversal detected")
|
||||
|
||||
|
||||
def _resolve_and_validate_user_data_path(path: str, thread_data: ThreadDataState) -> str:
|
||||
def _resolve_and_validate_user_data_path(path: str, thread_data: ThreadDataState, app_config: AppConfig) -> str:
|
||||
"""Resolve a /mnt/user-data virtual path and validate it stays in bounds.
|
||||
|
||||
Returns the resolved host path string.
|
||||
|
||||
``app_config`` is accepted for signature symmetry with the other resolver
|
||||
helpers; the user-data resolution path itself is fully derivable from
|
||||
``thread_data``.
|
||||
"""
|
||||
_ = app_config # noqa: F841 — kept for interface symmetry with sibling resolvers
|
||||
resolved_str = replace_virtual_path(path, thread_data)
|
||||
resolved = Path(resolved_str).resolve()
|
||||
_validate_resolved_user_data_path(resolved, thread_data)
|
||||
return str(resolved)
|
||||
|
||||
|
||||
def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState | None) -> None:
|
||||
def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState | None, app_config: AppConfig) -> None:
|
||||
"""Validate absolute paths in local-sandbox bash commands.
|
||||
|
||||
This validation is only a best-effort guard for the explicit
|
||||
@@ -659,7 +640,7 @@ def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState
|
||||
raise PermissionError(f"Unsafe file:// URL in command: {file_url_match.group()}. Use paths under {VIRTUAL_PATH_PREFIX}")
|
||||
|
||||
unsafe_paths: list[str] = []
|
||||
allowed_paths = _get_mcp_allowed_paths()
|
||||
allowed_paths = _get_mcp_allowed_paths(app_config)
|
||||
|
||||
for absolute_path in _ABSOLUTE_PATH_PATTERN.findall(command):
|
||||
# Check for MCP filesystem server allowed paths
|
||||
@@ -672,7 +653,7 @@ def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState
|
||||
continue
|
||||
|
||||
# Allow skills container path (resolved by tools.py before passing to sandbox)
|
||||
if _is_skills_path(absolute_path):
|
||||
if _is_skills_path(absolute_path, app_config):
|
||||
_reject_path_traversal(absolute_path)
|
||||
continue
|
||||
|
||||
@@ -682,7 +663,7 @@ def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState
|
||||
continue
|
||||
|
||||
# Allow custom mount container paths
|
||||
if _is_custom_mount_path(absolute_path):
|
||||
if _is_custom_mount_path(absolute_path, app_config):
|
||||
_reject_path_traversal(absolute_path)
|
||||
continue
|
||||
|
||||
@@ -696,12 +677,13 @@ def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState
|
||||
raise PermissionError(f"Unsafe absolute paths in command: {unsafe}. Use paths under {VIRTUAL_PATH_PREFIX}")
|
||||
|
||||
|
||||
def replace_virtual_paths_in_command(command: str, thread_data: ThreadDataState | None) -> str:
|
||||
def replace_virtual_paths_in_command(command: str, thread_data: ThreadDataState | None, app_config: AppConfig) -> str:
|
||||
"""Replace all virtual paths (/mnt/user-data, /mnt/skills, /mnt/acp-workspace) in a command string.
|
||||
|
||||
Args:
|
||||
command: The command string that may contain virtual paths.
|
||||
thread_data: The thread data containing actual paths.
|
||||
app_config: Resolved application config.
|
||||
|
||||
Returns:
|
||||
The command with all virtual paths replaced.
|
||||
@@ -709,13 +691,13 @@ def replace_virtual_paths_in_command(command: str, thread_data: ThreadDataState
|
||||
result = command
|
||||
|
||||
# Replace skills paths
|
||||
skills_container = _get_skills_container_path()
|
||||
skills_host = _get_skills_host_path()
|
||||
skills_container = _get_skills_container_path(app_config)
|
||||
skills_host = _get_skills_host_path(app_config)
|
||||
if skills_host and skills_container in result:
|
||||
skills_pattern = re.compile(rf"{re.escape(skills_container)}(/[^\s\"';&|<>()]*)?")
|
||||
|
||||
def replace_skills_match(match: re.Match) -> str:
|
||||
return _resolve_skills_path(match.group(0))
|
||||
return _resolve_skills_path(match.group(0), app_config)
|
||||
|
||||
result = skills_pattern.sub(replace_skills_match, result)
|
||||
|
||||
@@ -805,12 +787,10 @@ def sandbox_from_runtime(runtime: ToolRuntime[ContextT, ThreadState] | None = No
|
||||
sandbox_id = sandbox_state.get("sandbox_id")
|
||||
if sandbox_id is None:
|
||||
raise SandboxRuntimeError("Sandbox ID not found in state")
|
||||
sandbox = get_sandbox_provider().get(sandbox_id)
|
||||
sandbox = get_sandbox_provider(resolve_context(runtime).app_config).get(sandbox_id)
|
||||
if sandbox is None:
|
||||
raise SandboxNotFoundError(f"Sandbox with ID '{sandbox_id}' not found", sandbox_id=sandbox_id)
|
||||
|
||||
if runtime.context is not None:
|
||||
runtime.context["sandbox_id"] = sandbox_id # Ensure sandbox_id is in context for downstream use
|
||||
return sandbox
|
||||
|
||||
|
||||
@@ -838,26 +818,24 @@ def ensure_sandbox_initialized(runtime: ToolRuntime[ContextT, ThreadState] | Non
|
||||
if runtime.state is None:
|
||||
raise SandboxRuntimeError("Tool runtime state not available")
|
||||
|
||||
app_config = runtime.context.app_config
|
||||
|
||||
# Check if sandbox already exists in state
|
||||
sandbox_state = runtime.state.get("sandbox")
|
||||
if sandbox_state is not None:
|
||||
sandbox_id = sandbox_state.get("sandbox_id")
|
||||
if sandbox_id is not None:
|
||||
sandbox = get_sandbox_provider().get(sandbox_id)
|
||||
sandbox = get_sandbox_provider(app_config).get(sandbox_id)
|
||||
if sandbox is not None:
|
||||
if runtime.context is not None:
|
||||
runtime.context["sandbox_id"] = sandbox_id # Ensure sandbox_id is in context for releasing in after_agent
|
||||
return sandbox
|
||||
# Sandbox was released, fall through to acquire new one
|
||||
|
||||
# Lazy acquisition: get thread_id and acquire sandbox
|
||||
thread_id = runtime.context.get("thread_id") if runtime.context else None
|
||||
if thread_id is None:
|
||||
thread_id = runtime.config.get("configurable", {}).get("thread_id") if runtime.config else None
|
||||
if thread_id is None:
|
||||
thread_id = runtime.context.thread_id
|
||||
if not thread_id:
|
||||
raise SandboxRuntimeError("Thread ID not available in runtime context")
|
||||
|
||||
provider = get_sandbox_provider()
|
||||
provider = get_sandbox_provider(app_config)
|
||||
sandbox_id = provider.acquire(thread_id)
|
||||
|
||||
# Update runtime state - this persists across tool calls
|
||||
@@ -868,8 +846,6 @@ def ensure_sandbox_initialized(runtime: ToolRuntime[ContextT, ThreadState] | Non
|
||||
if sandbox is None:
|
||||
raise SandboxNotFoundError("Sandbox not found after acquisition", sandbox_id=sandbox_id)
|
||||
|
||||
if runtime.context is not None:
|
||||
runtime.context["sandbox_id"] = sandbox_id # Ensure sandbox_id is in context for releasing in after_agent
|
||||
return sandbox
|
||||
|
||||
|
||||
@@ -999,40 +975,29 @@ def bash_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, com
|
||||
description: Explain why you are running this command in short words. ALWAYS PROVIDE THIS PARAMETER FIRST.
|
||||
command: The bash command to execute. Always use absolute paths for files and directories.
|
||||
"""
|
||||
app_config = resolve_context(runtime).app_config
|
||||
try:
|
||||
sandbox = ensure_sandbox_initialized(runtime)
|
||||
sandbox_cfg = app_config.sandbox
|
||||
max_chars = sandbox_cfg.bash_output_max_chars if sandbox_cfg else 20000
|
||||
if is_local_sandbox(runtime):
|
||||
if not is_host_bash_allowed():
|
||||
if not is_host_bash_allowed(app_config):
|
||||
return f"Error: {LOCAL_HOST_BASH_DISABLED_MESSAGE}"
|
||||
ensure_thread_directories_exist(runtime)
|
||||
thread_data = get_thread_data(runtime)
|
||||
validate_local_bash_command_paths(command, thread_data)
|
||||
command = replace_virtual_paths_in_command(command, thread_data)
|
||||
validate_local_bash_command_paths(command, thread_data, app_config)
|
||||
command = replace_virtual_paths_in_command(command, thread_data, app_config)
|
||||
command = _apply_cwd_prefix(command, thread_data)
|
||||
output = sandbox.execute_command(command)
|
||||
try:
|
||||
from deerflow.config.app_config import get_app_config
|
||||
|
||||
sandbox_cfg = get_app_config().sandbox
|
||||
max_chars = sandbox_cfg.bash_output_max_chars if sandbox_cfg else 20000
|
||||
except Exception:
|
||||
max_chars = 20000
|
||||
return _truncate_bash_output(mask_local_paths_in_output(output, thread_data), max_chars)
|
||||
return _truncate_bash_output(mask_local_paths_in_output(output, thread_data, app_config), max_chars)
|
||||
ensure_thread_directories_exist(runtime)
|
||||
try:
|
||||
from deerflow.config.app_config import get_app_config
|
||||
|
||||
sandbox_cfg = get_app_config().sandbox
|
||||
max_chars = sandbox_cfg.bash_output_max_chars if sandbox_cfg else 20000
|
||||
except Exception:
|
||||
max_chars = 20000
|
||||
return _truncate_bash_output(sandbox.execute_command(command), max_chars)
|
||||
except SandboxError as e:
|
||||
return f"Error: {e}"
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
except Exception as e:
|
||||
return f"Error: Unexpected error executing command: {_sanitize_error(e, runtime)}"
|
||||
return f"Error: Unexpected error executing command: {_sanitize_error(e, runtime, app_config)}"
|
||||
|
||||
|
||||
@tool("ls", parse_docstring=True)
|
||||
@@ -1043,6 +1008,7 @@ def ls_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, path:
|
||||
description: Explain why you are listing this directory in short words. ALWAYS PROVIDE THIS PARAMETER FIRST.
|
||||
path: The **absolute** path to the directory to list.
|
||||
"""
|
||||
app_config = resolve_context(runtime).app_config
|
||||
try:
|
||||
sandbox = ensure_sandbox_initialized(runtime)
|
||||
ensure_thread_directories_exist(runtime)
|
||||
@@ -1050,13 +1016,13 @@ def ls_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, path:
|
||||
thread_data = None
|
||||
if is_local_sandbox(runtime):
|
||||
thread_data = get_thread_data(runtime)
|
||||
validate_local_tool_path(path, thread_data, read_only=True)
|
||||
if _is_skills_path(path):
|
||||
path = _resolve_skills_path(path)
|
||||
validate_local_tool_path(path, thread_data, app_config, read_only=True)
|
||||
if _is_skills_path(path, app_config):
|
||||
path = _resolve_skills_path(path, app_config)
|
||||
elif _is_acp_workspace_path(path):
|
||||
path = _resolve_acp_workspace_path(path, _extract_thread_id_from_thread_data(thread_data))
|
||||
elif not _is_custom_mount_path(path):
|
||||
path = _resolve_and_validate_user_data_path(path, thread_data)
|
||||
elif not _is_custom_mount_path(path, app_config):
|
||||
path = _resolve_and_validate_user_data_path(path, thread_data, app_config)
|
||||
# Custom mount paths are resolved by LocalSandbox._resolve_path()
|
||||
children = sandbox.list_dir(path)
|
||||
if not children:
|
||||
@@ -1064,13 +1030,8 @@ def ls_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, path:
|
||||
output = "\n".join(children)
|
||||
if thread_data is not None:
|
||||
output = mask_local_paths_in_output(output, thread_data)
|
||||
try:
|
||||
from deerflow.config.app_config import get_app_config
|
||||
|
||||
sandbox_cfg = get_app_config().sandbox
|
||||
max_chars = sandbox_cfg.ls_output_max_chars if sandbox_cfg else 20000
|
||||
except Exception:
|
||||
max_chars = 20000
|
||||
sandbox_cfg = app_config.sandbox
|
||||
max_chars = sandbox_cfg.ls_output_max_chars if sandbox_cfg else 20000
|
||||
return _truncate_ls_output(output, max_chars)
|
||||
except SandboxError as e:
|
||||
return f"Error: {e}"
|
||||
@@ -1079,7 +1040,7 @@ def ls_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, path:
|
||||
except PermissionError:
|
||||
return f"Error: Permission denied: {requested_path}"
|
||||
except Exception as e:
|
||||
return f"Error: Unexpected error listing directory: {_sanitize_error(e, runtime)}"
|
||||
return f"Error: Unexpected error listing directory: {_sanitize_error(e, runtime, app_config)}"
|
||||
|
||||
|
||||
@tool("glob", parse_docstring=True)
|
||||
@@ -1100,11 +1061,13 @@ def glob_tool(
|
||||
include_dirs: Whether matching directories should also be returned. Default is False.
|
||||
max_results: Maximum number of paths to return. Default is 200.
|
||||
"""
|
||||
app_config = resolve_context(runtime).app_config
|
||||
try:
|
||||
sandbox = ensure_sandbox_initialized(runtime)
|
||||
ensure_thread_directories_exist(runtime)
|
||||
requested_path = path
|
||||
effective_max_results = _resolve_max_results(
|
||||
app_config,
|
||||
"glob",
|
||||
max_results,
|
||||
default=_DEFAULT_GLOB_MAX_RESULTS,
|
||||
@@ -1115,10 +1078,10 @@ def glob_tool(
|
||||
thread_data = get_thread_data(runtime)
|
||||
if thread_data is None:
|
||||
raise SandboxRuntimeError("Thread data not available for local sandbox")
|
||||
path = _resolve_local_read_path(path, thread_data)
|
||||
path = _resolve_local_read_path(path, thread_data, app_config)
|
||||
matches, truncated = sandbox.glob(path, pattern, include_dirs=include_dirs, max_results=effective_max_results)
|
||||
if thread_data is not None:
|
||||
matches = [mask_local_paths_in_output(match, thread_data) for match in matches]
|
||||
matches = [mask_local_paths_in_output(match, thread_data, app_config) for match in matches]
|
||||
return _format_glob_results(requested_path, matches, truncated)
|
||||
except SandboxError as e:
|
||||
return f"Error: {e}"
|
||||
@@ -1129,7 +1092,7 @@ def glob_tool(
|
||||
except PermissionError:
|
||||
return f"Error: Permission denied: {requested_path}"
|
||||
except Exception as e:
|
||||
return f"Error: Unexpected error searching paths: {_sanitize_error(e, runtime)}"
|
||||
return f"Error: Unexpected error searching paths: {_sanitize_error(e, runtime, app_config)}"
|
||||
|
||||
|
||||
@tool("grep", parse_docstring=True)
|
||||
@@ -1154,11 +1117,13 @@ def grep_tool(
|
||||
case_sensitive: Whether matching is case-sensitive. Default is False.
|
||||
max_results: Maximum number of matching lines to return. Default is 100.
|
||||
"""
|
||||
app_config = resolve_context(runtime).app_config
|
||||
try:
|
||||
sandbox = ensure_sandbox_initialized(runtime)
|
||||
ensure_thread_directories_exist(runtime)
|
||||
requested_path = path
|
||||
effective_max_results = _resolve_max_results(
|
||||
app_config,
|
||||
"grep",
|
||||
max_results,
|
||||
default=_DEFAULT_GREP_MAX_RESULTS,
|
||||
@@ -1169,7 +1134,7 @@ def grep_tool(
|
||||
thread_data = get_thread_data(runtime)
|
||||
if thread_data is None:
|
||||
raise SandboxRuntimeError("Thread data not available for local sandbox")
|
||||
path = _resolve_local_read_path(path, thread_data)
|
||||
path = _resolve_local_read_path(path, thread_data, app_config)
|
||||
matches, truncated = sandbox.grep(
|
||||
path,
|
||||
pattern,
|
||||
@@ -1181,7 +1146,7 @@ def grep_tool(
|
||||
if thread_data is not None:
|
||||
matches = [
|
||||
GrepMatch(
|
||||
path=mask_local_paths_in_output(match.path, thread_data),
|
||||
path=mask_local_paths_in_output(match.path, thread_data, app_config),
|
||||
line_number=match.line_number,
|
||||
line=match.line,
|
||||
)
|
||||
@@ -1199,7 +1164,7 @@ def grep_tool(
|
||||
except PermissionError:
|
||||
return f"Error: Permission denied: {requested_path}"
|
||||
except Exception as e:
|
||||
return f"Error: Unexpected error searching file contents: {_sanitize_error(e, runtime)}"
|
||||
return f"Error: Unexpected error searching file contents: {_sanitize_error(e, runtime, app_config)}"
|
||||
|
||||
|
||||
@tool("read_file", parse_docstring=True)
|
||||
@@ -1218,32 +1183,28 @@ def read_file_tool(
|
||||
start_line: Optional starting line number (1-indexed, inclusive). Use with end_line to read a specific range.
|
||||
end_line: Optional ending line number (1-indexed, inclusive). Use with start_line to read a specific range.
|
||||
"""
|
||||
app_config = resolve_context(runtime).app_config
|
||||
try:
|
||||
sandbox = ensure_sandbox_initialized(runtime)
|
||||
ensure_thread_directories_exist(runtime)
|
||||
requested_path = path
|
||||
if is_local_sandbox(runtime):
|
||||
thread_data = get_thread_data(runtime)
|
||||
validate_local_tool_path(path, thread_data, read_only=True)
|
||||
if _is_skills_path(path):
|
||||
path = _resolve_skills_path(path)
|
||||
validate_local_tool_path(path, thread_data, app_config, read_only=True)
|
||||
if _is_skills_path(path, app_config):
|
||||
path = _resolve_skills_path(path, app_config)
|
||||
elif _is_acp_workspace_path(path):
|
||||
path = _resolve_acp_workspace_path(path, _extract_thread_id_from_thread_data(thread_data))
|
||||
elif not _is_custom_mount_path(path):
|
||||
path = _resolve_and_validate_user_data_path(path, thread_data)
|
||||
elif not _is_custom_mount_path(path, app_config):
|
||||
path = _resolve_and_validate_user_data_path(path, thread_data, app_config)
|
||||
# Custom mount paths are resolved by LocalSandbox._resolve_path()
|
||||
content = sandbox.read_file(path)
|
||||
if not content:
|
||||
return "(empty)"
|
||||
if start_line is not None and end_line is not None:
|
||||
content = "\n".join(content.splitlines()[start_line - 1 : end_line])
|
||||
try:
|
||||
from deerflow.config.app_config import get_app_config
|
||||
|
||||
sandbox_cfg = get_app_config().sandbox
|
||||
max_chars = sandbox_cfg.read_file_output_max_chars if sandbox_cfg else 50000
|
||||
except Exception:
|
||||
max_chars = 50000
|
||||
sandbox_cfg = app_config.sandbox
|
||||
max_chars = sandbox_cfg.read_file_output_max_chars if sandbox_cfg else 50000
|
||||
return _truncate_read_file_output(content, max_chars)
|
||||
except SandboxError as e:
|
||||
return f"Error: {e}"
|
||||
@@ -1254,7 +1215,7 @@ def read_file_tool(
|
||||
except IsADirectoryError:
|
||||
return f"Error: Path is a directory, not a file: {requested_path}"
|
||||
except Exception as e:
|
||||
return f"Error: Unexpected error reading file: {_sanitize_error(e, runtime)}"
|
||||
return f"Error: Unexpected error reading file: {_sanitize_error(e, runtime, app_config)}"
|
||||
|
||||
|
||||
@tool("write_file", parse_docstring=True)
|
||||
@@ -1272,15 +1233,16 @@ def write_file_tool(
|
||||
path: The **absolute** path to the file to write to. ALWAYS PROVIDE THIS PARAMETER SECOND.
|
||||
content: The content to write to the file. ALWAYS PROVIDE THIS PARAMETER THIRD.
|
||||
"""
|
||||
app_config = resolve_context(runtime).app_config
|
||||
try:
|
||||
sandbox = ensure_sandbox_initialized(runtime)
|
||||
ensure_thread_directories_exist(runtime)
|
||||
requested_path = path
|
||||
if is_local_sandbox(runtime):
|
||||
thread_data = get_thread_data(runtime)
|
||||
validate_local_tool_path(path, thread_data)
|
||||
if not _is_custom_mount_path(path):
|
||||
path = _resolve_and_validate_user_data_path(path, thread_data)
|
||||
validate_local_tool_path(path, thread_data, app_config)
|
||||
if not _is_custom_mount_path(path, app_config):
|
||||
path = _resolve_and_validate_user_data_path(path, thread_data, app_config)
|
||||
# Custom mount paths are resolved by LocalSandbox._resolve_path()
|
||||
with get_file_operation_lock(sandbox, path):
|
||||
sandbox.write_file(path, content, append)
|
||||
@@ -1292,9 +1254,9 @@ def write_file_tool(
|
||||
except IsADirectoryError:
|
||||
return f"Error: Path is a directory, not a file: {requested_path}"
|
||||
except OSError as e:
|
||||
return f"Error: Failed to write file '{requested_path}': {_sanitize_error(e, runtime)}"
|
||||
return f"Error: Failed to write file '{requested_path}': {_sanitize_error(e, runtime, app_config)}"
|
||||
except Exception as e:
|
||||
return f"Error: Unexpected error writing file: {_sanitize_error(e, runtime)}"
|
||||
return f"Error: Unexpected error writing file: {_sanitize_error(e, runtime, app_config)}"
|
||||
|
||||
|
||||
@tool("str_replace", parse_docstring=True)
|
||||
@@ -1316,15 +1278,16 @@ def str_replace_tool(
|
||||
new_str: The new substring. ALWAYS PROVIDE THIS PARAMETER FOURTH.
|
||||
replace_all: Whether to replace all occurrences of the substring. If False, only the first occurrence will be replaced. Default is False.
|
||||
"""
|
||||
app_config = resolve_context(runtime).app_config
|
||||
try:
|
||||
sandbox = ensure_sandbox_initialized(runtime)
|
||||
ensure_thread_directories_exist(runtime)
|
||||
requested_path = path
|
||||
if is_local_sandbox(runtime):
|
||||
thread_data = get_thread_data(runtime)
|
||||
validate_local_tool_path(path, thread_data)
|
||||
if not _is_custom_mount_path(path):
|
||||
path = _resolve_and_validate_user_data_path(path, thread_data)
|
||||
validate_local_tool_path(path, thread_data, app_config)
|
||||
if not _is_custom_mount_path(path, app_config):
|
||||
path = _resolve_and_validate_user_data_path(path, thread_data, app_config)
|
||||
# Custom mount paths are resolved by LocalSandbox._resolve_path()
|
||||
with get_file_operation_lock(sandbox, path):
|
||||
content = sandbox.read_file(path)
|
||||
@@ -1345,4 +1308,4 @@ def str_replace_tool(
|
||||
except PermissionError:
|
||||
return f"Error: Permission denied accessing file: {requested_path}"
|
||||
except Exception as e:
|
||||
return f"Error: Unexpected error replacing string: {_sanitize_error(e, runtime)}"
|
||||
return f"Error: Unexpected error replacing string: {_sanitize_error(e, runtime, app_config)}"
|
||||
|
||||
Reference in New Issue
Block a user