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:
greatmengqi
2026-04-26 21:45:02 +08:00
parent 9dc25987e0
commit 3e6a34297d
365 changed files with 31220 additions and 5303 deletions
+161 -198
View File
@@ -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)}"