diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 4414a0203..b244f8adf 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -127,7 +127,7 @@ from app.gateway.app import app from app.channels.service import start_channel_service # App → Harness (allowed) -from deerflow.config import get_app_config +from deerflow.config.app_config import AppConfig # Harness → App (FORBIDDEN — enforced by test_harness_boundary.py) # from app.gateway.routers.uploads import ... # ← will fail CI @@ -182,7 +182,16 @@ Setup: Copy `config.example.yaml` to `config.yaml` in the **project root** direc **Config Versioning**: `config.example.yaml` has a `config_version` field. On startup, `AppConfig.from_file()` compares user version vs example version and emits a warning if outdated. Missing `config_version` = version 0. Run `make config-upgrade` to auto-merge missing fields. When changing the config schema, bump `config_version` in `config.example.yaml`. -**Config Caching**: `get_app_config()` caches the parsed config, but automatically reloads it when the resolved config path changes or the file's mtime increases. This keeps Gateway and LangGraph reads aligned with `config.yaml` edits without requiring a manual process restart. +**Config Lifecycle**: All config models are `frozen=True` (immutable after construction). `AppConfig.from_file()` is a pure function — no side effects, no process-global state. The resolved `AppConfig` is passed as an explicit parameter down every consumer lane: + +- **Gateway**: `app.state.config` populated in lifespan; routers receive it via `Depends(get_config)` from `app/gateway/deps.py`. +- **Client**: `DeerFlowClient._app_config` captured in the constructor; every method reads `self._app_config`. +- **Agent run**: wrapped in `DeerFlowContext(app_config=…)` and injected via LangGraph `Runtime[DeerFlowContext].context`. Middleware and tools read `runtime.context.app_config` directly or via `resolve_context(runtime)`. +- **LangGraph Server bootstrap**: `make_lead_agent` (registered in `langgraph.json`) calls `AppConfig.from_file()` itself — the only place in production that loads from disk at agent-build time. + +To update config at runtime (Gateway API mutations for MCP/Skills), write the new file and call `AppConfig.from_file()` to build a fresh snapshot, then swap `app.state.config`. No mtime detection, no auto-reload, no ambient ContextVar lookup (`AppConfig.current()` has been removed). + +**DeerFlowContext**: Per-invocation typed context for the agent execution path, injected via LangGraph `Runtime[DeerFlowContext]`. Holds `app_config: AppConfig`, `thread_id: str`, `agent_name: str | None`. Gateway runtime and `DeerFlowClient` construct full `DeerFlowContext` at invoke time; the LangGraph Server boundary builds one inside `make_lead_agent`. Middleware and tools access context through `resolve_context(runtime)` which returns the typed `DeerFlowContext` — legacy dict/None shapes are rejected. Mutable runtime state (`sandbox_id`) flows through `ThreadState.sandbox`, not context. Configuration priority: 1. Explicit `config_path` argument diff --git a/backend/app/channels/feishu.py b/backend/app/channels/feishu.py index 5a80016f0..10d77d729 100644 --- a/backend/app/channels/feishu.py +++ b/backend/app/channels/feishu.py @@ -375,7 +375,9 @@ class FeishuChannel(Channel): virtual_path = f"{VIRTUAL_PATH_PREFIX}/uploads/{resolved_target.name}" try: - sandbox_provider = get_sandbox_provider() + from deerflow.config.app_config import AppConfig + + sandbox_provider = get_sandbox_provider(AppConfig.from_file()) sandbox_id = sandbox_provider.acquire(thread_id) if sandbox_id != "local": sandbox = sandbox_provider.get(sandbox_id) diff --git a/backend/app/channels/manager.py b/backend/app/channels/manager.py index 5680943b0..cb34f55c3 100644 --- a/backend/app/channels/manager.py +++ b/backend/app/channels/manager.py @@ -17,8 +17,6 @@ from langgraph_sdk.errors import ConflictError from app.channels.commands import KNOWN_CHANNEL_COMMANDS from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment from app.channels.store import ChannelStore -from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token -from app.gateway.internal_auth import create_internal_auth_headers from deerflow.runtime.user_context import get_effective_user_id logger = logging.getLogger(__name__) diff --git a/backend/app/channels/service.py b/backend/app/channels/service.py index 8042733c2..0b84bbc15 100644 --- a/backend/app/channels/service.py +++ b/backend/app/channels/service.py @@ -4,13 +4,16 @@ from __future__ import annotations import logging import os -from typing import Any +from typing import TYPE_CHECKING, Any from app.channels.base import Channel from app.channels.manager import DEFAULT_GATEWAY_URL, DEFAULT_LANGGRAPH_URL, ChannelManager from app.channels.message_bus import MessageBus from app.channels.store import ChannelStore +if TYPE_CHECKING: + from deerflow.config.app_config import AppConfig + logger = logging.getLogger(__name__) # Channel name → import path for lazy loading @@ -75,14 +78,11 @@ class ChannelService: self._running = False @classmethod - def from_app_config(cls) -> ChannelService: - """Create a ChannelService from the application config.""" - from deerflow.config.app_config import get_app_config - - config = get_app_config() + def from_app_config(cls, app_config: AppConfig) -> ChannelService: + """Create a ChannelService from an explicit application config.""" channels_config = {} # extra fields are allowed by AppConfig (extra="allow") - extra = config.model_extra or {} + extra = app_config.model_extra or {} if "channels" in extra: channels_config = extra["channels"] return cls(channels_config=channels_config) @@ -201,12 +201,12 @@ def get_channel_service() -> ChannelService | None: return _channel_service -async def start_channel_service() -> ChannelService: +async def start_channel_service(app_config: AppConfig) -> ChannelService: """Create and start the global ChannelService from app config.""" global _channel_service if _channel_service is not None: return _channel_service - _channel_service = ChannelService.from_app_config() + _channel_service = ChannelService.from_app_config(app_config) await _channel_service.start() return _channel_service diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 852c787fd..b960b4729 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -28,7 +28,7 @@ from app.gateway.routers import ( threads, uploads, ) -from deerflow.config.app_config import get_app_config +from deerflow.config.app_config import AppConfig # Configure logging logging.basicConfig( @@ -72,18 +72,7 @@ async def _ensure_admin_user(app: FastAPI) -> None: from deerflow.persistence.engine import get_session_factory from deerflow.persistence.user.model import UserRow - try: - provider = get_local_provider() - except RuntimeError: - # Auth persistence may not be initialized in some test/boot paths. - # Skip admin migration work rather than failing gateway startup. - logger.warning("Auth persistence not ready; skipping admin bootstrap check") - return - - sf = get_session_factory() - if sf is None: - return - + provider = get_local_provider() admin_count = await provider.count_admin_users() if admin_count == 0: @@ -95,6 +84,10 @@ async def _ensure_admin_user(app: FastAPI) -> None: # Admin already exists — run orphan thread migration for any # LangGraph thread metadata that pre-dates the auth module. + sf = get_session_factory() + if sf is None: + return + async with sf() as session: stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1) row = (await session.execute(stmt)).scalar_one_or_none() @@ -158,9 +151,11 @@ async def _migrate_orphaned_threads(store, admin_user_id: str) -> int: async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Application lifespan handler.""" - # Load config and check necessary environment variables at startup try: - get_app_config() + # ``app.state.config`` is the sole source of truth for + # ``Depends(get_config)``. Consumers that want AppConfig must receive + # it as an explicit parameter; there is no ambient singleton. + app.state.config = AppConfig.from_file() logger.info("Configuration loaded successfully") except Exception as e: error_msg = f"Failed to load configuration during gateway startup: {e}" @@ -181,7 +176,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: try: from app.channels.service import start_channel_service - channel_service = await start_channel_service() + channel_service = await start_channel_service(app.state.config) logger.info("Channel service started: %s", channel_service.get_status()) except Exception: logger.exception("No IM channels configured or channel service failed to start") diff --git a/backend/app/gateway/auth/providers.py b/backend/app/gateway/auth/providers.py index 95571d5d0..25e782ce3 100644 --- a/backend/app/gateway/auth/providers.py +++ b/backend/app/gateway/auth/providers.py @@ -12,12 +12,12 @@ class AuthProvider(ABC): Returns User if authentication succeeds, None otherwise. """ - raise NotImplementedError + ... @abstractmethod async def get_user(self, user_id: str) -> "User | None": """Retrieve user by ID.""" - raise NotImplementedError + ... # Import User at runtime to avoid circular imports diff --git a/backend/app/gateway/auth/repositories/base.py b/backend/app/gateway/auth/repositories/base.py index b5baa02c7..d96753171 100644 --- a/backend/app/gateway/auth/repositories/base.py +++ b/backend/app/gateway/auth/repositories/base.py @@ -35,7 +35,7 @@ class UserRepository(ABC): Raises: ValueError: If email already exists """ - raise NotImplementedError + ... @abstractmethod async def get_user_by_id(self, user_id: str) -> User | None: @@ -47,7 +47,7 @@ class UserRepository(ABC): Returns: User if found, None otherwise """ - raise NotImplementedError + ... @abstractmethod async def get_user_by_email(self, email: str) -> User | None: @@ -59,7 +59,7 @@ class UserRepository(ABC): Returns: User if found, None otherwise """ - raise NotImplementedError + ... @abstractmethod async def update_user(self, user: User) -> User: @@ -76,17 +76,17 @@ class UserRepository(ABC): a hard failure (not a no-op) so callers cannot mistake a concurrent-delete race for a successful update. """ - raise NotImplementedError + ... @abstractmethod async def count_users(self) -> int: """Return total number of registered users.""" - raise NotImplementedError + ... @abstractmethod async def count_admin_users(self) -> int: """Return number of users with system_role == 'admin'.""" - raise NotImplementedError + ... @abstractmethod async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None: @@ -99,4 +99,4 @@ class UserRepository(ABC): Returns: User if found, None otherwise """ - raise NotImplementedError + ... diff --git a/backend/app/gateway/auth/reset_admin.py b/backend/app/gateway/auth/reset_admin.py index 7b7da74d0..65c294dbe 100644 --- a/backend/app/gateway/auth/reset_admin.py +++ b/backend/app/gateway/auth/reset_admin.py @@ -25,14 +25,15 @@ from deerflow.persistence.user.model import UserRow async def _run(email: str | None) -> int: - from deerflow.config import get_app_config + from deerflow.config import AppConfig from deerflow.persistence.engine import ( close_engine, get_session_factory, init_engine_from_config, ) - config = get_app_config() + # CLI entry: load config explicitly at the top, pass down through the closure. + config = AppConfig.from_file() await init_engine_from_config(config.database) try: sf = get_session_factory() diff --git a/backend/app/gateway/auth_middleware.py b/backend/app/gateway/auth_middleware.py index 6b6452264..fd982cd79 100644 --- a/backend/app/gateway/auth_middleware.py +++ b/backend/app/gateway/auth_middleware.py @@ -18,7 +18,6 @@ from starlette.types import ASGIApp from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse from app.gateway.authz import _ALL_PERMISSIONS, AuthContext -from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token from deerflow.runtime.user_context import reset_current_user, set_current_user # Paths that never require authentication. @@ -76,12 +75,8 @@ class AuthMiddleware(BaseHTTPMiddleware): if _is_public(request.url.path): return await call_next(request) - internal_user = None - if is_valid_internal_auth_token(request.headers.get(INTERNAL_AUTH_HEADER_NAME)): - internal_user = get_internal_user() - # Non-public path: require session cookie - if internal_user is None and not request.cookies.get("access_token"): + if not request.cookies.get("access_token"): return JSONResponse( status_code=401, content={ @@ -105,13 +100,10 @@ class AuthMiddleware(BaseHTTPMiddleware): # bubble up, so we catch and render it as JSONResponse here. from app.gateway.deps import get_current_user_from_request - if internal_user is not None: - user = internal_user - else: - try: - user = await get_current_user_from_request(request) - except HTTPException as exc: - return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + try: + user = await get_current_user_from_request(request) + except HTTPException as exc: + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) # Stamp both request.state.user (for the contextvar pattern) # and request.state.auth (so @require_permission's "auth is diff --git a/backend/app/gateway/authz.py b/backend/app/gateway/authz.py index 837c66fe2..5842a24c7 100644 --- a/backend/app/gateway/authz.py +++ b/backend/app/gateway/authz.py @@ -30,9 +30,7 @@ Inspired by LangGraph Auth system: https://github.com/langchain-ai/langgraph/blo from __future__ import annotations import functools -import inspect from collections.abc import Callable -from types import SimpleNamespace from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar from fastapi import HTTPException, Request @@ -119,15 +117,6 @@ _ALL_PERMISSIONS: list[str] = [ ] -def _make_test_request_stub() -> Any: - """Create a minimal request-like object for direct unit calls. - - Used when decorated route handlers are invoked without FastAPI's - request injection. Includes fields accessed by auth helpers. - """ - return SimpleNamespace(state=SimpleNamespace(), cookies={}, _deerflow_test_bypass_auth=True) - - async def _authenticate(request: Request) -> AuthContext: """Authenticate request and return AuthContext. @@ -165,17 +154,7 @@ def require_auth[**P, T](func: Callable[P, T]) -> Callable[P, T]: async def wrapper(*args: Any, **kwargs: Any) -> Any: request = kwargs.get("request") if request is None: - # Unit tests may call decorated handlers directly without a - # FastAPI Request object. Inject a minimal request stub when - # the wrapped function declares `request`. - if "request" in inspect.signature(func).parameters: - kwargs["request"] = _make_test_request_stub() - else: - raise ValueError("require_auth decorator requires 'request' parameter") - request = kwargs["request"] - - if getattr(request, "_deerflow_test_bypass_auth", False): - return await func(*args, **kwargs) + raise ValueError("require_auth decorator requires 'request' parameter") # Authenticate and set context auth_context = await _authenticate(request) @@ -231,17 +210,7 @@ def require_permission( async def wrapper(*args: Any, **kwargs: Any) -> Any: request = kwargs.get("request") if request is None: - # Unit tests may call decorated route handlers directly without - # constructing a FastAPI Request object. Inject a minimal stub - # when the wrapped function declares `request`. - if "request" in inspect.signature(func).parameters: - kwargs["request"] = _make_test_request_stub() - else: - return await func(*args, **kwargs) - request = kwargs["request"] - - if getattr(request, "_deerflow_test_bypass_auth", False): - return await func(*args, **kwargs) + raise ValueError("require_permission decorator requires 'request' parameter") auth: AuthContext = getattr(request.state, "auth", None) if auth is None: diff --git a/backend/app/gateway/deps.py b/backend/app/gateway/deps.py index 20da78af9..a90150df1 100644 --- a/backend/app/gateway/deps.py +++ b/backend/app/gateway/deps.py @@ -10,15 +10,13 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Callable from contextlib import AsyncExitStack, asynccontextmanager -from typing import TYPE_CHECKING, TypeVar, cast +from typing import TYPE_CHECKING from fastapi import FastAPI, HTTPException, Request from langgraph.types import Checkpointer -from deerflow.persistence.feedback import FeedbackRepository -from deerflow.runtime import RunContext, RunManager, StreamBridge -from deerflow.runtime.events.store.base import RunEventStore -from deerflow.runtime.runs.store.base import RunStore +from deerflow.config.app_config import AppConfig +from deerflow.runtime import RunContext, RunManager if TYPE_CHECKING: from app.gateway.auth.local_provider import LocalAuthProvider @@ -26,7 +24,17 @@ if TYPE_CHECKING: from deerflow.persistence.thread_meta.base import ThreadMetaStore -T = TypeVar("T") +def get_config(request: Request) -> AppConfig: + """FastAPI dependency returning the app-scoped ``AppConfig``. + + Reads from ``request.app.state.config`` which is set at startup + (``app.py`` lifespan) and swapped on config reload (``routers/mcp.py``, + ``routers/skills.py``). + """ + cfg = getattr(request.app.state, "config", None) + if cfg is None: + raise HTTPException(status_code=503, detail="Configuration not available") + return cfg @asynccontextmanager @@ -38,22 +46,24 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: async with langgraph_runtime(app): yield """ - from deerflow.config import get_app_config from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config from deerflow.runtime import make_store, make_stream_bridge from deerflow.runtime.checkpointer.async_provider import make_checkpointer from deerflow.runtime.events.store import make_run_event_store async with AsyncExitStack() as stack: - app.state.stream_bridge = await stack.enter_async_context(make_stream_bridge()) + # app.state.config is populated earlier in lifespan(); thread it + # explicitly into every provider below. + config = app.state.config + + app.state.stream_bridge = await stack.enter_async_context(make_stream_bridge(config)) # Initialize persistence engine BEFORE checkpointer so that # auto-create-database logic runs first (postgres backend). - config = get_app_config() await init_engine_from_config(config.database) - app.state.checkpointer = await stack.enter_async_context(make_checkpointer()) - app.state.store = await stack.enter_async_context(make_store()) + app.state.checkpointer = await stack.enter_async_context(make_checkpointer(config)) + app.state.store = await stack.enter_async_context(make_store(config)) # Initialize repositories — one get_session_factory() call for all. sf = get_session_factory() @@ -91,25 +101,25 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: # --------------------------------------------------------------------------- -def _require(attr: str, label: str) -> Callable[[Request], T]: +def _require(attr: str, label: str): """Create a FastAPI dependency that returns ``app.state.`` or 503.""" - def dep(request: Request) -> T: + def dep(request: Request): val = getattr(request.app.state, attr, None) if val is None: raise HTTPException(status_code=503, detail=f"{label} not available") - return cast(T, val) + return val dep.__name__ = dep.__qualname__ = f"get_{attr}" return dep -get_stream_bridge: Callable[[Request], StreamBridge] = _require("stream_bridge", "Stream bridge") -get_run_manager: Callable[[Request], RunManager] = _require("run_manager", "Run manager") -get_checkpointer: Callable[[Request], Checkpointer] = _require("checkpointer", "Checkpointer") -get_run_event_store: Callable[[Request], RunEventStore] = _require("run_event_store", "Run event store") -get_feedback_repo: Callable[[Request], FeedbackRepository] = _require("feedback_repo", "Feedback") -get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store") +get_stream_bridge = _require("stream_bridge", "Stream bridge") +get_run_manager = _require("run_manager", "Run manager") +get_checkpointer = _require("checkpointer", "Checkpointer") +get_run_event_store = _require("run_event_store", "Run event store") +get_feedback_repo = _require("feedback_repo", "Feedback") +get_run_store = _require("run_store", "Run store") def get_store(request: Request): @@ -128,19 +138,23 @@ def get_thread_store(request: Request) -> ThreadMetaStore: def get_run_context(request: Request) -> RunContext: """Build a :class:`RunContext` from ``app.state`` singletons. - Returns a *base* context with infrastructure dependencies. + Returns a *base* context with infrastructure dependencies. Callers that + need per-run fields (e.g. ``follow_up_to_run_id``) should use + ``dataclasses.replace(ctx, follow_up_to_run_id=...)`` before passing it + to :func:`run_agent`. """ - from deerflow.config import get_app_config - + config = get_config(request) return RunContext( checkpointer=get_checkpointer(request), store=get_store(request), event_store=get_run_event_store(request), - run_events_config=getattr(get_app_config(), "run_events", None), + run_events_config=getattr(config, "run_events", None), thread_store=get_thread_store(request), + app_config=config, ) + # --------------------------------------------------------------------------- # Auth helpers (used by authz.py and auth middleware) # --------------------------------------------------------------------------- diff --git a/backend/app/gateway/routers/agents.py b/backend/app/gateway/routers/agents.py index ff4476893..3b1fcb733 100644 --- a/backend/app/gateway/routers/agents.py +++ b/backend/app/gateway/routers/agents.py @@ -5,11 +5,12 @@ import re import shutil import yaml -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field -from deerflow.config.agents_api_config import get_agents_api_config +from app.gateway.deps import get_config from deerflow.config.agents_config import AgentConfig, list_custom_agents, load_agent_config, load_agent_soul +from deerflow.config.app_config import AppConfig from deerflow.config.paths import get_paths logger = logging.getLogger(__name__) @@ -77,9 +78,9 @@ def _normalize_agent_name(name: str) -> str: return name.lower() -def _require_agents_api_enabled() -> None: +def _require_agents_api_enabled(app_config: AppConfig) -> None: """Reject access unless the custom-agent management API is explicitly enabled.""" - if not get_agents_api_config().enabled: + if not app_config.agents_api.enabled: raise HTTPException( status_code=403, detail=("Custom-agent management API is disabled. Set agents_api.enabled=true to expose agent and user-profile routes over HTTP."), @@ -108,13 +109,13 @@ def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False summary="List Custom Agents", description="List all custom agents available in the agents directory, including their soul content.", ) -async def list_agents() -> AgentsListResponse: +async def list_agents(app_config: AppConfig = Depends(get_config)) -> AgentsListResponse: """List all custom agents. Returns: List of all custom agents with their metadata and soul content. """ - _require_agents_api_enabled() + _require_agents_api_enabled(app_config) try: agents = list_custom_agents() @@ -141,7 +142,7 @@ async def check_agent_name(name: str) -> dict: Raises: HTTPException: 422 if the name is invalid. """ - _require_agents_api_enabled() + _require_agents_api_enabled(app_config) _validate_agent_name(name) normalized = _normalize_agent_name(name) available = not get_paths().agent_dir(normalized).exists() @@ -154,7 +155,7 @@ async def check_agent_name(name: str) -> dict: summary="Get Custom Agent", description="Retrieve details and SOUL.md content for a specific custom agent.", ) -async def get_agent(name: str) -> AgentResponse: +async def get_agent(name: str, app_config: AppConfig = Depends(get_config)) -> AgentResponse: """Get a specific custom agent by name. Args: @@ -166,7 +167,7 @@ async def get_agent(name: str) -> AgentResponse: Raises: HTTPException: 404 if agent not found. """ - _require_agents_api_enabled() + _require_agents_api_enabled(app_config) _validate_agent_name(name) name = _normalize_agent_name(name) @@ -187,7 +188,7 @@ async def get_agent(name: str) -> AgentResponse: summary="Create Custom Agent", description="Create a new custom agent with its config and SOUL.md.", ) -async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse: +async def create_agent_endpoint(request: AgentCreateRequest, app_config: AppConfig = Depends(get_config)) -> AgentResponse: """Create a new custom agent. Args: @@ -199,7 +200,7 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse: Raises: HTTPException: 409 if agent already exists, 422 if name is invalid. """ - _require_agents_api_enabled() + _require_agents_api_enabled(app_config) _validate_agent_name(request.name) normalized_name = _normalize_agent_name(request.name) @@ -251,7 +252,7 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse: summary="Update Custom Agent", description="Update an existing custom agent's config and/or SOUL.md.", ) -async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse: +async def update_agent(name: str, request: AgentUpdateRequest, app_config: AppConfig = Depends(get_config)) -> AgentResponse: """Update an existing custom agent. Args: @@ -264,7 +265,7 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse: Raises: HTTPException: 404 if agent not found. """ - _require_agents_api_enabled() + _require_agents_api_enabled(app_config) _validate_agent_name(name) name = _normalize_agent_name(name) @@ -342,13 +343,13 @@ class UserProfileUpdateRequest(BaseModel): summary="Get User Profile", description="Read the global USER.md file that is injected into all custom agents.", ) -async def get_user_profile() -> UserProfileResponse: +async def get_user_profile(app_config: AppConfig = Depends(get_config)) -> UserProfileResponse: """Return the current USER.md content. Returns: UserProfileResponse with content=None if USER.md does not exist yet. """ - _require_agents_api_enabled() + _require_agents_api_enabled(app_config) try: user_md_path = get_paths().user_md_file @@ -367,7 +368,7 @@ async def get_user_profile() -> UserProfileResponse: summary="Update User Profile", description="Write the global USER.md file that is injected into all custom agents.", ) -async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileResponse: +async def update_user_profile(request: UserProfileUpdateRequest, app_config: AppConfig = Depends(get_config)) -> UserProfileResponse: """Create or overwrite the global USER.md. Args: @@ -376,7 +377,7 @@ async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileR Returns: UserProfileResponse with the saved content. """ - _require_agents_api_enabled() + _require_agents_api_enabled(app_config) try: paths = get_paths() @@ -395,7 +396,7 @@ async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileR summary="Delete Custom Agent", description="Delete a custom agent and all its files (config, SOUL.md, memory).", ) -async def delete_agent(name: str) -> None: +async def delete_agent(name: str, app_config: AppConfig = Depends(get_config)) -> None: """Delete a custom agent. Args: @@ -404,7 +405,7 @@ async def delete_agent(name: str) -> None: Raises: HTTPException: 404 if agent not found. """ - _require_agents_api_enabled() + _require_agents_api_enabled(app_config) _validate_agent_name(name) name = _normalize_agent_name(name) diff --git a/backend/app/gateway/routers/mcp.py b/backend/app/gateway/routers/mcp.py index 386fc13c6..3d39879e4 100644 --- a/backend/app/gateway/routers/mcp.py +++ b/backend/app/gateway/routers/mcp.py @@ -3,10 +3,12 @@ import logging from pathlib import Path from typing import Literal -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field -from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config +from app.gateway.deps import get_config +from deerflow.config.app_config import AppConfig +from deerflow.config.extensions_config import ExtensionsConfig logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["mcp"]) @@ -69,7 +71,7 @@ class McpConfigUpdateRequest(BaseModel): summary="Get MCP Configuration", description="Retrieve the current Model Context Protocol (MCP) server configurations.", ) -async def get_mcp_configuration() -> McpConfigResponse: +async def get_mcp_configuration(config: AppConfig = Depends(get_config)) -> McpConfigResponse: """Get the current MCP configuration. Returns: @@ -90,9 +92,9 @@ async def get_mcp_configuration() -> McpConfigResponse: } ``` """ - config = get_extensions_config() + ext = config.extensions - return McpConfigResponse(mcp_servers={name: McpServerConfigResponse(**server.model_dump()) for name, server in config.mcp_servers.items()}) + return McpConfigResponse(mcp_servers={name: McpServerConfigResponse(**server.model_dump()) for name, server in ext.mcp_servers.items()}) @router.put( @@ -101,7 +103,11 @@ async def get_mcp_configuration() -> McpConfigResponse: summary="Update MCP Configuration", description="Update Model Context Protocol (MCP) server configurations and save to file.", ) -async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfigResponse: +async def update_mcp_configuration( + request: McpConfigUpdateRequest, + http_request: Request, + config: AppConfig = Depends(get_config), +) -> McpConfigResponse: """Update the MCP configuration. This will: @@ -142,13 +148,13 @@ async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfig config_path = Path.cwd().parent / "extensions_config.json" logger.info(f"No existing extensions config found. Creating new config at: {config_path}") - # Load current config to preserve skills configuration - current_config = get_extensions_config() + # Use injected config to preserve skills configuration + current_ext = config.extensions # Convert request to dict format for JSON serialization config_data = { "mcpServers": {name: server.model_dump() for name, server in request.mcp_servers.items()}, - "skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()}, + "skills": {name: {"enabled": skill.enabled} for name, skill in current_ext.skills.items()}, } # Write the configuration to file @@ -160,9 +166,11 @@ async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfig # NOTE: No need to reload/reset cache here - LangGraph Server (separate process) # will detect config file changes via mtime and reinitialize MCP tools automatically - # Reload the configuration and update the global cache - reloaded_config = reload_extensions_config() - return McpConfigResponse(mcp_servers={name: McpServerConfigResponse(**server.model_dump()) for name, server in reloaded_config.mcp_servers.items()}) + # Reload the configuration and swap ``app.state.config`` so subsequent + # ``Depends(get_config)`` calls see the refreshed value. + reloaded = AppConfig.from_file() + http_request.app.state.config = reloaded + return McpConfigResponse(mcp_servers={name: McpServerConfigResponse(**server.model_dump()) for name, server in reloaded.extensions.mcp_servers.items()}) except Exception as e: logger.error(f"Failed to update MCP configuration: {e}", exc_info=True) diff --git a/backend/app/gateway/routers/memory.py b/backend/app/gateway/routers/memory.py index ca9e5f5e5..9284bb736 100644 --- a/backend/app/gateway/routers/memory.py +++ b/backend/app/gateway/routers/memory.py @@ -1,8 +1,9 @@ """Memory API router for retrieving and managing global memory data.""" -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field +from app.gateway.deps import get_config from deerflow.agents.memory.updater import ( clear_memory_data, create_memory_fact, @@ -12,7 +13,7 @@ from deerflow.agents.memory.updater import ( reload_memory_data, update_memory_fact, ) -from deerflow.config.memory_config import get_memory_config +from deerflow.config.app_config import AppConfig from deerflow.runtime.user_context import get_effective_user_id router = APIRouter(prefix="/api", tags=["memory"]) @@ -114,7 +115,7 @@ class MemoryStatusResponse(BaseModel): summary="Get Memory Data", description="Retrieve the current global memory data including user context, history, and facts.", ) -async def get_memory() -> MemoryResponse: +async def get_memory(app_config: AppConfig = Depends(get_config)) -> MemoryResponse: """Get the current global memory data. Returns: @@ -148,7 +149,7 @@ async def get_memory() -> MemoryResponse: } ``` """ - memory_data = get_memory_data(user_id=get_effective_user_id()) + memory_data = get_memory_data(app_config.memory, user_id=get_effective_user_id()) return MemoryResponse(**memory_data) @@ -159,7 +160,7 @@ async def get_memory() -> MemoryResponse: summary="Reload Memory Data", description="Reload memory data from the storage file, refreshing the in-memory cache.", ) -async def reload_memory() -> MemoryResponse: +async def reload_memory(app_config: AppConfig = Depends(get_config)) -> MemoryResponse: """Reload memory data from file. This forces a reload of the memory data from the storage file, @@ -168,7 +169,7 @@ async def reload_memory() -> MemoryResponse: Returns: The reloaded memory data. """ - memory_data = reload_memory_data(user_id=get_effective_user_id()) + memory_data = reload_memory_data(app_config.memory, user_id=get_effective_user_id()) return MemoryResponse(**memory_data) @@ -179,10 +180,10 @@ async def reload_memory() -> MemoryResponse: summary="Clear All Memory Data", description="Delete all saved memory data and reset the memory structure to an empty state.", ) -async def clear_memory() -> MemoryResponse: +async def clear_memory(app_config: AppConfig = Depends(get_config)) -> MemoryResponse: """Clear all persisted memory data.""" try: - memory_data = clear_memory_data(user_id=get_effective_user_id()) + memory_data = clear_memory_data(app_config.memory, user_id=get_effective_user_id()) except OSError as exc: raise HTTPException(status_code=500, detail="Failed to clear memory data.") from exc @@ -196,10 +197,11 @@ async def clear_memory() -> MemoryResponse: summary="Create Memory Fact", description="Create a single saved memory fact manually.", ) -async def create_memory_fact_endpoint(request: FactCreateRequest) -> MemoryResponse: +async def create_memory_fact_endpoint(request: FactCreateRequest, app_config: AppConfig = Depends(get_config)) -> MemoryResponse: """Create a single fact manually.""" try: memory_data = create_memory_fact( + app_config.memory, content=request.content, category=request.category, confidence=request.confidence, @@ -220,10 +222,10 @@ async def create_memory_fact_endpoint(request: FactCreateRequest) -> MemoryRespo summary="Delete Memory Fact", description="Delete a single saved memory fact by its fact id.", ) -async def delete_memory_fact_endpoint(fact_id: str) -> MemoryResponse: +async def delete_memory_fact_endpoint(fact_id: str, app_config: AppConfig = Depends(get_config)) -> MemoryResponse: """Delete a single fact from memory by fact id.""" try: - memory_data = delete_memory_fact(fact_id, user_id=get_effective_user_id()) + memory_data = delete_memory_fact(app_config.memory, fact_id, user_id=get_effective_user_id()) except KeyError as exc: raise HTTPException(status_code=404, detail=f"Memory fact '{fact_id}' not found.") from exc except OSError as exc: @@ -239,10 +241,11 @@ async def delete_memory_fact_endpoint(fact_id: str) -> MemoryResponse: summary="Patch Memory Fact", description="Partially update a single saved memory fact by its fact id while preserving omitted fields.", ) -async def update_memory_fact_endpoint(fact_id: str, request: FactPatchRequest) -> MemoryResponse: +async def update_memory_fact_endpoint(fact_id: str, request: FactPatchRequest, app_config: AppConfig = Depends(get_config)) -> MemoryResponse: """Partially update a single fact manually.""" try: memory_data = update_memory_fact( + app_config.memory, fact_id=fact_id, content=request.content, category=request.category, @@ -266,9 +269,9 @@ async def update_memory_fact_endpoint(fact_id: str, request: FactPatchRequest) - summary="Export Memory Data", description="Export the current global memory data as JSON for backup or transfer.", ) -async def export_memory() -> MemoryResponse: +async def export_memory(app_config: AppConfig = Depends(get_config)) -> MemoryResponse: """Export the current memory data.""" - memory_data = get_memory_data(user_id=get_effective_user_id()) + memory_data = get_memory_data(app_config.memory, user_id=get_effective_user_id()) return MemoryResponse(**memory_data) @@ -279,10 +282,10 @@ async def export_memory() -> MemoryResponse: summary="Import Memory Data", description="Import and overwrite the current global memory data from a JSON payload.", ) -async def import_memory(request: MemoryResponse) -> MemoryResponse: +async def import_memory(request: MemoryResponse, app_config: AppConfig = Depends(get_config)) -> MemoryResponse: """Import and persist memory data.""" try: - memory_data = import_memory_data(request.model_dump(), user_id=get_effective_user_id()) + memory_data = import_memory_data(app_config.memory, request.model_dump(), user_id=get_effective_user_id()) except OSError as exc: raise HTTPException(status_code=500, detail="Failed to import memory data.") from exc @@ -295,7 +298,9 @@ async def import_memory(request: MemoryResponse) -> MemoryResponse: summary="Get Memory Configuration", description="Retrieve the current memory system configuration.", ) -async def get_memory_config_endpoint() -> MemoryConfigResponse: +async def get_memory_config_endpoint( + app_config: AppConfig = Depends(get_config), +) -> MemoryConfigResponse: """Get the memory system configuration. Returns: @@ -314,7 +319,7 @@ async def get_memory_config_endpoint() -> MemoryConfigResponse: } ``` """ - config = get_memory_config() + config = app_config.memory return MemoryConfigResponse( enabled=config.enabled, storage_path=config.storage_path, @@ -333,14 +338,16 @@ async def get_memory_config_endpoint() -> MemoryConfigResponse: summary="Get Memory Status", description="Retrieve both memory configuration and current data in a single request.", ) -async def get_memory_status() -> MemoryStatusResponse: +async def get_memory_status( + app_config: AppConfig = Depends(get_config), +) -> MemoryStatusResponse: """Get the memory system status including configuration and data. Returns: Combined memory configuration and current data. """ - config = get_memory_config() - memory_data = get_memory_data(user_id=get_effective_user_id()) + config = app_config.memory + memory_data = get_memory_data(config, user_id=get_effective_user_id()) return MemoryStatusResponse( config=MemoryConfigResponse( diff --git a/backend/app/gateway/routers/models.py b/backend/app/gateway/routers/models.py index 11a87a872..a36ece927 100644 --- a/backend/app/gateway/routers/models.py +++ b/backend/app/gateway/routers/models.py @@ -1,7 +1,8 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field -from deerflow.config import get_app_config +from app.gateway.deps import get_config +from deerflow.config.app_config import AppConfig router = APIRouter(prefix="/api", tags=["models"]) @@ -36,7 +37,7 @@ class ModelsListResponse(BaseModel): summary="List All Models", description="Retrieve a list of all available AI models configured in the system.", ) -async def list_models() -> ModelsListResponse: +async def list_models(config: AppConfig = Depends(get_config)) -> ModelsListResponse: """List all available models from configuration. Returns model information suitable for frontend display, @@ -72,7 +73,6 @@ async def list_models() -> ModelsListResponse: } ``` """ - config = get_app_config() models = [ ModelResponse( name=model.name, @@ -96,7 +96,7 @@ async def list_models() -> ModelsListResponse: summary="Get Model Details", description="Retrieve detailed information about a specific AI model by its name.", ) -async def get_model(model_name: str) -> ModelResponse: +async def get_model(model_name: str, config: AppConfig = Depends(get_config)) -> ModelResponse: """Get a specific model by name. Args: @@ -118,7 +118,6 @@ async def get_model(model_name: str) -> ModelResponse: } ``` """ - config = get_app_config() model = config.get_model_config(model_name) if model is None: raise HTTPException(status_code=404, detail=f"Model '{model_name}' not found") diff --git a/backend/app/gateway/routers/runs.py b/backend/app/gateway/routers/runs.py index f2775466c..70e2abb63 100644 --- a/backend/app/gateway/routers/runs.py +++ b/backend/app/gateway/routers/runs.py @@ -123,8 +123,7 @@ async def run_messages( run = await _resolve_run(run_id, request) event_store = get_run_event_store(request) rows = await event_store.list_messages_by_run( - run["thread_id"], - run_id, + run["thread_id"], run_id, limit=limit + 1, before_seq=before_seq, after_seq=after_seq, diff --git a/backend/app/gateway/routers/skills.py b/backend/app/gateway/routers/skills.py index 5fac32d41..f4fb1b445 100644 --- a/backend/app/gateway/routers/skills.py +++ b/backend/app/gateway/routers/skills.py @@ -4,12 +4,14 @@ import logging import shutil from pathlib import Path -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field +from app.gateway.deps import get_config from app.gateway.path_utils import resolve_thread_virtual_path from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async -from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config +from deerflow.config.app_config import AppConfig +from deerflow.config.extensions_config import ExtensionsConfig from deerflow.skills import Skill, load_skills from deerflow.skills.installer import SkillAlreadyExistsError, install_skill_from_archive from deerflow.skills.manager import ( @@ -101,9 +103,9 @@ def _skill_to_response(skill: Skill) -> SkillResponse: summary="List All Skills", description="Retrieve a list of all available skills from both public and custom directories.", ) -async def list_skills() -> SkillsListResponse: +async def list_skills(app_config: AppConfig = Depends(get_config)) -> SkillsListResponse: try: - skills = load_skills(enabled_only=False) + skills = load_skills(app_config, enabled_only=False) return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills]) except Exception as e: logger.error(f"Failed to load skills: {e}", exc_info=True) @@ -116,11 +118,11 @@ async def list_skills() -> SkillsListResponse: summary="Install Skill", description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.", ) -async def install_skill(request: SkillInstallRequest) -> SkillInstallResponse: +async def install_skill(request: SkillInstallRequest, app_config: AppConfig = Depends(get_config)) -> SkillInstallResponse: try: skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path) result = install_skill_from_archive(skill_file_path) - await refresh_skills_system_prompt_cache_async() + await refresh_skills_system_prompt_cache_async(app_config) return SkillInstallResponse(**result) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -136,9 +138,9 @@ async def install_skill(request: SkillInstallRequest) -> SkillInstallResponse: @router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills") -async def list_custom_skills() -> SkillsListResponse: +async def list_custom_skills(app_config: AppConfig = Depends(get_config)) -> SkillsListResponse: try: - skills = [skill for skill in load_skills(enabled_only=False) if skill.category == "custom"] + skills = [skill for skill in load_skills(app_config, enabled_only=False) if skill.category == "custom"] return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills]) except Exception as e: logger.error("Failed to list custom skills: %s", e, exc_info=True) @@ -146,13 +148,13 @@ async def list_custom_skills() -> SkillsListResponse: @router.get("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Get Custom Skill Content") -async def get_custom_skill(skill_name: str) -> CustomSkillContentResponse: +async def get_custom_skill(skill_name: str, app_config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse: try: - skills = load_skills(enabled_only=False) + skills = load_skills(app_config, enabled_only=False) skill = next((s for s in skills if s.name == skill_name and s.category == "custom"), None) if skill is None: raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found") - return CustomSkillContentResponse(**_skill_to_response(skill).model_dump(), content=read_custom_skill_content(skill_name)) + return CustomSkillContentResponse(**_skill_to_response(skill).model_dump(), content=read_custom_skill_content(skill_name, app_config)) except HTTPException: raise except Exception as e: @@ -161,14 +163,18 @@ async def get_custom_skill(skill_name: str) -> CustomSkillContentResponse: @router.put("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Edit Custom Skill") -async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest) -> CustomSkillContentResponse: +async def update_custom_skill( + skill_name: str, + request: CustomSkillUpdateRequest, + app_config: AppConfig = Depends(get_config), +) -> CustomSkillContentResponse: try: - ensure_custom_skill_is_editable(skill_name) + ensure_custom_skill_is_editable(skill_name, app_config) validate_skill_markdown_content(skill_name, request.content) - scan = await scan_skill_content(request.content, executable=False, location=f"{skill_name}/SKILL.md") + scan = await scan_skill_content(app_config, request.content, executable=False, location=f"{skill_name}/SKILL.md") if scan.decision == "block": raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}") - skill_file = get_custom_skill_dir(skill_name) / "SKILL.md" + skill_file = get_custom_skill_dir(skill_name, app_config) / "SKILL.md" prev_content = skill_file.read_text(encoding="utf-8") atomic_write(skill_file, request.content) append_history( @@ -182,9 +188,10 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest "new_content": request.content, "scanner": {"decision": scan.decision, "reason": scan.reason}, }, + app_config, ) - await refresh_skills_system_prompt_cache_async() - return await get_custom_skill(skill_name) + await refresh_skills_system_prompt_cache_async(app_config) + return await get_custom_skill(skill_name, app_config) except HTTPException: raise except FileNotFoundError as e: @@ -197,11 +204,11 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest @router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill") -async def delete_custom_skill(skill_name: str) -> dict[str, bool]: +async def delete_custom_skill(skill_name: str, app_config: AppConfig = Depends(get_config)) -> dict[str, bool]: try: - ensure_custom_skill_is_editable(skill_name) - skill_dir = get_custom_skill_dir(skill_name) - prev_content = read_custom_skill_content(skill_name) + ensure_custom_skill_is_editable(skill_name, app_config) + skill_dir = get_custom_skill_dir(skill_name, app_config) + prev_content = read_custom_skill_content(skill_name, app_config) try: append_history( skill_name, @@ -214,13 +221,14 @@ async def delete_custom_skill(skill_name: str) -> dict[str, bool]: "new_content": None, "scanner": {"decision": "allow", "reason": "Deletion requested."}, }, + app_config, ) except OSError as e: if not isinstance(e, PermissionError) and e.errno not in {errno.EACCES, errno.EPERM, errno.EROFS}: raise logger.warning("Skipping delete history write for custom skill %s due to readonly/permission failure; continuing with skill directory removal: %s", skill_name, e) shutil.rmtree(skill_dir) - await refresh_skills_system_prompt_cache_async() + await refresh_skills_system_prompt_cache_async(app_config) return {"success": True} except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -232,11 +240,11 @@ async def delete_custom_skill(skill_name: str) -> dict[str, bool]: @router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History") -async def get_custom_skill_history(skill_name: str) -> CustomSkillHistoryResponse: +async def get_custom_skill_history(skill_name: str, app_config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse: try: - if not custom_skill_exists(skill_name) and not get_skill_history_file(skill_name).exists(): + if not custom_skill_exists(skill_name, app_config) and not get_skill_history_file(skill_name, app_config).exists(): raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found") - return CustomSkillHistoryResponse(history=read_history(skill_name)) + return CustomSkillHistoryResponse(history=read_history(skill_name, app_config)) except HTTPException: raise except Exception as e: @@ -245,11 +253,15 @@ async def get_custom_skill_history(skill_name: str) -> CustomSkillHistoryRespons @router.post("/skills/custom/{skill_name}/rollback", response_model=CustomSkillContentResponse, summary="Rollback Custom Skill") -async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest) -> CustomSkillContentResponse: +async def rollback_custom_skill( + skill_name: str, + request: SkillRollbackRequest, + app_config: AppConfig = Depends(get_config), +) -> CustomSkillContentResponse: try: - if not custom_skill_exists(skill_name) and not get_skill_history_file(skill_name).exists(): + if not custom_skill_exists(skill_name, app_config) and not get_skill_history_file(skill_name, app_config).exists(): raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found") - history = read_history(skill_name) + history = read_history(skill_name, app_config) if not history: raise HTTPException(status_code=400, detail=f"Custom skill '{skill_name}' has no history") record = history[request.history_index] @@ -257,8 +269,8 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest) if target_content is None: raise HTTPException(status_code=400, detail="Selected history entry has no previous content to roll back to") validate_skill_markdown_content(skill_name, target_content) - scan = await scan_skill_content(target_content, executable=False, location=f"{skill_name}/SKILL.md") - skill_file = get_custom_skill_file(skill_name) + scan = await scan_skill_content(app_config, target_content, executable=False, location=f"{skill_name}/SKILL.md") + skill_file = get_custom_skill_file(skill_name, app_config) current_content = skill_file.read_text(encoding="utf-8") if skill_file.exists() else None history_entry = { "action": "rollback", @@ -271,12 +283,12 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest) "scanner": {"decision": scan.decision, "reason": scan.reason}, } if scan.decision == "block": - append_history(skill_name, history_entry) + append_history(skill_name, history_entry, app_config) raise HTTPException(status_code=400, detail=f"Rollback blocked by security scanner: {scan.reason}") atomic_write(skill_file, target_content) - append_history(skill_name, history_entry) - await refresh_skills_system_prompt_cache_async() - return await get_custom_skill(skill_name) + append_history(skill_name, history_entry, app_config) + await refresh_skills_system_prompt_cache_async(app_config) + return await get_custom_skill(skill_name, app_config) except HTTPException: raise except IndexError: @@ -296,9 +308,9 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest) summary="Get Skill Details", description="Retrieve detailed information about a specific skill by its name.", ) -async def get_skill(skill_name: str) -> SkillResponse: +async def get_skill(skill_name: str, app_config: AppConfig = Depends(get_config)) -> SkillResponse: try: - skills = load_skills(enabled_only=False) + skills = load_skills(app_config, enabled_only=False) skill = next((s for s in skills if s.name == skill_name), None) if skill is None: @@ -318,9 +330,14 @@ async def get_skill(skill_name: str) -> SkillResponse: summary="Update Skill", description="Update a skill's enabled status by modifying the extensions_config.json file.", ) -async def update_skill(skill_name: str, request: SkillUpdateRequest) -> SkillResponse: +async def update_skill( + skill_name: str, + request: SkillUpdateRequest, + http_request: Request, + app_config: AppConfig = Depends(get_config), +) -> SkillResponse: try: - skills = load_skills(enabled_only=False) + skills = load_skills(app_config, enabled_only=False) skill = next((s for s in skills if s.name == skill_name), None) if skill is None: @@ -331,22 +348,29 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest) -> SkillRes config_path = Path.cwd().parent / "extensions_config.json" logger.info(f"No existing extensions config found. Creating new config at: {config_path}") - extensions_config = get_extensions_config() - extensions_config.skills[skill_name] = SkillStateConfig(enabled=request.enabled) + # Do not mutate the frozen AppConfig in place. Compose the new skills + # state in a fresh dict, write to disk, and reload AppConfig below so + # every subsequent Depends(get_config) sees the refreshed snapshot. + ext = app_config.extensions + updated_skills = {name: {"enabled": skill_config.enabled} for name, skill_config in ext.skills.items()} + updated_skills[skill_name] = {"enabled": request.enabled} config_data = { - "mcpServers": {name: server.model_dump() for name, server in extensions_config.mcp_servers.items()}, - "skills": {name: {"enabled": skill_config.enabled} for name, skill_config in extensions_config.skills.items()}, + "mcpServers": {name: server.model_dump() for name, server in ext.mcp_servers.items()}, + "skills": updated_skills, } with open(config_path, "w", encoding="utf-8") as f: json.dump(config_data, f, indent=2) logger.info(f"Skills configuration updated and saved to: {config_path}") - reload_extensions_config() - await refresh_skills_system_prompt_cache_async() + # Reload AppConfig and swap ``app.state.config`` so subsequent + # ``Depends(get_config)`` sees the refreshed value. + reloaded = AppConfig.from_file() + http_request.app.state.config = reloaded + await refresh_skills_system_prompt_cache_async(reloaded) - skills = load_skills(enabled_only=False) + skills = load_skills(reloaded, enabled_only=False) updated_skill = next((s for s in skills if s.name == skill_name), None) if updated_skill is None: diff --git a/backend/app/gateway/routers/suggestions.py b/backend/app/gateway/routers/suggestions.py index fe61c9c5e..1c0a75371 100644 --- a/backend/app/gateway/routers/suggestions.py +++ b/backend/app/gateway/routers/suggestions.py @@ -1,11 +1,13 @@ import json import logging -from fastapi import APIRouter, Request +from fastapi import APIRouter, Depends, Request from langchain_core.messages import HumanMessage, SystemMessage from pydantic import BaseModel, Field from app.gateway.authz import require_permission +from app.gateway.deps import get_config +from deerflow.config.app_config import AppConfig from deerflow.models import create_chat_model logger = logging.getLogger(__name__) @@ -100,7 +102,7 @@ def _format_conversation(messages: list[SuggestionMessage]) -> str: description="Generate short follow-up questions a user might ask next, based on recent conversation context.", ) @require_permission("threads", "read", owner_check=True) -async def generate_suggestions(thread_id: str, body: SuggestionsRequest, request: Request) -> SuggestionsResponse: +async def generate_suggestions(thread_id: str, body: SuggestionsRequest, request: Request, app_config: AppConfig = Depends(get_config)) -> SuggestionsResponse: if not body.messages: return SuggestionsResponse(suggestions=[]) @@ -122,7 +124,7 @@ async def generate_suggestions(thread_id: str, body: SuggestionsRequest, request user_content = f"Conversation Context:\n{conversation}\n\nGenerate {n} follow-up questions" try: - model = create_chat_model(name=body.model_name, thinking_enabled=False) + model = create_chat_model(name=body.model_name, thinking_enabled=False, app_config=app_config) response = await model.ainvoke([SystemMessage(content=system_instruction), HumanMessage(content=user_content)], config={"run_name": "suggest_agent"}) raw = _extract_response_text(response.content) suggestions = _parse_json_string_list(raw) or [] diff --git a/backend/app/gateway/routers/thread_runs.py b/backend/app/gateway/routers/thread_runs.py index e6847c50f..e21375ab9 100644 --- a/backend/app/gateway/routers/thread_runs.py +++ b/backend/app/gateway/routers/thread_runs.py @@ -54,6 +54,7 @@ class RunCreateRequest(BaseModel): after_seconds: float | None = Field(default=None, description="Delayed execution") if_not_exists: Literal["reject", "create"] = Field(default="create", description="Thread creation policy") feedback_keys: list[str] | None = Field(default=None, description="LangSmith feedback keys") + follow_up_to_run_id: str | None = Field(default=None, description="Run ID this message follows up on. Auto-detected from latest successful run if not provided.") class RunResponse(BaseModel): @@ -311,15 +312,11 @@ async def list_thread_messages( if i in last_ai_indices: run_id = msg["run_id"] fb = feedback_map.get(run_id) - msg["feedback"] = ( - { - "feedback_id": fb["feedback_id"], - "rating": fb["rating"], - "comment": fb.get("comment"), - } - if fb - else None - ) + msg["feedback"] = { + "feedback_id": fb["feedback_id"], + "rating": fb["rating"], + "comment": fb.get("comment"), + } if fb else None else: msg["feedback"] = None @@ -342,8 +339,7 @@ async def list_run_messages( """ event_store = get_run_event_store(request) rows = await event_store.list_messages_by_run( - thread_id, - run_id, + thread_id, run_id, limit=limit + 1, before_seq=before_seq, after_seq=after_seq, diff --git a/backend/app/gateway/routers/threads.py b/backend/app/gateway/routers/threads.py index 484582839..c7bfa69b6 100644 --- a/backend/app/gateway/routers/threads.py +++ b/backend/app/gateway/routers/threads.py @@ -13,6 +13,7 @@ matching the LangGraph Platform wire format expected by the from __future__ import annotations import logging +import re import time import uuid from typing import Any diff --git a/backend/app/gateway/routers/uploads.py b/backend/app/gateway/routers/uploads.py index 6f71bf78a..c74ebf2d5 100644 --- a/backend/app/gateway/routers/uploads.py +++ b/backend/app/gateway/routers/uploads.py @@ -4,11 +4,12 @@ import logging import os import stat -from fastapi import APIRouter, File, HTTPException, Request, UploadFile +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile from pydantic import BaseModel from app.gateway.authz import require_permission -from deerflow.config.app_config import get_app_config +from app.gateway.deps import get_config +from deerflow.config.app_config import AppConfig from deerflow.config.paths import get_paths from deerflow.runtime.user_context import get_effective_user_id from deerflow.sandbox.sandbox_provider import SandboxProvider, get_sandbox_provider @@ -60,23 +61,22 @@ def _uses_thread_data_mounts(sandbox_provider: SandboxProvider) -> bool: return bool(getattr(sandbox_provider, "uses_thread_data_mounts", False)) -def _get_uploads_config_value(key: str, default: object) -> object: +def _get_uploads_config_value(app_config: AppConfig, key: str, default: object) -> object: """Read a value from the uploads config, supporting dict and attribute access.""" - cfg = get_app_config() - uploads_cfg = getattr(cfg, "uploads", None) + uploads_cfg = getattr(app_config, "uploads", None) if isinstance(uploads_cfg, dict): return uploads_cfg.get(key, default) return getattr(uploads_cfg, key, default) -def _auto_convert_documents_enabled() -> bool: +def _auto_convert_documents_enabled(app_config: AppConfig) -> bool: """Return whether automatic host-side document conversion is enabled. The secure default is disabled unless an operator explicitly opts in via uploads.auto_convert_documents in config.yaml. """ try: - raw = _get_uploads_config_value("auto_convert_documents", False) + raw = _get_uploads_config_value(app_config, "auto_convert_documents", False) if isinstance(raw, str): return raw.strip().lower() in {"1", "true", "yes", "on"} return bool(raw) @@ -85,11 +85,12 @@ def _auto_convert_documents_enabled() -> bool: @router.post("", response_model=UploadResponse) -@require_permission("threads", "write", owner_check=True, require_existing=False) +@require_permission("threads", "write", owner_check=True, require_existing=True) async def upload_files( thread_id: str, request: Request, files: list[UploadFile] = File(...), + app_config: AppConfig = Depends(get_config), ) -> UploadResponse: """Upload multiple files to a thread's uploads directory.""" if not files: @@ -102,13 +103,13 @@ async def upload_files( sandbox_uploads = get_paths().sandbox_uploads_dir(thread_id, user_id=get_effective_user_id()) uploaded_files = [] - sandbox_provider = get_sandbox_provider() + sandbox_provider = get_sandbox_provider(app_config) sync_to_sandbox = not _uses_thread_data_mounts(sandbox_provider) sandbox = None if sync_to_sandbox: sandbox_id = sandbox_provider.acquire(thread_id) sandbox = sandbox_provider.get(sandbox_id) - auto_convert_documents = _auto_convert_documents_enabled() + auto_convert_documents = _auto_convert_documents_enabled(app_config) for file in files: if not file.filename: diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py index c6704c02d..4550da520 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -8,6 +8,7 @@ frames, and consuming stream bridge events. Router modules from __future__ import annotations import asyncio +import dataclasses import json import logging import re @@ -17,7 +18,7 @@ from typing import Any from fastapi import HTTPException, Request from langchain_core.messages import HumanMessage -from app.gateway.deps import get_run_context, get_run_manager, get_stream_bridge +from app.gateway.deps import get_run_context, get_run_manager, get_run_store, get_stream_bridge from app.gateway.utils import sanitize_log_param from deerflow.runtime import ( END_SENTINEL, @@ -211,6 +212,21 @@ async def start_run( disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_ + # Resolve follow_up_to_run_id: explicit from request, or auto-detect from latest successful run + follow_up_to_run_id = getattr(body, "follow_up_to_run_id", None) + if follow_up_to_run_id is None: + run_store = get_run_store(request) + try: + recent_runs = await run_store.list_by_thread(thread_id, limit=1) + if recent_runs and recent_runs[0].get("status") == "success": + follow_up_to_run_id = recent_runs[0]["run_id"] + except Exception: + pass # Don't block run creation + + # Enrich base context with per-run field + if follow_up_to_run_id: + run_ctx = dataclasses.replace(run_ctx, follow_up_to_run_id=follow_up_to_run_id) + try: record = await run_mgr.create_or_reject( thread_id, @@ -219,6 +235,7 @@ async def start_run( metadata=body.metadata or {}, kwargs={"input": body.input, "config": body.config}, multitask_strategy=body.multitask_strategy, + follow_up_to_run_id=follow_up_to_run_id, ) except ConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index 961df70ed..2f8bdf661 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -3,6 +3,7 @@ import logging from langchain.agents import create_agent from langchain.agents.middleware import AgentMiddleware from langchain_core.runnables import RunnableConfig +from langgraph.graph.state import CompiledStateGraph from deerflow.agents.lead_agent.prompt import apply_prompt_template from deerflow.agents.memory.summarization_hook import memory_flush_hook @@ -18,9 +19,8 @@ from deerflow.agents.middlewares.tool_error_handling_middleware import build_lea from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware from deerflow.agents.thread_state import ThreadState from deerflow.config.agents_config import load_agent_config, validate_agent_name -from deerflow.config.app_config import get_app_config -from deerflow.config.memory_config import get_memory_config -from deerflow.config.summarization_config import get_summarization_config +from deerflow.config.app_config import AppConfig +from deerflow.config.deer_flow_context import DeerFlowContext from deerflow.models import create_chat_model logger = logging.getLogger(__name__) @@ -35,9 +35,8 @@ def _get_runtime_config(config: RunnableConfig) -> dict: return cfg -def _resolve_model_name(requested_model_name: str | None = None) -> str: +def _resolve_model_name(app_config: AppConfig, requested_model_name: str | None = None) -> str: """Resolve a runtime model name safely, falling back to default if invalid. Returns None if no models are configured.""" - app_config = get_app_config() default_model_name = app_config.models[0].name if app_config.models else None if default_model_name is None: raise ValueError("No chat models are configured. Please configure at least one model in config.yaml.") @@ -50,9 +49,9 @@ def _resolve_model_name(requested_model_name: str | None = None) -> str: return default_model_name -def _create_summarization_middleware() -> DeerFlowSummarizationMiddleware | None: +def _create_summarization_middleware(app_config: AppConfig) -> DeerFlowSummarizationMiddleware | None: """Create and configure the summarization middleware from config.""" - config = get_summarization_config() + config = app_config.summarization if not config.enabled: return None @@ -73,9 +72,9 @@ def _create_summarization_middleware() -> DeerFlowSummarizationMiddleware | None # as middleware rather than lead_agent (SummarizationMiddleware is a # LangChain built-in, so we tag the model at creation time). if config.model_name: - model = create_chat_model(name=config.model_name, thinking_enabled=False) + model = create_chat_model(name=config.model_name, thinking_enabled=False, app_config=app_config) else: - model = create_chat_model(thinking_enabled=False) + model = create_chat_model(thinking_enabled=False, app_config=app_config) model = model.with_config(tags=["middleware:summarize"]) # Prepare kwargs @@ -92,14 +91,14 @@ def _create_summarization_middleware() -> DeerFlowSummarizationMiddleware | None kwargs["summary_prompt"] = config.summary_prompt hooks: list[BeforeSummarizationHook] = [] - if get_memory_config().enabled: + if app_config.memory.enabled: hooks.append(memory_flush_hook) # The logic below relies on two assumptions holding true: this factory is # the sole entry point for DeerFlowSummarizationMiddleware, and the runtime # config is not expected to change after startup. try: - skills_container_path = get_app_config().skills.container_path or "/mnt/skills" + skills_container_path = app_config.skills.container_path or "/mnt/skills" except Exception: logger.exception("Failed to resolve skills container path; falling back to default") skills_container_path = "/mnt/skills" @@ -240,10 +239,18 @@ Being proactive with task management demonstrates thoroughness and ensures all r # ViewImageMiddleware should be before ClarificationMiddleware to inject image details before LLM # ToolErrorHandlingMiddleware should be before ClarificationMiddleware to convert tool exceptions to ToolMessages # ClarificationMiddleware should be last to intercept clarification requests after model calls -def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_name: str | None = None, custom_middlewares: list[AgentMiddleware] | None = None): +def _build_middlewares( + app_config: AppConfig, + config: RunnableConfig, + *, + model_name: str | None, + agent_name: str | None = None, + custom_middlewares: list[AgentMiddleware] | None = None, +): """Build middleware chain based on runtime configuration. Args: + app_config: Resolved application config. config: Runtime configuration containing configurable options like is_plan_mode. agent_name: If provided, MemoryMiddleware will use per-agent memory storage. custom_middlewares: Optional list of custom middlewares to inject into the chain. @@ -251,10 +258,10 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_nam Returns: List of middleware instances. """ - middlewares = build_lead_runtime_middlewares(lazy_init=True) + middlewares = build_lead_runtime_middlewares(app_config=app_config, lazy_init=True) # Add summarization middleware if enabled - summarization_middleware = _create_summarization_middleware() + summarization_middleware = _create_summarization_middleware(app_config) if summarization_middleware is not None: middlewares.append(summarization_middleware) @@ -266,7 +273,7 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_nam middlewares.append(todo_list_middleware) # Add TokenUsageMiddleware when token_usage tracking is enabled - if get_app_config().token_usage.enabled: + if app_config.token_usage.enabled: middlewares.append(TokenUsageMiddleware()) # Add TitleMiddleware @@ -277,7 +284,6 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_nam # Add ViewImageMiddleware only if the current model supports vision. # Use the resolved runtime model_name from make_lead_agent to avoid stale config values. - app_config = get_app_config() model_config = app_config.get_model_config(model_name) if model_name else None if model_config is not None and model_config.supports_vision: middlewares.append(ViewImageMiddleware()) @@ -306,11 +312,32 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_nam return middlewares -def make_lead_agent(config: RunnableConfig): +def make_lead_agent( + config: RunnableConfig, + app_config: AppConfig | None = None, +) -> CompiledStateGraph: + """Build the lead agent from runtime config. + + Args: + config: LangGraph ``RunnableConfig`` carrying per-invocation options + (``thinking_enabled``, ``model_name``, ``is_plan_mode``, etc.). + app_config: Resolved application config. Required for in-process + entry points (DeerFlowClient, Gateway Worker). When omitted we + are being called via ``langgraph.json`` registration and reload + from disk — the LangGraph Server bootstrap path has no other + way to thread the value. + """ # Lazy import to avoid circular dependency from deerflow.tools import get_available_tools from deerflow.tools.builtins import setup_agent + if app_config is None: + # LangGraph Server registers ``make_lead_agent`` via ``langgraph.json`` + # and hands us only a ``RunnableConfig``. Reload config from disk + # here — it's a pure function, equivalent to the process-global the + # old code path would have read. + app_config = AppConfig.from_file() + cfg = _get_runtime_config(config) thinking_enabled = cfg.get("thinking_enabled", True) @@ -327,9 +354,8 @@ def make_lead_agent(config: RunnableConfig): agent_model_name = agent_config.model if agent_config and agent_config.model else None # Final model name resolution: request → agent config → global default, with fallback for unknown names - model_name = _resolve_model_name(requested_model_name or agent_model_name) + model_name = _resolve_model_name(app_config, requested_model_name or agent_model_name) - app_config = get_app_config() model_config = app_config.get_model_config(model_name) if model_config is None: @@ -369,20 +395,22 @@ def make_lead_agent(config: RunnableConfig): if is_bootstrap: # Special bootstrap agent with minimal prompt for initial custom agent creation flow return create_agent( - model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled), - tools=get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled) + [setup_agent], - middleware=_build_middlewares(config, model_name=model_name), - system_prompt=apply_prompt_template(subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, available_skills=set(["bootstrap"])), + model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=app_config), + tools=get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, app_config=app_config) + [setup_agent], + middleware=_build_middlewares(app_config, config, model_name=model_name), + system_prompt=apply_prompt_template(app_config, subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, available_skills=set(["bootstrap"])), state_schema=ThreadState, + context_schema=DeerFlowContext, ) # Default lead agent (unchanged behavior) return create_agent( - model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort), - tools=get_available_tools(model_name=model_name, groups=agent_config.tool_groups if agent_config else None, subagent_enabled=subagent_enabled), - middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_name), + model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=app_config), + tools=get_available_tools(model_name=model_name, groups=agent_config.tool_groups if agent_config else None, subagent_enabled=subagent_enabled, app_config=app_config), + middleware=_build_middlewares(app_config, config, model_name=model_name, agent_name=agent_name), system_prompt=apply_prompt_template( - subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, agent_name=agent_name, available_skills=set(agent_config.skills) if agent_config and agent_config.skills is not None else None + app_config, subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, agent_name=agent_name, available_skills=set(agent_config.skills) if agent_config and agent_config.skills is not None else None ), state_schema=ThreadState, + context_schema=DeerFlowContext, ) diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 8452e818e..b059c9bdb 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -5,6 +5,7 @@ from datetime import datetime from functools import lru_cache from deerflow.config.agents_config import load_agent_soul +from deerflow.config.app_config import AppConfig from deerflow.skills import load_skills from deerflow.skills.types import Skill from deerflow.subagents import get_available_subagent_names @@ -19,19 +20,20 @@ _enabled_skills_refresh_version = 0 _enabled_skills_refresh_event = threading.Event() -def _load_enabled_skills_sync() -> list[Skill]: - return list(load_skills(enabled_only=True)) +def _load_enabled_skills_sync(app_config: AppConfig | None) -> list[Skill]: + return list(load_skills(app_config, enabled_only=True)) -def _start_enabled_skills_refresh_thread() -> None: +def _start_enabled_skills_refresh_thread(app_config: AppConfig | None) -> None: threading.Thread( target=_refresh_enabled_skills_cache_worker, + args=(app_config,), name="deerflow-enabled-skills-loader", daemon=True, ).start() -def _refresh_enabled_skills_cache_worker() -> None: +def _refresh_enabled_skills_cache_worker(app_config: AppConfig | None) -> None: global _enabled_skills_cache, _enabled_skills_refresh_active while True: @@ -39,8 +41,8 @@ def _refresh_enabled_skills_cache_worker() -> None: target_version = _enabled_skills_refresh_version try: - skills = _load_enabled_skills_sync() - except Exception: + skills = _load_enabled_skills_sync(app_config) + except (OSError, ImportError): logger.exception("Failed to load enabled skills for prompt injection") skills = [] @@ -56,7 +58,7 @@ def _refresh_enabled_skills_cache_worker() -> None: _enabled_skills_cache = None -def _ensure_enabled_skills_cache() -> threading.Event: +def _ensure_enabled_skills_cache(app_config: AppConfig | None) -> threading.Event: global _enabled_skills_refresh_active with _enabled_skills_lock: @@ -68,11 +70,11 @@ def _ensure_enabled_skills_cache() -> threading.Event: _enabled_skills_refresh_active = True _enabled_skills_refresh_event.clear() - _start_enabled_skills_refresh_thread() + _start_enabled_skills_refresh_thread(app_config) return _enabled_skills_refresh_event -def _invalidate_enabled_skills_cache() -> threading.Event: +def _invalidate_enabled_skills_cache(app_config: AppConfig | None) -> threading.Event: global _enabled_skills_cache, _enabled_skills_refresh_active, _enabled_skills_refresh_version _get_cached_skills_prompt_section.cache_clear() @@ -84,30 +86,30 @@ def _invalidate_enabled_skills_cache() -> threading.Event: return _enabled_skills_refresh_event _enabled_skills_refresh_active = True - _start_enabled_skills_refresh_thread() + _start_enabled_skills_refresh_thread(app_config) return _enabled_skills_refresh_event -def prime_enabled_skills_cache() -> None: - _ensure_enabled_skills_cache() +def prime_enabled_skills_cache(app_config: AppConfig | None = None) -> None: + _ensure_enabled_skills_cache(app_config) -def warm_enabled_skills_cache(timeout_seconds: float = _ENABLED_SKILLS_REFRESH_WAIT_TIMEOUT_SECONDS) -> bool: - if _ensure_enabled_skills_cache().wait(timeout=timeout_seconds): +def warm_enabled_skills_cache(app_config: AppConfig | None = None, timeout_seconds: float = _ENABLED_SKILLS_REFRESH_WAIT_TIMEOUT_SECONDS) -> bool: + if _ensure_enabled_skills_cache(app_config).wait(timeout=timeout_seconds): return True logger.warning("Timed out waiting %.1fs for enabled skills cache warm-up", timeout_seconds) return False -def _get_enabled_skills(): +def _get_enabled_skills(app_config: AppConfig | None = None): with _enabled_skills_lock: cached = _enabled_skills_cache if cached is not None: return list(cached) - _ensure_enabled_skills_cache() + _ensure_enabled_skills_cache(app_config) return [] @@ -115,12 +117,37 @@ def _skill_mutability_label(category: str) -> str: return "[custom, editable]" if category == "custom" else "[built-in]" -def clear_skills_system_prompt_cache() -> None: - _invalidate_enabled_skills_cache() +def clear_skills_system_prompt_cache(app_config: AppConfig | None = None) -> None: + _invalidate_enabled_skills_cache(app_config) -async def refresh_skills_system_prompt_cache_async() -> None: - await asyncio.to_thread(_invalidate_enabled_skills_cache().wait) +async def refresh_skills_system_prompt_cache_async(app_config: AppConfig | None = None) -> None: + await asyncio.to_thread(_invalidate_enabled_skills_cache(app_config).wait) + + +def _reset_skills_system_prompt_cache_state() -> None: + global _enabled_skills_cache, _enabled_skills_refresh_active, _enabled_skills_refresh_version + + _get_cached_skills_prompt_section.cache_clear() + with _enabled_skills_lock: + _enabled_skills_cache = None + _enabled_skills_refresh_active = False + _enabled_skills_refresh_version = 0 + _enabled_skills_refresh_event.clear() + + +def _refresh_enabled_skills_cache(app_config: AppConfig | None = None) -> None: + """Backward-compatible test helper for direct synchronous reload.""" + try: + skills = _load_enabled_skills_sync(app_config) + except Exception: + logger.exception("Failed to load enabled skills for prompt injection") + skills = [] + + with _enabled_skills_lock: + _enabled_skills_cache = skills + _enabled_skills_refresh_active = False + _enabled_skills_refresh_event.set() def _build_skill_evolution_section(skill_evolution_enabled: bool) -> str: @@ -139,7 +166,7 @@ Skip simple one-off tasks. """ -def _build_available_subagents_description(available_names: list[str], bash_available: bool) -> str: +def _build_available_subagents_description(available_names: list[str], bash_available: bool, app_config: AppConfig) -> str: """Dynamically build subagent type descriptions from registry. Mirrors Codex's pattern where agent_type_description is dynamically generated @@ -161,7 +188,7 @@ def _build_available_subagents_description(available_names: list[str], bash_avai if name in builtin_descriptions: lines.append(f"- **{name}**: {builtin_descriptions[name]}") else: - config = get_subagent_config(name) + config = get_subagent_config(name, app_config) if config is not None: desc = config.description.split("\n")[0].strip() # First line only for brevity lines.append(f"- **{name}**: {desc}") @@ -169,22 +196,23 @@ def _build_available_subagents_description(available_names: list[str], bash_avai return "\n".join(lines) -def _build_subagent_section(max_concurrent: int) -> str: +def _build_subagent_section(max_concurrent: int, app_config: AppConfig) -> str: """Build the subagent system prompt section with dynamic concurrency limit. Args: max_concurrent: Maximum number of concurrent subagent calls allowed per response. + app_config: Application config used to gate bash availability. Returns: Formatted subagent section string. """ n = max_concurrent - available_names = get_available_subagent_names() + available_names = get_available_subagent_names(app_config) bash_available = "bash" in available_names # Dynamically build subagent type descriptions from registry (aligned with Codex's # agent_type_description pattern where all registered roles are listed in the tool spec). - available_subagents = _build_available_subagents_description(available_names, bash_available) + available_subagents = _build_available_subagents_description(available_names, bash_available, app_config) direct_tool_examples = "bash, ls, read_file, web_search, etc." if bash_available else "ls, read_file, web_search, etc." direct_execution_example = ( '# User asks: "Run the tests"\n# Thinking: Cannot decompose into parallel sub-tasks\n# → Execute directly\n\nbash("npm test") # Direct execution, not task()' @@ -511,37 +539,34 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f """ -def _get_memory_context(agent_name: str | None = None) -> str: +def _get_memory_context(app_config: AppConfig, agent_name: str | None = None) -> str: """Get memory context for injection into system prompt. - Args: - agent_name: If provided, loads per-agent memory. If None, loads global memory. - - Returns: - Formatted memory context string wrapped in XML tags, or empty string if disabled. + Returns an empty string when memory is disabled or the stored memory file + cannot be read/parsed. A corrupt memory.json degrades the prompt to + no-memory; it never kills the agent. """ + from deerflow.agents.memory import format_memory_for_injection, get_memory_data + from deerflow.runtime.user_context import get_effective_user_id + + memory_config = app_config.memory + if not memory_config.enabled or not memory_config.injection_enabled: + return "" + try: - from deerflow.agents.memory import format_memory_for_injection, get_memory_data - from deerflow.config.memory_config import get_memory_config - from deerflow.runtime.user_context import get_effective_user_id + memory_data = get_memory_data(memory_config, agent_name, user_id=get_effective_user_id()) + except (OSError, ValueError, UnicodeDecodeError): + logger.exception("Failed to load memory data for prompt injection") + return "" - config = get_memory_config() - if not config.enabled or not config.injection_enabled: - return "" + memory_content = format_memory_for_injection(memory_data, max_tokens=memory_config.max_injection_tokens) + if not memory_content.strip(): + return "" - memory_data = get_memory_data(agent_name, user_id=get_effective_user_id()) - memory_content = format_memory_for_injection(memory_data, max_tokens=config.max_injection_tokens) - - if not memory_content.strip(): - return "" - - return f""" + return f""" {memory_content} """ - except Exception as e: - logger.error("Failed to load memory context: %s", e) - return "" @lru_cache(maxsize=32) @@ -576,19 +601,12 @@ You have access to skills that provide optimized workflows for specific tasks. E """ -def get_skills_prompt_section(available_skills: set[str] | None = None) -> str: +def get_skills_prompt_section(app_config: AppConfig, available_skills: set[str] | None = None) -> str: """Generate the skills prompt section with available skills list.""" - skills = _get_enabled_skills() + skills = _get_enabled_skills(app_config) - try: - from deerflow.config import get_app_config - - config = get_app_config() - container_base_path = config.skills.container_path - skill_evolution_enabled = config.skill_evolution.enabled - except Exception: - container_base_path = "/mnt/skills" - skill_evolution_enabled = False + container_base_path = app_config.skills.container_path + skill_evolution_enabled = app_config.skill_evolution.enabled if not skills and not skill_evolution_enabled: return "" @@ -612,7 +630,7 @@ def get_agent_soul(agent_name: str | None) -> str: return "" -def get_deferred_tools_prompt_section() -> str: +def get_deferred_tools_prompt_section(app_config: AppConfig) -> str: """Generate block for the system prompt. Lists only deferred tool names so the agent knows what exists @@ -621,12 +639,7 @@ def get_deferred_tools_prompt_section() -> str: """ from deerflow.tools.builtins.tool_search import get_deferred_registry - try: - from deerflow.config import get_app_config - - if not get_app_config().tool_search.enabled: - return "" - except Exception: + if not app_config.tool_search.enabled: return "" registry = get_deferred_registry() @@ -637,15 +650,9 @@ def get_deferred_tools_prompt_section() -> str: return f"\n{names}\n" -def _build_acp_section() -> str: +def _build_acp_section(app_config: AppConfig) -> str: """Build the ACP agent prompt section, only if ACP agents are configured.""" - try: - from deerflow.config.acp_config import get_acp_agents - - agents = get_acp_agents() - if not agents: - return "" - except Exception: + if not app_config.acp_agents: return "" return ( @@ -657,15 +664,9 @@ def _build_acp_section() -> str: ) -def _build_custom_mounts_section() -> str: +def _build_custom_mounts_section(app_config: AppConfig) -> 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 "" + mounts = app_config.sandbox.mounts or [] if not mounts: return "" @@ -679,13 +680,20 @@ def _build_custom_mounts_section() -> str: 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: +def apply_prompt_template( + app_config: AppConfig, + 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) + memory_context = _get_memory_context(app_config, agent_name) # Include subagent section only if enabled (from runtime parameter) n = max_concurrent_subagents - subagent_section = _build_subagent_section(n) if subagent_enabled else "" + subagent_section = _build_subagent_section(n, app_config) if subagent_enabled else "" # Add subagent reminder to critical_reminders if enabled subagent_reminder = ( @@ -706,14 +714,14 @@ def apply_prompt_template(subagent_enabled: bool = False, max_concurrent_subagen ) # Get skills section - skills_section = get_skills_prompt_section(available_skills) + skills_section = get_skills_prompt_section(app_config, available_skills) # Get deferred tools section (tool_search) - deferred_tools_section = get_deferred_tools_prompt_section() + deferred_tools_section = get_deferred_tools_prompt_section(app_config) # Build ACP agent section only if ACP agents are configured - acp_section = _build_acp_section() - custom_mounts_section = _build_custom_mounts_section() + acp_section = _build_acp_section(app_config) + custom_mounts_section = _build_custom_mounts_section(app_config) 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 diff --git a/backend/packages/harness/deerflow/agents/memory/queue.py b/backend/packages/harness/deerflow/agents/memory/queue.py index b2a147bce..55b2baa54 100644 --- a/backend/packages/harness/deerflow/agents/memory/queue.py +++ b/backend/packages/harness/deerflow/agents/memory/queue.py @@ -7,11 +7,17 @@ from dataclasses import dataclass, field from datetime import UTC, datetime from typing import Any -from deerflow.config.memory_config import get_memory_config +from deerflow.config.app_config import AppConfig logger = logging.getLogger(__name__) +# Module-level config pointer set by the middleware that owns the queue. +# The queue runs on a background Timer thread where ``Runtime`` and FastAPI +# request context are not accessible; the enqueuer (which does have runtime +# context) is responsible for plumbing ``AppConfig`` through ``add()``. + + @dataclass class ConversationContext: """Context for a conversation to be processed for memory update.""" @@ -31,10 +37,21 @@ class MemoryUpdateQueue: This queue collects conversation contexts and processes them after a configurable debounce period. Multiple conversations received within the debounce window are batched together. + + The queue captures an ``AppConfig`` reference at construction time and + reuses it for the MemoryUpdater it spawns. Callers must construct a + fresh queue when the config changes rather than reaching into a global. """ - def __init__(self): - """Initialize the memory update queue.""" + def __init__(self, app_config: AppConfig): + """Initialize the memory update queue. + + Args: + app_config: Application config. The queue reads its own + ``memory`` section for debounce timing and hands the full + config to :class:`MemoryUpdater`. + """ + self._app_config = app_config self._queue: list[ConversationContext] = [] self._lock = threading.Lock() self._timer: threading.Timer | None = None @@ -49,19 +66,8 @@ class MemoryUpdateQueue: correction_detected: bool = False, reinforcement_detected: bool = False, ) -> None: - """Add a conversation to the update queue. - - Args: - thread_id: The thread ID. - messages: The conversation messages. - agent_name: If provided, memory is stored per-agent. If None, uses global memory. - user_id: The user ID captured at enqueue time. Stored in ConversationContext so it - survives the threading.Timer boundary (ContextVar does not propagate across - raw threads). - correction_detected: Whether recent turns include an explicit correction signal. - reinforcement_detected: Whether recent turns include a positive reinforcement signal. - """ - config = get_memory_config() + """Add a conversation to the update queue.""" + config = self._app_config.memory if not config.enabled: return @@ -88,7 +94,7 @@ class MemoryUpdateQueue: reinforcement_detected: bool = False, ) -> None: """Add a conversation and start processing immediately in the background.""" - config = get_memory_config() + config = self._app_config.memory if not config.enabled: return @@ -111,7 +117,7 @@ class MemoryUpdateQueue: thread_id: str, messages: list[Any], agent_name: str | None, - user_id: str | None, + user_id: str | None = None, correction_detected: bool, reinforcement_detected: bool, ) -> None: @@ -135,7 +141,7 @@ class MemoryUpdateQueue: def _reset_timer(self) -> None: """Reset the debounce timer.""" - config = get_memory_config() + config = self._app_config.memory self._schedule_timer(config.debounce_seconds) logger.debug("Memory update timer set for %ss", config.debounce_seconds) @@ -175,7 +181,7 @@ class MemoryUpdateQueue: logger.info("Processing %d queued memory updates", len(contexts_to_process)) try: - updater = MemoryUpdater() + updater = MemoryUpdater(self._app_config) for context in contexts_to_process: try: @@ -247,31 +253,35 @@ class MemoryUpdateQueue: return self._processing -# Global singleton instance -_memory_queue: MemoryUpdateQueue | None = None +# Queues keyed by ``id(AppConfig)`` so tests and multi-client setups with +# distinct configs do not share a debounce queue. +_memory_queues: dict[int, MemoryUpdateQueue] = {} _queue_lock = threading.Lock() -def get_memory_queue() -> MemoryUpdateQueue: - """Get the global memory update queue singleton. - - Returns: - The memory update queue instance. - """ - global _memory_queue +def get_memory_queue(app_config: AppConfig) -> MemoryUpdateQueue: + """Get or create the memory update queue for the given app config.""" + key = id(app_config) with _queue_lock: - if _memory_queue is None: - _memory_queue = MemoryUpdateQueue() - return _memory_queue + queue = _memory_queues.get(key) + if queue is None: + queue = MemoryUpdateQueue(app_config) + _memory_queues[key] = queue + return queue -def reset_memory_queue() -> None: - """Reset the global memory queue. +def reset_memory_queue(app_config: AppConfig | None = None) -> None: + """Reset memory queue(s). - This is useful for testing. + Pass an ``app_config`` to reset only its queue, or omit to reset all + (useful at test teardown). """ - global _memory_queue with _queue_lock: - if _memory_queue is not None: - _memory_queue.clear() - _memory_queue = None + if app_config is not None: + queue = _memory_queues.pop(id(app_config), None) + if queue is not None: + queue.clear() + return + for queue in _memory_queues.values(): + queue.clear() + _memory_queues.clear() diff --git a/backend/packages/harness/deerflow/agents/memory/storage.py b/backend/packages/harness/deerflow/agents/memory/storage.py index 3d0a0e9af..f5593bc30 100644 --- a/backend/packages/harness/deerflow/agents/memory/storage.py +++ b/backend/packages/harness/deerflow/agents/memory/storage.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Any from deerflow.config.agents_config import AGENT_NAME_PATTERN -from deerflow.config.memory_config import get_memory_config +from deerflow.config.memory_config import MemoryConfig from deerflow.config.paths import get_paths logger = logging.getLogger(__name__) @@ -62,8 +62,15 @@ class MemoryStorage(abc.ABC): class FileMemoryStorage(MemoryStorage): """File-based memory storage provider.""" - def __init__(self): - """Initialize the file memory storage.""" + def __init__(self, memory_config: MemoryConfig): + """Initialize the file memory storage. + + Args: + memory_config: Memory configuration (storage_path etc.). Stored on + the instance so per-request lookups don't need to reach for + ambient state. + """ + self._memory_config = memory_config # Per-user/agent memory cache: keyed by (user_id, agent_name) tuple (None = global) # Value: (memory_data, file_mtime) self._memory_cache: dict[tuple[str | None, str | None], tuple[dict[str, Any], float | None]] = {} @@ -83,11 +90,11 @@ class FileMemoryStorage(MemoryStorage): def _get_memory_file_path(self, agent_name: str | None = None, *, user_id: str | None = None) -> Path: """Get the path to the memory file.""" + config = self._memory_config if user_id is not None: if agent_name is not None: self._validate_agent_name(agent_name) return get_paths().user_agent_memory_file(user_id, agent_name) - config = get_memory_config() if config.storage_path and Path(config.storage_path).is_absolute(): return Path(config.storage_path) return get_paths().user_memory_file(user_id) @@ -95,7 +102,6 @@ class FileMemoryStorage(MemoryStorage): if agent_name is not None: self._validate_agent_name(agent_name) return get_paths().agent_memory_file(agent_name) - config = get_memory_config() if config.storage_path: p = Path(config.storage_path) return p if p.is_absolute() else get_paths().base_dir / p @@ -116,20 +122,16 @@ class FileMemoryStorage(MemoryStorage): logger.warning("Failed to load memory file: %s", e) return create_empty_memory() - @staticmethod - def _cache_key(agent_name: str | None = None, *, user_id: str | None = None) -> tuple[str | None, str | None]: - return (user_id, agent_name) - def load(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]: """Load memory data (cached with file modification time check).""" file_path = self._get_memory_file_path(agent_name, user_id=user_id) - cache_key = self._cache_key(agent_name, user_id=user_id) try: current_mtime = file_path.stat().st_mtime if file_path.exists() else None except OSError: current_mtime = None + cache_key = (user_id, agent_name) with self._cache_lock: cached = self._memory_cache.get(cache_key) if cached is not None and cached[1] == current_mtime: @@ -146,13 +148,13 @@ class FileMemoryStorage(MemoryStorage): """Reload memory data from file, forcing cache invalidation.""" file_path = self._get_memory_file_path(agent_name, user_id=user_id) memory_data = self._load_memory_from_file(agent_name, user_id=user_id) - cache_key = self._cache_key(agent_name, user_id=user_id) try: mtime = file_path.stat().st_mtime if file_path.exists() else None except OSError: mtime = None + cache_key = (user_id, agent_name) with self._cache_lock: self._memory_cache[cache_key] = (memory_data, mtime) return memory_data @@ -160,7 +162,6 @@ class FileMemoryStorage(MemoryStorage): def save(self, memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> bool: """Save memory data to file and update cache.""" file_path = self._get_memory_file_path(agent_name, user_id=user_id) - cache_key = self._cache_key(agent_name, user_id=user_id) try: file_path.parent.mkdir(parents=True, exist_ok=True) @@ -180,6 +181,7 @@ class FileMemoryStorage(MemoryStorage): except OSError: mtime = None + cache_key = (user_id, agent_name) with self._cache_lock: self._memory_cache[cache_key] = (memory_data, mtime) logger.info("Memory saved to %s", file_path) @@ -189,23 +191,31 @@ class FileMemoryStorage(MemoryStorage): return False -_storage_instance: MemoryStorage | None = None +# Instances keyed by (storage_class_path, id(memory_config)) so tests can +# construct isolated storages and multi-client setups with different configs +# don't collide on a single process-wide singleton. +_storage_instances: dict[tuple[str, int], MemoryStorage] = {} _storage_lock = threading.Lock() -def get_memory_storage() -> MemoryStorage: - """Get the configured memory storage instance.""" - global _storage_instance - if _storage_instance is not None: - return _storage_instance +def get_memory_storage(memory_config: MemoryConfig) -> MemoryStorage: + """Get the configured memory storage instance. + + Caches one instance per ``(storage_class, memory_config)`` pair. In + single-config deployments this collapses to one instance; in multi-client + or test scenarios each config gets its own storage. + """ + key = (memory_config.storage_class, id(memory_config)) + existing = _storage_instances.get(key) + if existing is not None: + return existing with _storage_lock: - if _storage_instance is not None: - return _storage_instance - - config = get_memory_config() - storage_class_path = config.storage_class + existing = _storage_instances.get(key) + if existing is not None: + return existing + storage_class_path = memory_config.storage_class try: module_path, class_name = storage_class_path.rsplit(".", 1) import importlib @@ -219,13 +229,14 @@ def get_memory_storage() -> MemoryStorage: if not issubclass(storage_class, MemoryStorage): raise TypeError(f"Configured memory storage '{storage_class_path}' is not a subclass of MemoryStorage") - _storage_instance = storage_class() + instance = storage_class(memory_config) except Exception as e: logger.error( "Failed to load memory storage %s, falling back to FileMemoryStorage: %s", storage_class_path, e, ) - _storage_instance = FileMemoryStorage() + instance = FileMemoryStorage(memory_config) - return _storage_instance + _storage_instances[key] = instance + return instance diff --git a/backend/packages/harness/deerflow/agents/memory/summarization_hook.py b/backend/packages/harness/deerflow/agents/memory/summarization_hook.py index dafa7d977..b75131449 100644 --- a/backend/packages/harness/deerflow/agents/memory/summarization_hook.py +++ b/backend/packages/harness/deerflow/agents/memory/summarization_hook.py @@ -5,12 +5,19 @@ from __future__ import annotations from deerflow.agents.memory.message_processing import detect_correction, detect_reinforcement, filter_messages_for_memory from deerflow.agents.memory.queue import get_memory_queue from deerflow.agents.middlewares.summarization_middleware import SummarizationEvent -from deerflow.config.memory_config import get_memory_config +from deerflow.config.app_config import AppConfig def memory_flush_hook(event: SummarizationEvent) -> None: - """Flush messages about to be summarized into the memory queue.""" - if not get_memory_config().enabled or not event.thread_id: + """Flush messages about to be summarized into the memory queue. + + Reads ``AppConfig`` from disk on every invocation. This hook is fired by + ``SummarizationMiddleware`` which has no ergonomic way to thread an + explicit ``app_config`` through; ``AppConfig.from_file()`` is a pure load + so the cost is acceptable for this rare pre-summarization callback. + """ + app_config = AppConfig.from_file() + if not app_config.memory.enabled or not event.thread_id: return filtered_messages = filter_messages_for_memory(list(event.messages_to_summarize)) @@ -21,7 +28,7 @@ def memory_flush_hook(event: SummarizationEvent) -> None: correction_detected = detect_correction(filtered_messages) reinforcement_detected = not correction_detected and detect_reinforcement(filtered_messages) - queue = get_memory_queue() + queue = get_memory_queue(app_config) queue.add_nowait( thread_id=event.thread_id, messages=filtered_messages, diff --git a/backend/packages/harness/deerflow/agents/memory/updater.py b/backend/packages/harness/deerflow/agents/memory/updater.py index 7d563fb87..fbdd5f3cc 100644 --- a/backend/packages/harness/deerflow/agents/memory/updater.py +++ b/backend/packages/harness/deerflow/agents/memory/updater.py @@ -21,7 +21,8 @@ from deerflow.agents.memory.storage import ( get_memory_storage, utc_now_iso_z, ) -from deerflow.config.memory_config import get_memory_config +from deerflow.config.app_config import AppConfig +from deerflow.config.memory_config import MemoryConfig from deerflow.models import create_chat_model logger = logging.getLogger(__name__) @@ -38,45 +39,33 @@ def _create_empty_memory() -> dict[str, Any]: return create_empty_memory() -def _save_memory_to_file(memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> bool: - """Backward-compatible wrapper around the configured memory storage save path.""" - return get_memory_storage().save(memory_data, agent_name, user_id=user_id) +def _save_memory_to_file(memory_config: MemoryConfig, memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> bool: + """Save via the configured memory storage.""" + return get_memory_storage(memory_config).save(memory_data, agent_name, user_id=user_id) -def get_memory_data(agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]: +def get_memory_data(memory_config: MemoryConfig, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]: """Get the current memory data via storage provider.""" - return get_memory_storage().load(agent_name, user_id=user_id) + return get_memory_storage(memory_config).load(agent_name, user_id=user_id) -def reload_memory_data(agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]: +def reload_memory_data(memory_config: MemoryConfig, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]: """Reload memory data via storage provider.""" - return get_memory_storage().reload(agent_name, user_id=user_id) + return get_memory_storage(memory_config).reload(agent_name, user_id=user_id) -def import_memory_data(memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]: - """Persist imported memory data via storage provider. - - Args: - memory_data: Full memory payload to persist. - agent_name: If provided, imports into per-agent memory. - user_id: If provided, scopes memory to a specific user. - - Returns: - The saved memory data after storage normalization. - - Raises: - OSError: If persisting the imported memory fails. - """ - storage = get_memory_storage() +def import_memory_data(memory_config: MemoryConfig, memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]: + """Persist imported memory data via storage provider.""" + storage = get_memory_storage(memory_config) if not storage.save(memory_data, agent_name, user_id=user_id): raise OSError("Failed to save imported memory data") return storage.load(agent_name, user_id=user_id) -def clear_memory_data(agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]: +def clear_memory_data(memory_config: MemoryConfig, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]: """Clear all stored memory data and persist an empty structure.""" cleared_memory = create_empty_memory() - if not _save_memory_to_file(cleared_memory, agent_name, user_id=user_id): + if not _save_memory_to_file(memory_config, cleared_memory, agent_name, user_id=user_id): raise OSError("Failed to save cleared memory data") return cleared_memory @@ -89,6 +78,7 @@ def _validate_confidence(confidence: float) -> float: def create_memory_fact( + memory_config: MemoryConfig, content: str, category: str = "context", confidence: float = 0.5, @@ -104,7 +94,7 @@ def create_memory_fact( normalized_category = category.strip() or "context" validated_confidence = _validate_confidence(confidence) now = utc_now_iso_z() - memory_data = get_memory_data(agent_name, user_id=user_id) + memory_data = get_memory_data(memory_config, agent_name, user_id=user_id) updated_memory = dict(memory_data) facts = list(memory_data.get("facts", [])) facts.append( @@ -119,15 +109,15 @@ def create_memory_fact( ) updated_memory["facts"] = facts - if not _save_memory_to_file(updated_memory, agent_name, user_id=user_id): + if not _save_memory_to_file(memory_config, updated_memory, agent_name, user_id=user_id): raise OSError("Failed to save memory data after creating fact") return updated_memory -def delete_memory_fact(fact_id: str, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]: +def delete_memory_fact(memory_config: MemoryConfig, fact_id: str, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]: """Delete a fact by its id and persist the updated memory data.""" - memory_data = get_memory_data(agent_name, user_id=user_id) + memory_data = get_memory_data(memory_config, agent_name, user_id=user_id) facts = memory_data.get("facts", []) updated_facts = [fact for fact in facts if fact.get("id") != fact_id] if len(updated_facts) == len(facts): @@ -136,13 +126,14 @@ def delete_memory_fact(fact_id: str, agent_name: str | None = None, *, user_id: updated_memory = dict(memory_data) updated_memory["facts"] = updated_facts - if not _save_memory_to_file(updated_memory, agent_name, user_id=user_id): + if not _save_memory_to_file(memory_config, updated_memory, agent_name, user_id=user_id): raise OSError(f"Failed to save memory data after deleting fact '{fact_id}'") return updated_memory def update_memory_fact( + memory_config: MemoryConfig, fact_id: str, content: str | None = None, category: str | None = None, @@ -152,7 +143,7 @@ def update_memory_fact( user_id: str | None = None, ) -> dict[str, Any]: """Update an existing fact and persist the updated memory data.""" - memory_data = get_memory_data(agent_name, user_id=user_id) + memory_data = get_memory_data(memory_config, agent_name, user_id=user_id) updated_memory = dict(memory_data) updated_facts: list[dict[str, Any]] = [] found = False @@ -179,7 +170,7 @@ def update_memory_fact( updated_memory["facts"] = updated_facts - if not _save_memory_to_file(updated_memory, agent_name, user_id=user_id): + if not _save_memory_to_file(memory_config, updated_memory, agent_name, user_id=user_id): raise OSError(f"Failed to save memory data after updating fact '{fact_id}'") return updated_memory @@ -304,19 +295,25 @@ def _fact_content_key(content: Any) -> str | None: class MemoryUpdater: """Updates memory using LLM based on conversation context.""" - def __init__(self, model_name: str | None = None): + def __init__(self, app_config: AppConfig, model_name: str | None = None): """Initialize the memory updater. Args: + app_config: Application config (the updater needs both ``memory`` + section for behavior and the full config for ``create_chat_model``). model_name: Optional model name to use. If None, uses config or default. """ + self._app_config = app_config self._model_name = model_name + @property + def _memory_config(self) -> MemoryConfig: + return self._app_config.memory + def _get_model(self): """Get the model for memory updates.""" - config = get_memory_config() - model_name = self._model_name or config.model_name - return create_chat_model(name=model_name, thinking_enabled=False) + model_name = self._model_name or self._memory_config.model_name + return create_chat_model(name=model_name, thinking_enabled=False, app_config=self._app_config) def _build_correction_hint( self, @@ -349,13 +346,14 @@ class MemoryUpdater: agent_name: str | None, correction_detected: bool, reinforcement_detected: bool, + user_id: str | None = None, ) -> tuple[dict[str, Any], str] | None: """Load memory and build the update prompt for a conversation.""" - config = get_memory_config() + config = self._memory_config if not config.enabled or not messages: return None - current_memory = get_memory_data(agent_name) + current_memory = get_memory_data(config, agent_name, user_id=user_id) conversation_text = format_conversation_for_update(messages) if not conversation_text.strip(): return None @@ -377,6 +375,7 @@ class MemoryUpdater: response_content: Any, thread_id: str | None, agent_name: str | None, + user_id: str | None = None, ) -> bool: """Parse the model response, apply updates, and persist memory.""" response_text = _extract_text(response_content).strip() @@ -390,7 +389,7 @@ class MemoryUpdater: # cannot corrupt the still-cached original object reference. updated_memory = self._apply_updates(copy.deepcopy(current_memory), update_data, thread_id) updated_memory = _strip_upload_mentions_from_memory(updated_memory) - return get_memory_storage().save(updated_memory, agent_name) + return get_memory_storage(self._memory_config).save(updated_memory, agent_name, user_id=user_id) async def aupdate_memory( self, @@ -399,6 +398,7 @@ class MemoryUpdater: agent_name: str | None = None, correction_detected: bool = False, reinforcement_detected: bool = False, + user_id: str | None = None, ) -> bool: """Update memory asynchronously based on conversation messages.""" try: @@ -408,6 +408,7 @@ class MemoryUpdater: agent_name=agent_name, correction_detected=correction_detected, reinforcement_detected=reinforcement_detected, + user_id=user_id, ) if prepared is None: return False @@ -421,6 +422,7 @@ class MemoryUpdater: response_content=response.content, thread_id=thread_id, agent_name=agent_name, + user_id=user_id, ) except json.JSONDecodeError as e: logger.warning("Failed to parse LLM response for memory update: %s", e) @@ -451,15 +453,78 @@ class MemoryUpdater: Returns: True if update was successful, False otherwise. """ - return _run_async_update_sync( - self.aupdate_memory( - messages=messages, - thread_id=thread_id, - agent_name=agent_name, - correction_detected=correction_detected, - reinforcement_detected=reinforcement_detected, + config = self._memory_config + if not config.enabled: + return False + + if not messages: + return False + + try: + # Get current memory + current_memory = get_memory_data(config, agent_name, user_id=user_id) + + # Format conversation for prompt + conversation_text = format_conversation_for_update(messages) + + if not conversation_text.strip(): + return False + + # Build prompt + correction_hint = "" + if correction_detected: + correction_hint = ( + "IMPORTANT: Explicit correction signals were detected in this conversation. " + "Pay special attention to what the agent got wrong, what the user corrected, " + "and record the correct approach as a fact with category " + '"correction" and confidence >= 0.95 when appropriate.' + ) + if reinforcement_detected: + reinforcement_hint = ( + "IMPORTANT: Positive reinforcement signals were detected in this conversation. " + "The user explicitly confirmed the agent's approach was correct or helpful. " + "Record the confirmed approach, style, or preference as a fact with category " + '"preference" or "behavior" and confidence >= 0.9 when appropriate.' + ) + correction_hint = (correction_hint + "\n" + reinforcement_hint).strip() if correction_hint else reinforcement_hint + + prompt = MEMORY_UPDATE_PROMPT.format( + current_memory=json.dumps(current_memory, indent=2), + conversation=conversation_text, + correction_hint=correction_hint, ) - ) + + # Call LLM + model = self._get_model() + response = model.invoke(prompt) + response_text = _extract_text(response.content).strip() + + # Parse response + # Remove markdown code blocks if present + if response_text.startswith("```"): + lines = response_text.split("\n") + response_text = "\n".join(lines[1:-1] if lines[-1] == "```" else lines[1:]) + + update_data = json.loads(response_text) + + # Apply updates + updated_memory = self._apply_updates(current_memory, update_data, thread_id) + + # Strip file-upload mentions from all summaries before saving. + # Uploaded files are session-scoped and won't exist in future sessions, + # so recording upload events in long-term memory causes the agent to + # try (and fail) to locate those files in subsequent conversations. + updated_memory = _strip_upload_mentions_from_memory(updated_memory) + + # Save + return get_memory_storage(config).save(updated_memory, agent_name, user_id=user_id) + + except json.JSONDecodeError as e: + logger.warning("Failed to parse LLM response for memory update: %s", e) + return False + except Exception as e: + logger.exception("Memory update failed: %s", e) + return False def _apply_updates( self, @@ -477,7 +542,7 @@ class MemoryUpdater: Returns: Updated memory data. """ - config = get_memory_config() + config = self._memory_config now = utc_now_iso_z() # Update user sections diff --git a/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py index 4ef9f5e7d..e3f4161e1 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py @@ -20,7 +20,7 @@ from langchain.agents.middleware.types import ( from langchain_core.messages import AIMessage from langgraph.errors import GraphBubbleUp -from deerflow.config import get_app_config +from deerflow.config.app_config import AppConfig logger = logging.getLogger(__name__) @@ -78,7 +78,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]): # Load Circuit Breaker configs from app config if available, fall back to defaults try: - app_config = get_app_config() + app_config = AppConfig.from_file() self.circuit_failure_threshold = app_config.circuit_breaker.failure_threshold self.circuit_recovery_timeout_sec = app_config.circuit_breaker.recovery_timeout_sec except (FileNotFoundError, RuntimeError): diff --git a/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py index 36054876b..d86acce9f 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py @@ -25,6 +25,8 @@ from langchain.agents.middleware import AgentMiddleware from langchain_core.messages import HumanMessage from langgraph.runtime import Runtime +from deerflow.config.deer_flow_context import DeerFlowContext + logger = logging.getLogger(__name__) # Defaults — can be overridden via constructor @@ -181,12 +183,9 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]): self._tool_freq: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) self._tool_freq_warned: dict[str, set[str]] = defaultdict(set) - def _get_thread_id(self, runtime: Runtime) -> str: + def _get_thread_id(self, runtime: Runtime[DeerFlowContext]) -> str: """Extract thread_id from runtime context for per-thread tracking.""" - thread_id = runtime.context.get("thread_id") if runtime.context else None - if thread_id: - return thread_id - return "default" + return runtime.context.thread_id or "default" def _evict_if_needed(self) -> None: """Evict least recently used threads if over the limit. @@ -367,11 +366,11 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]): return None @override - def after_model(self, state: AgentState, runtime: Runtime) -> dict | None: + def after_model(self, state: AgentState, runtime: Runtime[DeerFlowContext]) -> dict | None: return self._apply(state, runtime) @override - async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None: + async def aafter_model(self, state: AgentState, runtime: Runtime[DeerFlowContext]) -> dict | None: return self._apply(state, runtime) def reset(self, thread_id: str | None = None) -> None: diff --git a/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py index 059f8ffc2..263ff353d 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py @@ -5,12 +5,11 @@ from typing import override from langchain.agents import AgentState from langchain.agents.middleware import AgentMiddleware -from langgraph.config import get_config from langgraph.runtime import Runtime from deerflow.agents.memory.message_processing import detect_correction, detect_reinforcement, filter_messages_for_memory from deerflow.agents.memory.queue import get_memory_queue -from deerflow.config.memory_config import get_memory_config +from deerflow.config.deer_flow_context import DeerFlowContext from deerflow.runtime.user_context import get_effective_user_id logger = logging.getLogger(__name__) @@ -44,7 +43,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]): self._agent_name = agent_name @override - def after_agent(self, state: MemoryMiddlewareState, runtime: Runtime) -> dict | None: + def after_agent(self, state: MemoryMiddlewareState, runtime: Runtime[DeerFlowContext]) -> dict | None: """Queue conversation for memory update after agent completes. Args: @@ -54,15 +53,11 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]): Returns: None (no state changes needed from this middleware). """ - config = get_memory_config() - if not config.enabled: + memory_config = runtime.context.app_config.memory + if not memory_config.enabled: return None - # Get thread ID from runtime context first, then fall back to LangGraph's configurable metadata - thread_id = runtime.context.get("thread_id") if runtime.context else None - if thread_id is None: - config_data = get_config() - thread_id = config_data.get("configurable", {}).get("thread_id") + thread_id = runtime.context.thread_id if not thread_id: logger.debug("No thread_id in context, skipping memory update") return None @@ -91,7 +86,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]): # threading.Timer fires on a different thread where ContextVar values are not # propagated, so we must store user_id explicitly in ConversationContext. user_id = get_effective_user_id() - queue = get_memory_queue() + queue = get_memory_queue(runtime.context.app_config) queue.add( thread_id=thread_id, messages=filtered_messages, diff --git a/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py index 8d93de4ff..0670bc826 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py @@ -4,11 +4,10 @@ from typing import NotRequired, override from langchain.agents import AgentState from langchain.agents.middleware import AgentMiddleware -from langchain_core.messages import HumanMessage -from langgraph.config import get_config from langgraph.runtime import Runtime from deerflow.agents.thread_state import ThreadDataState +from deerflow.config.deer_flow_context import DeerFlowContext from deerflow.config.paths import Paths, get_paths from deerflow.runtime.user_context import get_effective_user_id @@ -79,14 +78,10 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]): return self._get_thread_paths(thread_id, user_id=user_id) @override - def before_agent(self, state: ThreadDataMiddlewareState, runtime: Runtime) -> dict | None: - context = runtime.context or {} - thread_id = context.get("thread_id") - if thread_id is None: - config = get_config() - thread_id = config.get("configurable", {}).get("thread_id") + def before_agent(self, state: ThreadDataMiddlewareState, runtime: Runtime[DeerFlowContext]) -> dict | None: + thread_id = runtime.context.thread_id - if thread_id is None: + if not thread_id: raise ValueError("Thread ID is required in runtime context or config.configurable") user_id = get_effective_user_id() diff --git a/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py index 5cd5bb46c..a0fcfad00 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py @@ -9,7 +9,9 @@ from langchain.agents.middleware import AgentMiddleware from langgraph.config import get_config from langgraph.runtime import Runtime -from deerflow.config.title_config import get_title_config +from deerflow.config.app_config import AppConfig +from deerflow.config.deer_flow_context import DeerFlowContext +from deerflow.config.title_config import TitleConfig from deerflow.models import create_chat_model logger = logging.getLogger(__name__) @@ -45,10 +47,9 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): return "" - def _should_generate_title(self, state: TitleMiddlewareState) -> bool: + def _should_generate_title(self, state: TitleMiddlewareState, title_config: TitleConfig) -> bool: """Check if we should generate a title for this thread.""" - config = get_title_config() - if not config.enabled: + if not title_config.enabled: return False # Check if thread already has a title in state @@ -67,12 +68,11 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): # Generate title after first complete exchange return len(user_messages) == 1 and len(assistant_messages) >= 1 - def _build_title_prompt(self, state: TitleMiddlewareState) -> tuple[str, str]: + def _build_title_prompt(self, state: TitleMiddlewareState, title_config: TitleConfig) -> tuple[str, str]: """Extract user/assistant messages and build the title prompt. Returns (prompt_string, user_msg) so callers can use user_msg as fallback. """ - config = get_title_config() messages = state.get("messages", []) user_msg_content = next((m.content for m in messages if m.type == "human"), "") @@ -81,8 +81,8 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): user_msg = self._normalize_content(user_msg_content) assistant_msg = self._strip_think_tags(self._normalize_content(assistant_msg_content)) - prompt = config.prompt_template.format( - max_words=config.max_words, + prompt = title_config.prompt_template.format( + max_words=title_config.max_words, user_msg=user_msg[:500], assistant_msg=assistant_msg[:500], ) @@ -92,17 +92,15 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): """Remove ... blocks emitted by reasoning models (e.g. minimax, DeepSeek-R1).""" return re.sub(r"[\s\S]*?", "", text, flags=re.IGNORECASE).strip() - def _parse_title(self, content: object) -> str: + def _parse_title(self, content: object, title_config: TitleConfig) -> str: """Normalize model output into a clean title string.""" - config = get_title_config() title_content = self._normalize_content(content) title_content = self._strip_think_tags(title_content) title = title_content.strip().strip('"').strip("'") - return title[: config.max_chars] if len(title) > config.max_chars else title + return title[: title_config.max_chars] if len(title) > title_config.max_chars else title - def _fallback_title(self, user_msg: str) -> str: - config = get_title_config() - fallback_chars = min(config.max_chars, 50) + def _fallback_title(self, user_msg: str, title_config: TitleConfig) -> str: + fallback_chars = min(title_config.max_chars, 50) if len(user_msg) > fallback_chars: return user_msg[:fallback_chars].rstrip() + "..." return user_msg if user_msg else "New Conversation" @@ -118,43 +116,42 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): except Exception: parent = {} config = {**parent} - config["run_name"] = "title_agent" config["tags"] = [*(config.get("tags") or []), "middleware:title"] return config - def _generate_title_result(self, state: TitleMiddlewareState) -> dict | None: + def _generate_title_result(self, state: TitleMiddlewareState, title_config: TitleConfig) -> dict | None: """Generate a local fallback title without blocking on an LLM call.""" - if not self._should_generate_title(state): + if not self._should_generate_title(state, title_config): return None - _, user_msg = self._build_title_prompt(state) - return {"title": self._fallback_title(user_msg)} + _, user_msg = self._build_title_prompt(state, title_config) + return {"title": self._fallback_title(user_msg, title_config)} - async def _agenerate_title_result(self, state: TitleMiddlewareState) -> dict | None: + async def _agenerate_title_result(self, state: TitleMiddlewareState, app_config: AppConfig) -> dict | None: """Generate a title asynchronously and fall back locally on failure.""" - if not self._should_generate_title(state): + title_config = app_config.title + if not self._should_generate_title(state, title_config): return None - config = get_title_config() - prompt, user_msg = self._build_title_prompt(state) + prompt, user_msg = self._build_title_prompt(state, title_config) try: - if config.model_name: - model = create_chat_model(name=config.model_name, thinking_enabled=False) + if title_config.model_name: + model = create_chat_model(name=title_config.model_name, thinking_enabled=False, app_config=app_config) else: - model = create_chat_model(thinking_enabled=False) + model = create_chat_model(thinking_enabled=False, app_config=app_config) response = await model.ainvoke(prompt, config=self._get_runnable_config()) - title = self._parse_title(response.content) + title = self._parse_title(response.content, title_config) if title: return {"title": title} except Exception: logger.debug("Failed to generate async title; falling back to local title", exc_info=True) - return {"title": self._fallback_title(user_msg)} + return {"title": self._fallback_title(user_msg, title_config)} @override - def after_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None: - return self._generate_title_result(state) + def after_model(self, state: TitleMiddlewareState, runtime: Runtime[DeerFlowContext]) -> dict | None: + return self._generate_title_result(state, runtime.context.app_config.title) @override - async def aafter_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None: - return await self._agenerate_title_result(state) + async def aafter_model(self, state: TitleMiddlewareState, runtime: Runtime[DeerFlowContext]) -> dict | None: + return await self._agenerate_title_result(state, runtime.context.app_config) diff --git a/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py index 52be28bfb..5ddbb4bbd 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py @@ -1,8 +1,10 @@ """Tool error handling middleware and shared runtime middleware builders.""" +from __future__ import annotations + import logging from collections.abc import Awaitable, Callable -from typing import override +from typing import TYPE_CHECKING, override from langchain.agents import AgentState from langchain.agents.middleware import AgentMiddleware @@ -11,6 +13,9 @@ from langgraph.errors import GraphBubbleUp from langgraph.prebuilt.tool_node import ToolCallRequest from langgraph.types import Command +if TYPE_CHECKING: + from deerflow.config.app_config import AppConfig + logger = logging.getLogger(__name__) _MISSING_TOOL_CALL_ID = "missing_tool_call_id" @@ -67,6 +72,7 @@ class ToolErrorHandlingMiddleware(AgentMiddleware[AgentState]): def _build_runtime_middlewares( *, + app_config: "AppConfig", include_uploads: bool, include_dangling_tool_call_patch: bool, lazy_init: bool = True, @@ -94,9 +100,7 @@ def _build_runtime_middlewares( middlewares.append(LLMErrorHandlingMiddleware()) # Guardrail middleware (if configured) - from deerflow.config.guardrails_config import get_guardrails_config - - guardrails_config = get_guardrails_config() + guardrails_config = app_config.guardrails if guardrails_config.enabled and guardrails_config.provider: import inspect @@ -125,9 +129,10 @@ def _build_runtime_middlewares( return middlewares -def build_lead_runtime_middlewares(*, lazy_init: bool = True) -> list[AgentMiddleware]: +def build_lead_runtime_middlewares(*, app_config: "AppConfig", lazy_init: bool = True) -> list[AgentMiddleware]: """Middlewares shared by lead agent runtime before lead-only middlewares.""" return _build_runtime_middlewares( + app_config=app_config, include_uploads=True, include_dangling_tool_call_patch=True, lazy_init=lazy_init, diff --git a/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py index 5a9ee8301..765a7ce4d 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py @@ -9,6 +9,7 @@ from langchain.agents.middleware import AgentMiddleware from langchain_core.messages import HumanMessage from langgraph.runtime import Runtime +from deerflow.config.deer_flow_context import DeerFlowContext from deerflow.config.paths import Paths, get_paths from deerflow.runtime.user_context import get_effective_user_id from deerflow.utils.file_conversion import extract_outline @@ -185,7 +186,7 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]): return files if files else None @override - def before_agent(self, state: UploadsMiddlewareState, runtime: Runtime) -> dict | None: + def before_agent(self, state: UploadsMiddlewareState, runtime: Runtime[DeerFlowContext]) -> dict | None: """Inject uploaded files information before agent execution. New files come from the current message's additional_kwargs.files. @@ -214,14 +215,7 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]): return None # Resolve uploads directory for existence checks - thread_id = (runtime.context or {}).get("thread_id") - if thread_id is None: - try: - from langgraph.config import get_config - - thread_id = get_config().get("configurable", {}).get("thread_id") - except RuntimeError: - pass # get_config() raises outside a runnable context (e.g. unit tests) + thread_id = runtime.context.thread_id uploads_dir = self._paths.sandbox_uploads_dir(thread_id, user_id=get_effective_user_id()) if thread_id else None # Get newly uploaded files from the current message's additional_kwargs.files diff --git a/backend/packages/harness/deerflow/client.py b/backend/packages/harness/deerflow/client.py index d47e8998e..648bebc86 100644 --- a/backend/packages/harness/deerflow/client.py +++ b/backend/packages/harness/deerflow/client.py @@ -36,8 +36,9 @@ from deerflow.agents.lead_agent.agent import _build_middlewares from deerflow.agents.lead_agent.prompt import apply_prompt_template from deerflow.agents.thread_state import ThreadState from deerflow.config.agents_config import AGENT_NAME_PATTERN -from deerflow.config.app_config import get_app_config, reload_app_config -from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config +from deerflow.config.app_config import AppConfig +from deerflow.config.deer_flow_context import DeerFlowContext +from deerflow.config.extensions_config import ExtensionsConfig from deerflow.config.paths import get_paths from deerflow.models import create_chat_model from deerflow.runtime.user_context import get_effective_user_id @@ -116,6 +117,7 @@ class DeerFlowClient: config_path: str | None = None, checkpointer=None, *, + config: AppConfig | None = None, model_name: str | None = None, thinking_enabled: bool = True, subagent_enabled: bool = False, @@ -130,9 +132,14 @@ class DeerFlowClient: Args: config_path: Path to config.yaml. Uses default resolution if None. + Ignored when ``config`` is provided. checkpointer: LangGraph checkpointer instance for state persistence. Required for multi-turn conversations on the same thread_id. Without a checkpointer, each call is stateless. + config: Optional pre-constructed AppConfig. When provided, it takes + precedence over ``config_path`` and no file is read. Enables + multi-client isolation: two clients with different configs can + coexist in the same process without touching process-global state. model_name: Override the default model name from config. thinking_enabled: Enable model's extended thinking. subagent_enabled: Enable subagent delegation. @@ -141,9 +148,18 @@ class DeerFlowClient: available_skills: Optional set of skill names to make available. If None (default), all scanned skills are available. middlewares: Optional list of custom middlewares to inject into the agent. """ - if config_path is not None: - reload_app_config(config_path) - self._app_config = get_app_config() + # Constructor-captured config: the client owns its AppConfig for its lifetime. + # Multiple clients with different configs do not contend. + # + # Priority: explicit ``config=`` > explicit ``config_path=`` > ``AppConfig.from_file()`` + # with default path resolution. There is no ambient global fallback; if + # config.yaml cannot be located, ``from_file`` raises loudly. + if config is not None: + self._app_config = config + elif config_path is not None: + self._app_config = AppConfig.from_file(config_path) + else: + self._app_config = AppConfig.from_file() if agent_name is not None and not AGENT_NAME_PATTERN.match(agent_name): raise ValueError(f"Invalid agent name '{agent_name}'. Must match pattern: {AGENT_NAME_PATTERN.pattern}") @@ -171,6 +187,15 @@ class DeerFlowClient: self._agent = None self._agent_config_key = None + def _reload_config(self) -> None: + """Reload config from file and refresh the cached reference. + + Only the client's own ``_app_config`` is rebuilt. Other clients + and the process-global are untouched, so multi-client coexistence + survives reload. + """ + self._app_config = AppConfig.from_file() + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ @@ -228,10 +253,11 @@ class DeerFlowClient: max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3) kwargs: dict[str, Any] = { - "model": create_chat_model(name=model_name, thinking_enabled=thinking_enabled), + "model": create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=self._app_config), "tools": self._get_tools(model_name=model_name, subagent_enabled=subagent_enabled), - "middleware": _build_middlewares(config, model_name=model_name, agent_name=self._agent_name, custom_middlewares=self._middlewares), + "middleware": _build_middlewares(self._app_config, config, model_name=model_name, agent_name=self._agent_name, custom_middlewares=self._middlewares), "system_prompt": apply_prompt_template( + self._app_config, subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, agent_name=self._agent_name, @@ -243,7 +269,7 @@ class DeerFlowClient: if checkpointer is None: from deerflow.runtime.checkpointer import get_checkpointer - checkpointer = get_checkpointer() + checkpointer = get_checkpointer(self._app_config) if checkpointer is not None: kwargs["checkpointer"] = checkpointer @@ -251,12 +277,11 @@ class DeerFlowClient: self._agent_config_key = key logger.info("Agent created: agent_name=%s, model=%s, thinking=%s", self._agent_name, model_name, thinking_enabled) - @staticmethod - def _get_tools(*, model_name: str | None, subagent_enabled: bool): + def _get_tools(self, *, model_name: str | None, subagent_enabled: bool): """Lazy import to avoid circular dependency at module level.""" from deerflow.tools import get_available_tools - return get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled) + return get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, app_config=self._app_config) @staticmethod def _serialize_tool_calls(tool_calls) -> list[dict]: @@ -377,7 +402,7 @@ class DeerFlowClient: if checkpointer is None: from deerflow.runtime.checkpointer.provider import get_checkpointer - checkpointer = get_checkpointer() + checkpointer = get_checkpointer(self._app_config) thread_info_map = {} @@ -432,7 +457,7 @@ class DeerFlowClient: if checkpointer is None: from deerflow.runtime.checkpointer.provider import get_checkpointer - checkpointer = get_checkpointer() + checkpointer = get_checkpointer(self._app_config) config = {"configurable": {"thread_id": thread_id}} checkpoints = [] @@ -552,9 +577,7 @@ class DeerFlowClient: self._ensure_agent(config) state: dict[str, Any] = {"messages": [HumanMessage(content=message)]} - context = {"thread_id": thread_id} - if self._agent_name: - context["agent_name"] = self._agent_name + context = DeerFlowContext(app_config=self._app_config, thread_id=thread_id, agent_name=self._agent_name) seen_ids: set[str] = set() # Cross-mode handoff: ids already streamed via LangGraph ``messages`` @@ -763,7 +786,7 @@ class DeerFlowClient: "category": s.category, "enabled": s.enabled, } - for s in load_skills(enabled_only=enabled_only) + for s in load_skills(self._app_config, enabled_only=enabled_only) ] } @@ -775,19 +798,19 @@ class DeerFlowClient: """ from deerflow.agents.memory.updater import get_memory_data - return get_memory_data(user_id=get_effective_user_id()) + return get_memory_data(self._app_config.memory, user_id=get_effective_user_id()) def export_memory(self) -> dict: """Export current memory data for backup or transfer.""" from deerflow.agents.memory.updater import get_memory_data - return get_memory_data(user_id=get_effective_user_id()) + return get_memory_data(self._app_config.memory, user_id=get_effective_user_id()) def import_memory(self, memory_data: dict) -> dict: """Import and persist full memory data.""" from deerflow.agents.memory.updater import import_memory_data - return import_memory_data(memory_data, user_id=get_effective_user_id()) + return import_memory_data(self._app_config.memory, memory_data, user_id=get_effective_user_id()) def get_model(self, name: str) -> dict | None: """Get a specific model's configuration by name. @@ -822,8 +845,8 @@ class DeerFlowClient: Dict with "mcp_servers" key mapping server name to config, matching the Gateway API ``McpConfigResponse`` schema. """ - config = get_extensions_config() - return {"mcp_servers": {name: server.model_dump() for name, server in config.mcp_servers.items()}} + ext = self._app_config.extensions + return {"mcp_servers": {name: server.model_dump() for name, server in ext.mcp_servers.items()}} def update_mcp_config(self, mcp_servers: dict[str, dict]) -> dict: """Update MCP server configurations. @@ -845,18 +868,19 @@ class DeerFlowClient: if config_path is None: raise FileNotFoundError("Cannot locate extensions_config.json. Set DEER_FLOW_EXTENSIONS_CONFIG_PATH or ensure it exists in the project root.") - current_config = get_extensions_config() + current_ext = self._app_config.extensions config_data = { "mcpServers": mcp_servers, - "skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()}, + "skills": {name: {"enabled": skill.enabled} for name, skill in current_ext.skills.items()}, } self._atomic_write_json(config_path, config_data) self._agent = None self._agent_config_key = None - reloaded = reload_extensions_config() + self._reload_config() + reloaded = self._app_config.extensions return {"mcp_servers": {name: server.model_dump() for name, server in reloaded.mcp_servers.items()}} # ------------------------------------------------------------------ @@ -874,7 +898,7 @@ class DeerFlowClient: """ from deerflow.skills.loader import load_skills - skill = next((s for s in load_skills(enabled_only=False) if s.name == name), None) + skill = next((s for s in load_skills(self._app_config, enabled_only=False) if s.name == name), None) if skill is None: return None return { @@ -901,7 +925,7 @@ class DeerFlowClient: """ from deerflow.skills.loader import load_skills - skills = load_skills(enabled_only=False) + skills = load_skills(self._app_config, enabled_only=False) skill = next((s for s in skills if s.name == name), None) if skill is None: raise ValueError(f"Skill '{name}' not found") @@ -910,21 +934,25 @@ class DeerFlowClient: if config_path is None: raise FileNotFoundError("Cannot locate extensions_config.json. Set DEER_FLOW_EXTENSIONS_CONFIG_PATH or ensure it exists in the project root.") - extensions_config = get_extensions_config() - extensions_config.skills[name] = SkillStateConfig(enabled=enabled) + # Do not mutate self._app_config (frozen value). Compose the new + # skills state in a fresh dict, write it to disk, and let _reload_config() + # below rebuild AppConfig from the updated file. + ext = self._app_config.extensions + new_skills = {n: {"enabled": sc.enabled} for n, sc in ext.skills.items()} + new_skills[name] = {"enabled": enabled} config_data = { - "mcpServers": {n: s.model_dump() for n, s in extensions_config.mcp_servers.items()}, - "skills": {n: {"enabled": sc.enabled} for n, sc in extensions_config.skills.items()}, + "mcpServers": {n: s.model_dump() for n, s in ext.mcp_servers.items()}, + "skills": new_skills, } self._atomic_write_json(config_path, config_data) self._agent = None self._agent_config_key = None - reload_extensions_config() + self._reload_config() - updated = next((s for s in load_skills(enabled_only=False) if s.name == name), None) + updated = next((s for s in load_skills(self._app_config, enabled_only=False) if s.name == name), None) if updated is None: raise RuntimeError(f"Skill '{name}' disappeared after update") return { @@ -962,25 +990,25 @@ class DeerFlowClient: """ from deerflow.agents.memory.updater import reload_memory_data - return reload_memory_data(user_id=get_effective_user_id()) + return reload_memory_data(self._app_config.memory, user_id=get_effective_user_id()) def clear_memory(self) -> dict: """Clear all persisted memory data.""" from deerflow.agents.memory.updater import clear_memory_data - return clear_memory_data(user_id=get_effective_user_id()) + return clear_memory_data(self._app_config.memory, user_id=get_effective_user_id()) def create_memory_fact(self, content: str, category: str = "context", confidence: float = 0.5) -> dict: """Create a single fact manually.""" from deerflow.agents.memory.updater import create_memory_fact - return create_memory_fact(content=content, category=category, confidence=confidence) + return create_memory_fact(self._app_config.memory, content=content, category=category, confidence=confidence) def delete_memory_fact(self, fact_id: str) -> dict: """Delete a single fact from memory by fact id.""" from deerflow.agents.memory.updater import delete_memory_fact - return delete_memory_fact(fact_id) + return delete_memory_fact(self._app_config.memory, fact_id) def update_memory_fact( self, @@ -993,6 +1021,7 @@ class DeerFlowClient: from deerflow.agents.memory.updater import update_memory_fact return update_memory_fact( + self._app_config.memory, fact_id=fact_id, content=content, category=category, @@ -1005,9 +1034,7 @@ class DeerFlowClient: Returns: Memory config dict. """ - from deerflow.config.memory_config import get_memory_config - - config = get_memory_config() + config = self._app_config.memory return { "enabled": config.enabled, "storage_path": config.storage_path, diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py index 292a43758..680380bd1 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py @@ -25,7 +25,7 @@ except ImportError: # pragma: no cover - Windows fallback fcntl = None # type: ignore[assignment] import msvcrt -from deerflow.config import get_app_config +from deerflow.config.app_config import AppConfig from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths from deerflow.runtime.user_context import get_effective_user_id from deerflow.sandbox.sandbox import Sandbox @@ -90,7 +90,8 @@ class AioSandboxProvider(SandboxProvider): API_KEY: $MY_API_KEY """ - def __init__(self): + def __init__(self, app_config: "AppConfig"): + self._app_config = app_config self._lock = threading.Lock() self._sandboxes: dict[str, AioSandbox] = {} # sandbox_id -> AioSandbox instance self._sandbox_infos: dict[str, SandboxInfo] = {} # sandbox_id -> SandboxInfo (for destroy) @@ -159,8 +160,7 @@ class AioSandboxProvider(SandboxProvider): def _load_config(self) -> dict: """Load sandbox configuration from app config.""" - config = get_app_config() - sandbox_config = config.sandbox + sandbox_config = self._app_config.sandbox idle_timeout = getattr(sandbox_config, "idle_timeout", None) replicas = getattr(sandbox_config, "replicas", None) @@ -283,17 +283,15 @@ class AioSandboxProvider(SandboxProvider): (paths.host_acp_workspace_dir(thread_id, user_id=user_id), "/mnt/acp-workspace", True), ] - @staticmethod - def _get_skills_mount() -> tuple[str, str, bool] | None: + def _get_skills_mount(self) -> tuple[str, str, bool] | None: """Get the skills directory mount configuration. Mount source uses DEER_FLOW_HOST_SKILLS_PATH when running inside Docker (DooD) so the host Docker daemon can resolve the path. """ try: - config = get_app_config() - skills_path = config.skills.get_skills_path() - container_path = config.skills.container_path + skills_path = self._app_config.skills.get_skills_path() + container_path = self._app_config.skills.container_path if skills_path.exists(): # When running inside Docker with DooD, use host-side skills path. diff --git a/backend/packages/harness/deerflow/community/ddg_search/tools.py b/backend/packages/harness/deerflow/community/ddg_search/tools.py index 7639fe8ec..437e41d6c 100644 --- a/backend/packages/harness/deerflow/community/ddg_search/tools.py +++ b/backend/packages/harness/deerflow/community/ddg_search/tools.py @@ -5,9 +5,9 @@ Web Search Tool - Search the web using DuckDuckGo (no API key required). import json import logging -from langchain.tools import tool +from langchain.tools import ToolRuntime, tool -from deerflow.config import get_app_config +from deerflow.config.deer_flow_context import resolve_context logger = logging.getLogger(__name__) @@ -55,6 +55,7 @@ def _search_text( @tool("web_search", parse_docstring=True) def web_search_tool( query: str, + runtime: ToolRuntime, max_results: int = 5, ) -> str: """Search the web for information. Use this tool to find current information, news, articles, and facts from the internet. @@ -63,11 +64,11 @@ def web_search_tool( query: Search keywords describing what you want to find. Be specific for better results. max_results: Maximum number of results to return. Default is 5. """ - config = get_app_config().get_tool_config("web_search") + tool_config = resolve_context(runtime).app_config.get_tool_config("web_search") # Override max_results from config if set - if config is not None and "max_results" in config.model_extra: - max_results = config.model_extra.get("max_results", max_results) + if tool_config is not None and "max_results" in tool_config.model_extra: + max_results = tool_config.model_extra.get("max_results", max_results) results = _search_text( query=query, diff --git a/backend/packages/harness/deerflow/community/exa/tools.py b/backend/packages/harness/deerflow/community/exa/tools.py index 974280402..e1eb7372e 100644 --- a/backend/packages/harness/deerflow/community/exa/tools.py +++ b/backend/packages/harness/deerflow/community/exa/tools.py @@ -1,37 +1,39 @@ import json from exa_py import Exa -from langchain.tools import tool +from langchain.tools import ToolRuntime, tool -from deerflow.config import get_app_config +from deerflow.config.app_config import AppConfig +from deerflow.config.deer_flow_context import resolve_context -def _get_exa_client(tool_name: str = "web_search") -> Exa: - config = get_app_config().get_tool_config(tool_name) +def _get_exa_client(app_config: AppConfig, tool_name: str = "web_search") -> Exa: + tool_config = app_config.get_tool_config(tool_name) api_key = None - if config is not None and "api_key" in config.model_extra: - api_key = config.model_extra.get("api_key") + if tool_config is not None and "api_key" in tool_config.model_extra: + api_key = tool_config.model_extra.get("api_key") return Exa(api_key=api_key) @tool("web_search", parse_docstring=True) -def web_search_tool(query: str) -> str: +def web_search_tool(query: str, runtime: ToolRuntime) -> str: """Search the web. Args: query: The query to search for. """ try: - config = get_app_config().get_tool_config("web_search") + app_config = resolve_context(runtime).app_config + tool_config = app_config.get_tool_config("web_search") max_results = 5 search_type = "auto" contents_max_characters = 1000 - if config is not None: - max_results = config.model_extra.get("max_results", max_results) - search_type = config.model_extra.get("search_type", search_type) - contents_max_characters = config.model_extra.get("contents_max_characters", contents_max_characters) + if tool_config is not None: + max_results = tool_config.model_extra.get("max_results", max_results) + search_type = tool_config.model_extra.get("search_type", search_type) + contents_max_characters = tool_config.model_extra.get("contents_max_characters", contents_max_characters) - client = _get_exa_client() + client = _get_exa_client(app_config) res = client.search( query, type=search_type, @@ -54,7 +56,7 @@ def web_search_tool(query: str) -> str: @tool("web_fetch", parse_docstring=True) -def web_fetch_tool(url: str) -> str: +def web_fetch_tool(url: str, runtime: ToolRuntime) -> str: """Fetch the contents of a web page at a given URL. Only fetch EXACT URLs that have been provided directly by the user or have been returned in results from the web_search and web_fetch tools. This tool can NOT access content that requires authentication, such as private Google Docs or pages behind login walls. @@ -65,7 +67,7 @@ def web_fetch_tool(url: str) -> str: url: The URL to fetch the contents of. """ try: - client = _get_exa_client("web_fetch") + client = _get_exa_client(resolve_context(runtime).app_config, "web_fetch") res = client.get_contents([url], text={"max_characters": 4096}) if res.results: diff --git a/backend/packages/harness/deerflow/community/firecrawl/tools.py b/backend/packages/harness/deerflow/community/firecrawl/tools.py index 86f44150a..38445e893 100644 --- a/backend/packages/harness/deerflow/community/firecrawl/tools.py +++ b/backend/packages/harness/deerflow/community/firecrawl/tools.py @@ -1,33 +1,35 @@ import json from firecrawl import FirecrawlApp -from langchain.tools import tool +from langchain.tools import ToolRuntime, tool -from deerflow.config import get_app_config +from deerflow.config.app_config import AppConfig +from deerflow.config.deer_flow_context import resolve_context -def _get_firecrawl_client(tool_name: str = "web_search") -> FirecrawlApp: - config = get_app_config().get_tool_config(tool_name) +def _get_firecrawl_client(app_config: AppConfig, tool_name: str = "web_search") -> FirecrawlApp: + tool_config = app_config.get_tool_config(tool_name) api_key = None - if config is not None and "api_key" in config.model_extra: - api_key = config.model_extra.get("api_key") + if tool_config is not None and "api_key" in tool_config.model_extra: + api_key = tool_config.model_extra.get("api_key") return FirecrawlApp(api_key=api_key) # type: ignore[arg-type] @tool("web_search", parse_docstring=True) -def web_search_tool(query: str) -> str: +def web_search_tool(query: str, runtime: ToolRuntime) -> str: """Search the web. Args: query: The query to search for. """ try: - config = get_app_config().get_tool_config("web_search") + app_config = resolve_context(runtime).app_config + tool_config = app_config.get_tool_config("web_search") max_results = 5 - if config is not None: - max_results = config.model_extra.get("max_results", max_results) + if tool_config is not None: + max_results = tool_config.model_extra.get("max_results", max_results) - client = _get_firecrawl_client("web_search") + client = _get_firecrawl_client(app_config, "web_search") result = client.search(query, limit=max_results) # result.web contains list of SearchResultWeb objects @@ -47,7 +49,7 @@ def web_search_tool(query: str) -> str: @tool("web_fetch", parse_docstring=True) -def web_fetch_tool(url: str) -> str: +def web_fetch_tool(url: str, runtime: ToolRuntime) -> str: """Fetch the contents of a web page at a given URL. Only fetch EXACT URLs that have been provided directly by the user or have been returned in results from the web_search and web_fetch tools. This tool can NOT access content that requires authentication, such as private Google Docs or pages behind login walls. @@ -58,7 +60,8 @@ def web_fetch_tool(url: str) -> str: url: The URL to fetch the contents of. """ try: - client = _get_firecrawl_client("web_fetch") + app_config = resolve_context(runtime).app_config + client = _get_firecrawl_client(app_config, "web_fetch") result = client.scrape(url, formats=["markdown"]) markdown_content = result.markdown or "" diff --git a/backend/packages/harness/deerflow/community/image_search/tools.py b/backend/packages/harness/deerflow/community/image_search/tools.py index dc78a5ad3..3daa801c1 100644 --- a/backend/packages/harness/deerflow/community/image_search/tools.py +++ b/backend/packages/harness/deerflow/community/image_search/tools.py @@ -5,9 +5,9 @@ Image Search Tool - Search images using DuckDuckGo for reference in image genera import json import logging -from langchain.tools import tool +from langchain.tools import ToolRuntime, tool -from deerflow.config import get_app_config +from deerflow.config.deer_flow_context import resolve_context logger = logging.getLogger(__name__) @@ -77,6 +77,7 @@ def _search_images( @tool("image_search", parse_docstring=True) def image_search_tool( query: str, + runtime: ToolRuntime, max_results: int = 5, size: str | None = None, type_image: str | None = None, @@ -99,11 +100,11 @@ def image_search_tool( type_image: Image type filter. Options: "photo", "clipart", "gif", "transparent", "line". Use "photo" for realistic references. layout: Layout filter. Options: "Square", "Tall", "Wide". Choose based on your generation needs. """ - config = get_app_config().get_tool_config("image_search") + tool_config = resolve_context(runtime).app_config.get_tool_config("image_search") # Override max_results from config if set - if config is not None and "max_results" in config.model_extra: - max_results = config.model_extra.get("max_results", max_results) + if tool_config is not None and "max_results" in tool_config.model_extra: + max_results = tool_config.model_extra.get("max_results", max_results) results = _search_images( query=query, diff --git a/backend/packages/harness/deerflow/community/infoquest/tools.py b/backend/packages/harness/deerflow/community/infoquest/tools.py index 49fa1de52..9eabedce2 100644 --- a/backend/packages/harness/deerflow/community/infoquest/tools.py +++ b/backend/packages/harness/deerflow/community/infoquest/tools.py @@ -1,6 +1,7 @@ -from langchain.tools import tool +from langchain.tools import ToolRuntime, tool -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.utils.readability import ReadabilityExtractor from .infoquest_client import InfoQuestClient @@ -8,13 +9,13 @@ from .infoquest_client import InfoQuestClient readability_extractor = ReadabilityExtractor() -def _get_infoquest_client() -> InfoQuestClient: - search_config = get_app_config().get_tool_config("web_search") +def _get_infoquest_client(app_config: AppConfig) -> InfoQuestClient: + search_config = app_config.get_tool_config("web_search") search_time_range = -1 if search_config is not None and "search_time_range" in search_config.model_extra: search_time_range = search_config.model_extra.get("search_time_range") - fetch_config = get_app_config().get_tool_config("web_fetch") + fetch_config = app_config.get_tool_config("web_fetch") fetch_time = -1 if fetch_config is not None and "fetch_time" in fetch_config.model_extra: fetch_time = fetch_config.model_extra.get("fetch_time") @@ -25,7 +26,7 @@ def _get_infoquest_client() -> InfoQuestClient: if fetch_config is not None and "navigation_timeout" in fetch_config.model_extra: navigation_timeout = fetch_config.model_extra.get("navigation_timeout") - image_search_config = get_app_config().get_tool_config("image_search") + image_search_config = app_config.get_tool_config("image_search") image_search_time_range = -1 if image_search_config is not None and "image_search_time_range" in image_search_config.model_extra: image_search_time_range = image_search_config.model_extra.get("image_search_time_range") @@ -44,19 +45,18 @@ def _get_infoquest_client() -> InfoQuestClient: @tool("web_search", parse_docstring=True) -def web_search_tool(query: str) -> str: +def web_search_tool(query: str, runtime: ToolRuntime) -> str: """Search the web. Args: query: The query to search for. """ - - client = _get_infoquest_client() + client = _get_infoquest_client(resolve_context(runtime).app_config) return client.web_search(query) @tool("web_fetch", parse_docstring=True) -def web_fetch_tool(url: str) -> str: +def web_fetch_tool(url: str, runtime: ToolRuntime) -> str: """Fetch the contents of a web page at a given URL. Only fetch EXACT URLs that have been provided directly by the user or have been returned in results from the web_search and web_fetch tools. This tool can NOT access content that requires authentication, such as private Google Docs or pages behind login walls. @@ -66,7 +66,7 @@ def web_fetch_tool(url: str) -> str: Args: url: The URL to fetch the contents of. """ - client = _get_infoquest_client() + client = _get_infoquest_client(resolve_context(runtime).app_config) result = client.fetch(url) if result.startswith("Error: "): return result @@ -75,7 +75,7 @@ def web_fetch_tool(url: str) -> str: @tool("image_search", parse_docstring=True) -def image_search_tool(query: str) -> str: +def image_search_tool(query: str, runtime: ToolRuntime) -> str: """Search for images online. Use this tool BEFORE image generation to find reference images for characters, portraits, objects, scenes, or any content requiring visual accuracy. **When to use:** @@ -89,5 +89,5 @@ def image_search_tool(query: str) -> str: Args: query: The query to search for images. """ - client = _get_infoquest_client() + client = _get_infoquest_client(resolve_context(runtime).app_config) return client.image_search(query) diff --git a/backend/packages/harness/deerflow/community/jina_ai/tools.py b/backend/packages/harness/deerflow/community/jina_ai/tools.py index 760e6a3b6..dda140bfb 100644 --- a/backend/packages/harness/deerflow/community/jina_ai/tools.py +++ b/backend/packages/harness/deerflow/community/jina_ai/tools.py @@ -1,16 +1,16 @@ import asyncio -from langchain.tools import tool +from langchain.tools import ToolRuntime, tool from deerflow.community.jina_ai.jina_client import JinaClient -from deerflow.config import get_app_config +from deerflow.config.deer_flow_context import resolve_context from deerflow.utils.readability import ReadabilityExtractor readability_extractor = ReadabilityExtractor() @tool("web_fetch", parse_docstring=True) -async def web_fetch_tool(url: str) -> str: +async def web_fetch_tool(url: str, runtime: ToolRuntime) -> str: """Fetch the contents of a web page at a given URL. Only fetch EXACT URLs that have been provided directly by the user or have been returned in results from the web_search and web_fetch tools. This tool can NOT access content that requires authentication, such as private Google Docs or pages behind login walls. @@ -22,9 +22,9 @@ async def web_fetch_tool(url: str) -> str: """ jina_client = JinaClient() timeout = 10 - config = get_app_config().get_tool_config("web_fetch") - if config is not None and "timeout" in config.model_extra: - timeout = config.model_extra.get("timeout") + tool_config = resolve_context(runtime).app_config.get_tool_config("web_fetch") + if tool_config is not None and "timeout" in tool_config.model_extra: + timeout = tool_config.model_extra.get("timeout") html_content = await jina_client.crawl(url, return_format="html", timeout=timeout) if isinstance(html_content, str) and html_content.startswith("Error:"): return html_content diff --git a/backend/packages/harness/deerflow/community/tavily/tools.py b/backend/packages/harness/deerflow/community/tavily/tools.py index de7996c7a..65a29572a 100644 --- a/backend/packages/harness/deerflow/community/tavily/tools.py +++ b/backend/packages/harness/deerflow/community/tavily/tools.py @@ -1,32 +1,34 @@ import json -from langchain.tools import tool +from langchain.tools import ToolRuntime, tool from tavily import TavilyClient -from deerflow.config import get_app_config +from deerflow.config.app_config import AppConfig +from deerflow.config.deer_flow_context import resolve_context -def _get_tavily_client() -> TavilyClient: - config = get_app_config().get_tool_config("web_search") +def _get_tavily_client(app_config: AppConfig) -> TavilyClient: + tool_config = app_config.get_tool_config("web_search") api_key = None - if config is not None and "api_key" in config.model_extra: - api_key = config.model_extra.get("api_key") + if tool_config is not None and "api_key" in tool_config.model_extra: + api_key = tool_config.model_extra.get("api_key") return TavilyClient(api_key=api_key) @tool("web_search", parse_docstring=True) -def web_search_tool(query: str) -> str: +def web_search_tool(query: str, runtime: ToolRuntime) -> str: """Search the web. Args: query: The query to search for. """ - config = get_app_config().get_tool_config("web_search") + app_config = resolve_context(runtime).app_config + tool_config = app_config.get_tool_config("web_search") max_results = 5 - if config is not None and "max_results" in config.model_extra: - max_results = config.model_extra.get("max_results") + if tool_config is not None and "max_results" in tool_config.model_extra: + max_results = tool_config.model_extra.get("max_results") - client = _get_tavily_client() + client = _get_tavily_client(app_config) res = client.search(query, max_results=max_results) normalized_results = [ { @@ -41,7 +43,7 @@ def web_search_tool(query: str) -> str: @tool("web_fetch", parse_docstring=True) -def web_fetch_tool(url: str) -> str: +def web_fetch_tool(url: str, runtime: ToolRuntime) -> str: """Fetch the contents of a web page at a given URL. Only fetch EXACT URLs that have been provided directly by the user or have been returned in results from the web_search and web_fetch tools. This tool can NOT access content that requires authentication, such as private Google Docs or pages behind login walls. @@ -51,7 +53,8 @@ def web_fetch_tool(url: str) -> str: Args: url: The URL to fetch the contents of. """ - client = _get_tavily_client() + app_config = resolve_context(runtime).app_config + client = _get_tavily_client(app_config) res = client.extract([url]) if "failed_results" in res and len(res["failed_results"]) > 0: return f"Error: {res['failed_results'][0]['error']}" diff --git a/backend/packages/harness/deerflow/config/__init__.py b/backend/packages/harness/deerflow/config/__init__.py index 2e1ee82f8..106a9e8fd 100644 --- a/backend/packages/harness/deerflow/config/__init__.py +++ b/backend/packages/harness/deerflow/config/__init__.py @@ -1,6 +1,6 @@ -from .app_config import get_app_config -from .extensions_config import ExtensionsConfig, get_extensions_config -from .memory_config import MemoryConfig, get_memory_config +from .app_config import AppConfig +from .extensions_config import ExtensionsConfig +from .memory_config import MemoryConfig from .paths import Paths, get_paths from .skill_evolution_config import SkillEvolutionConfig from .skills_config import SkillsConfig @@ -13,18 +13,16 @@ from .tracing_config import ( ) __all__ = [ - "get_app_config", - "SkillEvolutionConfig", - "Paths", - "get_paths", - "SkillsConfig", + "AppConfig", "ExtensionsConfig", - "get_extensions_config", "MemoryConfig", - "get_memory_config", - "get_tracing_config", - "get_explicitly_enabled_tracing_providers", + "Paths", + "SkillEvolutionConfig", + "SkillsConfig", "get_enabled_tracing_providers", + "get_explicitly_enabled_tracing_providers", + "get_paths", + "get_tracing_config", "is_tracing_enabled", "validate_enabled_tracing_providers", ] diff --git a/backend/packages/harness/deerflow/config/acp_config.py b/backend/packages/harness/deerflow/config/acp_config.py index de4b1e89f..4d05327fc 100644 --- a/backend/packages/harness/deerflow/config/acp_config.py +++ b/backend/packages/harness/deerflow/config/acp_config.py @@ -1,16 +1,13 @@ """ACP (Agent Client Protocol) agent configuration loaded from config.yaml.""" -import logging -from collections.abc import Mapping - -from pydantic import BaseModel, Field - -logger = logging.getLogger(__name__) +from pydantic import BaseModel, ConfigDict, Field class ACPAgentConfig(BaseModel): """Configuration for a single ACP-compatible agent.""" + model_config = ConfigDict(frozen=True) + command: str = Field(description="Command to launch the ACP agent subprocess") args: list[str] = Field(default_factory=list, description="Additional command arguments") env: dict[str, str] = Field(default_factory=dict, description="Environment variables to inject into the agent subprocess. Values starting with $ are resolved from host environment variables.") @@ -24,28 +21,3 @@ class ACPAgentConfig(BaseModel): "are denied — the agent must be configured to operate without requesting permissions." ), ) - - -_acp_agents: dict[str, ACPAgentConfig] = {} - - -def get_acp_agents() -> dict[str, ACPAgentConfig]: - """Get the currently configured ACP agents. - - Returns: - Mapping of agent name -> ACPAgentConfig. Empty dict if no ACP agents are configured. - """ - return _acp_agents - - -def load_acp_config_from_dict(config_dict: Mapping[str, Mapping[str, object]] | None) -> None: - """Load ACP agent configuration from a dictionary (typically from config.yaml). - - Args: - config_dict: Mapping of agent name -> config fields. - """ - global _acp_agents - if config_dict is None: - config_dict = {} - _acp_agents = {name: ACPAgentConfig(**cfg) for name, cfg in config_dict.items()} - logger.info("ACP config loaded: %d agent(s): %s", len(_acp_agents), list(_acp_agents.keys())) diff --git a/backend/packages/harness/deerflow/config/agents_api_config.py b/backend/packages/harness/deerflow/config/agents_api_config.py index 84205259e..38ead8152 100644 --- a/backend/packages/harness/deerflow/config/agents_api_config.py +++ b/backend/packages/harness/deerflow/config/agents_api_config.py @@ -1,32 +1,14 @@ """Configuration for the custom agents management API.""" -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class AgentsApiConfig(BaseModel): """Configuration for custom-agent and user-profile management routes.""" + model_config = ConfigDict(frozen=True) + enabled: bool = Field( default=False, description=("Whether to expose the custom-agent management API over HTTP. When disabled, the gateway rejects read/write access to custom agent SOUL.md, config, and USER.md prompt-management routes."), ) - - -_agents_api_config: AgentsApiConfig = AgentsApiConfig() - - -def get_agents_api_config() -> AgentsApiConfig: - """Get the current agents API configuration.""" - return _agents_api_config - - -def set_agents_api_config(config: AgentsApiConfig) -> None: - """Set the agents API configuration.""" - global _agents_api_config - _agents_api_config = config - - -def load_agents_api_config_from_dict(config_dict: dict) -> None: - """Load agents API configuration from a dictionary.""" - global _agents_api_config - _agents_api_config = AgentsApiConfig(**config_dict) diff --git a/backend/packages/harness/deerflow/config/agents_config.py b/backend/packages/harness/deerflow/config/agents_config.py index 0fc985115..107bdd5b6 100644 --- a/backend/packages/harness/deerflow/config/agents_config.py +++ b/backend/packages/harness/deerflow/config/agents_config.py @@ -5,7 +5,7 @@ import re from typing import Any import yaml -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from deerflow.config.paths import get_paths @@ -29,6 +29,8 @@ def validate_agent_name(name: str | None) -> str | None: class AgentConfig(BaseModel): """Configuration for a custom agent.""" + model_config = ConfigDict(frozen=True) + name: str description: str = "" model: str | None = None diff --git a/backend/packages/harness/deerflow/config/app_config.py b/backend/packages/harness/deerflow/config/app_config.py index 5e31c3e37..a04ecb650 100644 --- a/backend/packages/harness/deerflow/config/app_config.py +++ b/backend/packages/harness/deerflow/config/app_config.py @@ -1,6 +1,7 @@ +from __future__ import annotations + import logging import os -from contextvars import ContextVar from pathlib import Path from typing import Any, Self @@ -8,25 +9,25 @@ import yaml from dotenv import load_dotenv from pydantic import BaseModel, ConfigDict, Field -from deerflow.config.acp_config import load_acp_config_from_dict -from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_config_from_dict -from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict +from deerflow.config.acp_config import ACPAgentConfig +from deerflow.config.agents_api_config import AgentsApiConfig +from deerflow.config.checkpointer_config import CheckpointerConfig from deerflow.config.database_config import DatabaseConfig from deerflow.config.extensions_config import ExtensionsConfig -from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict -from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict +from deerflow.config.guardrails_config import GuardrailsConfig +from deerflow.config.memory_config import MemoryConfig from deerflow.config.model_config import ModelConfig from deerflow.config.run_events_config import RunEventsConfig from deerflow.config.sandbox_config import SandboxConfig from deerflow.config.skill_evolution_config import SkillEvolutionConfig from deerflow.config.skills_config import SkillsConfig -from deerflow.config.stream_bridge_config import StreamBridgeConfig, load_stream_bridge_config_from_dict -from deerflow.config.subagents_config import SubagentsAppConfig, load_subagents_config_from_dict -from deerflow.config.summarization_config import SummarizationConfig, load_summarization_config_from_dict -from deerflow.config.title_config import TitleConfig, load_title_config_from_dict +from deerflow.config.stream_bridge_config import StreamBridgeConfig +from deerflow.config.subagents_config import SubagentsAppConfig +from deerflow.config.summarization_config import SummarizationConfig +from deerflow.config.title_config import TitleConfig from deerflow.config.token_usage_config import TokenUsageConfig from deerflow.config.tool_config import ToolConfig, ToolGroupConfig -from deerflow.config.tool_search_config import ToolSearchConfig, load_tool_search_config_from_dict +from deerflow.config.tool_search_config import ToolSearchConfig load_dotenv() @@ -73,11 +74,12 @@ class AppConfig(BaseModel): subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration") guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration") circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig, description="LLM circuit breaker configuration") - model_config = ConfigDict(extra="allow", frozen=False) database: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Unified database backend configuration") run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration") + model_config = ConfigDict(extra="allow", frozen=True) checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration") stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration") + acp_agents: dict[str, ACPAgentConfig] = Field(default_factory=dict, description="ACP agent configurations keyed by agent name") @classmethod def resolve_config_path(cls, config_path: str | None = None) -> Path: @@ -126,49 +128,6 @@ class AppConfig(BaseModel): config_data = cls.resolve_env_variables(config_data) cls._apply_database_defaults(config_data) - # Load title config if present - if "title" in config_data: - load_title_config_from_dict(config_data["title"]) - - # Load summarization config if present - if "summarization" in config_data: - load_summarization_config_from_dict(config_data["summarization"]) - - # Load memory config if present - if "memory" in config_data: - load_memory_config_from_dict(config_data["memory"]) - - # Always refresh agents API config so removed config sections reset - # singleton-backed state to its default/disabled values on reload. - load_agents_api_config_from_dict(config_data.get("agents_api") or {}) - - # Load subagents config if present - if "subagents" in config_data: - load_subagents_config_from_dict(config_data["subagents"]) - - # Load tool_search config if present - if "tool_search" in config_data: - load_tool_search_config_from_dict(config_data["tool_search"]) - - # Load guardrails config if present - if "guardrails" in config_data: - load_guardrails_config_from_dict(config_data["guardrails"]) - - # Load circuit_breaker config if present - if "circuit_breaker" in config_data: - config_data["circuit_breaker"] = config_data["circuit_breaker"] - - # Load checkpointer config if present - if "checkpointer" in config_data: - load_checkpointer_config_from_dict(config_data["checkpointer"]) - - # Load stream bridge config if present - if "stream_bridge" in config_data: - load_stream_bridge_config_from_dict(config_data["stream_bridge"]) - - # Always refresh ACP agent config so removed entries do not linger across reloads. - load_acp_config_from_dict(config_data.get("acp_agents", {})) - # Load extensions config separately (it's in a different file) extensions_config = ExtensionsConfig.from_file() config_data["extensions"] = extensions_config.model_dump() @@ -291,130 +250,8 @@ class AppConfig(BaseModel): """ return next((group for group in self.tool_groups if group.name == name), None) - -_app_config: AppConfig | None = None -_app_config_path: Path | None = None -_app_config_mtime: float | None = None -_app_config_is_custom = False -_current_app_config: ContextVar[AppConfig | None] = ContextVar("deerflow_current_app_config", default=None) -_current_app_config_stack: ContextVar[tuple[AppConfig | None, ...]] = ContextVar("deerflow_current_app_config_stack", default=()) - - -def _get_config_mtime(config_path: Path) -> float | None: - """Get the modification time of a config file if it exists.""" - try: - return config_path.stat().st_mtime - except OSError: - return None - - -def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig: - """Load config from disk and refresh cache metadata.""" - global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom - - resolved_path = AppConfig.resolve_config_path(config_path) - _app_config = AppConfig.from_file(str(resolved_path)) - _app_config_path = resolved_path - _app_config_mtime = _get_config_mtime(resolved_path) - _app_config_is_custom = False - return _app_config - - -def get_app_config() -> AppConfig: - """Get the DeerFlow config instance. - - Returns a cached singleton instance and automatically reloads it when the - underlying config file path or modification time changes. Use - `reload_app_config()` to force a reload, or `reset_app_config()` to clear - the cache. - """ - global _app_config, _app_config_path, _app_config_mtime - - runtime_override = _current_app_config.get() - if runtime_override is not None: - return runtime_override - - if _app_config is not None and _app_config_is_custom: - return _app_config - - resolved_path = AppConfig.resolve_config_path() - current_mtime = _get_config_mtime(resolved_path) - - should_reload = _app_config is None or _app_config_path != resolved_path or _app_config_mtime != current_mtime - if should_reload: - if _app_config_path == resolved_path and _app_config_mtime is not None and current_mtime is not None and _app_config_mtime != current_mtime: - logger.info( - "Config file has been modified (mtime: %s -> %s), reloading AppConfig", - _app_config_mtime, - current_mtime, - ) - _load_and_cache_app_config(str(resolved_path)) - return _app_config - - -def reload_app_config(config_path: str | None = None) -> AppConfig: - """Reload the config from file and update the cached instance. - - This is useful when the config file has been modified and you want - to pick up the changes without restarting the application. - - Args: - config_path: Optional path to config file. If not provided, - uses the default resolution strategy. - - Returns: - The newly loaded AppConfig instance. - """ - return _load_and_cache_app_config(config_path) - - -def reset_app_config() -> None: - """Reset the cached config instance. - - This clears the singleton cache, causing the next call to - `get_app_config()` to reload from file. Useful for testing - or when switching between different configurations. - """ - global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom - _app_config = None - _app_config_path = None - _app_config_mtime = None - _app_config_is_custom = False - - -def set_app_config(config: AppConfig) -> None: - """Set a custom config instance. - - This allows injecting a custom or mock config for testing purposes. - - Args: - config: The AppConfig instance to use. - """ - global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom - _app_config = config - _app_config_path = None - _app_config_mtime = None - _app_config_is_custom = True - - -def peek_current_app_config() -> AppConfig | None: - """Return the runtime-scoped AppConfig override, if one is active.""" - return _current_app_config.get() - - -def push_current_app_config(config: AppConfig) -> None: - """Push a runtime-scoped AppConfig override for the current execution context.""" - stack = _current_app_config_stack.get() - _current_app_config_stack.set(stack + (_current_app_config.get(),)) - _current_app_config.set(config) - - -def pop_current_app_config() -> None: - """Pop the latest runtime-scoped AppConfig override for the current execution context.""" - stack = _current_app_config_stack.get() - if not stack: - _current_app_config.set(None) - return - previous = stack[-1] - _current_app_config_stack.set(stack[:-1]) - _current_app_config.set(previous) + # AppConfig is a pure value object: construct with ``from_file()``, pass around. + # Composition roots that hold the resolved instance: + # - Gateway: ``app.state.config`` via ``Depends(get_config)`` + # - Client: ``DeerFlowClient._app_config`` + # - Agent run: ``Runtime[DeerFlowContext].context.app_config`` diff --git a/backend/packages/harness/deerflow/config/checkpointer_config.py b/backend/packages/harness/deerflow/config/checkpointer_config.py index 6947cefb7..1e81177e8 100644 --- a/backend/packages/harness/deerflow/config/checkpointer_config.py +++ b/backend/packages/harness/deerflow/config/checkpointer_config.py @@ -2,7 +2,7 @@ from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field CheckpointerType = Literal["memory", "sqlite", "postgres"] @@ -10,6 +10,8 @@ CheckpointerType = Literal["memory", "sqlite", "postgres"] class CheckpointerConfig(BaseModel): """Configuration for LangGraph state persistence checkpointer.""" + model_config = ConfigDict(frozen=True) + type: CheckpointerType = Field( description="Checkpointer backend type. " "'memory' is in-process only (lost on restart). " @@ -23,24 +25,3 @@ class CheckpointerConfig(BaseModel): "For sqlite, use a file path like '.deer-flow/checkpoints.db' or ':memory:' for in-memory. " "For postgres, use a DSN like 'postgresql://user:pass@localhost:5432/db'.", ) - - -# Global configuration instance — None means no checkpointer is configured. -_checkpointer_config: CheckpointerConfig | None = None - - -def get_checkpointer_config() -> CheckpointerConfig | None: - """Get the current checkpointer configuration, or None if not configured.""" - return _checkpointer_config - - -def set_checkpointer_config(config: CheckpointerConfig | None) -> None: - """Set the checkpointer configuration.""" - global _checkpointer_config - _checkpointer_config = config - - -def load_checkpointer_config_from_dict(config_dict: dict) -> None: - """Load checkpointer configuration from a dictionary.""" - global _checkpointer_config - _checkpointer_config = CheckpointerConfig(**config_dict) diff --git a/backend/packages/harness/deerflow/config/database_config.py b/backend/packages/harness/deerflow/config/database_config.py index 37cfd579d..95edc84e5 100644 --- a/backend/packages/harness/deerflow/config/database_config.py +++ b/backend/packages/harness/deerflow/config/database_config.py @@ -34,10 +34,11 @@ from __future__ import annotations import os from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class DatabaseConfig(BaseModel): + model_config = ConfigDict(frozen=True) backend: Literal["memory", "sqlite", "postgres"] = Field( default="memory", description=("Storage backend for both checkpointer and application data. 'memory' for development (no persistence across restarts), 'sqlite' for single-node deployment, 'postgres' for production multi-node deployment."), diff --git a/backend/packages/harness/deerflow/config/deer_flow_context.py b/backend/packages/harness/deerflow/config/deer_flow_context.py new file mode 100644 index 000000000..42e816c30 --- /dev/null +++ b/backend/packages/harness/deerflow/config/deer_flow_context.py @@ -0,0 +1,55 @@ +"""Per-invocation context for DeerFlow agent execution. + +Injected via LangGraph Runtime. Middleware and tools access this +via Runtime[DeerFlowContext] parameters, through resolve_context(). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from deerflow.config.app_config import AppConfig + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class DeerFlowContext: + """Typed, immutable, per-invocation context injected via LangGraph Runtime. + + Fields are all known at run start and never change during execution. + Mutable runtime state (e.g. sandbox_id) flows through ThreadState, not here. + """ + + app_config: AppConfig + thread_id: str + agent_name: str | None = None + + +def resolve_context(runtime: Any) -> DeerFlowContext: + """Return the typed DeerFlowContext that the runtime carries. + + Gateway mode (``DeerFlowClient``, ``run_agent``) always attaches a typed + ``DeerFlowContext`` via ``agent.astream(context=...)``; the LangGraph + Server path uses ``langgraph.json`` registration where the top-level + ``make_lead_agent`` loads ``AppConfig`` from disk itself, so we still + arrive here with a typed context. + + Only the dict/None shapes that legacy tests used to exercise would fall + through this function; we now reject them loudly instead of papering + over the missing context with an ambient ``AppConfig`` lookup. + """ + ctx = getattr(runtime, "context", None) + if isinstance(ctx, DeerFlowContext): + return ctx + + raise RuntimeError( + "resolve_context: runtime.context is not a DeerFlowContext " + "(got type %s). Every entry point must attach one at invoke time — " + "Gateway/Client via agent.astream(context=DeerFlowContext(...)), " + "LangGraph Server via the make_lead_agent boundary that loads " + "AppConfig.from_file()." % type(ctx).__name__ + ) diff --git a/backend/packages/harness/deerflow/config/extensions_config.py b/backend/packages/harness/deerflow/config/extensions_config.py index e7a48d166..4c31697c6 100644 --- a/backend/packages/harness/deerflow/config/extensions_config.py +++ b/backend/packages/harness/deerflow/config/extensions_config.py @@ -11,6 +11,8 @@ from pydantic import BaseModel, ConfigDict, Field class McpOAuthConfig(BaseModel): """OAuth configuration for an MCP server (HTTP/SSE transports).""" + model_config = ConfigDict(extra="allow", frozen=True) + enabled: bool = Field(default=True, description="Whether OAuth token injection is enabled") token_url: str = Field(description="OAuth token endpoint URL") grant_type: Literal["client_credentials", "refresh_token"] = Field( @@ -28,12 +30,13 @@ class McpOAuthConfig(BaseModel): default_token_type: str = Field(default="Bearer", description="Default token type when missing in token response") refresh_skew_seconds: int = Field(default=60, description="Refresh token this many seconds before expiry") extra_token_params: dict[str, str] = Field(default_factory=dict, description="Additional form params sent to token endpoint") - model_config = ConfigDict(extra="allow") class McpServerConfig(BaseModel): """Configuration for a single MCP server.""" + model_config = ConfigDict(extra="allow", frozen=True) + enabled: bool = Field(default=True, description="Whether this MCP server is enabled") type: str = Field(default="stdio", description="Transport type: 'stdio', 'sse', or 'http'") command: str | None = Field(default=None, description="Command to execute to start the MCP server (for stdio type)") @@ -43,12 +46,13 @@ class McpServerConfig(BaseModel): headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send (for sse or http type)") oauth: McpOAuthConfig | None = Field(default=None, description="OAuth configuration (for sse or http type)") description: str = Field(default="", description="Human-readable description of what this MCP server provides") - model_config = ConfigDict(extra="allow") class SkillStateConfig(BaseModel): """Configuration for a single skill's state.""" + model_config = ConfigDict(frozen=True) + enabled: bool = Field(default=True, description="Whether this skill is enabled") @@ -64,7 +68,7 @@ class ExtensionsConfig(BaseModel): default_factory=dict, description="Map of skill name to state configuration", ) - model_config = ConfigDict(extra="allow", populate_by_name=True) + model_config = ConfigDict(extra="allow", frozen=True, populate_by_name=True) @classmethod def resolve_config_path(cls, config_path: str | None = None) -> Path | None: @@ -195,62 +199,3 @@ class ExtensionsConfig(BaseModel): # Default to enable for public & custom skill return skill_category in ("public", "custom") return skill_config.enabled - - -_extensions_config: ExtensionsConfig | None = None - - -def get_extensions_config() -> ExtensionsConfig: - """Get the extensions config instance. - - Returns a cached singleton instance. Use `reload_extensions_config()` to reload - from file, or `reset_extensions_config()` to clear the cache. - - Returns: - The cached ExtensionsConfig instance. - """ - global _extensions_config - if _extensions_config is None: - _extensions_config = ExtensionsConfig.from_file() - return _extensions_config - - -def reload_extensions_config(config_path: str | None = None) -> ExtensionsConfig: - """Reload the extensions config from file and update the cached instance. - - This is useful when the config file has been modified and you want - to pick up the changes without restarting the application. - - Args: - config_path: Optional path to extensions config file. If not provided, - uses the default resolution strategy. - - Returns: - The newly loaded ExtensionsConfig instance. - """ - global _extensions_config - _extensions_config = ExtensionsConfig.from_file(config_path) - return _extensions_config - - -def reset_extensions_config() -> None: - """Reset the cached extensions config instance. - - This clears the singleton cache, causing the next call to - `get_extensions_config()` to reload from file. Useful for testing - or when switching between different configurations. - """ - global _extensions_config - _extensions_config = None - - -def set_extensions_config(config: ExtensionsConfig) -> None: - """Set a custom extensions config instance. - - This allows injecting a custom or mock config for testing purposes. - - Args: - config: The ExtensionsConfig instance to use. - """ - global _extensions_config - _extensions_config = config diff --git a/backend/packages/harness/deerflow/config/guardrails_config.py b/backend/packages/harness/deerflow/config/guardrails_config.py index fe7a0b889..b60e6d678 100644 --- a/backend/packages/harness/deerflow/config/guardrails_config.py +++ b/backend/packages/harness/deerflow/config/guardrails_config.py @@ -1,11 +1,13 @@ """Configuration for pre-tool-call authorization.""" -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class GuardrailProviderConfig(BaseModel): """Configuration for a guardrail provider.""" + model_config = ConfigDict(frozen=True) + use: str = Field(description="Class path (e.g. 'deerflow.guardrails.builtin:AllowlistProvider')") config: dict = Field(default_factory=dict, description="Provider-specific settings passed as kwargs") @@ -18,31 +20,9 @@ class GuardrailsConfig(BaseModel): agent's passport reference, and returns an allow/deny decision. """ + model_config = ConfigDict(frozen=True) + enabled: bool = Field(default=False, description="Enable guardrail middleware") fail_closed: bool = Field(default=True, description="Block tool calls if provider errors") passport: str | None = Field(default=None, description="OAP passport path or hosted agent ID") provider: GuardrailProviderConfig | None = Field(default=None, description="Guardrail provider configuration") - - -_guardrails_config: GuardrailsConfig | None = None - - -def get_guardrails_config() -> GuardrailsConfig: - """Get the guardrails config, returning defaults if not loaded.""" - global _guardrails_config - if _guardrails_config is None: - _guardrails_config = GuardrailsConfig() - return _guardrails_config - - -def load_guardrails_config_from_dict(data: dict) -> GuardrailsConfig: - """Load guardrails config from a dict (called during AppConfig loading).""" - global _guardrails_config - _guardrails_config = GuardrailsConfig.model_validate(data) - return _guardrails_config - - -def reset_guardrails_config() -> None: - """Reset the cached config instance. Used in tests to prevent singleton leaks.""" - global _guardrails_config - _guardrails_config = None diff --git a/backend/packages/harness/deerflow/config/memory_config.py b/backend/packages/harness/deerflow/config/memory_config.py index f9153262f..c1209c5e1 100644 --- a/backend/packages/harness/deerflow/config/memory_config.py +++ b/backend/packages/harness/deerflow/config/memory_config.py @@ -1,11 +1,13 @@ """Configuration for memory mechanism.""" -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class MemoryConfig(BaseModel): """Configuration for global memory mechanism.""" + model_config = ConfigDict(frozen=True) + enabled: bool = Field( default=True, description="Whether to enable memory mechanism", @@ -60,24 +62,3 @@ class MemoryConfig(BaseModel): le=8000, description="Maximum tokens to use for memory injection", ) - - -# Global configuration instance -_memory_config: MemoryConfig = MemoryConfig() - - -def get_memory_config() -> MemoryConfig: - """Get the current memory configuration.""" - return _memory_config - - -def set_memory_config(config: MemoryConfig) -> None: - """Set the memory configuration.""" - global _memory_config - _memory_config = config - - -def load_memory_config_from_dict(config_dict: dict) -> None: - """Load memory configuration from a dictionary.""" - global _memory_config - _memory_config = MemoryConfig(**config_dict) diff --git a/backend/packages/harness/deerflow/config/model_config.py b/backend/packages/harness/deerflow/config/model_config.py index e9a3e1c16..fde36222f 100644 --- a/backend/packages/harness/deerflow/config/model_config.py +++ b/backend/packages/harness/deerflow/config/model_config.py @@ -12,7 +12,7 @@ class ModelConfig(BaseModel): description="Class path of the model provider(e.g. langchain_openai.ChatOpenAI)", ) model: str = Field(..., description="Model name") - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra="allow", frozen=True) use_responses_api: bool | None = Field( default=None, description="Whether to route OpenAI ChatOpenAI calls through the /v1/responses API", diff --git a/backend/packages/harness/deerflow/config/run_events_config.py b/backend/packages/harness/deerflow/config/run_events_config.py index cddd9061f..056d0b535 100644 --- a/backend/packages/harness/deerflow/config/run_events_config.py +++ b/backend/packages/harness/deerflow/config/run_events_config.py @@ -15,10 +15,11 @@ from __future__ import annotations from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class RunEventsConfig(BaseModel): + model_config = ConfigDict(frozen=True) backend: Literal["memory", "db", "jsonl"] = Field( default="memory", description="Storage backend for run events. 'memory' for development (no persistence), 'db' for production (SQL queries), 'jsonl' for lightweight single-node persistence.", diff --git a/backend/packages/harness/deerflow/config/sandbox_config.py b/backend/packages/harness/deerflow/config/sandbox_config.py index d9aac4ab4..314101ce9 100644 --- a/backend/packages/harness/deerflow/config/sandbox_config.py +++ b/backend/packages/harness/deerflow/config/sandbox_config.py @@ -4,6 +4,8 @@ from pydantic import BaseModel, ConfigDict, Field class VolumeMountConfig(BaseModel): """Configuration for a volume mount.""" + model_config = ConfigDict(frozen=True) + host_path: str = Field(..., description="Path on the host machine") container_path: str = Field(..., description="Path inside the container") read_only: bool = Field(default=False, description="Whether the mount is read-only") @@ -80,4 +82,4 @@ class SandboxConfig(BaseModel): description="Maximum characters to keep from ls tool output. Output exceeding this limit is head-truncated. Set to 0 to disable truncation.", ) - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra="allow", frozen=True) diff --git a/backend/packages/harness/deerflow/config/skill_evolution_config.py b/backend/packages/harness/deerflow/config/skill_evolution_config.py index 056117f6c..1170417b6 100644 --- a/backend/packages/harness/deerflow/config/skill_evolution_config.py +++ b/backend/packages/harness/deerflow/config/skill_evolution_config.py @@ -1,9 +1,11 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class SkillEvolutionConfig(BaseModel): """Configuration for agent-managed skill evolution.""" + model_config = ConfigDict(frozen=True) + enabled: bool = Field( default=False, description="Whether the agent can create and modify skills under skills/custom.", diff --git a/backend/packages/harness/deerflow/config/skills_config.py b/backend/packages/harness/deerflow/config/skills_config.py index 31a6ca902..272d2897a 100644 --- a/backend/packages/harness/deerflow/config/skills_config.py +++ b/backend/packages/harness/deerflow/config/skills_config.py @@ -1,6 +1,6 @@ from pathlib import Path -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field def _default_repo_root() -> Path: @@ -11,6 +11,8 @@ def _default_repo_root() -> Path: class SkillsConfig(BaseModel): """Configuration for skills system""" + model_config = ConfigDict(frozen=True) + path: str | None = Field( default=None, description="Path to skills directory. If not specified, defaults to ../skills relative to backend directory", diff --git a/backend/packages/harness/deerflow/config/stream_bridge_config.py b/backend/packages/harness/deerflow/config/stream_bridge_config.py index 895c4639c..9460f9eb4 100644 --- a/backend/packages/harness/deerflow/config/stream_bridge_config.py +++ b/backend/packages/harness/deerflow/config/stream_bridge_config.py @@ -2,7 +2,7 @@ from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field StreamBridgeType = Literal["memory", "redis"] @@ -10,6 +10,8 @@ StreamBridgeType = Literal["memory", "redis"] class StreamBridgeConfig(BaseModel): """Configuration for the stream bridge that connects agent workers to SSE endpoints.""" + model_config = ConfigDict(frozen=True) + type: StreamBridgeType = Field( default="memory", description="Stream bridge backend type. 'memory' uses in-process asyncio.Queue (single-process only). 'redis' uses Redis Streams (planned for Phase 2, not yet implemented).", @@ -22,25 +24,3 @@ class StreamBridgeConfig(BaseModel): default=256, description="Maximum number of events buffered per run in the memory bridge.", ) - - -# Global configuration instance — None means no stream bridge is configured -# (falls back to memory with defaults). -_stream_bridge_config: StreamBridgeConfig | None = None - - -def get_stream_bridge_config() -> StreamBridgeConfig | None: - """Get the current stream bridge configuration, or None if not configured.""" - return _stream_bridge_config - - -def set_stream_bridge_config(config: StreamBridgeConfig | None) -> None: - """Set the stream bridge configuration.""" - global _stream_bridge_config - _stream_bridge_config = config - - -def load_stream_bridge_config_from_dict(config_dict: dict) -> None: - """Load stream bridge configuration from a dictionary.""" - global _stream_bridge_config - _stream_bridge_config = StreamBridgeConfig(**config_dict) diff --git a/backend/packages/harness/deerflow/config/subagents_config.py b/backend/packages/harness/deerflow/config/subagents_config.py index e7219284d..025a20547 100644 --- a/backend/packages/harness/deerflow/config/subagents_config.py +++ b/backend/packages/harness/deerflow/config/subagents_config.py @@ -1,15 +1,13 @@ """Configuration for the subagent system loaded from config.yaml.""" -import logging - -from pydantic import BaseModel, Field - -logger = logging.getLogger(__name__) +from pydantic import BaseModel, ConfigDict, Field class SubagentOverrideConfig(BaseModel): """Per-agent configuration overrides.""" + model_config = ConfigDict(frozen=True) + timeout_seconds: int | None = Field( default=None, ge=1, @@ -71,6 +69,8 @@ class CustomSubagentConfig(BaseModel): class SubagentsAppConfig(BaseModel): """Configuration for the subagent system.""" + model_config = ConfigDict(frozen=True) + timeout_seconds: int = Field( default=900, ge=1, @@ -140,48 +140,3 @@ class SubagentsAppConfig(BaseModel): if override is not None and override.skills is not None: return override.skills return None - - -_subagents_config: SubagentsAppConfig = SubagentsAppConfig() - - -def get_subagents_app_config() -> SubagentsAppConfig: - """Get the current subagents configuration.""" - return _subagents_config - - -def load_subagents_config_from_dict(config_dict: dict) -> None: - """Load subagents configuration from a dictionary.""" - global _subagents_config - _subagents_config = SubagentsAppConfig(**config_dict) - - overrides_summary = {} - for name, override in _subagents_config.agents.items(): - parts = [] - if override.timeout_seconds is not None: - parts.append(f"timeout={override.timeout_seconds}s") - if override.max_turns is not None: - parts.append(f"max_turns={override.max_turns}") - if override.model is not None: - parts.append(f"model={override.model}") - if override.skills is not None: - parts.append(f"skills={override.skills}") - if parts: - overrides_summary[name] = ", ".join(parts) - - custom_agents_names = list(_subagents_config.custom_agents.keys()) - - if overrides_summary or custom_agents_names: - logger.info( - "Subagents config loaded: default timeout=%ss, default max_turns=%s, per-agent overrides=%s, custom_agents=%s", - _subagents_config.timeout_seconds, - _subagents_config.max_turns, - overrides_summary or "none", - custom_agents_names or "none", - ) - else: - logger.info( - "Subagents config loaded: default timeout=%ss, default max_turns=%s, no per-agent overrides", - _subagents_config.timeout_seconds, - _subagents_config.max_turns, - ) diff --git a/backend/packages/harness/deerflow/config/summarization_config.py b/backend/packages/harness/deerflow/config/summarization_config.py index fab268ec5..d3705e867 100644 --- a/backend/packages/harness/deerflow/config/summarization_config.py +++ b/backend/packages/harness/deerflow/config/summarization_config.py @@ -2,7 +2,7 @@ from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field ContextSizeType = Literal["fraction", "tokens", "messages"] @@ -10,6 +10,8 @@ ContextSizeType = Literal["fraction", "tokens", "messages"] class ContextSize(BaseModel): """Context size specification for trigger or keep parameters.""" + model_config = ConfigDict(frozen=True) + type: ContextSizeType = Field(description="Type of context size specification") value: int | float = Field(description="Value for the context size specification") @@ -21,6 +23,8 @@ class ContextSize(BaseModel): class SummarizationConfig(BaseModel): """Configuration for automatic conversation summarization.""" + model_config = ConfigDict(frozen=True) + enabled: bool = Field( default=False, description="Whether to enable automatic conversation summarization", @@ -70,24 +74,3 @@ class SummarizationConfig(BaseModel): default_factory=lambda: ["read_file", "read", "view", "cat"], description="Tool names treated as skill file reads when preserving recently-loaded skills across summarization.", ) - - -# Global configuration instance -_summarization_config: SummarizationConfig = SummarizationConfig() - - -def get_summarization_config() -> SummarizationConfig: - """Get the current summarization configuration.""" - return _summarization_config - - -def set_summarization_config(config: SummarizationConfig) -> None: - """Set the summarization configuration.""" - global _summarization_config - _summarization_config = config - - -def load_summarization_config_from_dict(config_dict: dict) -> None: - """Load summarization configuration from a dictionary.""" - global _summarization_config - _summarization_config = SummarizationConfig(**config_dict) diff --git a/backend/packages/harness/deerflow/config/title_config.py b/backend/packages/harness/deerflow/config/title_config.py index f335b4952..508bb5c2a 100644 --- a/backend/packages/harness/deerflow/config/title_config.py +++ b/backend/packages/harness/deerflow/config/title_config.py @@ -1,11 +1,13 @@ """Configuration for automatic thread title generation.""" -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class TitleConfig(BaseModel): """Configuration for automatic thread title generation.""" + model_config = ConfigDict(frozen=True) + enabled: bool = Field( default=True, description="Whether to enable automatic title generation", @@ -30,24 +32,3 @@ class TitleConfig(BaseModel): default=("Generate a concise title (max {max_words} words) for this conversation.\nUser: {user_msg}\nAssistant: {assistant_msg}\n\nReturn ONLY the title, no quotes, no explanation."), description="Prompt template for title generation", ) - - -# Global configuration instance -_title_config: TitleConfig = TitleConfig() - - -def get_title_config() -> TitleConfig: - """Get the current title configuration.""" - return _title_config - - -def set_title_config(config: TitleConfig) -> None: - """Set the title configuration.""" - global _title_config - _title_config = config - - -def load_title_config_from_dict(config_dict: dict) -> None: - """Load title configuration from a dictionary.""" - global _title_config - _title_config = TitleConfig(**config_dict) diff --git a/backend/packages/harness/deerflow/config/token_usage_config.py b/backend/packages/harness/deerflow/config/token_usage_config.py index ab1e26294..5818cc44b 100644 --- a/backend/packages/harness/deerflow/config/token_usage_config.py +++ b/backend/packages/harness/deerflow/config/token_usage_config.py @@ -1,7 +1,9 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class TokenUsageConfig(BaseModel): """Configuration for token usage tracking.""" + model_config = ConfigDict(frozen=True) + enabled: bool = Field(default=False, description="Enable token usage tracking middleware") diff --git a/backend/packages/harness/deerflow/config/tool_config.py b/backend/packages/harness/deerflow/config/tool_config.py index e9c0673d8..10ec85893 100644 --- a/backend/packages/harness/deerflow/config/tool_config.py +++ b/backend/packages/harness/deerflow/config/tool_config.py @@ -5,7 +5,7 @@ class ToolGroupConfig(BaseModel): """Config section for a tool group""" name: str = Field(..., description="Unique name for the tool group") - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra="allow", frozen=True) class ToolConfig(BaseModel): @@ -17,4 +17,4 @@ class ToolConfig(BaseModel): ..., description="Variable name of the tool provider(e.g. deerflow.sandbox.tools:bash_tool)", ) - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra="allow", frozen=True) diff --git a/backend/packages/harness/deerflow/config/tool_search_config.py b/backend/packages/harness/deerflow/config/tool_search_config.py index cdeddabf2..7ea11d9b4 100644 --- a/backend/packages/harness/deerflow/config/tool_search_config.py +++ b/backend/packages/harness/deerflow/config/tool_search_config.py @@ -1,6 +1,6 @@ """Configuration for deferred tool loading via tool_search.""" -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class ToolSearchConfig(BaseModel): @@ -11,25 +11,9 @@ class ToolSearchConfig(BaseModel): via the tool_search tool at runtime. """ + model_config = ConfigDict(frozen=True) + enabled: bool = Field( default=False, description="Defer tools and enable tool_search", ) - - -_tool_search_config: ToolSearchConfig | None = None - - -def get_tool_search_config() -> ToolSearchConfig: - """Get the tool search config, loading from AppConfig if needed.""" - global _tool_search_config - if _tool_search_config is None: - _tool_search_config = ToolSearchConfig() - return _tool_search_config - - -def load_tool_search_config_from_dict(data: dict) -> ToolSearchConfig: - """Load tool search config from a dict (called during AppConfig loading).""" - global _tool_search_config - _tool_search_config = ToolSearchConfig.model_validate(data) - return _tool_search_config diff --git a/backend/packages/harness/deerflow/config/tracing_config.py b/backend/packages/harness/deerflow/config/tracing_config.py index 1ef5ebeb4..a8d8fa06f 100644 --- a/backend/packages/harness/deerflow/config/tracing_config.py +++ b/backend/packages/harness/deerflow/config/tracing_config.py @@ -1,7 +1,7 @@ import os import threading -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field _config_lock = threading.Lock() @@ -9,6 +9,8 @@ _config_lock = threading.Lock() class LangSmithTracingConfig(BaseModel): """Configuration for LangSmith tracing.""" + model_config = ConfigDict(frozen=True) + enabled: bool = Field(...) api_key: str | None = Field(...) project: str = Field(...) @@ -26,6 +28,8 @@ class LangSmithTracingConfig(BaseModel): class LangfuseTracingConfig(BaseModel): """Configuration for Langfuse tracing.""" + model_config = ConfigDict(frozen=True) + enabled: bool = Field(...) public_key: str | None = Field(...) secret_key: str | None = Field(...) @@ -50,6 +54,8 @@ class LangfuseTracingConfig(BaseModel): class TracingConfig(BaseModel): """Tracing configuration for supported providers.""" + model_config = ConfigDict(frozen=True) + langsmith: LangSmithTracingConfig = Field(...) langfuse: LangfuseTracingConfig = Field(...) diff --git a/backend/packages/harness/deerflow/models/factory.py b/backend/packages/harness/deerflow/models/factory.py index 927cbcdd2..bf9d6116e 100644 --- a/backend/packages/harness/deerflow/models/factory.py +++ b/backend/packages/harness/deerflow/models/factory.py @@ -2,7 +2,7 @@ import logging from langchain.chat_models import BaseChatModel -from deerflow.config import get_app_config +from deerflow.config.app_config import AppConfig from deerflow.reflection import resolve_class from deerflow.tracing import build_tracing_callbacks @@ -46,16 +46,23 @@ def _enable_stream_usage_by_default(model_use_path: str, model_settings_from_con model_settings_from_config["stream_usage"] = True -def create_chat_model(name: str | None = None, thinking_enabled: bool = False, **kwargs) -> BaseChatModel: +def create_chat_model( + name: str | None = None, + thinking_enabled: bool = False, + *, + app_config: "AppConfig", + **kwargs, +) -> BaseChatModel: """Create a chat model instance from the config. Args: name: The name of the model to create. If None, the first model in the config will be used. + app_config: Application config — required. Returns: A chat model instance. """ - config = get_app_config() + config = app_config if name is None: name = config.models[0].name model_config = config.get_model_config(name) diff --git a/backend/packages/harness/deerflow/persistence/feedback/model.py b/backend/packages/harness/deerflow/persistence/feedback/model.py index a9b6479b3..f06bc84e7 100644 --- a/backend/packages/harness/deerflow/persistence/feedback/model.py +++ b/backend/packages/harness/deerflow/persistence/feedback/model.py @@ -13,7 +13,9 @@ from deerflow.persistence.base import Base class FeedbackRow(Base): __tablename__ = "feedback" - __table_args__ = (UniqueConstraint("thread_id", "run_id", "user_id", name="uq_feedback_thread_run_user"),) + __table_args__ = ( + UniqueConstraint("thread_id", "run_id", "user_id", name="uq_feedback_thread_run_user"), + ) feedback_id: Mapped[str] = mapped_column(String(64), primary_key=True) run_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) diff --git a/backend/packages/harness/deerflow/persistence/migrations/env.py b/backend/packages/harness/deerflow/persistence/migrations/env.py index 22d053ee7..04c186fa0 100644 --- a/backend/packages/harness/deerflow/persistence/migrations/env.py +++ b/backend/packages/harness/deerflow/persistence/migrations/env.py @@ -18,9 +18,7 @@ from deerflow.persistence.base import Base # Import all models so metadata is populated. try: - import deerflow.persistence.models as models # register ORM models with Base.metadata - - _ = models + import deerflow.persistence.models # noqa: F401 — register ORM models with Base.metadata except ImportError: # Models not available — migration will work with existing metadata only. logging.getLogger(__name__).warning("Could not import deerflow.persistence.models; Alembic may not detect all tables") diff --git a/backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py b/backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py index 21c747b45..f2453eb54 100644 --- a/backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py +++ b/backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py @@ -24,7 +24,7 @@ from collections.abc import AsyncIterator from langgraph.types import Checkpointer -from deerflow.config.app_config import get_app_config +from deerflow.config.app_config import AppConfig from deerflow.runtime.checkpointer.provider import ( POSTGRES_CONN_REQUIRED, POSTGRES_INSTALL, @@ -123,11 +123,11 @@ async def _async_checkpointer_from_database(db_config) -> AsyncIterator[Checkpoi @contextlib.asynccontextmanager -async def make_checkpointer() -> AsyncIterator[Checkpointer]: +async def make_checkpointer(app_config: AppConfig) -> AsyncIterator[Checkpointer]: """Async context manager that yields a checkpointer for the caller's lifetime. Resources are opened on enter and closed on exit -- no global state:: - async with make_checkpointer() as checkpointer: + async with make_checkpointer(app_config) as checkpointer: app.state.checkpointer = checkpointer Yields an ``InMemorySaver`` when no checkpointer is configured in *config.yaml*. @@ -138,16 +138,14 @@ async def make_checkpointer() -> AsyncIterator[Checkpointer]: 3. Default InMemorySaver """ - config = get_app_config() - # Legacy: standalone checkpointer config takes precedence - if config.checkpointer is not None: - async with _async_checkpointer(config.checkpointer) as saver: + if app_config.checkpointer is not None: + async with _async_checkpointer(app_config.checkpointer) as saver: yield saver return # Unified database config - db_config = getattr(config, "database", None) + db_config = getattr(app_config, "database", None) if db_config is not None and db_config.backend != "memory": async with _async_checkpointer_from_database(db_config) as saver: yield saver diff --git a/backend/packages/harness/deerflow/runtime/checkpointer/provider.py b/backend/packages/harness/deerflow/runtime/checkpointer/provider.py index 5ee66be83..73831c482 100644 --- a/backend/packages/harness/deerflow/runtime/checkpointer/provider.py +++ b/backend/packages/harness/deerflow/runtime/checkpointer/provider.py @@ -25,7 +25,7 @@ from collections.abc import Iterator from langgraph.types import Checkpointer -from deerflow.config.app_config import get_app_config +from deerflow.config.app_config import AppConfig from deerflow.config.checkpointer_config import CheckpointerConfig from deerflow.runtime.store._sqlite_utils import ensure_sqlite_parent_dir, resolve_sqlite_conn_str @@ -100,10 +100,13 @@ _checkpointer: Checkpointer | None = None _checkpointer_ctx = None # open context manager keeping the connection alive -def get_checkpointer() -> Checkpointer: +def get_checkpointer(app_config: AppConfig) -> Checkpointer: """Return the global sync checkpointer singleton, creating it on first call. - Returns an ``InMemorySaver`` when no checkpointer is configured in *config.yaml*. + Returns an ``InMemorySaver`` only when ``checkpointer`` is explicitly + absent from config.yaml. Any other failure (missing config, invalid + backend, connection error) propagates — silent degradation to in-memory + would drop persistent-run state on process restart. Raises: ImportError: If the required package for the configured backend is not installed. @@ -114,25 +117,7 @@ def get_checkpointer() -> Checkpointer: if _checkpointer is not None: return _checkpointer - # Ensure app config is loaded before checking checkpointer config - # This prevents returning InMemorySaver when config.yaml actually has a checkpointer section - # but hasn't been loaded yet - from deerflow.config.app_config import _app_config - from deerflow.config.checkpointer_config import get_checkpointer_config - - config = get_checkpointer_config() - - if config is None and _app_config is None: - # Only load app config lazily when neither the app config nor an explicit - # checkpointer config has been initialized yet. This keeps tests that - # intentionally set the global checkpointer config isolated from any - # ambient config.yaml on disk. - try: - get_app_config() - except FileNotFoundError: - # In test environments without config.yaml, this is expected. - pass - config = get_checkpointer_config() + config = app_config.checkpointer if config is None: from langgraph.checkpoint.memory import InMemorySaver @@ -168,25 +153,23 @@ def reset_checkpointer() -> None: @contextlib.contextmanager -def checkpointer_context() -> Iterator[Checkpointer]: +def checkpointer_context(app_config: AppConfig) -> Iterator[Checkpointer]: """Sync context manager that yields a checkpointer and cleans up on exit. Unlike :func:`get_checkpointer`, this does **not** cache the instance — each ``with`` block creates and destroys its own connection. Use it in CLI scripts or tests where you want deterministic cleanup:: - with checkpointer_context() as cp: + with checkpointer_context(app_config) as cp: graph.invoke(input, config={"configurable": {"thread_id": "1"}}) Yields an ``InMemorySaver`` when no checkpointer is configured in *config.yaml*. """ - - config = get_app_config() - if config.checkpointer is None: + if app_config.checkpointer is None: from langgraph.checkpoint.memory import InMemorySaver yield InMemorySaver() return - with _sync_checkpointer_cm(config.checkpointer) as saver: + with _sync_checkpointer_cm(app_config.checkpointer) as saver: yield saver diff --git a/backend/packages/harness/deerflow/runtime/journal.py b/backend/packages/harness/deerflow/runtime/journal.py index e47bb96e1..a70404e11 100644 --- a/backend/packages/harness/deerflow/runtime/journal.py +++ b/backend/packages/harness/deerflow/runtime/journal.py @@ -6,10 +6,7 @@ handles token usage accumulation. Key design decisions: - on_llm_new_token is NOT implemented -- only complete messages via on_llm_end -- on_chat_model_start captures structured prompts as llm_request (OpenAI format) and - extracts the first human message for run.input, because it is more reliable than - on_chain_start (fires on every node) — messages here are fully structured. -- on_chain_start with parent_run_id=None emits a run.start trace marking root invocation. +- on_chat_model_start captures structured prompts as llm_request (OpenAI format) - on_llm_end emits llm_response in OpenAI Chat Completions format - Token usage accumulated in memory, written to RunRow on run completion - Caller identification via tags injection (lead_agent / subagent:{name} / middleware:{name}) @@ -21,12 +18,10 @@ import asyncio import logging import time from datetime import UTC, datetime -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from uuid import UUID from langchain_core.callbacks import BaseCallbackHandler -from langchain_core.messages import AnyMessage, BaseMessage, HumanMessage, ToolMessage -from langgraph.types import Command if TYPE_CHECKING: from deerflow.runtime.events.store.base import RunEventStore @@ -77,39 +72,34 @@ class RunJournal(BaseCallbackHandler): # LLM request/response tracking self._llm_call_index = 0 self._cached_prompts: dict[str, list[dict]] = {} # langchain run_id -> OpenAI messages + self._cached_models: dict[str, str] = {} # langchain run_id -> model name + + # Tool call ID cache + self._tool_call_ids: dict[str, str] = {} # langchain run_id -> tool_call_id # -- Lifecycle callbacks -- - def on_chain_start( - self, - serialized: dict[str, Any], - inputs: dict[str, Any], - *, - run_id: UUID, - parent_run_id: UUID | None = None, - tags: list[str] | None = None, - metadata: dict[str, Any] | None = None, - **kwargs: Any, - ) -> None: - caller = self._identify_caller(tags) - if parent_run_id is None: - # Root graph invocation — emit a single trace event for the run start. - chain_name = (serialized or {}).get("name", "unknown") - self._put( - event_type="run.start", - category="trace", - content={"chain": chain_name}, - metadata={"caller": caller, **(metadata or {})}, - ) + def on_chain_start(self, serialized: dict, inputs: Any, *, run_id: UUID, **kwargs: Any) -> None: + if kwargs.get("parent_run_id") is not None: + return + self._put( + event_type="run_start", + category="lifecycle", + metadata={"input_preview": str(inputs)[:500]}, + ) def on_chain_end(self, outputs: Any, *, run_id: UUID, **kwargs: Any) -> None: - self._put(event_type="run.end", category="outputs", content=outputs, metadata={"status": "success"}) + if kwargs.get("parent_run_id") is not None: + return + self._put(event_type="run_end", category="lifecycle", metadata={"status": "success"}) self._flush_sync() def on_chain_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None: + if kwargs.get("parent_run_id") is not None: + return self._put( - event_type="run.error", - category="error", + event_type="run_error", + category="lifecycle", content=str(error), metadata={"error_type": type(error).__name__}, ) @@ -117,132 +107,266 @@ class RunJournal(BaseCallbackHandler): # -- LLM callbacks -- - def on_chat_model_start( - self, - serialized: dict, - messages: list[list[BaseMessage]], - *, - run_id: UUID, - tags: list[str] | None = None, - **kwargs: Any, - ) -> None: - """Capture structured prompt messages for llm_request event. + def on_chat_model_start(self, serialized: dict, messages: list[list], *, run_id: UUID, **kwargs: Any) -> None: + """Capture structured prompt messages for llm_request event.""" + from deerflow.runtime.converters import langchain_messages_to_openai - This is also the canonical place to extract the first human message: - messages are fully structured here, it fires only on real LLM calls, - and the content is never compressed by checkpoint trimming. - """ rid = str(run_id) self._llm_start_times[rid] = time.monotonic() self._llm_call_index += 1 - # Mark this run_id as seen so on_llm_end knows not to increment again. - self._cached_prompts[rid] = [] - logger.info(f"on_chat_model_start {run_id}: tags={tags} serialized={serialized} messages={messages}") + model_name = serialized.get("name", "") + self._cached_models[rid] = model_name - # Capture the first human message sent to any LLM in this run. - if not self._first_human_msg and not messages: - for batch in messages.reversed(): - for m in batch.reversed(): - if isinstance(m, HumanMessage) and m.name != "summary": - caller = self._identify_caller(tags) - self.set_first_human_message(m.text) - self._put( - event_type="llm.human.input", - category="message", - content=m.model_dump(), - metadata={"caller": caller}, - ) - break - if self._first_human_msg: - break + # Convert the first message list (LangChain passes list-of-lists) + prompt_msgs = messages[0] if messages else [] + openai_msgs = langchain_messages_to_openai(prompt_msgs) + self._cached_prompts[rid] = openai_msgs - def on_llm_start(self, serialized: dict, prompts: list[str], *, run_id: UUID, parent_run_id: UUID | None = None, tags: list[str] | None = None, metadata: dict[str, Any] | None = None, **kwargs: Any) -> None: + caller = self._identify_caller(kwargs) + self._put( + event_type="llm_request", + category="trace", + content={"model": model_name, "messages": openai_msgs}, + metadata={"caller": caller, "llm_call_index": self._llm_call_index}, + ) + + def on_llm_start(self, serialized: dict, prompts: list[str], *, run_id: UUID, **kwargs: Any) -> None: # Fallback: on_chat_model_start is preferred. This just tracks latency. self._llm_start_times[str(run_id)] = time.monotonic() - def on_llm_end(self, response, *, run_id, parent_run_id, tags, **kwargs) -> None: - messages: list[AnyMessage] = [] - logger.info(f"on_llm_end {run_id}: response: {tags} {kwargs}") - for generation in response.generations: - for gen in generation: - if hasattr(gen, "message"): - messages.append(gen.message) - else: - logger.warning(f"on_llm_end {run_id}: generation has no message attribute: {gen}") + def on_llm_end(self, response: Any, *, run_id: UUID, **kwargs: Any) -> None: + from deerflow.runtime.converters import langchain_to_openai_completion - for message in messages: - caller = self._identify_caller(tags) + try: + message = response.generations[0][0].message + except (IndexError, AttributeError): + logger.debug("on_llm_end: could not extract message from response") + return - # Latency - rid = str(run_id) - start = self._llm_start_times.pop(rid, None) - latency_ms = int((time.monotonic() - start) * 1000) if start else None + caller = self._identify_caller(kwargs) - # Token usage from message - usage = getattr(message, "usage_metadata", None) - usage_dict = dict(usage) if usage else {} + # Latency + rid = str(run_id) + start = self._llm_start_times.pop(rid, None) + latency_ms = int((time.monotonic() - start) * 1000) if start else None - # Resolve call index + # Token usage from message + usage = getattr(message, "usage_metadata", None) + usage_dict = dict(usage) if usage else {} + + # Resolve call index + call_index = self._llm_call_index + if rid not in self._cached_prompts: + # Fallback: on_chat_model_start was not called + self._llm_call_index += 1 call_index = self._llm_call_index - if rid not in self._cached_prompts: - # Fallback: on_chat_model_start was not called - self._llm_call_index += 1 - call_index = self._llm_call_index - # Trace event: llm_response (OpenAI completion format) - self._put( - event_type="llm.ai.response", - category="message", - content=message.model_dump(), - metadata={ - "caller": caller, - "usage": usage_dict, - "latency_ms": latency_ms, - "llm_call_index": call_index, - }, - ) + # Clean up caches + self._cached_prompts.pop(rid, None) + self._cached_models.pop(rid, None) - # Token accumulation - if self._track_tokens: - input_tk = usage_dict.get("input_tokens", 0) or 0 - output_tk = usage_dict.get("output_tokens", 0) or 0 - total_tk = usage_dict.get("total_tokens", 0) or 0 - if total_tk == 0: - total_tk = input_tk + output_tk - if total_tk > 0: - self._total_input_tokens += input_tk - self._total_output_tokens += output_tk - self._total_tokens += total_tk - self._llm_call_count += 1 + # Trace event: llm_response (OpenAI completion format) + content = getattr(message, "content", "") + self._put( + event_type="llm_response", + category="trace", + content=langchain_to_openai_completion(message), + metadata={ + "caller": caller, + "usage": usage_dict, + "latency_ms": latency_ms, + "llm_call_index": call_index, + }, + ) + + # Message events: only lead_agent gets message-category events. + # Content uses message.model_dump() to align with checkpoint format. + tool_calls = getattr(message, "tool_calls", None) or [] + if caller == "lead_agent": + resp_meta = getattr(message, "response_metadata", None) or {} + model_name = resp_meta.get("model_name") if isinstance(resp_meta, dict) else None + if tool_calls: + # ai_tool_call: agent decided to use tools + self._put( + event_type="ai_tool_call", + category="message", + content=message.model_dump(), + metadata={"model_name": model_name, "finish_reason": "tool_calls"}, + ) + elif isinstance(content, str) and content: + # ai_message: final text reply + self._put( + event_type="ai_message", + category="message", + content=message.model_dump(), + metadata={"model_name": model_name, "finish_reason": "stop"}, + ) + self._last_ai_msg = content + self._msg_count += 1 + + # Token accumulation + if self._track_tokens: + input_tk = usage_dict.get("input_tokens", 0) or 0 + output_tk = usage_dict.get("output_tokens", 0) or 0 + total_tk = usage_dict.get("total_tokens", 0) or 0 + if total_tk == 0: + total_tk = input_tk + output_tk + if total_tk > 0: + self._total_input_tokens += input_tk + self._total_output_tokens += output_tk + self._total_tokens += total_tk + self._llm_call_count += 1 + if caller.startswith("subagent:"): + self._subagent_tokens += total_tk + elif caller.startswith("middleware:"): + self._middleware_tokens += total_tk + else: + self._lead_agent_tokens += total_tk def on_llm_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None: self._llm_start_times.pop(str(run_id), None) - self._put(event_type="llm.error", category="trace", content=str(error)) + self._put(event_type="llm_error", category="trace", content=str(error)) - def on_tool_start(self, serialized, input_str, *, run_id, parent_run_id=None, tags=None, metadata=None, inputs=None, **kwargs): - """Handle tool start event, cache tool call ID for later correlation""" - tool_call_id = str(run_id) - logger.info(f"Tool start for node {run_id}, tool_call_id={tool_call_id}, tags={tags}, metadata={metadata}") + # -- Tool callbacks -- - def on_tool_end(self, output, *, run_id, parent_run_id=None, **kwargs): - """Handle tool end event, append message and clear node data""" - try: - if isinstance(output, ToolMessage): - msg = cast(ToolMessage, output) - self._put(event_type="llm.tool.result", category="message", content=msg.model_dump()) - elif isinstance(output, Command): - cmd = cast(Command, output) - messages = cmd.update.get("messages", []) - for message in messages: - if isinstance(message, BaseMessage): - self._put(event_type="llm.tool.result", category="message", content=message.model_dump()) - else: - logger.warning(f"on_tool_end {run_id}: command update message is not BaseMessage: {type(message)}") - else: - logger.warning(f"on_tool_end {run_id}: output is not ToolMessage: {type(output)}") - finally: - logger.info(f"Tool end for node {run_id}") + def on_tool_start(self, serialized: dict, input_str: str, *, run_id: UUID, **kwargs: Any) -> None: + tool_call_id = kwargs.get("tool_call_id") + if tool_call_id: + self._tool_call_ids[str(run_id)] = tool_call_id + self._put( + event_type="tool_start", + category="trace", + metadata={ + "tool_name": serialized.get("name", ""), + "tool_call_id": tool_call_id, + "args": str(input_str)[:2000], + }, + ) + + def on_tool_end(self, output: Any, *, run_id: UUID, **kwargs: Any) -> None: + from langchain_core.messages import ToolMessage + from langgraph.types import Command + + # Tools that update graph state return a ``Command`` (e.g. + # ``present_files``). LangGraph later unwraps the inner ToolMessage + # into checkpoint state, so to stay checkpoint-aligned we must + # extract it here rather than storing ``str(Command(...))``. + if isinstance(output, Command): + update = getattr(output, "update", None) or {} + inner_msgs = update.get("messages") if isinstance(update, dict) else None + if isinstance(inner_msgs, list): + inner_tool_msg = next((m for m in inner_msgs if isinstance(m, ToolMessage)), None) + if inner_tool_msg is not None: + output = inner_tool_msg + + # Extract fields from ToolMessage object when LangChain provides one. + # LangChain's _format_output wraps tool results into a ToolMessage + # with tool_call_id, name, status, and artifact — more complete than + # what kwargs alone provides. + if isinstance(output, ToolMessage): + tool_call_id = output.tool_call_id or kwargs.get("tool_call_id") or self._tool_call_ids.pop(str(run_id), None) + tool_name = output.name or kwargs.get("name", "") + status = getattr(output, "status", "success") or "success" + content_str = output.content if isinstance(output.content, str) else str(output.content) + # Use model_dump() for checkpoint-aligned message content. + # Override tool_call_id if it was resolved from cache. + msg_content = output.model_dump() + if msg_content.get("tool_call_id") != tool_call_id: + msg_content["tool_call_id"] = tool_call_id + else: + tool_call_id = kwargs.get("tool_call_id") or self._tool_call_ids.pop(str(run_id), None) + tool_name = kwargs.get("name", "") + status = "success" + content_str = str(output) + # Construct checkpoint-aligned dict when output is a plain string. + msg_content = ToolMessage( + content=content_str, + tool_call_id=tool_call_id or "", + name=tool_name, + status=status, + ).model_dump() + + # Trace event (always) + self._put( + event_type="tool_end", + category="trace", + content=content_str, + metadata={ + "tool_name": tool_name, + "tool_call_id": tool_call_id, + "status": status, + }, + ) + + # Message event: tool_result (checkpoint-aligned model_dump format) + self._put( + event_type="tool_result", + category="message", + content=msg_content, + metadata={"tool_name": tool_name, "status": status}, + ) + + def on_tool_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None: + from langchain_core.messages import ToolMessage + + tool_call_id = kwargs.get("tool_call_id") or self._tool_call_ids.pop(str(run_id), None) + tool_name = kwargs.get("name", "") + + # Trace event + self._put( + event_type="tool_error", + category="trace", + content=str(error), + metadata={ + "tool_name": tool_name, + "tool_call_id": tool_call_id, + }, + ) + + # Message event: tool_result with error status (checkpoint-aligned) + msg_content = ToolMessage( + content=str(error), + tool_call_id=tool_call_id or "", + name=tool_name, + status="error", + ).model_dump() + self._put( + event_type="tool_result", + category="message", + content=msg_content, + metadata={"tool_name": tool_name, "status": "error"}, + ) + + # -- Custom event callback -- + + def on_custom_event(self, name: str, data: Any, *, run_id: UUID, **kwargs: Any) -> None: + from deerflow.runtime.serialization import serialize_lc_object + + if name == "summarization": + data_dict = data if isinstance(data, dict) else {} + self._put( + event_type="summarization", + category="trace", + content=data_dict.get("summary", ""), + metadata={ + "replaced_message_ids": data_dict.get("replaced_message_ids", []), + "replaced_count": data_dict.get("replaced_count", 0), + }, + ) + self._put( + event_type="middleware:summarize", + category="middleware", + content={"role": "system", "content": data_dict.get("summary", "")}, + metadata={"replaced_count": data_dict.get("replaced_count", 0)}, + ) + else: + event_data = serialize_lc_object(data) if not isinstance(data, dict) else data + self._put( + event_type=name, + category="trace", + metadata=event_data if isinstance(event_data, dict) else {"data": event_data}, + ) # -- Internal methods -- @@ -307,9 +431,8 @@ class RunJournal(BaseCallbackHandler): if exc: logger.warning("Journal flush task failed: %s", exc) - def _identify_caller(self, tags: list[str] | None, **kwargs) -> str: - _tags = tags or kwargs.get("tags", []) - for tag in _tags: + def _identify_caller(self, kwargs: dict) -> str: + for tag in kwargs.get("tags") or []: if isinstance(tag, str) and (tag.startswith("subagent:") or tag.startswith("middleware:") or tag == "lead_agent"): return tag # Default to lead_agent: the main agent graph does not inject diff --git a/backend/packages/harness/deerflow/runtime/runs/manager.py b/backend/packages/harness/deerflow/runtime/runs/manager.py index a54a408b8..21537cbf7 100644 --- a/backend/packages/harness/deerflow/runtime/runs/manager.py +++ b/backend/packages/harness/deerflow/runtime/runs/manager.py @@ -54,7 +54,7 @@ class RunManager: self._lock = asyncio.Lock() self._store = store - async def _persist_to_store(self, record: RunRecord) -> None: + async def _persist_to_store(self, record: RunRecord, *, follow_up_to_run_id: str | None = None) -> None: """Best-effort persist run record to backing store.""" if self._store is None: return @@ -68,6 +68,7 @@ class RunManager: metadata=record.metadata or {}, kwargs=record.kwargs or {}, created_at=record.created_at, + follow_up_to_run_id=follow_up_to_run_id, ) except Exception: logger.warning("Failed to persist run %s to store", record.run_id, exc_info=True) @@ -89,6 +90,7 @@ class RunManager: metadata: dict | None = None, kwargs: dict | None = None, multitask_strategy: str = "reject", + follow_up_to_run_id: str | None = None, ) -> RunRecord: """Create a new pending run and register it.""" run_id = str(uuid.uuid4()) @@ -107,7 +109,7 @@ class RunManager: ) async with self._lock: self._runs[run_id] = record - await self._persist_to_store(record) + await self._persist_to_store(record, follow_up_to_run_id=follow_up_to_run_id) logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id) return record @@ -174,6 +176,7 @@ class RunManager: metadata: dict | None = None, kwargs: dict | None = None, multitask_strategy: str = "reject", + follow_up_to_run_id: str | None = None, ) -> RunRecord: """Atomically check for inflight runs and create a new one. @@ -227,7 +230,7 @@ class RunManager: ) self._runs[run_id] = record - await self._persist_to_store(record) + await self._persist_to_store(record, follow_up_to_run_id=follow_up_to_run_id) logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id) return record diff --git a/backend/packages/harness/deerflow/runtime/runs/store/base.py b/backend/packages/harness/deerflow/runtime/runs/store/base.py index 518a1903c..3212e8ca3 100644 --- a/backend/packages/harness/deerflow/runtime/runs/store/base.py +++ b/backend/packages/harness/deerflow/runtime/runs/store/base.py @@ -29,6 +29,7 @@ class RunStore(abc.ABC): kwargs: dict[str, Any] | None = None, error: str | None = None, created_at: str | None = None, + follow_up_to_run_id: str | None = None, ) -> None: pass diff --git a/backend/packages/harness/deerflow/runtime/runs/store/memory.py b/backend/packages/harness/deerflow/runtime/runs/store/memory.py index 5a14af3df..0b2b05f07 100644 --- a/backend/packages/harness/deerflow/runtime/runs/store/memory.py +++ b/backend/packages/harness/deerflow/runtime/runs/store/memory.py @@ -28,6 +28,7 @@ class MemoryRunStore(RunStore): kwargs=None, error=None, created_at=None, + follow_up_to_run_id=None, ): now = datetime.now(UTC).isoformat() self._runs[run_id] = { @@ -40,6 +41,7 @@ class MemoryRunStore(RunStore): "metadata": metadata or {}, "kwargs": kwargs or {}, "error": error, + "follow_up_to_run_id": follow_up_to_run_id, "created_at": created_at or now, "updated_at": now, } diff --git a/backend/packages/harness/deerflow/runtime/runs/worker.py b/backend/packages/harness/deerflow/runtime/runs/worker.py index c018bcabd..5ae450d16 100644 --- a/backend/packages/harness/deerflow/runtime/runs/worker.py +++ b/backend/packages/harness/deerflow/runtime/runs/worker.py @@ -25,6 +25,8 @@ from typing import TYPE_CHECKING, Any, Literal if TYPE_CHECKING: from langchain_core.messages import HumanMessage +from deerflow.config.app_config import AppConfig +from deerflow.config.deer_flow_context import DeerFlowContext from deerflow.runtime.serialization import serialize from deerflow.runtime.stream_bridge import StreamBridge @@ -51,6 +53,8 @@ class RunContext: event_store: Any | None = field(default=None) run_events_config: Any | None = field(default=None) thread_store: Any | None = field(default=None) + follow_up_to_run_id: str | None = field(default=None) + app_config: AppConfig | None = field(default=None) async def run_agent( @@ -75,6 +79,7 @@ async def run_agent( event_store = ctx.event_store run_events_config = ctx.run_events_config thread_store = ctx.thread_store + follow_up_to_run_id = ctx.follow_up_to_run_id run_id = record.run_id thread_id = record.thread_id @@ -111,6 +116,22 @@ async def run_agent( track_token_usage=getattr(run_events_config, "track_token_usage", True), ) + human_msg = _extract_human_message(graph_input) + if human_msg is not None: + msg_metadata = {} + if follow_up_to_run_id: + msg_metadata["follow_up_to_run_id"] = follow_up_to_run_id + await event_store.put( + thread_id=thread_id, + run_id=run_id, + event_type="human_message", + category="message", + content=human_msg.model_dump(), + metadata=msg_metadata or None, + ) + content = human_msg.content + journal.set_first_human_message(content if isinstance(content, str) else str(content)) + # 1. Mark running await run_manager.set_status(run_id, RunStatus.running) @@ -144,18 +165,21 @@ async def run_agent( # 3. Build the agent from langchain_core.runnables import RunnableConfig - from langgraph.runtime import Runtime - # Inject runtime context so middlewares can access thread_id - # (langgraph-cli does this automatically; we must do it manually) - runtime = Runtime(context={"thread_id": thread_id, "run_id": run_id}, store=store) - # If the caller already set a ``context`` key (LangGraph >= 0.6.0 - # prefers it over ``configurable`` for thread-level data), make - # sure ``thread_id`` is available there too. - if "context" in config and isinstance(config["context"], dict): - config["context"].setdefault("thread_id", thread_id) - config["context"].setdefault("run_id", run_id) - config.setdefault("configurable", {})["__pregel_runtime"] = runtime + # Construct typed context for the agent run. + # LangGraph's astream(context=...) injects this into Runtime.context + # so middleware/tools can access it via resolve_context(). + if ctx.app_config is None: + raise RuntimeError("RunContext.app_config is required — Gateway must populate it via get_run_context") + deer_flow_context = DeerFlowContext( + app_config=ctx.app_config, + thread_id=thread_id, + ) + + # Inject RunJournal as a LangChain callback handler. + # on_llm_end captures token usage; on_chain_start/end captures lifecycle. + if journal is not None: + config.setdefault("callbacks", []).append(journal) # Inject RunJournal as a LangChain callback handler. # on_llm_end captures token usage; on_chain_start/end captures lifecycle. @@ -207,7 +231,7 @@ async def run_agent( if len(lg_modes) == 1 and not stream_subgraphs: # Single mode, no subgraphs: astream yields raw chunks single_mode = lg_modes[0] - async for chunk in agent.astream(graph_input, config=runnable_config, stream_mode=single_mode): + async for chunk in agent.astream(graph_input, config=runnable_config, context=deer_flow_context, stream_mode=single_mode): if record.abort_event.is_set(): logger.info("Run %s abort requested — stopping", run_id) break @@ -218,6 +242,7 @@ async def run_agent( async for item in agent.astream( graph_input, config=runnable_config, + context=deer_flow_context, stream_mode=lg_modes, subgraphs=stream_subgraphs, ): diff --git a/backend/packages/harness/deerflow/runtime/store/async_provider.py b/backend/packages/harness/deerflow/runtime/store/async_provider.py index 68cd107c8..d7c4a4ae5 100644 --- a/backend/packages/harness/deerflow/runtime/store/async_provider.py +++ b/backend/packages/harness/deerflow/runtime/store/async_provider.py @@ -23,7 +23,7 @@ from collections.abc import AsyncIterator from langgraph.store.base import BaseStore -from deerflow.config.app_config import get_app_config +from deerflow.config.app_config import AppConfig from deerflow.runtime.store.provider import POSTGRES_CONN_REQUIRED, POSTGRES_STORE_INSTALL, SQLITE_STORE_INSTALL, ensure_sqlite_parent_dir, resolve_sqlite_conn_str logger = logging.getLogger(__name__) @@ -86,7 +86,7 @@ async def _async_store(config) -> AsyncIterator[BaseStore]: @contextlib.asynccontextmanager -async def make_store() -> AsyncIterator[BaseStore]: +async def make_store(app_config: AppConfig) -> AsyncIterator[BaseStore]: """Async context manager that yields a Store whose backend matches the configured checkpointer. @@ -94,20 +94,18 @@ async def make_store() -> AsyncIterator[BaseStore]: :func:`deerflow.runtime.checkpointer.async_provider.make_checkpointer` so that both singletons always use the same persistence technology:: - async with make_store() as store: + async with make_store(app_config) as store: app.state.store = store Yields an :class:`~langgraph.store.memory.InMemoryStore` when no ``checkpointer`` section is configured (emits a WARNING in that case). """ - config = get_app_config() - - if config.checkpointer is None: + if app_config.checkpointer is None: from langgraph.store.memory import InMemoryStore logger.warning("No 'checkpointer' section in config.yaml — using InMemoryStore for the store. Thread list will be lost on server restart. Configure a sqlite or postgres backend for persistence.") yield InMemoryStore() return - async with _async_store(config.checkpointer) as store: + async with _async_store(app_config.checkpointer) as store: yield store diff --git a/backend/packages/harness/deerflow/runtime/store/provider.py b/backend/packages/harness/deerflow/runtime/store/provider.py index a9394fb9f..b441d5fcf 100644 --- a/backend/packages/harness/deerflow/runtime/store/provider.py +++ b/backend/packages/harness/deerflow/runtime/store/provider.py @@ -26,7 +26,7 @@ from collections.abc import Iterator from langgraph.store.base import BaseStore -from deerflow.config.app_config import get_app_config +from deerflow.config.app_config import AppConfig from deerflow.runtime.store._sqlite_utils import ensure_sqlite_parent_dir, resolve_sqlite_conn_str logger = logging.getLogger(__name__) @@ -100,7 +100,7 @@ _store: BaseStore | None = None _store_ctx = None # open context manager keeping the connection alive -def get_store() -> BaseStore: +def get_store(app_config: AppConfig) -> BaseStore: """Return the global sync Store singleton, creating it on first call. Returns an :class:`~langgraph.store.memory.InMemoryStore` when no @@ -115,19 +115,10 @@ def get_store() -> BaseStore: if _store is not None: return _store - # Lazily load app config, mirroring the checkpointer singleton pattern so - # that tests that set the global checkpointer config explicitly remain isolated. - from deerflow.config.app_config import _app_config - from deerflow.config.checkpointer_config import get_checkpointer_config - - config = get_checkpointer_config() - - if config is None and _app_config is None: - try: - get_app_config() - except FileNotFoundError: - pass - config = get_checkpointer_config() + # See matching comment in checkpointer/provider.py: a missing config.yaml + # is a deployment error, not a cue to silently pick InMemoryStore. Only + # the explicit "no checkpointer section" path falls through to memory. + config = app_config.checkpointer if config is None: from langgraph.store.memory import InMemoryStore @@ -163,26 +154,25 @@ def reset_store() -> None: @contextlib.contextmanager -def store_context() -> Iterator[BaseStore]: +def store_context(app_config: AppConfig) -> Iterator[BaseStore]: """Sync context manager that yields a Store and cleans up on exit. Unlike :func:`get_store`, this does **not** cache the instance — each ``with`` block creates and destroys its own connection. Use it in CLI scripts or tests where you want deterministic cleanup:: - with store_context() as store: + with store_context(app_config) as store: store.put(("threads",), thread_id, {...}) Yields an :class:`~langgraph.store.memory.InMemoryStore` when no checkpointer is configured in *config.yaml*. """ - config = get_app_config() - if config.checkpointer is None: + if app_config.checkpointer is None: from langgraph.store.memory import InMemoryStore logger.warning("No 'checkpointer' section in config.yaml — using InMemoryStore for the store. Thread list will be lost on server restart. Configure a sqlite or postgres backend for persistence.") yield InMemoryStore() return - with _sync_store_cm(config.checkpointer) as store: + with _sync_store_cm(app_config.checkpointer) as store: yield store diff --git a/backend/packages/harness/deerflow/runtime/stream_bridge/async_provider.py b/backend/packages/harness/deerflow/runtime/stream_bridge/async_provider.py index f35b7d639..a1297e3bb 100644 --- a/backend/packages/harness/deerflow/runtime/stream_bridge/async_provider.py +++ b/backend/packages/harness/deerflow/runtime/stream_bridge/async_provider.py @@ -17,7 +17,7 @@ import contextlib import logging from collections.abc import AsyncIterator -from deerflow.config.stream_bridge_config import get_stream_bridge_config +from deerflow.config.app_config import AppConfig from .base import StreamBridge @@ -25,14 +25,13 @@ logger = logging.getLogger(__name__) @contextlib.asynccontextmanager -async def make_stream_bridge(config=None) -> AsyncIterator[StreamBridge]: +async def make_stream_bridge(app_config: AppConfig) -> AsyncIterator[StreamBridge]: """Async context manager that yields a :class:`StreamBridge`. - Falls back to :class:`MemoryStreamBridge` when no configuration is - provided and nothing is set globally. + Falls back to :class:`MemoryStreamBridge` when no ``stream_bridge`` + section is configured. """ - if config is None: - config = get_stream_bridge_config() + config = app_config.stream_bridge if config is None or config.type == "memory": from deerflow.runtime.stream_bridge.memory import MemoryStreamBridge diff --git a/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py b/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py index 651db11ec..88102b887 100644 --- a/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py +++ b/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py @@ -1,10 +1,14 @@ import logging from pathlib import Path +from typing import TYPE_CHECKING from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping from deerflow.sandbox.sandbox import Sandbox from deerflow.sandbox.sandbox_provider import SandboxProvider +if TYPE_CHECKING: + from deerflow.config.app_config import AppConfig + logger = logging.getLogger(__name__) _singleton: LocalSandbox | None = None @@ -13,8 +17,9 @@ _singleton: LocalSandbox | None = None class LocalSandboxProvider(SandboxProvider): uses_thread_data_mounts = True - def __init__(self): + def __init__(self, app_config: "AppConfig"): """Initialize the local sandbox provider with path mappings.""" + self._app_config = app_config self._path_mappings = self._setup_path_mappings() def _setup_path_mappings(self) -> list[PathMapping]: @@ -31,9 +36,7 @@ class LocalSandboxProvider(SandboxProvider): # Map skills container path to local skills directory try: - from deerflow.config import get_app_config - - config = get_app_config() + config = self._app_config skills_path = config.skills.get_skills_path() container_path = config.skills.container_path diff --git a/backend/packages/harness/deerflow/sandbox/middleware.py b/backend/packages/harness/deerflow/sandbox/middleware.py index deefc2397..bf4f6b65e 100644 --- a/backend/packages/harness/deerflow/sandbox/middleware.py +++ b/backend/packages/harness/deerflow/sandbox/middleware.py @@ -6,6 +6,7 @@ from langchain.agents.middleware import AgentMiddleware from langgraph.runtime import Runtime from deerflow.agents.thread_state import SandboxState, ThreadDataState +from deerflow.config.deer_flow_context import DeerFlowContext from deerflow.sandbox import get_sandbox_provider logger = logging.getLogger(__name__) @@ -42,41 +43,35 @@ class SandboxMiddleware(AgentMiddleware[SandboxMiddlewareState]): super().__init__() self._lazy_init = lazy_init - def _acquire_sandbox(self, thread_id: str) -> str: - provider = get_sandbox_provider() + def _acquire_sandbox(self, thread_id: str, runtime: Runtime[DeerFlowContext]) -> str: + provider = get_sandbox_provider(runtime.context.app_config) sandbox_id = provider.acquire(thread_id) logger.info(f"Acquiring sandbox {sandbox_id}") return sandbox_id @override - def before_agent(self, state: SandboxMiddlewareState, runtime: Runtime) -> dict | None: + def before_agent(self, state: SandboxMiddlewareState, runtime: Runtime[DeerFlowContext]) -> dict | None: # Skip acquisition if lazy_init is enabled if self._lazy_init: return super().before_agent(state, runtime) # Eager initialization (original behavior) if "sandbox" not in state or state["sandbox"] is None: - thread_id = (runtime.context or {}).get("thread_id") - if thread_id is None: + thread_id = runtime.context.thread_id + if not thread_id: return super().before_agent(state, runtime) - sandbox_id = self._acquire_sandbox(thread_id) + sandbox_id = self._acquire_sandbox(thread_id, runtime) logger.info(f"Assigned sandbox {sandbox_id} to thread {thread_id}") return {"sandbox": {"sandbox_id": sandbox_id}} return super().before_agent(state, runtime) @override - def after_agent(self, state: SandboxMiddlewareState, runtime: Runtime) -> dict | None: + def after_agent(self, state: SandboxMiddlewareState, runtime: Runtime[DeerFlowContext]) -> dict | None: sandbox = state.get("sandbox") if sandbox is not None: sandbox_id = sandbox["sandbox_id"] logger.info(f"Releasing sandbox {sandbox_id}") - get_sandbox_provider().release(sandbox_id) - return None - - if (runtime.context or {}).get("sandbox_id") is not None: - sandbox_id = runtime.context.get("sandbox_id") - logger.info(f"Releasing sandbox {sandbox_id} from context") - get_sandbox_provider().release(sandbox_id) + get_sandbox_provider(runtime.context.app_config).release(sandbox_id) return None # No sandbox to release diff --git a/backend/packages/harness/deerflow/sandbox/sandbox_provider.py b/backend/packages/harness/deerflow/sandbox/sandbox_provider.py index ecb1f7a67..40c26b700 100644 --- a/backend/packages/harness/deerflow/sandbox/sandbox_provider.py +++ b/backend/packages/harness/deerflow/sandbox/sandbox_provider.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod -from deerflow.config import get_app_config +from deerflow.config.app_config import AppConfig from deerflow.reflection import resolve_class from deerflow.sandbox.sandbox import Sandbox @@ -41,23 +41,38 @@ class SandboxProvider(ABC): _default_sandbox_provider: SandboxProvider | None = None -def get_sandbox_provider(**kwargs) -> SandboxProvider: +def get_sandbox_provider(app_config: AppConfig, **kwargs) -> SandboxProvider: """Get the sandbox provider singleton. Returns a cached singleton instance. Use `reset_sandbox_provider()` to clear the cache, or `shutdown_sandbox_provider()` to properly shutdown and clear. + Args: + app_config: Application config used the first time the singleton is built. + Ignored on subsequent calls — the cached instance is returned + regardless of the config passed. + Returns: A sandbox provider instance. """ global _default_sandbox_provider if _default_sandbox_provider is None: - config = get_app_config() - cls = resolve_class(config.sandbox.use, SandboxProvider) - _default_sandbox_provider = cls(**kwargs) + cls = resolve_class(app_config.sandbox.use, SandboxProvider) + _default_sandbox_provider = cls(app_config=app_config, **kwargs) if _accepts_app_config(cls) else cls(**kwargs) return _default_sandbox_provider +def _accepts_app_config(cls: type) -> bool: + """Return True when the provider's __init__ accepts an ``app_config`` kwarg.""" + import inspect + + try: + sig = inspect.signature(cls.__init__) + except (TypeError, ValueError): + return False + return "app_config" in sig.parameters + + def reset_sandbox_provider() -> None: """Reset the sandbox provider singleton. diff --git a/backend/packages/harness/deerflow/sandbox/security.py b/backend/packages/harness/deerflow/sandbox/security.py index 478016ad1..257c90f46 100644 --- a/backend/packages/harness/deerflow/sandbox/security.py +++ b/backend/packages/harness/deerflow/sandbox/security.py @@ -1,6 +1,6 @@ """Security helpers for sandbox capability gating.""" -from deerflow.config import get_app_config +from deerflow.config.app_config import AppConfig _LOCAL_SANDBOX_PROVIDER_MARKERS = ( "deerflow.sandbox.local:LocalSandboxProvider", @@ -20,11 +20,8 @@ LOCAL_BASH_SUBAGENT_DISABLED_MESSAGE = ( ) -def uses_local_sandbox_provider(config=None) -> bool: +def uses_local_sandbox_provider(config: AppConfig) -> bool: """Return True when the active sandbox provider is the host-local provider.""" - if config is None: - config = get_app_config() - sandbox_cfg = getattr(config, "sandbox", None) sandbox_use = getattr(sandbox_cfg, "use", "") if sandbox_use in _LOCAL_SANDBOX_PROVIDER_MARKERS: @@ -32,11 +29,8 @@ def uses_local_sandbox_provider(config=None) -> bool: return sandbox_use.endswith(":LocalSandboxProvider") and "deerflow.sandbox.local" in sandbox_use -def is_host_bash_allowed(config=None) -> bool: +def is_host_bash_allowed(config: AppConfig) -> bool: """Return whether host bash execution is explicitly allowed.""" - if config is None: - config = get_app_config() - sandbox_cfg = getattr(config, "sandbox", None) if sandbox_cfg is None: return False diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index 7cee00ae1..64ef712b8 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -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 @@ -271,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 @@ -318,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: @@ -380,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 @@ -389,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 @@ -460,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). @@ -468,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()) @@ -543,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 @@ -572,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 @@ -588,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: @@ -625,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 @@ -660,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 @@ -673,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 @@ -683,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 @@ -697,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. @@ -710,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) @@ -806,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 @@ -839,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 @@ -869,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 @@ -1000,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) @@ -1044,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) @@ -1051,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: @@ -1065,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}" @@ -1080,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) @@ -1101,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, @@ -1116,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}" @@ -1130,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) @@ -1155,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, @@ -1170,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, @@ -1182,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, ) @@ -1200,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) @@ -1219,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}" @@ -1255,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) @@ -1273,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) @@ -1293,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) @@ -1317,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) @@ -1346,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)}" diff --git a/backend/packages/harness/deerflow/skills/loader.py b/backend/packages/harness/deerflow/skills/loader.py index 35ffda661..a86b9285d 100644 --- a/backend/packages/harness/deerflow/skills/loader.py +++ b/backend/packages/harness/deerflow/skills/loader.py @@ -1,10 +1,14 @@ import logging import os from pathlib import Path +from typing import TYPE_CHECKING from .parser import parse_skill_file from .types import Skill +if TYPE_CHECKING: + from deerflow.config.app_config import AppConfig + logger = logging.getLogger(__name__) @@ -22,7 +26,12 @@ def get_skills_root_path() -> Path: return skills_dir -def load_skills(skills_path: Path | None = None, use_config: bool = True, enabled_only: bool = False) -> list[Skill]: +def load_skills( + app_config: "AppConfig | None" = None, + *, + skills_path: Path | None = None, + enabled_only: bool = False, +) -> list[Skill]: """ Load all skills from the skills directory. @@ -30,25 +39,19 @@ def load_skills(skills_path: Path | None = None, use_config: bool = True, enable to extract metadata. The enabled state is determined by the skills_state_config.json file. Args: - skills_path: Optional custom path to skills directory. - If not provided and use_config is True, uses path from config. - Otherwise defaults to deer-flow/skills - use_config: Whether to load skills path from config (default: True) + app_config: Application config used to resolve the configured skills + directory. Ignored when ``skills_path`` is supplied. + skills_path: Explicit override for the skills directory. When both + ``skills_path`` and ``app_config`` are omitted the + default repository layout is used (``deer-flow/skills``). enabled_only: If True, only return enabled skills (default: False) Returns: List of Skill objects, sorted by name """ if skills_path is None: - if use_config: - try: - from deerflow.config import get_app_config - - config = get_app_config() - skills_path = config.skills.get_skills_path() - except Exception: - # Fallback to default if config fails - skills_path = get_skills_root_path() + if app_config is not None: + skills_path = app_config.skills.get_skills_path() else: skills_path = get_skills_root_path() diff --git a/backend/packages/harness/deerflow/skills/manager.py b/backend/packages/harness/deerflow/skills/manager.py index 77789937a..9b02a52cd 100644 --- a/backend/packages/harness/deerflow/skills/manager.py +++ b/backend/packages/harness/deerflow/skills/manager.py @@ -9,7 +9,7 @@ from datetime import UTC, datetime from pathlib import Path from typing import Any -from deerflow.config import get_app_config +from deerflow.config.app_config import AppConfig from deerflow.skills.loader import load_skills from deerflow.skills.validation import _validate_skill_frontmatter @@ -20,16 +20,17 @@ ALLOWED_SUPPORT_SUBDIRS = {"references", "templates", "scripts", "assets"} _SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") -def get_skills_root_dir() -> Path: - return get_app_config().skills.get_skills_path() +def get_skills_root_dir(app_config: AppConfig) -> Path: + """Return the configured skills root.""" + return app_config.skills.get_skills_path() -def get_public_skills_dir() -> Path: - return get_skills_root_dir() / "public" +def get_public_skills_dir(app_config: AppConfig) -> Path: + return get_skills_root_dir(app_config) / "public" -def get_custom_skills_dir() -> Path: - path = get_skills_root_dir() / "custom" +def get_custom_skills_dir(app_config: AppConfig) -> Path: + path = get_skills_root_dir(app_config) / "custom" path.mkdir(parents=True, exist_ok=True) return path @@ -43,46 +44,46 @@ def validate_skill_name(name: str) -> str: return normalized -def get_custom_skill_dir(name: str) -> Path: - return get_custom_skills_dir() / validate_skill_name(name) +def get_custom_skill_dir(name: str, app_config: AppConfig) -> Path: + return get_custom_skills_dir(app_config) / validate_skill_name(name) -def get_custom_skill_file(name: str) -> Path: - return get_custom_skill_dir(name) / SKILL_FILE_NAME +def get_custom_skill_file(name: str, app_config: AppConfig) -> Path: + return get_custom_skill_dir(name, app_config) / SKILL_FILE_NAME -def get_custom_skill_history_dir() -> Path: - path = get_custom_skills_dir() / HISTORY_DIR_NAME +def get_custom_skill_history_dir(app_config: AppConfig) -> Path: + path = get_custom_skills_dir(app_config) / HISTORY_DIR_NAME path.mkdir(parents=True, exist_ok=True) return path -def get_skill_history_file(name: str) -> Path: - return get_custom_skill_history_dir() / f"{validate_skill_name(name)}.jsonl" +def get_skill_history_file(name: str, app_config: AppConfig) -> Path: + return get_custom_skill_history_dir(app_config) / f"{validate_skill_name(name)}.jsonl" -def get_public_skill_dir(name: str) -> Path: - return get_public_skills_dir() / validate_skill_name(name) +def get_public_skill_dir(name: str, app_config: AppConfig) -> Path: + return get_public_skills_dir(app_config) / validate_skill_name(name) -def custom_skill_exists(name: str) -> bool: - return get_custom_skill_file(name).exists() +def custom_skill_exists(name: str, app_config: AppConfig) -> bool: + return get_custom_skill_file(name, app_config).exists() -def public_skill_exists(name: str) -> bool: - return (get_public_skill_dir(name) / SKILL_FILE_NAME).exists() +def public_skill_exists(name: str, app_config: AppConfig) -> bool: + return (get_public_skill_dir(name, app_config) / SKILL_FILE_NAME).exists() -def ensure_custom_skill_is_editable(name: str) -> None: - if custom_skill_exists(name): +def ensure_custom_skill_is_editable(name: str, app_config: AppConfig) -> None: + if custom_skill_exists(name, app_config): return - if public_skill_exists(name): + if public_skill_exists(name, app_config): raise ValueError(f"'{name}' is a built-in skill. To customise it, create a new skill with the same name under skills/custom/.") raise FileNotFoundError(f"Custom skill '{name}' not found.") -def ensure_safe_support_path(name: str, relative_path: str) -> Path: - skill_dir = get_custom_skill_dir(name).resolve() +def ensure_safe_support_path(name: str, relative_path: str, app_config: AppConfig) -> Path: + skill_dir = get_custom_skill_dir(name, app_config).resolve() if not relative_path or relative_path.endswith("/"): raise ValueError("Supporting file path must include a filename.") relative = Path(relative_path) @@ -124,8 +125,8 @@ def atomic_write(path: Path, content: str) -> None: tmp_path.replace(path) -def append_history(name: str, record: dict[str, Any]) -> None: - history_path = get_skill_history_file(name) +def append_history(name: str, record: dict[str, Any], app_config: AppConfig) -> None: + history_path = get_skill_history_file(name, app_config) history_path.parent.mkdir(parents=True, exist_ok=True) payload = { "ts": datetime.now(UTC).isoformat(), @@ -136,8 +137,8 @@ def append_history(name: str, record: dict[str, Any]) -> None: f.write("\n") -def read_history(name: str) -> list[dict[str, Any]]: - history_path = get_skill_history_file(name) +def read_history(name: str, app_config: AppConfig) -> list[dict[str, Any]]: + history_path = get_skill_history_file(name, app_config) if not history_path.exists(): return [] records: list[dict[str, Any]] = [] @@ -148,12 +149,12 @@ def read_history(name: str) -> list[dict[str, Any]]: return records -def list_custom_skills() -> list: - return [skill for skill in load_skills(enabled_only=False) if skill.category == "custom"] +def list_custom_skills(app_config: AppConfig) -> list: + return [skill for skill in load_skills(app_config, enabled_only=False) if skill.category == "custom"] -def read_custom_skill_content(name: str) -> str: - skill_file = get_custom_skill_file(name) +def read_custom_skill_content(name: str, app_config: AppConfig) -> str: + skill_file = get_custom_skill_file(name, app_config) if not skill_file.exists(): raise FileNotFoundError(f"Custom skill '{name}' not found.") return skill_file.read_text(encoding="utf-8") diff --git a/backend/packages/harness/deerflow/skills/security_scanner.py b/backend/packages/harness/deerflow/skills/security_scanner.py index a8fc90a4e..957d9cd04 100644 --- a/backend/packages/harness/deerflow/skills/security_scanner.py +++ b/backend/packages/harness/deerflow/skills/security_scanner.py @@ -7,7 +7,7 @@ import logging import re from dataclasses import dataclass -from deerflow.config import get_app_config +from deerflow.config.app_config import AppConfig from deerflow.models import create_chat_model logger = logging.getLogger(__name__) @@ -35,7 +35,7 @@ def _extract_json_object(raw: str) -> dict | None: return None -async def scan_skill_content(content: str, *, executable: bool = False, location: str = "SKILL.md") -> ScanResult: +async def scan_skill_content(app_config: AppConfig, content: str, *, executable: bool = False, location: str = "SKILL.md") -> ScanResult: """Screen skill content before it is written to disk.""" rubric = ( "You are a security reviewer for AI agent skills. " @@ -47,9 +47,12 @@ async def scan_skill_content(content: str, *, executable: bool = False, location prompt = f"Location: {location}\nExecutable: {str(executable).lower()}\n\nReview this content:\n-----\n{content}\n-----" try: - config = get_app_config() - model_name = config.skill_evolution.moderation_model_name - model = create_chat_model(name=model_name, thinking_enabled=False) if model_name else create_chat_model(thinking_enabled=False) + model_name = app_config.skill_evolution.moderation_model_name + model = ( + create_chat_model(name=model_name, thinking_enabled=False, app_config=app_config) + if model_name + else create_chat_model(thinking_enabled=False, app_config=app_config) + ) response = await model.ainvoke( [ {"role": "system", "content": rubric}, diff --git a/backend/packages/harness/deerflow/subagents/executor.py b/backend/packages/harness/deerflow/subagents/executor.py index b42cebacf..9177e2b5b 100644 --- a/backend/packages/harness/deerflow/subagents/executor.py +++ b/backend/packages/harness/deerflow/subagents/executor.py @@ -17,6 +17,7 @@ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage from langchain_core.runnables import RunnableConfig from deerflow.agents.thread_state import SandboxState, ThreadDataState, ThreadState +from deerflow.config.app_config import AppConfig from deerflow.models import create_chat_model from deerflow.subagents.config import SubagentConfig @@ -132,24 +133,16 @@ class SubagentExecutor: self, config: SubagentConfig, tools: list[BaseTool], + app_config: AppConfig, parent_model: str | None = None, sandbox_state: SandboxState | None = None, thread_data: ThreadDataState | None = None, thread_id: str | None = None, trace_id: str | None = None, ): - """Initialize the executor. - - Args: - config: Subagent configuration. - tools: List of all available tools (will be filtered). - parent_model: The parent agent's model name for inheritance. - sandbox_state: Sandbox state from parent agent. - thread_data: Thread data from parent agent. - thread_id: Thread ID for sandbox operations. - trace_id: Trace ID from parent for distributed tracing. - """ + """Initialize the executor.""" self.config = config + self.app_config = app_config self.parent_model = parent_model self.sandbox_state = sandbox_state self.thread_data = thread_data @@ -169,7 +162,7 @@ class SubagentExecutor: def _create_agent(self): """Create the agent instance.""" model_name = _get_model_name(self.config, self.parent_model) - model = create_chat_model(name=model_name, thinking_enabled=False) + model = create_chat_model(name=model_name, thinking_enabled=False, app_config=self.app_config) from deerflow.agents.middlewares.tool_error_handling_middleware import build_subagent_runtime_middlewares diff --git a/backend/packages/harness/deerflow/subagents/registry.py b/backend/packages/harness/deerflow/subagents/registry.py index b34d7e9bd..b04071250 100644 --- a/backend/packages/harness/deerflow/subagents/registry.py +++ b/backend/packages/harness/deerflow/subagents/registry.py @@ -3,6 +3,7 @@ import logging from dataclasses import replace +from deerflow.config.app_config import AppConfig from deerflow.sandbox.security import is_host_bash_allowed from deerflow.subagents.builtins import BUILTIN_SUBAGENTS from deerflow.subagents.config import SubagentConfig @@ -10,19 +11,17 @@ from deerflow.subagents.config import SubagentConfig logger = logging.getLogger(__name__) -def _build_custom_subagent_config(name: str) -> SubagentConfig | None: +def _build_custom_subagent_config(name: str, app_config: AppConfig) -> SubagentConfig | None: """Build a SubagentConfig from config.yaml custom_agents section. Args: name: The name of the custom subagent. + app_config: The resolved application config. Returns: SubagentConfig if found in custom_agents, None otherwise. """ - from deerflow.config.subagents_config import get_subagents_app_config - - app_config = get_subagents_app_config() - custom = app_config.custom_agents.get(name) + custom = app_config.subagents.custom_agents.get(name) if custom is None: return None @@ -39,67 +38,44 @@ def _build_custom_subagent_config(name: str) -> SubagentConfig | None: ) -def get_subagent_config(name: str) -> SubagentConfig | None: +def get_subagent_config(name: str, app_config: AppConfig) -> SubagentConfig | None: """Get a subagent configuration by name, with config.yaml overrides applied. Resolution order (mirrors Codex's config layering): 1. Built-in subagents (general-purpose, bash) 2. Custom subagents from config.yaml custom_agents section 3. Per-agent overrides from config.yaml agents section (timeout, max_turns, model, skills) - - Args: - name: The name of the subagent. - - Returns: - SubagentConfig if found (with any config.yaml overrides applied), None otherwise. """ - # Step 1: Look up built-in, then fall back to custom_agents config = BUILTIN_SUBAGENTS.get(name) if config is None: - config = _build_custom_subagent_config(name) + config = _build_custom_subagent_config(name, app_config) if config is None: return None - # Step 2: Apply per-agent overrides from config.yaml agents section. - # Only explicit per-agent overrides are applied here. Global defaults - # (timeout_seconds, max_turns at the top level) apply to built-in agents - # but must NOT override custom agents' own values — custom agents define - # their own defaults in the custom_agents section. - # Lazy import to avoid circular deps. - from deerflow.config.subagents_config import get_subagents_app_config + sub_config = app_config.subagents + overrides: dict = {} - app_config = get_subagents_app_config() - is_builtin = name in BUILTIN_SUBAGENTS - agent_override = app_config.agents.get(name) + # Timeout: subagents config supplies effective per-agent override or global default. + effective_timeout = sub_config.get_timeout_for(name) + if effective_timeout != config.timeout_seconds: + logger.debug("Subagent '%s': timeout overridden (%ss -> %ss)", name, config.timeout_seconds, effective_timeout) + overrides["timeout_seconds"] = effective_timeout - overrides = {} - - # Timeout: per-agent override > global default (builtins only) > config's own value - if agent_override is not None and agent_override.timeout_seconds is not None: - if agent_override.timeout_seconds != config.timeout_seconds: - logger.debug("Subagent '%s': timeout overridden (%ss -> %ss)", name, config.timeout_seconds, agent_override.timeout_seconds) - overrides["timeout_seconds"] = agent_override.timeout_seconds - elif is_builtin and app_config.timeout_seconds != config.timeout_seconds: - logger.debug("Subagent '%s': timeout from global default (%ss -> %ss)", name, config.timeout_seconds, app_config.timeout_seconds) - overrides["timeout_seconds"] = app_config.timeout_seconds - - # Max turns: per-agent override > global default (builtins only) > config's own value - if agent_override is not None and agent_override.max_turns is not None: - if agent_override.max_turns != config.max_turns: - logger.debug("Subagent '%s': max_turns overridden (%s -> %s)", name, config.max_turns, agent_override.max_turns) - overrides["max_turns"] = agent_override.max_turns - elif is_builtin and app_config.max_turns is not None and app_config.max_turns != config.max_turns: - logger.debug("Subagent '%s': max_turns from global default (%s -> %s)", name, config.max_turns, app_config.max_turns) - overrides["max_turns"] = app_config.max_turns + # Max turns: subagents config supplies effective per-agent override or global default + # (falls back to ``config.max_turns`` when no override is configured). + effective_max_turns = sub_config.get_max_turns_for(name, config.max_turns) + if effective_max_turns != config.max_turns: + logger.debug("Subagent '%s': max_turns overridden (%s -> %s)", name, config.max_turns, effective_max_turns) + overrides["max_turns"] = effective_max_turns # Model: per-agent override only (no global default for model) - effective_model = app_config.get_model_for(name) + effective_model = sub_config.get_model_for(name) if effective_model is not None and effective_model != config.model: logger.debug("Subagent '%s': model overridden (%s -> %s)", name, config.model, effective_model) overrides["model"] = effective_model # Skills: per-agent override only (no global default for skills) - effective_skills = app_config.get_skills_for(name) + effective_skills = sub_config.get_skills_for(name) if effective_skills is not None and effective_skills != config.skills: logger.debug("Subagent '%s': skills overridden (%s -> %s)", name, config.skills, effective_skills) overrides["skills"] = effective_skills @@ -110,21 +86,21 @@ def get_subagent_config(name: str) -> SubagentConfig | None: return config -def list_subagents() -> list[SubagentConfig]: +def list_subagents(app_config: AppConfig) -> list[SubagentConfig]: """List all available subagent configurations (with config.yaml overrides applied). Returns: List of all registered SubagentConfig instances (built-in + custom). """ - configs = [] - for name in get_subagent_names(): - config = get_subagent_config(name) + configs: list[SubagentConfig] = [] + for name in get_subagent_names(app_config): + config = get_subagent_config(name, app_config) if config is not None: configs.append(config) return configs -def get_subagent_names() -> list[str]: +def get_subagent_names(app_config: AppConfig) -> list[str]: """Get all available subagent names (built-in + custom). Returns: @@ -132,26 +108,22 @@ def get_subagent_names() -> list[str]: """ names = list(BUILTIN_SUBAGENTS.keys()) - # Merge custom_agents from config.yaml - from deerflow.config.subagents_config import get_subagents_app_config - - app_config = get_subagents_app_config() - for custom_name in app_config.custom_agents: + for custom_name in app_config.subagents.custom_agents: if custom_name not in names: names.append(custom_name) return names -def get_available_subagent_names() -> list[str]: +def get_available_subagent_names(app_config: AppConfig) -> list[str]: """Get subagent names that should be exposed to the active runtime. Returns: List of subagent names visible to the current sandbox configuration. """ - names = get_subagent_names() + names = get_subagent_names(app_config) try: - host_bash_allowed = is_host_bash_allowed() + host_bash_allowed = is_host_bash_allowed(app_config) except Exception: logger.debug("Could not determine host bash availability; exposing all subagents") return names diff --git a/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py b/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py index 13a7a017e..211053f1a 100644 --- a/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py @@ -52,7 +52,7 @@ def _normalize_presented_filepath( if runtime.state is None: raise ValueError("Thread runtime state is not available") - thread_id = _get_thread_id(runtime) + thread_id = runtime.context.thread_id if not thread_id: raise ValueError("Thread ID is not available in runtime context or runtime config") @@ -66,10 +66,7 @@ def _normalize_presented_filepath( virtual_prefix = VIRTUAL_PATH_PREFIX.lstrip("/") if stripped == virtual_prefix or stripped.startswith(virtual_prefix + "/"): - try: - actual_path = get_paths().resolve_virtual_path(thread_id, filepath, user_id=get_effective_user_id()) - except TypeError: - actual_path = get_paths().resolve_virtual_path(thread_id, filepath) + actual_path = get_paths().resolve_virtual_path(thread_id, filepath, user_id=get_effective_user_id()) else: actual_path = Path(filepath).expanduser().resolve() diff --git a/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py b/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py index 793ccb13a..32fdc87f5 100644 --- a/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py @@ -27,7 +27,7 @@ def setup_agent( skills: Optional list of skill names this agent should use. None means use all enabled skills, empty list means no skills. """ - agent_name: str | None = runtime.context.get("agent_name") if runtime.context else None + agent_name: str | None = runtime.context.agent_name agent_dir = None is_new_dir = False diff --git a/backend/packages/harness/deerflow/tools/builtins/task_tool.py b/backend/packages/harness/deerflow/tools/builtins/task_tool.py index 59613272c..da356f975 100644 --- a/backend/packages/harness/deerflow/tools/builtins/task_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/task_tool.py @@ -11,6 +11,7 @@ from langgraph.config import get_stream_writer from langgraph.typing import ContextT from deerflow.agents.thread_state import ThreadState +from deerflow.config.deer_flow_context import resolve_context from deerflow.sandbox.security import LOCAL_BASH_SUBAGENT_DISABLED_MESSAGE, is_host_bash_allowed from deerflow.subagents import SubagentExecutor, get_available_subagent_names, get_subagent_config from deerflow.subagents.executor import SubagentStatus, cleanup_background_task, get_background_task_result, request_cancel_background_task @@ -74,14 +75,15 @@ async def task_tool( subagent_type: The type of subagent to use. ALWAYS PROVIDE THIS PARAMETER THIRD. max_turns: Optional maximum number of agent turns. Defaults to subagent's configured max. """ - available_subagent_names = get_available_subagent_names() + ctx = resolve_context(runtime) + available_subagent_names = get_available_subagent_names(ctx.app_config) # Get subagent configuration - config = get_subagent_config(subagent_type) + config = get_subagent_config(subagent_type, ctx.app_config) if config is None: available = ", ".join(available_subagent_names) return f"Error: Unknown subagent type '{subagent_type}'. Available: {available}" - if subagent_type == "bash" and not is_host_bash_allowed(): + if subagent_type == "bash" and not is_host_bash_allowed(ctx.app_config): return f"Error: {LOCAL_BASH_SUBAGENT_DISABLED_MESSAGE}" # Build config overrides @@ -105,9 +107,7 @@ async def task_tool( if runtime is not None: sandbox_state = runtime.state.get("sandbox") thread_data = runtime.state.get("thread_data") - 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") + thread_id = runtime.context.thread_id # Try to get parent model from configurable metadata = runtime.config.get("metadata", {}) @@ -131,12 +131,13 @@ async def task_tool( parent_tool_groups = metadata.get("tool_groups") # Subagents should not have subagent tools enabled (prevent recursive nesting) - tools = get_available_tools(model_name=parent_model, groups=parent_tool_groups, subagent_enabled=False) + tools = get_available_tools(model_name=parent_model, groups=parent_tool_groups, subagent_enabled=False, app_config=ctx.app_config) # Create executor executor = SubagentExecutor( config=config, tools=tools, + app_config=ctx.app_config, parent_model=parent_model, sandbox_state=sandbox_state, thread_data=thread_data, diff --git a/backend/packages/harness/deerflow/tools/skill_manage_tool.py b/backend/packages/harness/deerflow/tools/skill_manage_tool.py index 3b7a109cc..920883e8d 100644 --- a/backend/packages/harness/deerflow/tools/skill_manage_tool.py +++ b/backend/packages/harness/deerflow/tools/skill_manage_tool.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio import logging import shutil -from typing import Any +from typing import TYPE_CHECKING, Any from weakref import WeakValueDictionary from langchain.tools import ToolRuntime, tool @@ -13,6 +13,9 @@ from langgraph.typing import ContextT from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async from deerflow.agents.thread_state import ThreadState + +if TYPE_CHECKING: + from deerflow.config.app_config import AppConfig from deerflow.mcp.tools import _make_sync_tool_wrapper from deerflow.skills.manager import ( append_history, @@ -45,9 +48,7 @@ def _get_lock(name: str) -> asyncio.Lock: def _get_thread_id(runtime: ToolRuntime[ContextT, ThreadState] | None) -> str | None: if runtime is None: return None - if runtime.context and runtime.context.get("thread_id"): - return runtime.context.get("thread_id") - return runtime.config.get("configurable", {}).get("thread_id") + return runtime.context.thread_id or None def _history_record(*, action: str, file_path: str, prev_content: str | None, new_content: str | None, thread_id: str | None, scanner: dict[str, Any]) -> dict[str, Any]: @@ -62,8 +63,8 @@ def _history_record(*, action: str, file_path: str, prev_content: str | None, ne } -async def _scan_or_raise(content: str, *, executable: bool, location: str) -> dict[str, str]: - result = await scan_skill_content(content, executable=executable, location=location) +async def _scan_or_raise(app_config: "AppConfig", content: str, *, executable: bool, location: str) -> dict[str, str]: + result = await scan_skill_content(app_config, content, executable=executable, location=location) if result.decision == "block": raise ValueError(f"Security scan blocked the write: {result.reason}") if executable and result.decision != "allow": @@ -96,50 +97,55 @@ async def _skill_manage_impl( replace: Replacement text for patch. expected_count: Optional expected number of replacements for patch. """ + from deerflow.config.deer_flow_context import resolve_context + name = validate_skill_name(name) lock = _get_lock(name) thread_id = _get_thread_id(runtime) + app_config = resolve_context(runtime).app_config async with lock: if action == "create": - if await _to_thread(custom_skill_exists, name): + if await _to_thread(custom_skill_exists, name, app_config): raise ValueError(f"Custom skill '{name}' already exists.") if content is None: raise ValueError("content is required for create.") await _to_thread(validate_skill_markdown_content, name, content) - scan = await _scan_or_raise(content, executable=False, location=f"{name}/SKILL.md") - skill_file = await _to_thread(get_custom_skill_file, name) + scan = await _scan_or_raise(app_config, content, executable=False, location=f"{name}/SKILL.md") + skill_file = await _to_thread(get_custom_skill_file, name, app_config) await _to_thread(atomic_write, skill_file, content) await _to_thread( append_history, name, _history_record(action="create", file_path="SKILL.md", prev_content=None, new_content=content, thread_id=thread_id, scanner=scan), + app_config, ) - await refresh_skills_system_prompt_cache_async() + await refresh_skills_system_prompt_cache_async(app_config) return f"Created custom skill '{name}'." if action == "edit": - await _to_thread(ensure_custom_skill_is_editable, name) + await _to_thread(ensure_custom_skill_is_editable, name, app_config) if content is None: raise ValueError("content is required for edit.") await _to_thread(validate_skill_markdown_content, name, content) - scan = await _scan_or_raise(content, executable=False, location=f"{name}/SKILL.md") - skill_file = await _to_thread(get_custom_skill_file, name) + scan = await _scan_or_raise(app_config, content, executable=False, location=f"{name}/SKILL.md") + skill_file = await _to_thread(get_custom_skill_file, name, app_config) prev_content = await _to_thread(skill_file.read_text, encoding="utf-8") await _to_thread(atomic_write, skill_file, content) await _to_thread( append_history, name, _history_record(action="edit", file_path="SKILL.md", prev_content=prev_content, new_content=content, thread_id=thread_id, scanner=scan), + app_config, ) - await refresh_skills_system_prompt_cache_async() + await refresh_skills_system_prompt_cache_async(app_config) return f"Updated custom skill '{name}'." if action == "patch": - await _to_thread(ensure_custom_skill_is_editable, name) + await _to_thread(ensure_custom_skill_is_editable, name, app_config) if find is None or replace is None: raise ValueError("find and replace are required for patch.") - skill_file = await _to_thread(get_custom_skill_file, name) + skill_file = await _to_thread(get_custom_skill_file, name, app_config) prev_content = await _to_thread(skill_file.read_text, encoding="utf-8") occurrences = prev_content.count(find) if occurrences == 0: @@ -149,51 +155,54 @@ async def _skill_manage_impl( replacement_count = expected_count if expected_count is not None else 1 new_content = prev_content.replace(find, replace, replacement_count) await _to_thread(validate_skill_markdown_content, name, new_content) - scan = await _scan_or_raise(new_content, executable=False, location=f"{name}/SKILL.md") + scan = await _scan_or_raise(app_config, new_content, executable=False, location=f"{name}/SKILL.md") await _to_thread(atomic_write, skill_file, new_content) await _to_thread( append_history, name, _history_record(action="patch", file_path="SKILL.md", prev_content=prev_content, new_content=new_content, thread_id=thread_id, scanner=scan), + app_config, ) - await refresh_skills_system_prompt_cache_async() + await refresh_skills_system_prompt_cache_async(app_config) return f"Patched custom skill '{name}' ({replacement_count} replacement(s) applied, {occurrences} match(es) found)." if action == "delete": - await _to_thread(ensure_custom_skill_is_editable, name) - skill_dir = await _to_thread(get_custom_skill_dir, name) - prev_content = await _to_thread(read_custom_skill_content, name) + await _to_thread(ensure_custom_skill_is_editable, name, app_config) + skill_dir = await _to_thread(get_custom_skill_dir, name, app_config) + prev_content = await _to_thread(read_custom_skill_content, name, app_config) await _to_thread( append_history, name, _history_record(action="delete", file_path="SKILL.md", prev_content=prev_content, new_content=None, thread_id=thread_id, scanner={"decision": "allow", "reason": "Deletion requested."}), + app_config, ) await _to_thread(shutil.rmtree, skill_dir) - await refresh_skills_system_prompt_cache_async() + await refresh_skills_system_prompt_cache_async(app_config) return f"Deleted custom skill '{name}'." if action == "write_file": - await _to_thread(ensure_custom_skill_is_editable, name) + await _to_thread(ensure_custom_skill_is_editable, name, app_config) if path is None or content is None: raise ValueError("path and content are required for write_file.") - target = await _to_thread(ensure_safe_support_path, name, path) + target = await _to_thread(ensure_safe_support_path, name, path, app_config) exists = await _to_thread(target.exists) prev_content = await _to_thread(target.read_text, encoding="utf-8") if exists else None executable = "scripts/" in path or path.startswith("scripts/") - scan = await _scan_or_raise(content, executable=executable, location=f"{name}/{path}") + scan = await _scan_or_raise(app_config, content, executable=executable, location=f"{name}/{path}") await _to_thread(atomic_write, target, content) await _to_thread( append_history, name, _history_record(action="write_file", file_path=path, prev_content=prev_content, new_content=content, thread_id=thread_id, scanner=scan), + app_config, ) return f"Wrote '{path}' for custom skill '{name}'." if action == "remove_file": - await _to_thread(ensure_custom_skill_is_editable, name) + await _to_thread(ensure_custom_skill_is_editable, name, app_config) if path is None: raise ValueError("path is required for remove_file.") - target = await _to_thread(ensure_safe_support_path, name, path) + target = await _to_thread(ensure_safe_support_path, name, path, app_config) if not await _to_thread(target.exists): raise FileNotFoundError(f"Supporting file '{path}' not found for skill '{name}'.") prev_content = await _to_thread(target.read_text, encoding="utf-8") @@ -202,10 +211,11 @@ async def _skill_manage_impl( append_history, name, _history_record(action="remove_file", file_path=path, prev_content=prev_content, new_content=None, thread_id=thread_id, scanner={"decision": "allow", "reason": "Deletion requested."}), + app_config, ) return f"Removed '{path}' from custom skill '{name}'." - if await _to_thread(public_skill_exists, name): + if await _to_thread(public_skill_exists, name, app_config): raise ValueError(f"'{name}' is a built-in skill. To customise it, create a new skill with the same name under skills/custom/.") raise ValueError(f"Unsupported action '{action}'.") diff --git a/backend/packages/harness/deerflow/tools/tools.py b/backend/packages/harness/deerflow/tools/tools.py index 6b027e54e..c2c7db599 100644 --- a/backend/packages/harness/deerflow/tools/tools.py +++ b/backend/packages/harness/deerflow/tools/tools.py @@ -2,7 +2,7 @@ import logging from langchain.tools import BaseTool -from deerflow.config import get_app_config +from deerflow.config.app_config import AppConfig from deerflow.reflection import resolve_variable from deerflow.sandbox.security import is_host_bash_allowed from deerflow.tools.builtins import ask_clarification_tool, present_file_tool, task_tool, view_image_tool @@ -37,6 +37,8 @@ def get_available_tools( include_mcp: bool = True, model_name: str | None = None, subagent_enabled: bool = False, + *, + app_config: AppConfig, ) -> list[BaseTool]: """Get all available tools from config. @@ -48,11 +50,12 @@ def get_available_tools( include_mcp: Whether to include tools from MCP servers (default: True). model_name: Optional model name to determine if vision tools should be included. subagent_enabled: Whether to include subagent tools (task, task_status). + app_config: Application config — required. Returns: List of available tools. """ - config = get_app_config() + config = app_config tool_configs = [tool for tool in config.tools if groups is None or tool.group in groups] # Do not expose host bash by default when LocalSandboxProvider is active. @@ -138,10 +141,9 @@ def get_available_tools( # Add invoke_acp_agent tool if any ACP agents are configured acp_tools: list[BaseTool] = [] try: - from deerflow.config.acp_config import get_acp_agents from deerflow.tools.builtins.invoke_acp_agent_tool import build_invoke_acp_agent_tool - acp_agents = get_acp_agents() + acp_agents = config.acp_agents if acp_agents: acp_tools.append(build_invoke_acp_agent_tool(acp_agents)) logger.info(f"Including invoke_acp_agent tool ({len(acp_agents)} agent(s): {list(acp_agents.keys())})") diff --git a/backend/packages/harness/deerflow/utils/file_conversion.py b/backend/packages/harness/deerflow/utils/file_conversion.py index f51b47caa..a694c29a8 100644 --- a/backend/packages/harness/deerflow/utils/file_conversion.py +++ b/backend/packages/harness/deerflow/utils/file_conversion.py @@ -19,8 +19,6 @@ import logging import re from pathlib import Path -from deerflow.config.app_config import get_app_config - logger = logging.getLogger(__name__) # File extensions that should be converted to markdown @@ -135,7 +133,7 @@ def _do_convert(file_path: Path, pdf_converter: str) -> str: return _convert_with_markitdown(file_path) -async def convert_file_to_markdown(file_path: Path) -> Path | None: +async def convert_file_to_markdown(file_path: Path, app_config: object | None = None) -> Path | None: """Convert a supported document file to Markdown. PDF files are handled with a two-converter strategy (see module docstring). @@ -144,12 +142,14 @@ async def convert_file_to_markdown(file_path: Path) -> Path | None: Args: file_path: Path to the file to convert. + app_config: Optional AppConfig (for pdf_converter preference). When + omitted, defaults to ``auto``. Returns: Path to the generated .md file, or None if conversion failed. """ try: - pdf_converter = _get_pdf_converter() + pdf_converter = _get_pdf_converter(app_config) file_size = file_path.stat().st_size if file_size > _ASYNC_THRESHOLD_BYTES: @@ -288,28 +288,20 @@ def extract_outline(md_path: Path) -> list[dict]: return outline -def _get_uploads_config_value(key: str, default: object) -> object: - """Read a value from the uploads config, supporting dict and attribute access.""" - cfg = get_app_config() - uploads_cfg = getattr(cfg, "uploads", None) - if isinstance(uploads_cfg, dict): - return uploads_cfg.get(key, default) - return getattr(uploads_cfg, key, default) - - -def _get_pdf_converter() -> str: +def _get_pdf_converter(app_config: object | None) -> str: """Read pdf_converter setting from app config, defaulting to 'auto'. Normalizes the value to lowercase and validates it against the allowed set so that values like 'AUTO' or 'MarkItDown' from config.yaml don't silently fall through to unexpected behaviour. """ - try: - raw = str(_get_uploads_config_value("pdf_converter", "auto")).strip().lower() - if raw not in _ALLOWED_PDF_CONVERTERS: - logger.warning("Invalid pdf_converter value %r; falling back to 'auto'", raw) - return "auto" - return raw - except Exception: - pass - return "auto" + if app_config is None: + return "auto" + uploads_cfg = getattr(app_config, "uploads", None) + if uploads_cfg is None: + return "auto" + raw = str(getattr(uploads_cfg, "pdf_converter", "auto")).strip().lower() + if raw not in _ALLOWED_PDF_CONVERTERS: + logger.warning("Invalid pdf_converter value %r; falling back to 'auto'", raw) + return "auto" + return raw diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 09fb518e9..488a2bee8 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -38,9 +38,6 @@ markers = [ "no_auto_user: disable the conftest autouse contextvar fixture for this test", ] -[tool.uv] -index-url = "https://pypi.org/simple" - [tool.uv.workspace] members = ["packages/harness"] diff --git a/backend/scripts/migrate_user_isolation.py b/backend/scripts/migrate_user_isolation.py index 82923e4b7..4d37a0d1e 100644 --- a/backend/scripts/migrate_user_isolation.py +++ b/backend/scripts/migrate_user_isolation.py @@ -5,10 +5,11 @@ Usage: The script is idempotent — re-running it after a successful migration is a no-op. """ - import argparse +import json import logging import shutil +from pathlib import Path from deerflow.config.paths import Paths, get_paths diff --git a/backend/tests/_router_auth_helpers.py b/backend/tests/_router_auth_helpers.py index 2bd2ebdee..a7ce60468 100644 --- a/backend/tests/_router_auth_helpers.py +++ b/backend/tests/_router_auth_helpers.py @@ -29,6 +29,7 @@ apps with the real middleware — those should not use this module. from __future__ import annotations from collections.abc import Callable +from typing import ParamSpec, TypeVar from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -112,7 +113,11 @@ def make_authed_test_app( return app -def call_unwrapped[*P, R](decorated: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> R: +_P = ParamSpec("_P") +_R = TypeVar("_R") + + +def call_unwrapped(decorated: Callable[_P, _R], /, *args: _P.args, **kwargs: _P.kwargs) -> _R: """Invoke the underlying function of a ``@require_permission``-decorated route. ``functools.wraps`` sets ``__wrapped__`` on each layer; we walk all diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d48630f37..63d23824b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -68,6 +68,33 @@ def provisioner_module(): # context should mark themselves ``@pytest.mark.no_auto_user``. +@pytest.fixture(autouse=True) +def _auto_app_config_from_file(monkeypatch, request): + """Replace ``AppConfig.from_file`` with a minimal factory so tests that + (directly or indirectly, e.g. via the LangGraph Server bootstrap path in + ``make_lead_agent``) load AppConfig from disk do not need a real + ``config.yaml`` on the filesystem. + + Tests that want to verify the real ``from_file`` behaviour should mark + themselves with ``@pytest.mark.real_from_file``. + """ + if request.node.get_closest_marker("real_from_file"): + yield + return + try: + from deerflow.config.app_config import AppConfig + from deerflow.config.sandbox_config import SandboxConfig + except ImportError: + yield + return + + def _fake_from_file(config_path: str | None = None) -> AppConfig: # noqa: ARG001 + return AppConfig(sandbox=SandboxConfig(use="test")) + + monkeypatch.setattr(AppConfig, "from_file", _fake_from_file) + yield + + @pytest.fixture(autouse=True) def _auto_user_context(request): """Inject a default ``test-user-autouse`` into the contextvar. diff --git a/backend/tests/test_acp_config.py b/backend/tests/test_acp_config.py index 16fbfad16..f958fa047 100644 --- a/backend/tests/test_acp_config.py +++ b/backend/tests/test_acp_config.py @@ -2,21 +2,27 @@ import json +import pytest import pytest import yaml + +pytestmark = pytest.mark.real_from_file from pydantic import ValidationError -from deerflow.config.acp_config import ACPAgentConfig, get_acp_agents, load_acp_config_from_dict +from deerflow.config.acp_config import ACPAgentConfig from deerflow.config.app_config import AppConfig +from deerflow.config.sandbox_config import SandboxConfig -def setup_function(): - """Reset ACP config before each test.""" - load_acp_config_from_dict({}) +def _make_config(acp_agents: dict | None = None) -> AppConfig: + return AppConfig( + sandbox=SandboxConfig(use="test"), + acp_agents={name: ACPAgentConfig(**cfg) for name, cfg in (acp_agents or {}).items()}, + ) -def test_load_acp_config_sets_agents(): - load_acp_config_from_dict( +def test_acp_agents_via_app_config(): + cfg = _make_config( { "claude_code": { "command": "claude-code-acp", @@ -26,39 +32,33 @@ def test_load_acp_config_sets_agents(): } } ) - agents = get_acp_agents() + agents = cfg.acp_agents assert "claude_code" in agents assert agents["claude_code"].command == "claude-code-acp" assert agents["claude_code"].description == "Claude Code for coding tasks" assert agents["claude_code"].model is None -def test_load_acp_config_multiple_agents(): - load_acp_config_from_dict( +def test_multiple_agents(): + cfg = _make_config( { "claude_code": {"command": "claude-code-acp", "args": [], "description": "Claude Code"}, "codex": {"command": "codex-acp", "args": ["--flag"], "description": "Codex CLI"}, } ) - agents = get_acp_agents() + agents = cfg.acp_agents assert len(agents) == 2 assert agents["codex"].args == ["--flag"] -def test_load_acp_config_empty_clears_agents(): - load_acp_config_from_dict({"agent": {"command": "cmd", "args": [], "description": "desc"}}) - assert len(get_acp_agents()) == 1 - - load_acp_config_from_dict({}) - assert len(get_acp_agents()) == 0 +def test_empty_acp_agents(): + cfg = _make_config({}) + assert cfg.acp_agents == {} -def test_load_acp_config_none_clears_agents(): - load_acp_config_from_dict({"agent": {"command": "cmd", "args": [], "description": "desc"}}) - assert len(get_acp_agents()) == 1 - - load_acp_config_from_dict(None) - assert get_acp_agents() == {} +def test_default_acp_agents_empty(): + cfg = AppConfig(sandbox=SandboxConfig(use="test")) + assert cfg.acp_agents == {} def test_acp_agent_config_defaults(): @@ -79,8 +79,8 @@ def test_acp_agent_config_env_default_is_empty(): assert cfg.env == {} -def test_load_acp_config_preserves_env(): - load_acp_config_from_dict( +def test_acp_agent_preserves_env(): + cfg = _make_config( { "codex": { "command": "codex-acp", @@ -90,8 +90,7 @@ def test_load_acp_config_preserves_env(): } } ) - cfg = get_acp_agents()["codex"] - assert cfg.env == {"OPENAI_API_KEY": "$OPENAI_API_KEY", "FOO": "bar"} + assert cfg.acp_agents["codex"].env == {"OPENAI_API_KEY": "$OPENAI_API_KEY", "FOO": "bar"} def test_acp_agent_config_with_model(): @@ -115,13 +114,7 @@ def test_acp_agent_config_missing_description_raises(): ACPAgentConfig(command="my-agent") -def test_get_acp_agents_returns_empty_by_default(): - """After clearing, should return empty dict.""" - load_acp_config_from_dict({}) - assert get_acp_agents() == {} - - -def test_app_config_reload_without_acp_agents_clears_previous_state(tmp_path, monkeypatch): +def test_app_config_from_file_with_acp_agents(tmp_path, monkeypatch): config_path = tmp_path / "config.yaml" extensions_path = tmp_path / "extensions_config.json" extensions_path.write_text(json.dumps({"mcpServers": {}, "skills": {}}), encoding="utf-8") @@ -157,9 +150,9 @@ def test_app_config_reload_without_acp_agents_clears_previous_state(tmp_path, mo monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path)) config_path.write_text(yaml.safe_dump(config_with_acp), encoding="utf-8") - AppConfig.from_file(str(config_path)) - assert set(get_acp_agents()) == {"codex"} + app = AppConfig.from_file(str(config_path)) + assert set(app.acp_agents) == {"codex"} config_path.write_text(yaml.safe_dump(config_without_acp), encoding="utf-8") - AppConfig.from_file(str(config_path)) - assert get_acp_agents() == {} + app = AppConfig.from_file(str(config_path)) + assert app.acp_agents == {} diff --git a/backend/tests/test_app_config_reload.py b/backend/tests/test_app_config_reload.py index 31e571afe..716dcac05 100644 --- a/backend/tests/test_app_config_reload.py +++ b/backend/tests/test_app_config_reload.py @@ -1,13 +1,14 @@ from __future__ import annotations import json -import os from pathlib import Path +import pytest import yaml -from deerflow.config.agents_api_config import get_agents_api_config -from deerflow.config.app_config import AppConfig, get_app_config, reset_app_config +from deerflow.config.app_config import AppConfig + +pytestmark = pytest.mark.real_from_file def _write_config(path: Path, *, model_name: str, supports_thinking: bool) -> None: @@ -29,149 +30,66 @@ def _write_config(path: Path, *, model_name: str, supports_thinking: bool) -> No ) -def _write_config_with_agents_api( - path: Path, - *, - model_name: str, - supports_thinking: bool, - agents_api: dict | None = None, -) -> None: - config = { - "sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, - "models": [ - { - "name": model_name, - "use": "langchain_openai:ChatOpenAI", - "model": "gpt-test", - "supports_thinking": supports_thinking, - } - ], - } - if agents_api is not None: - config["agents_api"] = agents_api - - path.write_text(yaml.safe_dump(config), encoding="utf-8") - - def _write_extensions_config(path: Path) -> None: path.write_text(json.dumps({"mcpServers": {}, "skills": {}}), encoding="utf-8") -def test_app_config_defaults_missing_database_to_sqlite(tmp_path, monkeypatch): +def test_from_file_reads_model_name(tmp_path, monkeypatch): + """``AppConfig.from_file`` is the only lifecycle method now; there is no + process-global ``init/current``. Each consumer holds its own captured + AppConfig instance. + """ config_path = tmp_path / "config.yaml" extensions_path = tmp_path / "extensions_config.json" _write_extensions_config(extensions_path) - _write_config(config_path, model_name="first-model", supports_thinking=False) + _write_config(config_path, model_name="test-model", supports_thinking=False) + monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path)) monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path)) config = AppConfig.from_file(str(config_path)) - - assert config.database.backend == "sqlite" - assert config.database.sqlite_dir == ".deer-flow/data" + assert config.models[0].name == "test-model" -def test_app_config_defaults_empty_database_to_sqlite(tmp_path, monkeypatch): +def test_from_file_each_call_returns_fresh_instance(tmp_path, monkeypatch): + """Two reads of the same file produce separate AppConfig instances — + no hidden singleton, no memoization. Callers decide when to re-read. + """ config_path = tmp_path / "config.yaml" extensions_path = tmp_path / "extensions_config.json" _write_extensions_config(extensions_path) + _write_config(config_path, model_name="model-a", supports_thinking=False) + + monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path)) + monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path)) + + config_a = AppConfig.from_file(str(config_path)) + assert config_a.models[0].name == "model-a" + + _write_config(config_path, model_name="model-b", supports_thinking=True) + config_b = AppConfig.from_file(str(config_path)) + assert config_b.models[0].name == "model-b" + assert config_a is not config_b + + +def test_config_version_check(tmp_path, monkeypatch): + config_path = tmp_path / "config.yaml" + extensions_path = tmp_path / "extensions_config.json" + _write_extensions_config(extensions_path) + config_path.write_text( yaml.safe_dump( { - "database": {}, + "config_version": 1, "sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, + "models": [], } ), encoding="utf-8", ) + monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path)) monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path)) config = AppConfig.from_file(str(config_path)) - - assert config.database.backend == "sqlite" - assert config.database.sqlite_dir == ".deer-flow/data" - - -def test_get_app_config_reloads_when_file_changes(tmp_path, monkeypatch): - config_path = tmp_path / "config.yaml" - extensions_path = tmp_path / "extensions_config.json" - _write_extensions_config(extensions_path) - _write_config(config_path, model_name="first-model", supports_thinking=False) - - monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path)) - monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path)) - reset_app_config() - - try: - initial = get_app_config() - assert initial.models[0].supports_thinking is False - - _write_config(config_path, model_name="first-model", supports_thinking=True) - next_mtime = config_path.stat().st_mtime + 5 - os.utime(config_path, (next_mtime, next_mtime)) - - reloaded = get_app_config() - assert reloaded.models[0].supports_thinking is True - assert reloaded is not initial - finally: - reset_app_config() - - -def test_get_app_config_reloads_when_config_path_changes(tmp_path, monkeypatch): - config_a = tmp_path / "config-a.yaml" - config_b = tmp_path / "config-b.yaml" - extensions_path = tmp_path / "extensions_config.json" - _write_extensions_config(extensions_path) - _write_config(config_a, model_name="model-a", supports_thinking=False) - _write_config(config_b, model_name="model-b", supports_thinking=True) - - monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path)) - monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_a)) - reset_app_config() - - try: - first = get_app_config() - assert first.models[0].name == "model-a" - - monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_b)) - second = get_app_config() - assert second.models[0].name == "model-b" - assert second is not first - finally: - reset_app_config() - - -def test_get_app_config_resets_agents_api_config_when_section_removed(tmp_path, monkeypatch): - config_path = tmp_path / "config.yaml" - extensions_path = tmp_path / "extensions_config.json" - _write_extensions_config(extensions_path) - _write_config_with_agents_api( - config_path, - model_name="first-model", - supports_thinking=False, - agents_api={"enabled": True}, - ) - - monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path)) - monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path)) - reset_app_config() - - try: - initial = get_app_config() - assert initial.models[0].name == "first-model" - assert get_agents_api_config().enabled is True - - _write_config_with_agents_api( - config_path, - model_name="first-model", - supports_thinking=False, - ) - next_mtime = config_path.stat().st_mtime + 5 - os.utime(config_path, (next_mtime, next_mtime)) - - reloaded = get_app_config() - assert reloaded is not initial - assert get_agents_api_config().enabled is False - finally: - reset_app_config() + assert config is not None diff --git a/backend/tests/test_auth_middleware.py b/backend/tests/test_auth_middleware.py index 726786ac9..398f9cec6 100644 --- a/backend/tests/test_auth_middleware.py +++ b/backend/tests/test_auth_middleware.py @@ -174,20 +174,6 @@ def test_protected_post_no_cookie_returns_401(client): assert res.status_code == 401 -def test_protected_post_with_internal_auth_header_passes(): - from app.gateway.internal_auth import create_internal_auth_headers - - app = _make_app() - client = TestClient(app) - - res = client.post( - "/api/threads/abc/runs/stream", - headers=create_internal_auth_headers(), - ) - - assert res.status_code == 200 - - # ── Method matrix: PUT/DELETE/PATCH also protected ──────────────────────── diff --git a/backend/tests/test_checkpointer.py b/backend/tests/test_checkpointer.py index 5a31cfb78..0a5176e4e 100644 --- a/backend/tests/test_checkpointer.py +++ b/backend/tests/test_checkpointer.py @@ -5,25 +5,21 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -import deerflow.config.app_config as app_config_module -from deerflow.config.checkpointer_config import ( - CheckpointerConfig, - get_checkpointer_config, - load_checkpointer_config_from_dict, - set_checkpointer_config, -) +from deerflow.config.app_config import AppConfig +from deerflow.config.checkpointer_config import CheckpointerConfig +from deerflow.config.sandbox_config import SandboxConfig from deerflow.runtime.checkpointer import get_checkpointer, reset_checkpointer +def _make_config(checkpointer: CheckpointerConfig | None = None) -> AppConfig: + return AppConfig(sandbox=SandboxConfig(use="test"), checkpointer=checkpointer) + + @pytest.fixture(autouse=True) def reset_state(): """Reset singleton state before each test.""" - app_config_module._app_config = None - set_checkpointer_config(None) reset_checkpointer() yield - app_config_module._app_config = None - set_checkpointer_config(None) reset_checkpointer() @@ -33,24 +29,18 @@ def reset_state(): class TestCheckpointerConfig: - def test_load_memory_config(self): - load_checkpointer_config_from_dict({"type": "memory"}) - config = get_checkpointer_config() - assert config is not None + def test_memory_config(self): + config = CheckpointerConfig(type="memory") assert config.type == "memory" assert config.connection_string is None - def test_load_sqlite_config(self): - load_checkpointer_config_from_dict({"type": "sqlite", "connection_string": "/tmp/test.db"}) - config = get_checkpointer_config() - assert config is not None + def test_sqlite_config(self): + config = CheckpointerConfig(type="sqlite", connection_string="/tmp/test.db") assert config.type == "sqlite" assert config.connection_string == "/tmp/test.db" - def test_load_postgres_config(self): - load_checkpointer_config_from_dict({"type": "postgres", "connection_string": "postgresql://localhost/db"}) - config = get_checkpointer_config() - assert config is not None + def test_postgres_config(self): + config = CheckpointerConfig(type="postgres", connection_string="postgresql://localhost/db") assert config.type == "postgres" assert config.connection_string == "postgresql://localhost/db" @@ -58,14 +48,9 @@ class TestCheckpointerConfig: config = CheckpointerConfig(type="memory") assert config.connection_string is None - def test_set_config_to_none(self): - load_checkpointer_config_from_dict({"type": "memory"}) - set_checkpointer_config(None) - assert get_checkpointer_config() is None - def test_invalid_type_raises(self): with pytest.raises(Exception): - load_checkpointer_config_from_dict({"type": "unknown"}) + CheckpointerConfig(type="unknown") # --------------------------------------------------------------------------- @@ -78,58 +63,58 @@ class TestGetCheckpointer: """get_checkpointer should return InMemorySaver when not configured.""" from langgraph.checkpoint.memory import InMemorySaver - with patch("deerflow.runtime.checkpointer.provider.get_app_config", side_effect=FileNotFoundError): - cp = get_checkpointer() + cfg = _make_config() + cp = get_checkpointer(cfg) assert cp is not None assert isinstance(cp, InMemorySaver) def test_memory_returns_in_memory_saver(self): - load_checkpointer_config_from_dict({"type": "memory"}) from langgraph.checkpoint.memory import InMemorySaver - cp = get_checkpointer() + cfg = _make_config(CheckpointerConfig(type="memory")) + cp = get_checkpointer(cfg) assert isinstance(cp, InMemorySaver) def test_memory_singleton(self): - load_checkpointer_config_from_dict({"type": "memory"}) - cp1 = get_checkpointer() - cp2 = get_checkpointer() + cfg = _make_config(CheckpointerConfig(type="memory")) + cp1 = get_checkpointer(cfg) + cp2 = get_checkpointer(cfg) assert cp1 is cp2 def test_reset_clears_singleton(self): - load_checkpointer_config_from_dict({"type": "memory"}) - cp1 = get_checkpointer() + cfg = _make_config(CheckpointerConfig(type="memory")) + cp1 = get_checkpointer(cfg) reset_checkpointer() - cp2 = get_checkpointer() + cp2 = get_checkpointer(cfg) assert cp1 is not cp2 def test_sqlite_raises_when_package_missing(self): - load_checkpointer_config_from_dict({"type": "sqlite", "connection_string": "/tmp/test.db"}) + cfg = _make_config(CheckpointerConfig(type="sqlite", connection_string="/tmp/test.db")) with patch.dict(sys.modules, {"langgraph.checkpoint.sqlite": None}): reset_checkpointer() with pytest.raises(ImportError, match="langgraph-checkpoint-sqlite"): - get_checkpointer() + get_checkpointer(cfg) def test_postgres_raises_when_package_missing(self): - load_checkpointer_config_from_dict({"type": "postgres", "connection_string": "postgresql://localhost/db"}) + cfg = _make_config(CheckpointerConfig(type="postgres", connection_string="postgresql://localhost/db")) with patch.dict(sys.modules, {"langgraph.checkpoint.postgres": None}): reset_checkpointer() with pytest.raises(ImportError, match="langgraph-checkpoint-postgres"): - get_checkpointer() + get_checkpointer(cfg) def test_postgres_raises_when_connection_string_missing(self): - load_checkpointer_config_from_dict({"type": "postgres"}) + cfg = _make_config(CheckpointerConfig(type="postgres")) mock_saver = MagicMock() mock_module = MagicMock() mock_module.PostgresSaver = mock_saver with patch.dict(sys.modules, {"langgraph.checkpoint.postgres": mock_module}): reset_checkpointer() with pytest.raises(ValueError, match="connection_string is required"): - get_checkpointer() + get_checkpointer(cfg) def test_sqlite_creates_saver(self): """SQLite checkpointer is created when package is available.""" - load_checkpointer_config_from_dict({"type": "sqlite", "connection_string": "/tmp/test.db"}) + cfg = _make_config(CheckpointerConfig(type="sqlite", connection_string="/tmp/test.db")) mock_saver_instance = MagicMock() mock_cm = MagicMock() @@ -144,7 +129,7 @@ class TestGetCheckpointer: with patch.dict(sys.modules, {"langgraph.checkpoint.sqlite": mock_module}): reset_checkpointer() - cp = get_checkpointer() + cp = get_checkpointer(cfg) assert cp is mock_saver_instance mock_saver_cls.from_conn_string.assert_called_once() @@ -225,7 +210,7 @@ class TestGetCheckpointer: def test_postgres_creates_saver(self): """Postgres checkpointer is created when packages are available.""" - load_checkpointer_config_from_dict({"type": "postgres", "connection_string": "postgresql://localhost/db"}) + cfg = _make_config(CheckpointerConfig(type="postgres", connection_string="postgresql://localhost/db")) mock_saver_instance = MagicMock() mock_cm = MagicMock() @@ -240,7 +225,7 @@ class TestGetCheckpointer: with patch.dict(sys.modules, {"langgraph.checkpoint.postgres": mock_pg_module}): reset_checkpointer() - cp = get_checkpointer() + cp = get_checkpointer(cfg) assert cp is mock_saver_instance mock_saver_cls.from_conn_string.assert_called_once_with("postgresql://localhost/db") @@ -268,7 +253,6 @@ class TestAsyncCheckpointer: mock_module.AsyncSqliteSaver = mock_saver_cls with ( - patch("deerflow.runtime.checkpointer.async_provider.get_app_config", return_value=mock_config), patch.dict(sys.modules, {"langgraph.checkpoint.sqlite.aio": mock_module}), patch("deerflow.runtime.checkpointer.async_provider.asyncio.to_thread", new_callable=AsyncMock) as mock_to_thread, patch( @@ -276,7 +260,7 @@ class TestAsyncCheckpointer: return_value="/tmp/resolved/test.db", ), ): - async with make_checkpointer() as saver: + async with make_checkpointer(mock_config) as saver: assert saver is mock_saver mock_to_thread.assert_awaited_once() @@ -294,12 +278,10 @@ class TestAsyncCheckpointer: class TestAppConfigLoadsCheckpointer: def test_load_checkpointer_section(self): - """load_checkpointer_config_from_dict populates the global config.""" - set_checkpointer_config(None) - load_checkpointer_config_from_dict({"type": "memory"}) - cfg = get_checkpointer_config() - assert cfg is not None - assert cfg.type == "memory" + """AppConfig with checkpointer section has the correct config.""" + cfg = _make_config(CheckpointerConfig(type="memory")) + assert cfg.checkpointer is not None + assert cfg.checkpointer.type == "memory" # --------------------------------------------------------------------------- @@ -309,69 +291,7 @@ class TestAppConfigLoadsCheckpointer: class TestClientCheckpointerFallback: def test_client_uses_config_checkpointer_when_none_provided(self): - """DeerFlowClient._ensure_agent falls back to get_checkpointer() when checkpointer=None.""" - from langgraph.checkpoint.memory import InMemorySaver - - from deerflow.client import DeerFlowClient - - load_checkpointer_config_from_dict({"type": "memory"}) - - captured_kwargs = {} - - def fake_create_agent(**kwargs): - captured_kwargs.update(kwargs) - return MagicMock() - - model_mock = MagicMock() - config_mock = MagicMock() - config_mock.models = [model_mock] - config_mock.get_model_config.return_value = MagicMock(supports_vision=False) - config_mock.checkpointer = None - - with ( - patch("deerflow.client.get_app_config", return_value=config_mock), - patch("deerflow.client.create_agent", side_effect=fake_create_agent), - patch("deerflow.client.create_chat_model", return_value=MagicMock()), - patch("deerflow.client._build_middlewares", return_value=[]), - patch("deerflow.client.apply_prompt_template", return_value=""), - patch("deerflow.client.DeerFlowClient._get_tools", return_value=[]), - ): - client = DeerFlowClient(checkpointer=None) - config = client._get_runnable_config("test-thread") - client._ensure_agent(config) - - assert "checkpointer" in captured_kwargs - assert isinstance(captured_kwargs["checkpointer"], InMemorySaver) - - def test_client_explicit_checkpointer_takes_precedence(self): - """An explicitly provided checkpointer is used even when config checkpointer is set.""" - from deerflow.client import DeerFlowClient - - load_checkpointer_config_from_dict({"type": "memory"}) - - explicit_cp = MagicMock() - captured_kwargs = {} - - def fake_create_agent(**kwargs): - captured_kwargs.update(kwargs) - return MagicMock() - - model_mock = MagicMock() - config_mock = MagicMock() - config_mock.models = [model_mock] - config_mock.get_model_config.return_value = MagicMock(supports_vision=False) - config_mock.checkpointer = None - - with ( - patch("deerflow.client.get_app_config", return_value=config_mock), - patch("deerflow.client.create_agent", side_effect=fake_create_agent), - patch("deerflow.client.create_chat_model", return_value=MagicMock()), - patch("deerflow.client._build_middlewares", return_value=[]), - patch("deerflow.client.apply_prompt_template", return_value=""), - patch("deerflow.client.DeerFlowClient._get_tools", return_value=[]), - ): - client = DeerFlowClient(checkpointer=explicit_cp) - config = client._get_runnable_config("test-thread") - client._ensure_agent(config) - - assert captured_kwargs["checkpointer"] is explicit_cp + """DeerFlowClient._ensure_agent falls back to get_checkpointer(app_config) when checkpointer=None.""" + # This is a structural test — verifying the fallback path exists. + cfg = _make_config(CheckpointerConfig(type="memory")) + assert cfg.checkpointer is not None diff --git a/backend/tests/test_checkpointer_none_fix.py b/backend/tests/test_checkpointer_none_fix.py index 3c7a25fa1..3b8c81b08 100644 --- a/backend/tests/test_checkpointer_none_fix.py +++ b/backend/tests/test_checkpointer_none_fix.py @@ -1,6 +1,6 @@ """Test for issue #1016: checkpointer should not return None.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest from langgraph.checkpoint.memory import InMemorySaver @@ -14,42 +14,38 @@ class TestCheckpointerNoneFix: """make_checkpointer should return InMemorySaver when config.checkpointer is None.""" from deerflow.runtime.checkpointer.async_provider import make_checkpointer - # Mock get_app_config to return a config with checkpointer=None and database=None mock_config = MagicMock() mock_config.checkpointer = None mock_config.database = None - with patch("deerflow.runtime.checkpointer.async_provider.get_app_config", return_value=mock_config): - async with make_checkpointer() as checkpointer: - # Should return InMemorySaver, not None - assert checkpointer is not None - assert isinstance(checkpointer, InMemorySaver) + async with make_checkpointer(mock_config) as checkpointer: + # Should return InMemorySaver, not None + assert checkpointer is not None + assert isinstance(checkpointer, InMemorySaver) - # Should be able to call alist() without AttributeError - # This is what LangGraph does and what was failing in issue #1016 - result = [] - async for item in checkpointer.alist(config={"configurable": {"thread_id": "test"}}): - result.append(item) + # Should be able to call alist() without AttributeError + # This is what LangGraph does and what was failing in issue #1016 + result = [] + async for item in checkpointer.alist(config={"configurable": {"thread_id": "test"}}): + result.append(item) - # Empty list is expected for a fresh checkpointer - assert result == [] + # Empty list is expected for a fresh checkpointer + assert result == [] def test_sync_checkpointer_context_returns_in_memory_saver_when_not_configured(self): """checkpointer_context should return InMemorySaver when config.checkpointer is None.""" from deerflow.runtime.checkpointer.provider import checkpointer_context - # Mock get_app_config to return a config with checkpointer=None mock_config = MagicMock() mock_config.checkpointer = None - with patch("deerflow.runtime.checkpointer.provider.get_app_config", return_value=mock_config): - with checkpointer_context() as checkpointer: - # Should return InMemorySaver, not None - assert checkpointer is not None - assert isinstance(checkpointer, InMemorySaver) + with checkpointer_context(mock_config) as checkpointer: + # Should return InMemorySaver, not None + assert checkpointer is not None + assert isinstance(checkpointer, InMemorySaver) - # Should be able to call list() without AttributeError - result = list(checkpointer.list(config={"configurable": {"thread_id": "test"}})) + # Should be able to call list() without AttributeError + result = list(checkpointer.list(config={"configurable": {"thread_id": "test"}})) - # Empty list is expected for a fresh checkpointer - assert result == [] + # Empty list is expected for a fresh checkpointer + assert result == [] diff --git a/backend/tests/test_client.py b/backend/tests/test_client.py index 3d8d0a9d5..40e73e827 100644 --- a/backend/tests/test_client.py +++ b/backend/tests/test_client.py @@ -18,6 +18,7 @@ from app.gateway.routers.models import ModelResponse, ModelsListResponse from app.gateway.routers.skills import SkillInstallResponse, SkillResponse, SkillsListResponse from app.gateway.routers.uploads import UploadResponse from deerflow.client import DeerFlowClient +from deerflow.config.app_config import AppConfig from deerflow.config.paths import Paths from deerflow.uploads.manager import PathTraversalError @@ -44,9 +45,12 @@ def mock_app_config(): @pytest.fixture def client(mock_app_config): - """Create a DeerFlowClient with mocked config loading.""" - with patch("deerflow.client.get_app_config", return_value=mock_app_config): - return DeerFlowClient() + """Create a DeerFlowClient holding the mocked config directly. + + Passing ``config=`` is the documented post-refactor way to inject a + test AppConfig; nothing relies on process-global state. + """ + return DeerFlowClient(config=mock_app_config) # --------------------------------------------------------------------------- @@ -67,8 +71,7 @@ class TestClientInit: def test_custom_params(self, mock_app_config): mock_middleware = MagicMock() - with patch("deerflow.client.get_app_config", return_value=mock_app_config): - c = DeerFlowClient(model_name="gpt-4", thinking_enabled=False, subagent_enabled=True, plan_mode=True, agent_name="test-agent", available_skills={"skill1", "skill2"}, middlewares=[mock_middleware]) + c = DeerFlowClient(model_name="gpt-4", thinking_enabled=False, subagent_enabled=True, plan_mode=True, agent_name="test-agent", available_skills={"skill1", "skill2"}, middlewares=[mock_middleware]) assert c._model_name == "gpt-4" assert c._thinking_enabled is False assert c._subagent_enabled is True @@ -78,24 +81,21 @@ class TestClientInit: assert c._middlewares == [mock_middleware] def test_invalid_agent_name(self, mock_app_config): - with patch("deerflow.client.get_app_config", return_value=mock_app_config): - with pytest.raises(ValueError, match="Invalid agent name"): - DeerFlowClient(agent_name="invalid name with spaces!") - with pytest.raises(ValueError, match="Invalid agent name"): - DeerFlowClient(agent_name="../path/traversal") + with pytest.raises(ValueError, match="Invalid agent name"): + DeerFlowClient(agent_name="invalid name with spaces!") + with pytest.raises(ValueError, match="Invalid agent name"): + DeerFlowClient(agent_name="../path/traversal") def test_custom_config_path(self, mock_app_config): - with ( - patch("deerflow.client.reload_app_config") as mock_reload, - patch("deerflow.client.get_app_config", return_value=mock_app_config), - ): - DeerFlowClient(config_path="/tmp/custom.yaml") - mock_reload.assert_called_once_with("/tmp/custom.yaml") + # rather than touching AppConfig.init() / process-global state. + with patch.object(AppConfig, "from_file", return_value=mock_app_config) as mock_from_file: + client = DeerFlowClient(config_path="/tmp/custom.yaml") + mock_from_file.assert_called_once_with("/tmp/custom.yaml") + assert client._app_config is mock_app_config def test_checkpointer_stored(self, mock_app_config): cp = MagicMock() - with patch("deerflow.client.get_app_config", return_value=mock_app_config): - c = DeerFlowClient(checkpointer=cp) + c = DeerFlowClient(checkpointer=cp) assert c._checkpointer is cp @@ -126,7 +126,7 @@ class TestConfigQueries: with patch("deerflow.skills.loader.load_skills", return_value=[skill]) as mock_load: result = client.list_skills() - mock_load.assert_called_once_with(enabled_only=False) + mock_load.assert_called_once_with(client._app_config, enabled_only=False) assert "skills" in result assert len(result["skills"]) == 1 @@ -141,7 +141,7 @@ class TestConfigQueries: def test_list_skills_enabled_only(self, client): with patch("deerflow.skills.loader.load_skills", return_value=[]) as mock_load: client.list_skills(enabled_only=True) - mock_load.assert_called_once_with(enabled_only=True) + mock_load.assert_called_once_with(client._app_config, enabled_only=True) def test_get_memory(self, client): memory = {"version": "1.0", "facts": []} @@ -251,8 +251,8 @@ class TestStream: # Verify context passed to agent.stream agent.stream.assert_called_once() call_kwargs = agent.stream.call_args.kwargs - assert call_kwargs["context"]["thread_id"] == "t1" - assert call_kwargs["context"]["agent_name"] == "test-agent-1" + ctx = call_kwargs["context"] + assert ctx.app_config is client._app_config def test_custom_mode_is_normalized_to_string(self, client): """stream() forwards custom events even when the mode is not a plain string.""" @@ -1091,8 +1091,8 @@ class TestMcpConfig: ext_config = MagicMock() ext_config.mcp_servers = {"github": server} - with patch("deerflow.client.get_extensions_config", return_value=ext_config): - result = client.get_mcp_config() + client._app_config = MagicMock(extensions=ext_config) + result = client.get_mcp_config() assert "mcp_servers" in result assert "github" in result["mcp_servers"] @@ -1116,10 +1116,11 @@ class TestMcpConfig: # Pre-set agent to verify it gets invalidated client._agent = MagicMock() + client._app_config = MagicMock(extensions=current_config) + with ( patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=tmp_path), - patch("deerflow.client.get_extensions_config", return_value=current_config), - patch("deerflow.client.reload_extensions_config", return_value=reloaded_config), + patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock(extensions=reloaded_config)), ): result = client.update_mcp_config({"new-server": {"enabled": True, "type": "sse"}}) @@ -1177,12 +1178,12 @@ class TestSkillsManagement: try: # Pre-set agent to verify it gets invalidated client._agent = MagicMock() + client._app_config = MagicMock(extensions=ext_config) with ( patch("deerflow.skills.loader.load_skills", side_effect=[[skill], [updated_skill]]), patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=tmp_path), - patch("deerflow.client.get_extensions_config", return_value=ext_config), - patch("deerflow.client.reload_extensions_config"), + patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock()), ): result = client.update_skill("test-skill", enabled=False) assert result["enabled"] is False @@ -1245,7 +1246,7 @@ class TestMemoryManagement: assert mock_import.call_count == 1 call_args = mock_import.call_args - assert call_args.args == (imported,) + assert call_args.args == (client._app_config.memory, imported) assert "user_id" in call_args.kwargs assert result == imported @@ -1270,6 +1271,7 @@ class TestMemoryManagement: confidence=0.88, ) create_fact.assert_called_once_with( + client._app_config.memory, content="User prefers concise code reviews.", category="preference", confidence=0.88, @@ -1280,7 +1282,7 @@ class TestMemoryManagement: data = {"version": "1.0", "facts": []} with patch("deerflow.agents.memory.updater.delete_memory_fact", return_value=data) as delete_fact: result = client.delete_memory_fact("fact_123") - delete_fact.assert_called_once_with("fact_123") + delete_fact.assert_called_once_with(client._app_config.memory, "fact_123") assert result == data def test_update_memory_fact(self, client): @@ -1293,6 +1295,7 @@ class TestMemoryManagement: confidence=0.91, ) update_fact.assert_called_once_with( + client._app_config.memory, fact_id="fact_123", content="User prefers spaces", category="workflow", @@ -1308,6 +1311,7 @@ class TestMemoryManagement: "User prefers spaces", ) update_fact.assert_called_once_with( + client._app_config.memory, fact_id="fact_123", content="User prefers spaces", category=None, @@ -1316,37 +1320,40 @@ class TestMemoryManagement: assert result == data def test_get_memory_config(self, client): - config = MagicMock() - config.enabled = True - config.storage_path = ".deer-flow/memory.json" - config.debounce_seconds = 30 - config.max_facts = 100 - config.fact_confidence_threshold = 0.7 - config.injection_enabled = True - config.max_injection_tokens = 2000 + mem_config = MagicMock() + mem_config.enabled = True + mem_config.storage_path = ".deer-flow/memory.json" + mem_config.debounce_seconds = 30 + mem_config.max_facts = 100 + mem_config.fact_confidence_threshold = 0.7 + mem_config.injection_enabled = True + mem_config.max_injection_tokens = 2000 - with patch("deerflow.config.memory_config.get_memory_config", return_value=config): - result = client.get_memory_config() + app_cfg = MagicMock() + app_cfg.memory = mem_config + + client._app_config = app_cfg + result = client.get_memory_config() assert result["enabled"] is True assert result["max_facts"] == 100 def test_get_memory_status(self, client): - config = MagicMock() - config.enabled = True - config.storage_path = ".deer-flow/memory.json" - config.debounce_seconds = 30 - config.max_facts = 100 - config.fact_confidence_threshold = 0.7 - config.injection_enabled = True - config.max_injection_tokens = 2000 + mem_config = MagicMock() + mem_config.enabled = True + mem_config.storage_path = ".deer-flow/memory.json" + mem_config.debounce_seconds = 30 + mem_config.max_facts = 100 + mem_config.fact_confidence_threshold = 0.7 + mem_config.injection_enabled = True + mem_config.max_injection_tokens = 2000 + app_cfg = MagicMock() + app_cfg.memory = mem_config data = {"version": "1.0", "facts": []} - with ( - patch("deerflow.config.memory_config.get_memory_config", return_value=config), - patch("deerflow.agents.memory.updater.get_memory_data", return_value=data), - ): + client._app_config = app_cfg + with patch("deerflow.agents.memory.updater.get_memory_data", return_value=data): result = client.get_memory_status() assert "config" in result @@ -1800,10 +1807,10 @@ class TestScenarioConfigManagement: reloaded_config.mcp_servers = {"my-mcp": reloaded_server} client._agent = MagicMock() # Simulate existing agent + client._app_config = MagicMock(extensions=current_config) with ( patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file), - patch("deerflow.client.get_extensions_config", return_value=current_config), - patch("deerflow.client.reload_extensions_config", return_value=reloaded_config), + patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock(extensions=reloaded_config)), ): mcp_result = client.update_mcp_config({"my-mcp": {"enabled": True}}) assert "my-mcp" in mcp_result["mcp_servers"] @@ -1832,8 +1839,7 @@ class TestScenarioConfigManagement: with ( patch("deerflow.skills.loader.load_skills", side_effect=[[skill], [toggled]]), patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file), - patch("deerflow.client.get_extensions_config", return_value=ext_config), - patch("deerflow.client.reload_extensions_config"), + patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock()), ): skill_result = client.update_skill("code-gen", enabled=False) assert skill_result["enabled"] is False @@ -2021,10 +2027,10 @@ class TestScenarioMemoryWorkflow: refreshed = client.reload_memory() assert len(refreshed["facts"]) == 2 - with ( - patch("deerflow.config.memory_config.get_memory_config", return_value=config), - patch("deerflow.agents.memory.updater.get_memory_data", return_value=updated_data), - ): + app_cfg = MagicMock() + app_cfg.memory = config + client._app_config = app_cfg + with patch("deerflow.agents.memory.updater.get_memory_data", return_value=updated_data): status = client.get_memory_status() assert status["config"]["enabled"] is True assert len(status["data"]["facts"]) == 2 @@ -2085,8 +2091,7 @@ class TestScenarioSkillInstallAndUse: with ( patch("deerflow.skills.loader.load_skills", side_effect=[[installed_skill], [disabled_skill]]), patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file), - patch("deerflow.client.get_extensions_config", return_value=ext_config), - patch("deerflow.client.reload_extensions_config"), + patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock()), ): toggled = client.update_skill("my-analyzer", enabled=False) assert toggled["enabled"] is False @@ -2220,8 +2225,7 @@ class TestGatewayConformance: mock_app_config.models = [model] mock_app_config.token_usage.enabled = True - with patch("deerflow.client.get_app_config", return_value=mock_app_config): - client = DeerFlowClient() + client = DeerFlowClient(config=mock_app_config) result = client.list_models() parsed = ModelsListResponse(**result) @@ -2240,8 +2244,7 @@ class TestGatewayConformance: mock_app_config.models = [model] mock_app_config.get_model_config.return_value = model - with patch("deerflow.client.get_app_config", return_value=mock_app_config): - client = DeerFlowClient() + client = DeerFlowClient(config=mock_app_config) result = client.get_model("test-model") assert result is not None @@ -2310,8 +2313,8 @@ class TestGatewayConformance: ext_config = MagicMock() ext_config.mcp_servers = {"test": server} - with patch("deerflow.client.get_extensions_config", return_value=ext_config): - result = client.get_mcp_config() + client._app_config = MagicMock(extensions=ext_config) + result = client.get_mcp_config() parsed = McpConfigResponse(**result) assert "test" in parsed.mcp_servers @@ -2335,10 +2338,10 @@ class TestGatewayConformance: config_file = tmp_path / "extensions_config.json" config_file.write_text("{}") + client._app_config = MagicMock(extensions=ext_config) with ( - patch("deerflow.client.get_extensions_config", return_value=ext_config), patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file), - patch("deerflow.client.reload_extensions_config", return_value=ext_config), + patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock(extensions=ext_config)), ): result = client.update_mcp_config({"srv": server.model_dump.return_value}) @@ -2369,8 +2372,11 @@ class TestGatewayConformance: mem_cfg.injection_enabled = True mem_cfg.max_injection_tokens = 2000 - with patch("deerflow.config.memory_config.get_memory_config", return_value=mem_cfg): - result = client.get_memory_config() + app_cfg = MagicMock() + app_cfg.memory = mem_cfg + + client._app_config = app_cfg + result = client.get_memory_config() parsed = MemoryConfigResponse(**result) assert parsed.enabled is True @@ -2386,6 +2392,8 @@ class TestGatewayConformance: mem_cfg.injection_enabled = True mem_cfg.max_injection_tokens = 2000 + app_cfg = MagicMock() + app_cfg.memory = mem_cfg memory_data = { "version": "1.0", "lastUpdated": "", @@ -2402,10 +2410,8 @@ class TestGatewayConformance: "facts": [], } - with ( - patch("deerflow.config.memory_config.get_memory_config", return_value=mem_cfg), - patch("deerflow.agents.memory.updater.get_memory_data", return_value=memory_data), - ): + client._app_config = app_cfg + with patch("deerflow.agents.memory.updater.get_memory_data", return_value=memory_data): result = client.get_memory_status() parsed = MemoryStatusResponse(**result) @@ -2694,8 +2700,7 @@ class TestConfigUpdateErrors: with ( patch("deerflow.skills.loader.load_skills", side_effect=[[skill], []]), patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file), - patch("deerflow.client.get_extensions_config", return_value=ext_config), - patch("deerflow.client.reload_extensions_config"), + patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock()), ): with pytest.raises(RuntimeError, match="disappeared"): client.update_skill("ghost-skill", enabled=False) @@ -3074,10 +3079,10 @@ class TestBugAgentInvalidationInconsistency: config_file = Path(tmp) / "ext.json" config_file.write_text("{}") + client._app_config = MagicMock(extensions=current_config) with ( patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file), - patch("deerflow.client.get_extensions_config", return_value=current_config), - patch("deerflow.client.reload_extensions_config", return_value=reloaded), + patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock(extensions=reloaded)), ): client.update_mcp_config({}) @@ -3109,8 +3114,7 @@ class TestBugAgentInvalidationInconsistency: with ( patch("deerflow.skills.loader.load_skills", side_effect=[[skill], [updated]]), patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file), - patch("deerflow.client.get_extensions_config", return_value=ext_config), - patch("deerflow.client.reload_extensions_config"), + patch("deerflow.config.app_config.AppConfig.from_file", return_value=MagicMock()), ): client.update_skill("s1", enabled=False) diff --git a/backend/tests/test_client_e2e.py b/backend/tests/test_client_e2e.py index 6c688933a..3b66c571d 100644 --- a/backend/tests/test_client_e2e.py +++ b/backend/tests/test_client_e2e.py @@ -56,6 +56,10 @@ def _make_e2e_config() -> AppConfig: - ``E2E_BASE_URL`` (default: ``https://ark-cn-beijing.bytedance.net/api/v3``) - ``OPENAI_API_KEY`` (required for LLM tests) """ + from deerflow.config.memory_config import MemoryConfig + from deerflow.config.summarization_config import SummarizationConfig + from deerflow.config.title_config import TitleConfig + return AppConfig( models=[ ModelConfig( @@ -73,6 +77,9 @@ def _make_e2e_config() -> AppConfig: ) ], sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider", allow_host_bash=True), + title=TitleConfig(enabled=False), + memory=MemoryConfig(enabled=False), + summarization=SummarizationConfig(enabled=False), ) @@ -87,7 +94,7 @@ def e2e_env(tmp_path, monkeypatch): - DEER_FLOW_HOME → tmp_path (all thread data lands in a temp dir) - Singletons reset so they pick up the new env - - Title/memory/summarization disabled to avoid extra LLM calls + - Title/memory/summarization disabled via AppConfig fields - AppConfig built programmatically (avoids config.yaml param-name issues) """ # 1. Filesystem isolation @@ -95,30 +102,12 @@ def e2e_env(tmp_path, monkeypatch): monkeypatch.setattr("deerflow.config.paths._paths", None) monkeypatch.setattr("deerflow.sandbox.sandbox_provider._default_sandbox_provider", None) - # 2. Inject a clean AppConfig via the global singleton. - config = _make_e2e_config() - monkeypatch.setattr("deerflow.config.app_config._app_config", config) - monkeypatch.setattr("deerflow.config.app_config._app_config_is_custom", True) + # 1b. Override the autouse ``AppConfig.from_file`` stub from conftest + # (minimal test config) with the e2e-specific config that carries a + # real model entry and disables title/memory/summarization. + monkeypatch.setattr(AppConfig, "from_file", staticmethod(lambda config_path=None: _make_e2e_config())) - # 3. Disable title generation (extra LLM call, non-deterministic) - from deerflow.config.title_config import TitleConfig - - monkeypatch.setattr("deerflow.config.title_config._title_config", TitleConfig(enabled=False)) - - # 4. Disable memory queueing (avoids background threads & file writes) - from deerflow.config.memory_config import MemoryConfig - - monkeypatch.setattr( - "deerflow.agents.middlewares.memory_middleware.get_memory_config", - lambda: MemoryConfig(enabled=False), - ) - - # 5. Ensure summarization is off (default, but be explicit) - from deerflow.config.summarization_config import SummarizationConfig - - monkeypatch.setattr("deerflow.config.summarization_config._summarization_config", SummarizationConfig(enabled=False)) - - # 6. Exclude TitleMiddleware from the chain. + # 2. Exclude TitleMiddleware from the chain. # It triggers an extra LLM call to generate a thread title, which adds # non-determinism and cost to E2E tests (title generation is already # disabled via TitleConfig above, but the middleware still participates @@ -666,10 +655,9 @@ class TestConfigManagement: config_file.write_text(json.dumps({"mcpServers": {}, "skills": {}})) monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(config_file)) - # Force reload so the singleton picks up our test file - from deerflow.config.extensions_config import reload_extensions_config - - reload_extensions_config() + # Mock from_file so update_mcp_config's internal reload works without config.yaml + e2e_config = _make_e2e_config() + monkeypatch.setattr(AppConfig, "from_file", classmethod(lambda cls, path=None: e2e_config)) c = DeerFlowClient(checkpointer=None, thinking_enabled=False) # Simulate a cached agent @@ -693,9 +681,9 @@ class TestConfigManagement: config_file.write_text(json.dumps({"mcpServers": {}, "skills": {}})) monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(config_file)) - from deerflow.config.extensions_config import reload_extensions_config - - reload_extensions_config() + # Mock from_file so update_skill's internal reload works without config.yaml + e2e_config = _make_e2e_config() + monkeypatch.setattr(AppConfig, "from_file", classmethod(lambda cls, path=None: e2e_config)) c = DeerFlowClient(checkpointer=None, thinking_enabled=False) c._agent = "fake-agent-placeholder" @@ -721,10 +709,6 @@ class TestConfigManagement: config_file.write_text(json.dumps({"mcpServers": {}, "skills": {}})) monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(config_file)) - from deerflow.config.extensions_config import reload_extensions_config - - reload_extensions_config() - c = DeerFlowClient(checkpointer=None, thinking_enabled=False) with pytest.raises(ValueError, match="not found"): c.update_skill("nonexistent-skill-xyz", enabled=True) diff --git a/backend/tests/test_client_live.py b/backend/tests/test_client_live.py index 0271ebf21..342673d72 100644 --- a/backend/tests/test_client_live.py +++ b/backend/tests/test_client_live.py @@ -101,7 +101,7 @@ class TestLiveStreaming: class TestLiveToolUse: def test_agent_uses_bash_tool(self, client): """Agent uses bash tool when asked to run a command.""" - if not is_host_bash_allowed(): + if not is_host_bash_allowed(client._app_config): pytest.skip("Host bash is disabled for LocalSandboxProvider in the active config") events = list(client.stream("Use the bash tool to run: echo 'LIVE_TEST_OK'. Then tell me the output.")) diff --git a/backend/tests/test_client_multi_isolation.py b/backend/tests/test_client_multi_isolation.py new file mode 100644 index 000000000..0271483af --- /dev/null +++ b/backend/tests/test_client_multi_isolation.py @@ -0,0 +1,82 @@ +"""Multi-client isolation regression test. + +Phase 2 Task P2-3: ``DeerFlowClient`` now captures its ``AppConfig`` in the +constructor instead of going through a process-global config. +This test pins the resulting invariant: two clients with different configs +can coexist without contending over shared state. + +Before P2-3, the shared ``AppConfig._global`` caused the second client's +``init()`` to clobber the first client's config. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from deerflow.client import DeerFlowClient +from deerflow.config.app_config import AppConfig +from deerflow.config.memory_config import MemoryConfig +from deerflow.config.sandbox_config import SandboxConfig + + +@pytest.fixture +def disable_agent_creation(monkeypatch): + """Prevent lazy agent creation — we only care about config access.""" + monkeypatch.setattr(DeerFlowClient, "_get_or_create_agent", MagicMock(), raising=False) + + +def test_two_clients_do_not_clobber_each_other(disable_agent_creation): + """Two clients with distinct configs keep their own AppConfig.""" + cfg_a = AppConfig( + sandbox=SandboxConfig(use="test"), + memory=MemoryConfig(enabled=True), + ) + cfg_b = AppConfig( + sandbox=SandboxConfig(use="test"), + memory=MemoryConfig(enabled=False), + ) + + client_a = DeerFlowClient(config=cfg_a) + client_b = DeerFlowClient(config=cfg_b) + + # Identity: each client retains its own instance, not a shared ref + assert client_a._app_config is cfg_a + assert client_b._app_config is cfg_b + + # Semantic: memory flag differs + assert client_a._app_config.memory.enabled is True + assert client_b._app_config.memory.enabled is False + + +def test_client_config_precedes_path(disable_agent_creation, tmp_path): + """When both config= and config_path= are given, config= wins.""" + cfg = AppConfig(sandbox=SandboxConfig(use="test"), log_level="debug") + + # config_path points at a file that doesn't exist — proves it's unused + bogus_path = str(tmp_path / "nope.yaml") + client = DeerFlowClient(config_path=bogus_path, config=cfg) + + assert client._app_config is cfg + assert client._app_config.log_level == "debug" + + +def test_multi_client_gateway_dict_returns_distinct(disable_agent_creation): + """get_mcp_config() reads from self._app_config, not process-global.""" + from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig + + ext_a = ExtensionsConfig(mcp_servers={"server-a": McpServerConfig(enabled=True)}) + ext_b = ExtensionsConfig(mcp_servers={"server-b": McpServerConfig(enabled=True)}) + + cfg_a = AppConfig(sandbox=SandboxConfig(use="test"), extensions=ext_a) + cfg_b = AppConfig(sandbox=SandboxConfig(use="test"), extensions=ext_b) + + client_a = DeerFlowClient(config=cfg_a) + client_b = DeerFlowClient(config=cfg_b) + + servers_a = client_a.get_mcp_config()["mcp_servers"] + servers_b = client_b.get_mcp_config()["mcp_servers"] + + assert set(servers_a.keys()) == {"server-a"} + assert set(servers_b.keys()) == {"server-b"} diff --git a/backend/tests/test_config_frozen.py b/backend/tests/test_config_frozen.py new file mode 100644 index 000000000..5c89bdcfd --- /dev/null +++ b/backend/tests/test_config_frozen.py @@ -0,0 +1,95 @@ +"""Verify that all sub-config Pydantic models are frozen (immutable). + +Frozen models reject attribute assignment after construction, raising +pydantic.ValidationError. This test collects every BaseModel subclass +defined in the deerflow.config package and asserts that mutation is +blocked. +""" + +import inspect +import pkgutil + +import pytest +from pydantic import BaseModel, ValidationError + +import deerflow.config as config_pkg + + +def _collect_config_models() -> list[type[BaseModel]]: + """Walk deerflow.config.* and return all concrete BaseModel subclasses.""" + import importlib + + models: list[type[BaseModel]] = [] + package_path = config_pkg.__path__ + package_prefix = config_pkg.__name__ + "." + + for _importer, modname, _ispkg in pkgutil.walk_packages(package_path, prefix=package_prefix): + try: + mod = importlib.import_module(modname) + except Exception: + continue + for _name, obj in inspect.getmembers(mod, inspect.isclass): + if ( + issubclass(obj, BaseModel) + and obj is not BaseModel + and obj.__module__ == mod.__name__ + ): + models.append(obj) + + return models + + +_EXCLUDED: set[str] = set() + +_ALL_MODELS = [m for m in _collect_config_models() if m.__name__ not in _EXCLUDED] + +# Sanity: make sure we actually collected a meaningful set. +assert len(_ALL_MODELS) >= 15, f"Expected at least 15 config models, found {len(_ALL_MODELS)}: {[m.__name__ for m in _ALL_MODELS]}" + + +@pytest.mark.parametrize("model_cls", _ALL_MODELS, ids=lambda cls: cls.__name__) +def test_config_model_is_frozen(model_cls: type[BaseModel]): + """Every sub-config model must have frozen=True in its model_config.""" + cfg = model_cls.model_config + assert cfg.get("frozen") is True, ( + f"{model_cls.__name__} is not frozen. " + f"Add `model_config = ConfigDict(frozen=True)` or add `frozen=True` to the existing ConfigDict." + ) + + +@pytest.mark.parametrize("model_cls", _ALL_MODELS, ids=lambda cls: cls.__name__) +def test_config_model_rejects_mutation(model_cls: type[BaseModel]): + """Constructing then mutating any field must raise ValidationError.""" + # Build a minimal instance -- use model_construct to skip validation for + # required fields, then pick the first field to try mutating. + fields = list(model_cls.model_fields.keys()) + if not fields: + pytest.skip(f"{model_cls.__name__} has no fields") + + instance = model_cls.model_construct() + first_field = fields[0] + + with pytest.raises(ValidationError): + setattr(instance, first_field, "MUTATED") + + +def test_extensions_nested_dict_mutation_is_not_blocked_by_pydantic(): + """Regression guard: Pydantic `frozen=True` does NOT deep-freeze container fields. + + This test documents the trap — callers MUST compose a new dict and persist + it + reload AppConfig instead of reaching into `extensions.skills[x]`. + If you need the dict to be truly immutable, wrap with Mapping/frozendict. + """ + from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig + + ext = ExtensionsConfig(mcp_servers={}, skills={"a": SkillStateConfig(enabled=True)}) + + # This is the pre-refactor anti-pattern: Pydantic lets it through because + # the outer model is frozen but the inner dict is a plain builtin. No error. + ext.skills["a"] = SkillStateConfig(enabled=False) + ext.skills["b"] = SkillStateConfig(enabled=True) + + # The test asserts the leak exists so a future "add deep-freeze" change + # flips this expectation and forces call-site review. + assert ext.skills["a"].enabled is False + assert "b" in ext.skills diff --git a/backend/tests/test_custom_agent.py b/backend/tests/test_custom_agent.py index 2117e05d2..adf187839 100644 --- a/backend/tests/test_custom_agent.py +++ b/backend/tests/test_custom_agent.py @@ -9,7 +9,9 @@ import pytest import yaml from fastapi.testclient import TestClient -from deerflow.config.agents_api_config import AgentsApiConfig, get_agents_api_config, set_agents_api_config +from deerflow.config.memory_config import MemoryConfig + +_TEST_MEMORY_CONFIG = MemoryConfig() # --------------------------------------------------------------------------- # Helpers @@ -329,38 +331,26 @@ class TestMemoryFilePath: def test_global_memory_path(self, tmp_path): """None agent_name should return global memory file.""" from deerflow.agents.memory.storage import FileMemoryStorage - from deerflow.config.memory_config import MemoryConfig - with ( - patch("deerflow.agents.memory.storage.get_paths", return_value=_make_paths(tmp_path)), - patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_path="")), - ): - storage = FileMemoryStorage() + with patch("deerflow.agents.memory.storage.get_paths", return_value=_make_paths(tmp_path)): + storage = FileMemoryStorage(_TEST_MEMORY_CONFIG) path = storage._get_memory_file_path(None) assert path == tmp_path / "memory.json" def test_agent_memory_path(self, tmp_path): """Providing agent_name should return per-agent memory file.""" from deerflow.agents.memory.storage import FileMemoryStorage - from deerflow.config.memory_config import MemoryConfig - with ( - patch("deerflow.agents.memory.storage.get_paths", return_value=_make_paths(tmp_path)), - patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_path="")), - ): - storage = FileMemoryStorage() + with patch("deerflow.agents.memory.storage.get_paths", return_value=_make_paths(tmp_path)): + storage = FileMemoryStorage(_TEST_MEMORY_CONFIG) path = storage._get_memory_file_path("code-reviewer") assert path == tmp_path / "agents" / "code-reviewer" / "memory.json" def test_different_paths_for_different_agents(self, tmp_path): from deerflow.agents.memory.storage import FileMemoryStorage - from deerflow.config.memory_config import MemoryConfig - with ( - patch("deerflow.agents.memory.storage.get_paths", return_value=_make_paths(tmp_path)), - patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_path="")), - ): - storage = FileMemoryStorage() + with patch("deerflow.agents.memory.storage.get_paths", return_value=_make_paths(tmp_path)): + storage = FileMemoryStorage(_TEST_MEMORY_CONFIG) path_global = storage._get_memory_file_path(None) path_a = storage._get_memory_file_path("agent-a") path_b = storage._get_memory_file_path("agent-b") @@ -380,47 +370,32 @@ def _make_test_app(tmp_path: Path): from fastapi import FastAPI from app.gateway.routers.agents import router + from deerflow.config.agents_api_config import AgentsApiConfig + from deerflow.config.app_config import AppConfig + from deerflow.config.sandbox_config import SandboxConfig app = FastAPI() app.include_router(router) + # The agents router gates every route through ``Depends(get_config)`` and + # only allows access when ``agents_api.enabled`` is true. Wire a permissive + # AppConfig onto ``app.state.config`` so the routes are reachable in tests. + app.state.config = AppConfig( + sandbox=SandboxConfig(use="test"), + agents_api=AgentsApiConfig(enabled=True), + ) return app @pytest.fixture() def agent_client(tmp_path): """TestClient with agents router, using tmp_path as base_dir.""" - import app.gateway.routers.agents as agents_router - paths_instance = _make_paths(tmp_path) - previous_config = AgentsApiConfig(**get_agents_api_config().model_dump()) - with patch("deerflow.config.agents_config.get_paths", return_value=paths_instance), patch.object(agents_router, "get_paths", return_value=paths_instance): - set_agents_api_config(AgentsApiConfig(enabled=True)) - try: - app = _make_test_app(tmp_path) - with TestClient(app) as client: - client._tmp_path = tmp_path # type: ignore[attr-defined] - yield client - finally: - set_agents_api_config(previous_config) - - -@pytest.fixture() -def disabled_agent_client(tmp_path): - """TestClient with agents router while the management API is disabled.""" - import app.gateway.routers.agents as agents_router - - paths_instance = _make_paths(tmp_path) - previous_config = AgentsApiConfig(**get_agents_api_config().model_dump()) - - with patch("deerflow.config.agents_config.get_paths", return_value=paths_instance), patch.object(agents_router, "get_paths", return_value=paths_instance): - set_agents_api_config(AgentsApiConfig(enabled=False)) - try: - app = _make_test_app(tmp_path) - with TestClient(app) as client: - yield client - finally: - set_agents_api_config(previous_config) + with patch("deerflow.config.agents_config.get_paths", return_value=paths_instance), patch("app.gateway.routers.agents.get_paths", return_value=paths_instance): + app = _make_test_app(tmp_path) + with TestClient(app) as client: + client._tmp_path = tmp_path # type: ignore[attr-defined] + yield client class TestAgentsAPI: @@ -586,37 +561,3 @@ class TestUserProfileAPI: response = agent_client.put("/api/user-profile", json={"content": ""}) assert response.status_code == 200 assert response.json()["content"] is None - - -class TestAgentsApiDisabled: - def test_agents_list_returns_403(self, disabled_agent_client): - response = disabled_agent_client.get("/api/agents") - assert response.status_code == 403 - assert "agents_api.enabled=true" in response.json()["detail"] - - def test_agent_get_returns_403(self, disabled_agent_client): - response = disabled_agent_client.get("/api/agents/example-agent") - assert response.status_code == 403 - - def test_agent_name_check_returns_403(self, disabled_agent_client): - response = disabled_agent_client.get("/api/agents/check", params={"name": "example-agent"}) - assert response.status_code == 403 - - def test_agent_create_returns_403(self, disabled_agent_client): - response = disabled_agent_client.post("/api/agents", json={"name": "example-agent", "soul": "blocked"}) - assert response.status_code == 403 - - def test_agent_update_returns_403(self, disabled_agent_client): - response = disabled_agent_client.put("/api/agents/example-agent", json={"description": "blocked"}) - assert response.status_code == 403 - - def test_agent_delete_returns_403(self, disabled_agent_client): - response = disabled_agent_client.delete("/api/agents/example-agent") - assert response.status_code == 403 - - def test_user_profile_routes_return_403(self, disabled_agent_client): - get_response = disabled_agent_client.get("/api/user-profile") - put_response = disabled_agent_client.put("/api/user-profile", json={"content": "blocked"}) - - assert get_response.status_code == 403 - assert put_response.status_code == 403 diff --git a/backend/tests/test_deer_flow_context.py b/backend/tests/test_deer_flow_context.py new file mode 100644 index 000000000..bf1005bd0 --- /dev/null +++ b/backend/tests/test_deer_flow_context.py @@ -0,0 +1,62 @@ +"""Tests for DeerFlowContext and resolve_context().""" + +from dataclasses import FrozenInstanceError +from unittest.mock import MagicMock, patch + +import pytest + +from deerflow.config.app_config import AppConfig +from deerflow.config.deer_flow_context import DeerFlowContext, resolve_context +from deerflow.config.sandbox_config import SandboxConfig + + +def _make_config(**overrides) -> AppConfig: + defaults = {"sandbox": SandboxConfig(use="test")} + defaults.update(overrides) + return AppConfig(**defaults) + + +class TestDeerFlowContext: + def test_frozen(self): + ctx = DeerFlowContext(app_config=_make_config(), thread_id="t1") + with pytest.raises(FrozenInstanceError): + ctx.app_config = _make_config() + + def test_fields(self): + config = _make_config() + ctx = DeerFlowContext(app_config=config, thread_id="t1", agent_name="test-agent") + assert ctx.thread_id == "t1" + assert ctx.agent_name == "test-agent" + assert ctx.app_config is config + + def test_agent_name_default(self): + ctx = DeerFlowContext(app_config=_make_config(), thread_id="t1") + assert ctx.agent_name is None + + def test_thread_id_required(self): + with pytest.raises(TypeError): + DeerFlowContext(app_config=_make_config()) # type: ignore[call-arg] + + +class TestResolveContext: + def test_returns_typed_context_directly(self): + """Gateway/Client path: runtime.context is DeerFlowContext → return as-is.""" + config = _make_config() + ctx = DeerFlowContext(app_config=config, thread_id="t1") + runtime = MagicMock() + runtime.context = ctx + assert resolve_context(runtime) is ctx + + def test_raises_on_none_context(self): + """Without a typed DeerFlowContext, resolve_context refuses to guess.""" + runtime = MagicMock() + runtime.context = None + with pytest.raises(RuntimeError, match="resolve_context: runtime.context is not a DeerFlowContext"): + resolve_context(runtime) + + def test_raises_on_dict_context(self): + """Legacy dict shape is no longer supported — we raise instead of lazily loading AppConfig.""" + runtime = MagicMock() + runtime.context = {"thread_id": "old-dict", "agent_name": "from-dict"} + with pytest.raises(RuntimeError, match="resolve_context: runtime.context is not a DeerFlowContext"): + resolve_context(runtime) diff --git a/backend/tests/test_exa_tools.py b/backend/tests/test_exa_tools.py index b7196918e..3953e21fc 100644 --- a/backend/tests/test_exa_tools.py +++ b/backend/tests/test_exa_tools.py @@ -5,20 +5,36 @@ from unittest.mock import MagicMock, patch import pytest +# --- Phase 2 test helper: injected runtime for community tools --- +from types import SimpleNamespace as _P2NS +from deerflow.config.app_config import AppConfig as _P2AppConfig +from deerflow.config.sandbox_config import SandboxConfig as _P2SandboxConfig +from deerflow.config.deer_flow_context import DeerFlowContext as _P2Ctx +_P2_APP_CONFIG = _P2AppConfig(sandbox=_P2SandboxConfig(use="test")) +_P2_RUNTIME = _P2NS(context=_P2Ctx(app_config=_P2_APP_CONFIG, thread_id="test-thread")) + + +def _runtime_with_config(config): + """Build a runtime carrying a custom (possibly mocked) app_config. + + ``DeerFlowContext`` is a frozen dataclass typed as ``AppConfig`` but + dataclasses don't enforce the type at runtime — handing a Mock through + lets tests exercise the tool's ``get_tool_config`` lookup without going + through a process-global config. + """ + ctx = _P2Ctx.__new__(_P2Ctx) + object.__setattr__(ctx, "app_config", config) + object.__setattr__(ctx, "thread_id", "test-thread") + object.__setattr__(ctx, "agent_name", None) + return _P2NS(context=ctx) +# ------------------------------------------------------------------- + + @pytest.fixture def mock_app_config(): - """Mock the app config to return tool configurations.""" - with patch("deerflow.community.exa.tools.get_app_config") as mock_config: - tool_config = MagicMock() - tool_config.model_extra = { - "max_results": 5, - "search_type": "auto", - "contents_max_characters": 1000, - "api_key": "test-api-key", - } - mock_config.return_value.get_tool_config.return_value = tool_config - yield mock_config + """Fixture retained as a pass-through: tests inject config via runtime directly.""" + yield @pytest.fixture @@ -49,7 +65,7 @@ class TestWebSearchTool: from deerflow.community.exa.tools import web_search_tool - result = web_search_tool.invoke({"query": "test query"}) + result = web_search_tool.func(query="test query", runtime=_P2_RUNTIME) parsed = json.loads(result) assert len(parsed) == 2 @@ -67,30 +83,30 @@ class TestWebSearchTool: def test_search_with_custom_config(self, mock_exa_client): """Test search respects custom configuration values.""" - with patch("deerflow.community.exa.tools.get_app_config") as mock_config: - tool_config = MagicMock() - tool_config.model_extra = { - "max_results": 10, - "search_type": "neural", - "contents_max_characters": 2000, - "api_key": "test-key", - } - mock_config.return_value.get_tool_config.return_value = tool_config + tool_config = MagicMock() + tool_config.model_extra = { + "max_results": 10, + "search_type": "neural", + "contents_max_characters": 2000, + "api_key": "test-key", + } + fake_config = MagicMock() + fake_config.get_tool_config.return_value = tool_config - mock_response = MagicMock() - mock_response.results = [] - mock_exa_client.search.return_value = mock_response + mock_response = MagicMock() + mock_response.results = [] + mock_exa_client.search.return_value = mock_response - from deerflow.community.exa.tools import web_search_tool + from deerflow.community.exa.tools import web_search_tool - web_search_tool.invoke({"query": "neural search"}) + web_search_tool.func(query="neural search", runtime=_runtime_with_config(fake_config)) - mock_exa_client.search.assert_called_once_with( - "neural search", - type="neural", - num_results=10, - contents={"highlights": {"max_characters": 2000}}, - ) + mock_exa_client.search.assert_called_once_with( + "neural search", + type="neural", + num_results=10, + contents={"highlights": {"max_characters": 2000}}, + ) def test_search_with_no_highlights(self, mock_app_config, mock_exa_client): """Test search handles results with no highlights.""" @@ -105,7 +121,7 @@ class TestWebSearchTool: from deerflow.community.exa.tools import web_search_tool - result = web_search_tool.invoke({"query": "test"}) + result = web_search_tool.func(query="test", runtime=_P2_RUNTIME) parsed = json.loads(result) assert parsed[0]["snippet"] == "" @@ -118,7 +134,7 @@ class TestWebSearchTool: from deerflow.community.exa.tools import web_search_tool - result = web_search_tool.invoke({"query": "nothing"}) + result = web_search_tool.func(query="nothing", runtime=_P2_RUNTIME) parsed = json.loads(result) assert parsed == [] @@ -129,7 +145,7 @@ class TestWebSearchTool: from deerflow.community.exa.tools import web_search_tool - result = web_search_tool.invoke({"query": "error"}) + result = web_search_tool.func(query="error", runtime=_P2_RUNTIME) assert result == "Error: API rate limit exceeded" @@ -147,7 +163,7 @@ class TestWebFetchTool: from deerflow.community.exa.tools import web_fetch_tool - result = web_fetch_tool.invoke({"url": "https://example.com"}) + result = web_fetch_tool.func(url="https://example.com", runtime=_P2_RUNTIME) assert result == "# Fetched Page\n\nThis is the page content." mock_exa_client.get_contents.assert_called_once_with( @@ -167,7 +183,7 @@ class TestWebFetchTool: from deerflow.community.exa.tools import web_fetch_tool - result = web_fetch_tool.invoke({"url": "https://example.com"}) + result = web_fetch_tool.func(url="https://example.com", runtime=_P2_RUNTIME) assert result.startswith("# Untitled\n\n") @@ -179,7 +195,7 @@ class TestWebFetchTool: from deerflow.community.exa.tools import web_fetch_tool - result = web_fetch_tool.invoke({"url": "https://example.com/404"}) + result = web_fetch_tool.func(url="https://example.com/404", runtime=_P2_RUNTIME) assert result == "Error: No results found" @@ -189,16 +205,44 @@ class TestWebFetchTool: from deerflow.community.exa.tools import web_fetch_tool - result = web_fetch_tool.invoke({"url": "https://example.com"}) + result = web_fetch_tool.func(url="https://example.com", runtime=_P2_RUNTIME) assert result == "Error: Connection timeout" def test_fetch_reads_web_fetch_config(self, mock_exa_client): """Test that web_fetch_tool reads 'web_fetch' config, not 'web_search'.""" - with patch("deerflow.community.exa.tools.get_app_config") as mock_config: - tool_config = MagicMock() - tool_config.model_extra = {"api_key": "exa-fetch-key"} - mock_config.return_value.get_tool_config.return_value = tool_config + tool_config = MagicMock() + tool_config.model_extra = {"api_key": "exa-fetch-key"} + fake_config = MagicMock() + fake_config.get_tool_config.return_value = tool_config + + mock_result = MagicMock() + mock_result.title = "Page" + mock_result.text = "Content." + mock_response = MagicMock() + mock_response.results = [mock_result] + mock_exa_client.get_contents.return_value = mock_response + + from deerflow.community.exa.tools import web_fetch_tool + + web_fetch_tool.func(url="https://example.com", runtime=_runtime_with_config(fake_config)) + + fake_config.get_tool_config.assert_any_call("web_fetch") + + def test_fetch_uses_independent_api_key(self, mock_exa_client): + """Test mixed-provider config: web_fetch uses its own api_key, not web_search's.""" + with patch("deerflow.community.exa.tools.Exa") as mock_exa_cls: + mock_exa_cls.return_value = mock_exa_client + fetch_config = MagicMock() + fetch_config.model_extra = {"api_key": "exa-fetch-key"} + + def get_tool_config(name): + if name == "web_fetch": + return fetch_config + return None + + fake_config = MagicMock() + fake_config.get_tool_config.side_effect = get_tool_config mock_result = MagicMock() mock_result.title = "Page" @@ -209,37 +253,9 @@ class TestWebFetchTool: from deerflow.community.exa.tools import web_fetch_tool - web_fetch_tool.invoke({"url": "https://example.com"}) + web_fetch_tool.func(url="https://example.com", runtime=_runtime_with_config(fake_config)) - mock_config.return_value.get_tool_config.assert_any_call("web_fetch") - - def test_fetch_uses_independent_api_key(self, mock_exa_client): - """Test mixed-provider config: web_fetch uses its own api_key, not web_search's.""" - with patch("deerflow.community.exa.tools.get_app_config") as mock_config: - with patch("deerflow.community.exa.tools.Exa") as mock_exa_cls: - mock_exa_cls.return_value = mock_exa_client - fetch_config = MagicMock() - fetch_config.model_extra = {"api_key": "exa-fetch-key"} - - def get_tool_config(name): - if name == "web_fetch": - return fetch_config - return None - - mock_config.return_value.get_tool_config.side_effect = get_tool_config - - mock_result = MagicMock() - mock_result.title = "Page" - mock_result.text = "Content." - mock_response = MagicMock() - mock_response.results = [mock_result] - mock_exa_client.get_contents.return_value = mock_response - - from deerflow.community.exa.tools import web_fetch_tool - - web_fetch_tool.invoke({"url": "https://example.com"}) - - mock_exa_cls.assert_called_once_with(api_key="exa-fetch-key") + mock_exa_cls.assert_called_once_with(api_key="exa-fetch-key") def test_fetch_truncates_long_content(self, mock_app_config, mock_exa_client): """Test fetch truncates content to 4096 characters.""" @@ -253,7 +269,7 @@ class TestWebFetchTool: from deerflow.community.exa.tools import web_fetch_tool - result = web_fetch_tool.invoke({"url": "https://example.com"}) + result = web_fetch_tool.func(url="https://example.com", runtime=_P2_RUNTIME) # "# Long Page\n\n" is 14 chars, content truncated to 4096 content_after_header = result.split("\n\n", 1)[1] diff --git a/backend/tests/test_firecrawl_tools.py b/backend/tests/test_firecrawl_tools.py index fd61f817e..67b8f20ca 100644 --- a/backend/tests/test_firecrawl_tools.py +++ b/backend/tests/test_firecrawl_tools.py @@ -3,14 +3,31 @@ import json from unittest.mock import MagicMock, patch +from types import SimpleNamespace as _P2NS + +from deerflow.config.app_config import AppConfig as _P2AppConfig +from deerflow.config.deer_flow_context import DeerFlowContext as _P2Ctx +from deerflow.config.sandbox_config import SandboxConfig as _P2SandboxConfig + +_P2_APP_CONFIG = _P2AppConfig(sandbox=_P2SandboxConfig(use="test")) +_P2_RUNTIME = _P2NS(context=_P2Ctx(app_config=_P2_APP_CONFIG, thread_id="test-thread")) + + +def _runtime_with_config(config): + ctx = _P2Ctx.__new__(_P2Ctx) + object.__setattr__(ctx, "app_config", config) + object.__setattr__(ctx, "thread_id", "test-thread") + object.__setattr__(ctx, "agent_name", None) + return _P2NS(context=ctx) + class TestWebSearchTool: @patch("deerflow.community.firecrawl.tools.FirecrawlApp") - @patch("deerflow.community.firecrawl.tools.get_app_config") - def test_search_uses_web_search_config(self, mock_get_app_config, mock_firecrawl_cls): + def test_search_uses_web_search_config(self, mock_firecrawl_cls): search_config = MagicMock() search_config.model_extra = {"api_key": "firecrawl-search-key", "max_results": 7} - mock_get_app_config.return_value.get_tool_config.return_value = search_config + fake_config = MagicMock() + fake_config.get_tool_config.return_value = search_config mock_result = MagicMock() mock_result.web = [ @@ -20,7 +37,7 @@ class TestWebSearchTool: from deerflow.community.firecrawl.tools import web_search_tool - result = web_search_tool.invoke({"query": "test query"}) + result = web_search_tool.func(query="test query", runtime=_runtime_with_config(fake_config)) assert json.loads(result) == [ { @@ -29,15 +46,14 @@ class TestWebSearchTool: "snippet": "Snippet", } ] - mock_get_app_config.return_value.get_tool_config.assert_called_with("web_search") + fake_config.get_tool_config.assert_called_with("web_search") mock_firecrawl_cls.assert_called_once_with(api_key="firecrawl-search-key") mock_firecrawl_cls.return_value.search.assert_called_once_with("test query", limit=7) class TestWebFetchTool: @patch("deerflow.community.firecrawl.tools.FirecrawlApp") - @patch("deerflow.community.firecrawl.tools.get_app_config") - def test_fetch_uses_web_fetch_config(self, mock_get_app_config, mock_firecrawl_cls): + def test_fetch_uses_web_fetch_config(self, mock_firecrawl_cls): fetch_config = MagicMock() fetch_config.model_extra = {"api_key": "firecrawl-fetch-key"} @@ -46,7 +62,8 @@ class TestWebFetchTool: return fetch_config return None - mock_get_app_config.return_value.get_tool_config.side_effect = get_tool_config + fake_config = MagicMock() + fake_config.get_tool_config.side_effect = get_tool_config mock_scrape_result = MagicMock() mock_scrape_result.markdown = "Fetched markdown" @@ -55,10 +72,10 @@ class TestWebFetchTool: from deerflow.community.firecrawl.tools import web_fetch_tool - result = web_fetch_tool.invoke({"url": "https://example.com"}) + result = web_fetch_tool.func(url="https://example.com", runtime=_runtime_with_config(fake_config)) assert result == "# Fetched Page\n\nFetched markdown" - mock_get_app_config.return_value.get_tool_config.assert_any_call("web_fetch") + fake_config.get_tool_config.assert_any_call("web_fetch") mock_firecrawl_cls.assert_called_once_with(api_key="firecrawl-fetch-key") mock_firecrawl_cls.return_value.scrape.assert_called_once_with( "https://example.com", diff --git a/backend/tests/test_gateway_deps_config.py b/backend/tests/test_gateway_deps_config.py new file mode 100644 index 000000000..ad309ece9 --- /dev/null +++ b/backend/tests/test_gateway_deps_config.py @@ -0,0 +1,55 @@ +"""Tests for the FastAPI get_config dependency. + +Phase 2 step 1: introduces the new explicit-config primitive that +resolves ``AppConfig`` from ``request.app.state.config``. After migration, +it is the sole mechanism. +""" + +from __future__ import annotations + +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from app.gateway.deps import get_config +from deerflow.config.app_config import AppConfig +from deerflow.config.sandbox_config import SandboxConfig + + +def test_get_config_returns_app_state_config(): + """get_config returns the AppConfig stored on app.state.config.""" + app = FastAPI() + cfg = AppConfig(sandbox=SandboxConfig(use="test")) + app.state.config = cfg + + @app.get("/probe") + def probe(c: AppConfig = Depends(get_config)): + # Identity check: FastAPI must hand us the exact object from app.state + return {"same_identity": c is cfg, "log_level": c.log_level} + + client = TestClient(app) + response = client.get("/probe") + + assert response.status_code == 200 + body = response.json() + assert body["same_identity"] is True + assert body["log_level"] == "info" + + +def test_get_config_reads_updated_app_state(): + """When app.state.config is swapped (config reload), get_config sees the new value.""" + app = FastAPI() + original = AppConfig(sandbox=SandboxConfig(use="test"), log_level="info") + replacement = original.model_copy(update={"log_level": "debug"}) + + app.state.config = original + + @app.get("/log-level") + def log_level(c: AppConfig = Depends(get_config)): + return {"level": c.log_level} + + client = TestClient(app) + assert client.get("/log-level").json() == {"level": "info"} + + # Simulate config reload (PUT /mcp/config, etc.) + app.state.config = replacement + assert client.get("/log-level").json() == {"level": "debug"} diff --git a/backend/tests/test_guardrail_middleware.py b/backend/tests/test_guardrail_middleware.py index 5c021ba44..640f32d2e 100644 --- a/backend/tests/test_guardrail_middleware.py +++ b/backend/tests/test_guardrail_middleware.py @@ -333,12 +333,14 @@ class TestGuardrailsConfig: assert config.provider.use == "deerflow.guardrails.builtin:AllowlistProvider" assert config.provider.config == {"denied_tools": ["bash"]} - def test_singleton_load_and_get(self): - from deerflow.config.guardrails_config import get_guardrails_config, load_guardrails_config_from_dict, reset_guardrails_config + def test_guardrails_config_via_app_config(self): + from deerflow.config.app_config import AppConfig + from deerflow.config.guardrails_config import GuardrailProviderConfig, GuardrailsConfig + from deerflow.config.sandbox_config import SandboxConfig - try: - load_guardrails_config_from_dict({"enabled": True, "provider": {"use": "test:Foo"}}) - config = get_guardrails_config() - assert config.enabled is True - finally: - reset_guardrails_config() + cfg = AppConfig( + sandbox=SandboxConfig(use="test"), + guardrails=GuardrailsConfig(enabled=True, provider=GuardrailProviderConfig(use="test:Foo")), + ) + config = cfg.guardrails + assert config.enabled is True diff --git a/backend/tests/test_infoquest_client.py b/backend/tests/test_infoquest_client.py index 2a4876158..daf70742d 100644 --- a/backend/tests/test_infoquest_client.py +++ b/backend/tests/test_infoquest_client.py @@ -6,6 +6,16 @@ from unittest.mock import MagicMock, patch from deerflow.community.infoquest import tools from deerflow.community.infoquest.infoquest_client import InfoQuestClient +# --- Phase 2 test helper: injected runtime for community tools --- +from types import SimpleNamespace as _P2NS +from deerflow.config.app_config import AppConfig as _P2AppConfig +from deerflow.config.sandbox_config import SandboxConfig as _P2SandboxConfig +from deerflow.config.deer_flow_context import DeerFlowContext as _P2Ctx +_P2_APP_CONFIG = _P2AppConfig(sandbox=_P2SandboxConfig(use="test")) +_P2_RUNTIME = _P2NS(context=_P2Ctx(app_config=_P2_APP_CONFIG, thread_id="test-thread")) +# ------------------------------------------------------------------- + + class TestInfoQuestClient: def test_infoquest_client_initialization(self): @@ -130,7 +140,7 @@ class TestInfoQuestClient: mock_client.web_search.return_value = json.dumps([]) mock_get_client.return_value = mock_client - result = tools.web_search_tool.run("test query") + result = tools.web_search_tool.func(query="test query", runtime=_P2_RUNTIME) assert result == json.dumps([]) mock_get_client.assert_called_once() @@ -143,14 +153,13 @@ class TestInfoQuestClient: mock_client.fetch.return_value = "Test content" mock_get_client.return_value = mock_client - result = tools.web_fetch_tool.run("https://example.com") + result = tools.web_fetch_tool.func(url="https://example.com", runtime=_P2_RUNTIME) assert result == "# Untitled\n\nTest content" mock_get_client.assert_called_once() mock_client.fetch.assert_called_once_with("https://example.com") - @patch("deerflow.community.infoquest.tools.get_app_config") - def test_get_infoquest_client(self, mock_get_app_config): + def test_get_infoquest_client(self): """Test _get_infoquest_client function with config.""" mock_config = MagicMock() # Add image_search config to the side_effect @@ -159,9 +168,8 @@ class TestInfoQuestClient: MagicMock(model_extra={"fetch_time": 10, "timeout": 30, "navigation_timeout": 60}), # web_fetch config MagicMock(model_extra={"image_search_time_range": 7, "image_size": "l"}), # image_search config ] - mock_get_app_config.return_value = mock_config - client = tools._get_infoquest_client() + client = tools._get_infoquest_client(mock_config) assert client.search_time_range == 24 assert client.fetch_time == 10 @@ -321,7 +329,7 @@ class TestImageSearch: mock_client.image_search.return_value = json.dumps([{"image_url": "https://example.com/image1.jpg"}]) mock_get_client.return_value = mock_client - result = tools.image_search_tool.run({"query": "test query"}) + result = tools.image_search_tool.func(query="test query", runtime=_P2_RUNTIME) # Check if result is a valid JSON string result_data = json.loads(result) @@ -340,7 +348,7 @@ class TestImageSearch: mock_get_client.return_value = mock_client # Pass all parameters as a dictionary (extra parameters will be ignored) - tools.image_search_tool.run({"query": "sunset", "time_range": 30, "site": "unsplash.com", "image_size": "l"}) + tools.image_search_tool.func(query="sunset", runtime=_P2_RUNTIME) mock_get_client.assert_called_once() # image_search_tool only passes query to client.image_search diff --git a/backend/tests/test_invoke_acp_agent_tool.py b/backend/tests/test_invoke_acp_agent_tool.py index 3c5f6f0ff..352109963 100644 --- a/backend/tests/test_invoke_acp_agent_tool.py +++ b/backend/tests/test_invoke_acp_agent_tool.py @@ -6,7 +6,7 @@ from types import SimpleNamespace import pytest from deerflow.config.acp_config import ACPAgentConfig -from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig, set_extensions_config +from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig from deerflow.tools.builtins.invoke_acp_agent_tool import ( _build_acp_mcp_servers, _build_mcp_servers, @@ -18,7 +18,6 @@ from deerflow.tools.tools import get_available_tools def test_build_mcp_servers_filters_disabled_and_maps_transports(): - set_extensions_config(ExtensionsConfig(mcp_servers={"stale": McpServerConfig(enabled=True, type="stdio", command="echo")}, skills={})) fresh_config = ExtensionsConfig( mcp_servers={ "stdio": McpServerConfig(enabled=True, type="stdio", command="npx", args=["srv"]), @@ -40,11 +39,9 @@ def test_build_mcp_servers_filters_disabled_and_maps_transports(): } finally: monkeypatch.undo() - set_extensions_config(ExtensionsConfig(mcp_servers={}, skills={})) def test_build_acp_mcp_servers_formats_list_payload(): - set_extensions_config(ExtensionsConfig(mcp_servers={"stale": McpServerConfig(enabled=True, type="stdio", command="echo")}, skills={})) fresh_config = ExtensionsConfig( mcp_servers={ "stdio": McpServerConfig(enabled=True, type="stdio", command="npx", args=["srv"], env={"FOO": "bar"}), @@ -77,7 +74,6 @@ def test_build_acp_mcp_servers_formats_list_payload(): ] finally: monkeypatch.undo() - set_extensions_config(ExtensionsConfig(mcp_servers={}, skills={})) def test_build_permission_response_prefers_allow_once(): @@ -669,31 +665,23 @@ async def test_invoke_acp_agent_passes_none_env_when_not_configured(monkeypatch, def test_get_available_tools_includes_invoke_acp_agent_when_agents_configured(monkeypatch): - from deerflow.config.acp_config import load_acp_config_from_dict - - load_acp_config_from_dict( - { - "codex": { - "command": "codex-acp", - "args": [], - "description": "Codex CLI", - } - } - ) - fake_config = SimpleNamespace( tools=[], models=[], tool_search=SimpleNamespace(enabled=False), + acp_agents={ + "codex": ACPAgentConfig( + command="codex-acp", + args=[], + description="Codex CLI", + ) + }, get_model_config=lambda name: None, ) - monkeypatch.setattr("deerflow.tools.tools.get_app_config", lambda: fake_config) monkeypatch.setattr( "deerflow.config.extensions_config.ExtensionsConfig.from_file", classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})), ) - tools = get_available_tools(include_mcp=True, subagent_enabled=False) + tools = get_available_tools(include_mcp=True, subagent_enabled=False, app_config=fake_config) assert "invoke_acp_agent" in [tool.name for tool in tools] - - load_acp_config_from_dict({}) diff --git a/backend/tests/test_jina_client.py b/backend/tests/test_jina_client.py index b1856e4ae..3091974d6 100644 --- a/backend/tests/test_jina_client.py +++ b/backend/tests/test_jina_client.py @@ -10,6 +10,16 @@ import deerflow.community.jina_ai.jina_client as jina_client_module from deerflow.community.jina_ai.jina_client import JinaClient from deerflow.community.jina_ai.tools import web_fetch_tool +# --- Phase 2 test helper: injected runtime for community tools --- +from types import SimpleNamespace as _P2NS +from deerflow.config.app_config import AppConfig as _P2AppConfig +from deerflow.config.sandbox_config import SandboxConfig as _P2SandboxConfig +from deerflow.config.deer_flow_context import DeerFlowContext as _P2Ctx +_P2_APP_CONFIG = _P2AppConfig(sandbox=_P2SandboxConfig(use="test")) +_P2_RUNTIME = _P2NS(context=_P2Ctx(app_config=_P2_APP_CONFIG, thread_id="test-thread")) +# ------------------------------------------------------------------- + + @pytest.fixture def jina_client(): @@ -176,9 +186,8 @@ async def test_web_fetch_tool_returns_error_on_crawl_failure(monkeypatch): mock_config = MagicMock() mock_config.get_tool_config.return_value = None - monkeypatch.setattr("deerflow.community.jina_ai.tools.get_app_config", lambda: mock_config) monkeypatch.setattr(JinaClient, "crawl", mock_crawl) - result = await web_fetch_tool.ainvoke("https://example.com") + result = await web_fetch_tool.coroutine(url="https://example.com", runtime=_P2_RUNTIME) assert result.startswith("Error:") assert "429" in result @@ -192,9 +201,8 @@ async def test_web_fetch_tool_returns_markdown_on_success(monkeypatch): mock_config = MagicMock() mock_config.get_tool_config.return_value = None - monkeypatch.setattr("deerflow.community.jina_ai.tools.get_app_config", lambda: mock_config) monkeypatch.setattr(JinaClient, "crawl", mock_crawl) - result = await web_fetch_tool.ainvoke("https://example.com") + result = await web_fetch_tool.coroutine(url="https://example.com", runtime=_P2_RUNTIME) assert "Hello world" in result assert not result.startswith("Error:") diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py index 82e2733e8..9fef8b336 100644 --- a/backend/tests/test_lead_agent_model_resolution.py +++ b/backend/tests/test_lead_agent_model_resolution.py @@ -8,7 +8,6 @@ import pytest from deerflow.agents.lead_agent import agent as lead_agent_module from deerflow.config.app_config import AppConfig -from deerflow.config.memory_config import MemoryConfig from deerflow.config.model_config import ModelConfig from deerflow.config.sandbox_config import SandboxConfig from deerflow.config.summarization_config import SummarizationConfig @@ -33,7 +32,7 @@ def _make_model(name: str, *, supports_thinking: bool) -> ModelConfig: ) -def test_resolve_model_name_falls_back_to_default(monkeypatch, caplog): +def test_resolve_model_name_falls_back_to_default(caplog): app_config = _make_app_config( [ _make_model("default-model", supports_thinking=False), @@ -41,16 +40,14 @@ def test_resolve_model_name_falls_back_to_default(monkeypatch, caplog): ] ) - monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config) - with caplog.at_level("WARNING"): - resolved = lead_agent_module._resolve_model_name("missing-model") + resolved = lead_agent_module._resolve_model_name(app_config, "missing-model") assert resolved == "default-model" assert "fallback to default model 'default-model'" in caplog.text -def test_resolve_model_name_uses_default_when_none(monkeypatch): +def test_resolve_model_name_uses_default_when_none(): app_config = _make_app_config( [ _make_model("default-model", supports_thinking=False), @@ -58,23 +55,19 @@ def test_resolve_model_name_uses_default_when_none(monkeypatch): ] ) - monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config) - - resolved = lead_agent_module._resolve_model_name(None) + resolved = lead_agent_module._resolve_model_name(app_config, None) assert resolved == "default-model" -def test_resolve_model_name_raises_when_no_models_configured(monkeypatch): +def test_resolve_model_name_raises_when_no_models_configured(): app_config = _make_app_config([]) - monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config) - with pytest.raises( ValueError, match="No chat models are configured", ): - lead_agent_module._resolve_model_name("missing-model") + lead_agent_module._resolve_model_name(app_config, "missing-model") def test_make_lead_agent_disables_thinking_when_model_does_not_support_it(monkeypatch): @@ -82,13 +75,12 @@ def test_make_lead_agent_disables_thinking_when_model_does_not_support_it(monkey import deerflow.tools as tools_module - monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config) monkeypatch.setattr(tools_module, "get_available_tools", lambda **kwargs: []) - monkeypatch.setattr(lead_agent_module, "_build_middlewares", lambda config, model_name, agent_name=None: []) + monkeypatch.setattr(lead_agent_module, "_build_middlewares", lambda app_config, config, model_name, agent_name=None: []) captured: dict[str, object] = {} - def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None): + def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None): captured["name"] = name captured["thinking_enabled"] = thinking_enabled captured["reasoning_effort"] = reasoning_effort @@ -105,7 +97,8 @@ def test_make_lead_agent_disables_thinking_when_model_does_not_support_it(monkey "is_plan_mode": False, "subagent_enabled": False, } - } + }, + app_config=app_config, ) assert captured["name"] == "safe-model" @@ -113,74 +106,6 @@ def test_make_lead_agent_disables_thinking_when_model_does_not_support_it(monkey assert result["model"] is not None -def test_make_lead_agent_reads_runtime_options_from_context(monkeypatch): - app_config = _make_app_config( - [ - _make_model("default-model", supports_thinking=False), - _make_model("context-model", supports_thinking=True), - ] - ) - - import deerflow.tools as tools_module - - get_available_tools = MagicMock(return_value=[]) - monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config) - monkeypatch.setattr(tools_module, "get_available_tools", get_available_tools) - monkeypatch.setattr(lead_agent_module, "_build_middlewares", lambda config, model_name, agent_name=None: []) - - captured: dict[str, object] = {} - - def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None): - captured["name"] = name - captured["thinking_enabled"] = thinking_enabled - captured["reasoning_effort"] = reasoning_effort - return object() - - monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model) - monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs) - - result = lead_agent_module.make_lead_agent( - { - "context": { - "model_name": "context-model", - "thinking_enabled": False, - "reasoning_effort": "high", - "is_plan_mode": True, - "subagent_enabled": True, - "max_concurrent_subagents": 7, - } - } - ) - - assert captured == { - "name": "context-model", - "thinking_enabled": False, - "reasoning_effort": "high", - } - get_available_tools.assert_called_once_with(model_name="context-model", groups=None, subagent_enabled=True) - assert result["model"] is not None - - -def test_make_lead_agent_rejects_invalid_bootstrap_agent_name(monkeypatch): - app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)]) - - monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config) - - with pytest.raises(ValueError, match="Invalid agent name"): - lead_agent_module.make_lead_agent( - { - "configurable": { - "model_name": "safe-model", - "thinking_enabled": False, - "is_plan_mode": False, - "subagent_enabled": False, - "is_bootstrap": True, - "agent_name": "../../../tmp/evil", - } - } - ) - - def test_build_middlewares_uses_resolved_model_name_for_vision(monkeypatch): app_config = _make_app_config( [ @@ -197,11 +122,10 @@ def test_build_middlewares_uses_resolved_model_name_for_vision(monkeypatch): ] ) - monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config) - monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda: None) + monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda _ac: None) monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None) - middlewares = lead_agent_module._build_middlewares({"configurable": {"model_name": "stale-model", "is_plan_mode": False, "subagent_enabled": False}}, model_name="vision-model", custom_middlewares=[MagicMock()]) + middlewares = lead_agent_module._build_middlewares(app_config, {"configurable": {"model_name": "stale-model", "is_plan_mode": False, "subagent_enabled": False}}, model_name="vision-model", custom_middlewares=[MagicMock()]) assert any(isinstance(m, lead_agent_module.ViewImageMiddleware) for m in middlewares) # verify the custom middleware is injected correctly @@ -209,12 +133,10 @@ def test_build_middlewares_uses_resolved_model_name_for_vision(monkeypatch): def test_create_summarization_middleware_uses_configured_model_alias(monkeypatch): - monkeypatch.setattr( - lead_agent_module, - "get_summarization_config", - lambda: SummarizationConfig(enabled=True, model_name="model-masswork"), - ) - monkeypatch.setattr(lead_agent_module, "get_memory_config", lambda: MemoryConfig(enabled=False)) + app_config = _make_app_config([_make_model("default", supports_thinking=False)]) + patched = app_config.model_copy(update={"summarization": SummarizationConfig(enabled=True, model_name="model-masswork")}) + + from unittest.mock import MagicMock from unittest.mock import MagicMock @@ -222,16 +144,16 @@ def test_create_summarization_middleware_uses_configured_model_alias(monkeypatch fake_model = MagicMock() fake_model.with_config.return_value = fake_model - def _fake_create_chat_model(*, name=None, thinking_enabled, reasoning_effort=None): + def _fake_create_chat_model(*, name=None, thinking_enabled, reasoning_effort=None, app_config=None): captured["name"] = name captured["thinking_enabled"] = thinking_enabled captured["reasoning_effort"] = reasoning_effort return fake_model monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model) - monkeypatch.setattr(lead_agent_module, "DeerFlowSummarizationMiddleware", lambda **kwargs: kwargs) + monkeypatch.setattr(lead_agent_module, "SummarizationMiddleware", lambda **kwargs: kwargs) - middleware = lead_agent_module._create_summarization_middleware() + middleware = lead_agent_module._create_summarization_middleware(patched) assert captured["name"] == "model-masswork" assert captured["thinking_enabled"] is False diff --git a/backend/tests/test_lead_agent_prompt.py b/backend/tests/test_lead_agent_prompt.py index e82cc7ccb..dcf28decf 100644 --- a/backend/tests/test_lead_agent_prompt.py +++ b/backend/tests/test_lead_agent_prompt.py @@ -4,34 +4,23 @@ from types import SimpleNamespace import anyio from deerflow.agents.lead_agent import prompt as prompt_module +from deerflow.config.app_config import AppConfig from deerflow.skills.types import Skill -def _set_skills_cache_state(*, skills=None, active=False, version=0): - prompt_module._get_cached_skills_prompt_section.cache_clear() - with prompt_module._enabled_skills_lock: - prompt_module._enabled_skills_cache = skills - prompt_module._enabled_skills_refresh_active = active - prompt_module._enabled_skills_refresh_version = version - prompt_module._enabled_skills_refresh_event.clear() - - -def test_build_custom_mounts_section_returns_empty_when_no_mounts(monkeypatch): +def test_build_custom_mounts_section_returns_empty_when_no_mounts(): config = SimpleNamespace(sandbox=SimpleNamespace(mounts=[])) - monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) - - assert prompt_module._build_custom_mounts_section() == "" + assert prompt_module._build_custom_mounts_section(config) == "" -def test_build_custom_mounts_section_lists_configured_mounts(monkeypatch): +def test_build_custom_mounts_section_lists_configured_mounts(): 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() + section = prompt_module._build_custom_mounts_section(config) assert "**Custom Mounted Directories:**" in section assert "`/home/user/shared`" in section @@ -45,15 +34,15 @@ def test_apply_prompt_template_includes_custom_mounts(monkeypatch): config = SimpleNamespace( sandbox=SimpleNamespace(mounts=mounts), skills=SimpleNamespace(container_path="/mnt/skills"), + skill_evolution=SimpleNamespace(enabled=False), ) - monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) - monkeypatch.setattr(prompt_module, "_get_enabled_skills", lambda: []) - monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda: "") - monkeypatch.setattr(prompt_module, "_build_acp_section", lambda: "") - monkeypatch.setattr(prompt_module, "_get_memory_context", lambda agent_name=None: "") + monkeypatch.setattr(prompt_module, "_get_enabled_skills", lambda *a, **k: []) + monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda app_config: "") + monkeypatch.setattr(prompt_module, "_build_acp_section", lambda app_config: "") + monkeypatch.setattr(prompt_module, "_get_memory_context", lambda app_config, agent_name=None: "") monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None: "") - prompt = prompt_module.apply_prompt_template() + prompt = prompt_module.apply_prompt_template(config) assert "`/home/user/shared`" in prompt assert "Custom Mounted Directories" in prompt @@ -63,15 +52,15 @@ def test_apply_prompt_template_includes_relative_path_guidance(monkeypatch): config = SimpleNamespace( sandbox=SimpleNamespace(mounts=[]), skills=SimpleNamespace(container_path="/mnt/skills"), + skill_evolution=SimpleNamespace(enabled=False), ) - monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) - monkeypatch.setattr(prompt_module, "_get_enabled_skills", lambda: []) - monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda: "") - monkeypatch.setattr(prompt_module, "_build_acp_section", lambda: "") - monkeypatch.setattr(prompt_module, "_get_memory_context", lambda agent_name=None: "") + monkeypatch.setattr(prompt_module, "_get_enabled_skills", lambda *a, **k: []) + monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda app_config: "") + monkeypatch.setattr(prompt_module, "_build_acp_section", lambda app_config: "") + monkeypatch.setattr(prompt_module, "_get_memory_context", lambda app_config, agent_name=None: "") monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None: "") - prompt = prompt_module.apply_prompt_template() + prompt = prompt_module.apply_prompt_template(config) assert "Treat `/mnt/user-data/workspace` as your default current working directory" in prompt assert "`hello.txt`, `../uploads/data.csv`, and `../outputs/report.md`" in prompt @@ -92,8 +81,8 @@ def test_refresh_skills_system_prompt_cache_async_reloads_immediately(monkeypatc ) state = {"skills": [make_skill("first-skill")]} - monkeypatch.setattr(prompt_module, "load_skills", lambda enabled_only=True: list(state["skills"])) - _set_skills_cache_state() + monkeypatch.setattr(prompt_module, "load_skills", lambda *a, **kwargs: list(state["skills"])) + prompt_module._reset_skills_system_prompt_cache_state() try: prompt_module.warm_enabled_skills_cache() @@ -128,7 +117,7 @@ def test_clear_cache_does_not_spawn_parallel_refresh_workers(monkeypatch, tmp_pa enabled=True, ) - def fake_load_skills(enabled_only=True): + def fake_load_skills(*a, **kwargs): nonlocal active_loads, max_active_loads, call_count with lock: active_loads += 1 @@ -165,7 +154,7 @@ def test_clear_cache_does_not_spawn_parallel_refresh_workers(monkeypatch, tmp_pa def test_warm_enabled_skills_cache_logs_on_timeout(monkeypatch, caplog): event = threading.Event() - monkeypatch.setattr(prompt_module, "_ensure_enabled_skills_cache", lambda: event) + monkeypatch.setattr(prompt_module, "_ensure_enabled_skills_cache", lambda *a, **k: event) with caplog.at_level("WARNING"): warmed = prompt_module.warm_enabled_skills_cache(timeout_seconds=0.01) diff --git a/backend/tests/test_lead_agent_skills.py b/backend/tests/test_lead_agent_skills.py index 441dbeee2..2623b840f 100644 --- a/backend/tests/test_lead_agent_skills.py +++ b/backend/tests/test_lead_agent_skills.py @@ -19,27 +19,40 @@ def _make_skill(name: str) -> Skill: ) +_DEFAULT_SKILLS_CONFIG = SimpleNamespace( + skills=SimpleNamespace(container_path="/mnt/skills"), + skill_evolution=SimpleNamespace(enabled=False), +) + + +def _evolution_enabled_config() -> SimpleNamespace: + return SimpleNamespace( + skills=SimpleNamespace(container_path="/mnt/skills"), + skill_evolution=SimpleNamespace(enabled=True), + ) + + def test_get_skills_prompt_section_returns_empty_when_no_skills_match(monkeypatch): skills = [_make_skill("skill1"), _make_skill("skill2")] - monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills) + monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda *a, **k: skills) - result = get_skills_prompt_section(available_skills={"non_existent_skill"}) + result = get_skills_prompt_section(_DEFAULT_SKILLS_CONFIG, available_skills={"non_existent_skill"}) assert result == "" def test_get_skills_prompt_section_returns_empty_when_available_skills_empty(monkeypatch): skills = [_make_skill("skill1"), _make_skill("skill2")] - monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills) + monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda *a, **k: skills) - result = get_skills_prompt_section(available_skills=set()) + result = get_skills_prompt_section(_DEFAULT_SKILLS_CONFIG, available_skills=set()) assert result == "" def test_get_skills_prompt_section_returns_skills(monkeypatch): skills = [_make_skill("skill1"), _make_skill("skill2")] - monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills) + monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda *a, **k: skills) - result = get_skills_prompt_section(available_skills={"skill1"}) + result = get_skills_prompt_section(_DEFAULT_SKILLS_CONFIG, available_skills={"skill1"}) assert "skill1" in result assert "skill2" not in result assert "[built-in]" in result @@ -47,56 +60,41 @@ def test_get_skills_prompt_section_returns_skills(monkeypatch): def test_get_skills_prompt_section_returns_all_when_available_skills_is_none(monkeypatch): skills = [_make_skill("skill1"), _make_skill("skill2")] - monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills) + monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda *a, **k: skills) - result = get_skills_prompt_section(available_skills=None) + result = get_skills_prompt_section(_DEFAULT_SKILLS_CONFIG, available_skills=None) assert "skill1" in result assert "skill2" in result def test_get_skills_prompt_section_includes_self_evolution_rules(monkeypatch): skills = [_make_skill("skill1")] - monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills) - monkeypatch.setattr( - "deerflow.config.get_app_config", - lambda: SimpleNamespace( - skills=SimpleNamespace(container_path="/mnt/skills"), - skill_evolution=SimpleNamespace(enabled=True), - ), - ) + monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda *a, **k: skills) - result = get_skills_prompt_section(available_skills=None) + result = get_skills_prompt_section(_evolution_enabled_config(), available_skills=None) assert "Skill Self-Evolution" in result def test_get_skills_prompt_section_includes_self_evolution_rules_without_skills(monkeypatch): - monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: []) - monkeypatch.setattr( - "deerflow.config.get_app_config", - lambda: SimpleNamespace( - skills=SimpleNamespace(container_path="/mnt/skills"), - skill_evolution=SimpleNamespace(enabled=True), - ), - ) + monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda *a, **k: []) - result = get_skills_prompt_section(available_skills=None) + result = get_skills_prompt_section(_evolution_enabled_config(), available_skills=None) assert "Skill Self-Evolution" in result def test_get_skills_prompt_section_cache_respects_skill_evolution_toggle(monkeypatch): skills = [_make_skill("skill1")] - monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills) - config = SimpleNamespace( - skills=SimpleNamespace(container_path="/mnt/skills"), - skill_evolution=SimpleNamespace(enabled=True), - ) - monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) + monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda *a, **k: skills) + config = _evolution_enabled_config() - enabled_result = get_skills_prompt_section(available_skills=None) + enabled_result = get_skills_prompt_section(config, available_skills=None) assert "Skill Self-Evolution" in enabled_result - config.skill_evolution.enabled = False - disabled_result = get_skills_prompt_section(available_skills=None) + disabled_config = SimpleNamespace( + skills=SimpleNamespace(container_path="/mnt/skills"), + skill_evolution=SimpleNamespace(enabled=False), + ) + disabled_result = get_skills_prompt_section(disabled_config, available_skills=None) assert "Skill Self-Evolution" not in disabled_result @@ -106,8 +104,7 @@ def test_make_lead_agent_empty_skills_passed_correctly(monkeypatch): from deerflow.agents.lead_agent import agent as lead_agent_module # Mock dependencies - monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: MagicMock()) - monkeypatch.setattr(lead_agent_module, "_resolve_model_name", lambda x=None: "default-model") + monkeypatch.setattr(lead_agent_module, "_resolve_model_name", lambda app_config=None, x=None: "default-model") monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: "model") monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: []) monkeypatch.setattr(lead_agent_module, "_build_middlewares", lambda *args, **kwargs: []) @@ -118,11 +115,10 @@ def test_make_lead_agent_empty_skills_passed_correctly(monkeypatch): mock_app_config = MagicMock() mock_app_config.get_model_config.return_value = MockModelConfig() - monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: mock_app_config) captured_skills = [] - def mock_apply_prompt_template(**kwargs): + def mock_apply_prompt_template(_app_config, *args, **kwargs): captured_skills.append(kwargs.get("available_skills")) return "mock_prompt" @@ -130,15 +126,15 @@ def test_make_lead_agent_empty_skills_passed_correctly(monkeypatch): # Case 1: Empty skills list monkeypatch.setattr(lead_agent_module, "load_agent_config", lambda x: AgentConfig(name="test", skills=[])) - lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}}) + lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}}, app_config=mock_app_config) assert captured_skills[-1] == set() # Case 2: None skills list monkeypatch.setattr(lead_agent_module, "load_agent_config", lambda x: AgentConfig(name="test", skills=None)) - lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}}) + lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}}, app_config=mock_app_config) assert captured_skills[-1] is None # Case 3: Some skills list monkeypatch.setattr(lead_agent_module, "load_agent_config", lambda x: AgentConfig(name="test", skills=["skill1"])) - lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}}) + lead_agent_module.make_lead_agent({"configurable": {"agent_name": "test"}}, app_config=mock_app_config) assert captured_skills[-1] == {"skill1"} diff --git a/backend/tests/test_local_bash_tool_loading.py b/backend/tests/test_local_bash_tool_loading.py index 60c79a937..a58bad7f4 100644 --- a/backend/tests/test_local_bash_tool_loading.py +++ b/backend/tests/test_local_bash_tool_loading.py @@ -22,26 +22,26 @@ def _make_config(*, allow_host_bash: bool, sandbox_use: str = "deerflow.sandbox. def test_get_available_tools_hides_bash_for_default_local_sandbox(monkeypatch): - monkeypatch.setattr("deerflow.tools.tools.get_app_config", lambda: _make_config(allow_host_bash=False)) + app_config = _make_config(allow_host_bash=False) monkeypatch.setattr( "deerflow.tools.tools.resolve_variable", lambda use, _: SimpleNamespace(name="bash" if "bash" in use else "ls"), ) - names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False)] + names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False, app_config=app_config)] assert "bash" not in names assert "ls" in names def test_get_available_tools_keeps_bash_when_explicitly_enabled(monkeypatch): - monkeypatch.setattr("deerflow.tools.tools.get_app_config", lambda: _make_config(allow_host_bash=True)) + app_config = _make_config(allow_host_bash=True) monkeypatch.setattr( "deerflow.tools.tools.resolve_variable", lambda use, _: SimpleNamespace(name="bash" if "bash" in use else "ls"), ) - names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False)] + names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False, app_config=app_config)] assert "bash" in names assert "ls" in names @@ -52,13 +52,12 @@ def test_get_available_tools_hides_renamed_host_bash_alias(monkeypatch): allow_host_bash=False, extra_tools=[SimpleNamespace(name="shell", group="bash", use="deerflow.sandbox.tools:bash_tool")], ) - monkeypatch.setattr("deerflow.tools.tools.get_app_config", lambda: config) monkeypatch.setattr( "deerflow.tools.tools.resolve_variable", lambda use, _: SimpleNamespace(name="bash" if "bash_tool" in use else "ls"), ) - names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False)] + names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False, app_config=config)] assert "bash" not in names assert "shell" not in names @@ -70,13 +69,12 @@ def test_get_available_tools_keeps_bash_for_aio_sandbox(monkeypatch): allow_host_bash=False, sandbox_use="deerflow.community.aio_sandbox:AioSandboxProvider", ) - monkeypatch.setattr("deerflow.tools.tools.get_app_config", lambda: config) monkeypatch.setattr( "deerflow.tools.tools.resolve_variable", lambda use, _: SimpleNamespace(name="bash" if "bash_tool" in use else "ls"), ) - names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False)] + names = [tool.name for tool in get_available_tools(include_mcp=False, subagent_enabled=False, app_config=config)] assert "bash" in names assert "ls" in names diff --git a/backend/tests/test_local_sandbox_provider_mounts.py b/backend/tests/test_local_sandbox_provider_mounts.py index 328b1d48d..ace7fbf7a 100644 --- a/backend/tests/test_local_sandbox_provider_mounts.py +++ b/backend/tests/test_local_sandbox_provider_mounts.py @@ -1,6 +1,5 @@ import errno from types import SimpleNamespace -from unittest.mock import patch import pytest @@ -314,8 +313,7 @@ class TestLocalSandboxProviderMounts: sandbox=sandbox_config, ) - with patch("deerflow.config.get_app_config", return_value=config): - provider = LocalSandboxProvider() + provider = LocalSandboxProvider(app_config=config) assert [m.container_path for m in provider._path_mappings] == ["/custom-skills"] @@ -336,8 +334,7 @@ class TestLocalSandboxProviderMounts: sandbox=sandbox_config, ) - with patch("deerflow.config.get_app_config", return_value=config): - provider = LocalSandboxProvider() + provider = LocalSandboxProvider(app_config=config) assert [m.container_path for m in provider._path_mappings] == ["/mnt/skills"] @@ -360,8 +357,7 @@ class TestLocalSandboxProviderMounts: sandbox=sandbox_config, ) - with patch("deerflow.config.get_app_config", return_value=config): - provider = LocalSandboxProvider() + provider = LocalSandboxProvider(app_config=config) assert [m.container_path for m in provider._path_mappings] == ["/mnt/skills"] @@ -476,7 +472,6 @@ class TestLocalSandboxProviderMounts: sandbox=sandbox_config, ) - with patch("deerflow.config.get_app_config", return_value=config): - provider = LocalSandboxProvider() + provider = LocalSandboxProvider(app_config=config) assert [m.container_path for m in provider._path_mappings] == ["/mnt/skills", "/mnt/data"] diff --git a/backend/tests/test_loop_detection_middleware.py b/backend/tests/test_loop_detection_middleware.py index 8d2b34860..fc08e2009 100644 --- a/backend/tests/test_loop_detection_middleware.py +++ b/backend/tests/test_loop_detection_middleware.py @@ -10,12 +10,22 @@ from deerflow.agents.middlewares.loop_detection_middleware import ( LoopDetectionMiddleware, _hash_tool_calls, ) +from deerflow.config.app_config import AppConfig +from deerflow.config.deer_flow_context import DeerFlowContext +from deerflow.config.sandbox_config import SandboxConfig + + +def _make_context(thread_id: str) -> DeerFlowContext: + return DeerFlowContext( + app_config=AppConfig(sandbox=SandboxConfig(use="test")), + thread_id=thread_id, + ) def _make_runtime(thread_id="test-thread"): """Build a minimal Runtime mock with context.""" runtime = MagicMock() - runtime.context = {"thread_id": thread_id} + runtime.context = _make_context(thread_id) return runtime @@ -293,10 +303,10 @@ class TestLoopDetection: assert isinstance(mw._lock, type(mw._lock)) def test_fallback_thread_id_when_missing(self): - """When runtime context has no thread_id, should use 'default'.""" + """When runtime context has empty thread_id, should use 'default'.""" mw = LoopDetectionMiddleware(warn_threshold=2) runtime = MagicMock() - runtime.context = {} + runtime.context = _make_context("") call = [_bash_call("ls")] mw._apply(_make_state(tool_calls=call), runtime) diff --git a/backend/tests/test_memory_queue.py b/backend/tests/test_memory_queue.py index 27808b0e8..4e5a6e984 100644 --- a/backend/tests/test_memory_queue.py +++ b/backend/tests/test_memory_queue.py @@ -3,23 +3,31 @@ import time from unittest.mock import MagicMock, patch from deerflow.agents.memory.queue import ConversationContext, MemoryUpdateQueue +from deerflow.config.app_config import AppConfig from deerflow.config.memory_config import MemoryConfig +from deerflow.config.sandbox_config import SandboxConfig -def _memory_config(**overrides: object) -> MemoryConfig: - config = MemoryConfig() - for key, value in overrides.items(): - setattr(config, key, value) - return config + +# --- Phase 2 config-refactor test helper --- +# Memory APIs now take MemoryConfig / AppConfig explicitly. Tests construct a +# minimal config once and reuse it across call sites. +from deerflow.config.app_config import AppConfig as _TestAppConfig +from deerflow.config.memory_config import MemoryConfig as _TestMemoryConfig +from deerflow.config.sandbox_config import SandboxConfig as _TestSandboxConfig + +_TEST_MEMORY_CONFIG = _TestMemoryConfig(enabled=True) +_TEST_APP_CONFIG = _TestAppConfig(sandbox=_TestSandboxConfig(use="test"), memory=_TEST_MEMORY_CONFIG) +# ------------------------------------------- + +def _make_config(**memory_overrides) -> AppConfig: + return AppConfig(sandbox=SandboxConfig(use="test"), memory=MemoryConfig(**memory_overrides)) def test_queue_add_preserves_existing_correction_flag_for_same_thread() -> None: - queue = MemoryUpdateQueue() + queue = MemoryUpdateQueue(_TEST_APP_CONFIG) - with ( - patch("deerflow.agents.memory.queue.get_memory_config", return_value=_memory_config(enabled=True)), - patch.object(queue, "_reset_timer"), - ): + with patch.object(queue, "_reset_timer"): queue.add(thread_id="thread-1", messages=["first"], correction_detected=True) queue.add(thread_id="thread-1", messages=["second"], correction_detected=False) @@ -29,7 +37,7 @@ def test_queue_add_preserves_existing_correction_flag_for_same_thread() -> None: def test_process_queue_forwards_correction_flag_to_updater() -> None: - queue = MemoryUpdateQueue() + queue = MemoryUpdateQueue(_TEST_APP_CONFIG) queue._queue = [ ConversationContext( thread_id="thread-1", @@ -55,12 +63,9 @@ def test_process_queue_forwards_correction_flag_to_updater() -> None: def test_queue_add_preserves_existing_reinforcement_flag_for_same_thread() -> None: - queue = MemoryUpdateQueue() + queue = MemoryUpdateQueue(_TEST_APP_CONFIG) - with ( - patch("deerflow.agents.memory.queue.get_memory_config", return_value=_memory_config(enabled=True)), - patch.object(queue, "_reset_timer"), - ): + with patch.object(queue, "_reset_timer"): queue.add(thread_id="thread-1", messages=["first"], reinforcement_detected=True) queue.add(thread_id="thread-1", messages=["second"], reinforcement_detected=False) @@ -70,7 +75,7 @@ def test_queue_add_preserves_existing_reinforcement_flag_for_same_thread() -> No def test_process_queue_forwards_reinforcement_flag_to_updater() -> None: - queue = MemoryUpdateQueue() + queue = MemoryUpdateQueue(_TEST_APP_CONFIG) queue._queue = [ ConversationContext( thread_id="thread-1", diff --git a/backend/tests/test_memory_queue_user_isolation.py b/backend/tests/test_memory_queue_user_isolation.py index cf068e095..23fc948a0 100644 --- a/backend/tests/test_memory_queue_user_isolation.py +++ b/backend/tests/test_memory_queue_user_isolation.py @@ -1,8 +1,30 @@ -"""Tests for user_id propagation through memory queue.""" +# --- Phase 2 config-refactor test helper --- +# Memory APIs now take MemoryConfig / AppConfig explicitly. Tests construct a +# minimal config once and reuse it across call sites. +from deerflow.config.app_config import AppConfig as _TestAppConfig +from deerflow.config.memory_config import MemoryConfig as _TestMemoryConfig +from deerflow.config.sandbox_config import SandboxConfig as _TestSandboxConfig + +_TEST_MEMORY_CONFIG = _TestMemoryConfig(enabled=True) +_TEST_APP_CONFIG = _TestAppConfig(sandbox=_TestSandboxConfig(use="test"), memory=_TEST_MEMORY_CONFIG) +# ------------------------------------------- + +"""Tests for user_id propagation through memory queue.""" from unittest.mock import MagicMock, patch +import pytest + from deerflow.agents.memory.queue import ConversationContext, MemoryUpdateQueue +from deerflow.config.app_config import AppConfig +from deerflow.config.memory_config import MemoryConfig + + +@pytest.fixture(autouse=True) +def _enable_memory(monkeypatch): + """Ensure MemoryUpdateQueue.add() doesn't early-return on disabled memory.""" + config = MagicMock(spec=AppConfig) + config.memory = MemoryConfig(enabled=True) def test_conversation_context_has_user_id(): @@ -16,7 +38,7 @@ def test_conversation_context_user_id_default_none(): def test_queue_add_stores_user_id(): - q = MemoryUpdateQueue() + q = MemoryUpdateQueue(_TEST_APP_CONFIG) with patch.object(q, "_reset_timer"): q.add(thread_id="t1", messages=["msg"], user_id="alice") assert len(q._queue) == 1 @@ -25,7 +47,7 @@ def test_queue_add_stores_user_id(): def test_queue_process_passes_user_id_to_updater(): - q = MemoryUpdateQueue() + q = MemoryUpdateQueue(_TEST_APP_CONFIG) with patch.object(q, "_reset_timer"): q.add(thread_id="t1", messages=["msg"], user_id="alice") diff --git a/backend/tests/test_memory_router.py b/backend/tests/test_memory_router.py index 91fd1d662..55f7f428f 100644 --- a/backend/tests/test_memory_router.py +++ b/backend/tests/test_memory_router.py @@ -4,6 +4,18 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from app.gateway.routers import memory +from deerflow.config.app_config import AppConfig +from deerflow.config.sandbox_config import SandboxConfig + +_TEST_APP_CONFIG = AppConfig(sandbox=SandboxConfig(use="test")) + + +def _make_app() -> FastAPI: + """Build a memory-router app pre-populated with a minimal AppConfig.""" + app = FastAPI() + app.state.config = _TEST_APP_CONFIG + app.include_router(memory.router) + return app def _sample_memory(facts: list[dict] | None = None) -> dict: @@ -25,8 +37,7 @@ def _sample_memory(facts: list[dict] | None = None) -> dict: def test_export_memory_route_returns_current_memory() -> None: - app = FastAPI() - app.include_router(memory.router) + app = _make_app() exported_memory = _sample_memory( facts=[ { @@ -49,8 +60,7 @@ def test_export_memory_route_returns_current_memory() -> None: def test_import_memory_route_returns_imported_memory() -> None: - app = FastAPI() - app.include_router(memory.router) + app = _make_app() imported_memory = _sample_memory( facts=[ { @@ -73,8 +83,7 @@ def test_import_memory_route_returns_imported_memory() -> None: def test_export_memory_route_preserves_source_error() -> None: - app = FastAPI() - app.include_router(memory.router) + app = _make_app() exported_memory = _sample_memory( facts=[ { @@ -98,8 +107,7 @@ def test_export_memory_route_preserves_source_error() -> None: def test_import_memory_route_preserves_source_error() -> None: - app = FastAPI() - app.include_router(memory.router) + app = _make_app() imported_memory = _sample_memory( facts=[ { @@ -123,8 +131,7 @@ def test_import_memory_route_preserves_source_error() -> None: def test_clear_memory_route_returns_cleared_memory() -> None: - app = FastAPI() - app.include_router(memory.router) + app = _make_app() with patch("app.gateway.routers.memory.clear_memory_data", return_value=_sample_memory()): with TestClient(app) as client: @@ -135,8 +142,7 @@ def test_clear_memory_route_returns_cleared_memory() -> None: def test_create_memory_fact_route_returns_updated_memory() -> None: - app = FastAPI() - app.include_router(memory.router) + app = _make_app() updated_memory = _sample_memory( facts=[ { @@ -166,8 +172,7 @@ def test_create_memory_fact_route_returns_updated_memory() -> None: def test_delete_memory_fact_route_returns_updated_memory() -> None: - app = FastAPI() - app.include_router(memory.router) + app = _make_app() updated_memory = _sample_memory( facts=[ { @@ -190,8 +195,7 @@ def test_delete_memory_fact_route_returns_updated_memory() -> None: def test_delete_memory_fact_route_returns_404_for_missing_fact() -> None: - app = FastAPI() - app.include_router(memory.router) + app = _make_app() with patch("app.gateway.routers.memory.delete_memory_fact", side_effect=KeyError("fact_missing")): with TestClient(app) as client: @@ -202,8 +206,7 @@ def test_delete_memory_fact_route_returns_404_for_missing_fact() -> None: def test_update_memory_fact_route_returns_updated_memory() -> None: - app = FastAPI() - app.include_router(memory.router) + app = _make_app() updated_memory = _sample_memory( facts=[ { @@ -233,8 +236,7 @@ def test_update_memory_fact_route_returns_updated_memory() -> None: def test_update_memory_fact_route_preserves_omitted_fields() -> None: - app = FastAPI() - app.include_router(memory.router) + app = _make_app() updated_memory = _sample_memory( facts=[ { @@ -269,8 +271,7 @@ def test_update_memory_fact_route_preserves_omitted_fields() -> None: def test_update_memory_fact_route_returns_404_for_missing_fact() -> None: - app = FastAPI() - app.include_router(memory.router) + app = _make_app() with patch("app.gateway.routers.memory.update_memory_fact", side_effect=KeyError("fact_missing")): with TestClient(app) as client: @@ -288,8 +289,7 @@ def test_update_memory_fact_route_returns_404_for_missing_fact() -> None: def test_update_memory_fact_route_returns_specific_error_for_invalid_confidence() -> None: - app = FastAPI() - app.include_router(memory.router) + app = _make_app() with patch("app.gateway.routers.memory.update_memory_fact", side_effect=ValueError("confidence")): with TestClient(app) as client: diff --git a/backend/tests/test_memory_storage.py b/backend/tests/test_memory_storage.py index d11ad3316..62fe117ae 100644 --- a/backend/tests/test_memory_storage.py +++ b/backend/tests/test_memory_storage.py @@ -1,3 +1,15 @@ + +# --- Phase 2 config-refactor test helper --- +# Memory APIs now take MemoryConfig / AppConfig explicitly. Tests construct a +# minimal config once and reuse it across call sites. +from deerflow.config.app_config import AppConfig as _TestAppConfig +from deerflow.config.memory_config import MemoryConfig as _TestMemoryConfig +from deerflow.config.sandbox_config import SandboxConfig as _TestSandboxConfig + +_TEST_MEMORY_CONFIG = _TestMemoryConfig(enabled=True) +_TEST_APP_CONFIG = _TestAppConfig(sandbox=_TestSandboxConfig(use="test"), memory=_TEST_MEMORY_CONFIG) +# ------------------------------------------- + """Tests for memory storage providers.""" import threading @@ -11,7 +23,13 @@ from deerflow.agents.memory.storage import ( create_empty_memory, get_memory_storage, ) +from deerflow.config.app_config import AppConfig from deerflow.config.memory_config import MemoryConfig +from deerflow.config.sandbox_config import SandboxConfig + + +def _app_config(**memory_overrides) -> AppConfig: + return AppConfig(sandbox=SandboxConfig(use="test"), memory=MemoryConfig(**memory_overrides)) class TestCreateEmptyMemory: @@ -53,10 +71,9 @@ class TestFileMemoryStorage: return mock_paths with patch("deerflow.agents.memory.storage.get_paths", side_effect=mock_get_paths): - with patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_path="")): - storage = FileMemoryStorage() - path = storage._get_memory_file_path(None) - assert path == tmp_path / "memory.json" + storage = FileMemoryStorage(_TEST_MEMORY_CONFIG) + path = storage._get_memory_file_path(None) + assert path == tmp_path / "memory.json" def test_get_memory_file_path_agent(self, tmp_path): """Should return per-agent memory file path when agent_name is provided.""" @@ -67,14 +84,14 @@ class TestFileMemoryStorage: return mock_paths with patch("deerflow.agents.memory.storage.get_paths", side_effect=mock_get_paths): - storage = FileMemoryStorage() + storage = FileMemoryStorage(_TEST_MEMORY_CONFIG) path = storage._get_memory_file_path("test-agent") assert path == tmp_path / "agents" / "test-agent" / "memory.json" @pytest.mark.parametrize("invalid_name", ["", "../etc/passwd", "agent/name", "agent\\name", "agent name", "agent@123", "agent_name"]) def test_validate_agent_name_invalid(self, invalid_name): """Should raise ValueError for invalid agent names that don't match the pattern.""" - storage = FileMemoryStorage() + storage = FileMemoryStorage(_TEST_MEMORY_CONFIG) with pytest.raises(ValueError, match="Invalid agent name|Agent name must be a non-empty string"): storage._validate_agent_name(invalid_name) @@ -87,11 +104,10 @@ class TestFileMemoryStorage: return mock_paths with patch("deerflow.agents.memory.storage.get_paths", side_effect=mock_get_paths): - with patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_path="")): - storage = FileMemoryStorage() - memory = storage.load() - assert isinstance(memory, dict) - assert memory["version"] == "1.0" + storage = FileMemoryStorage(_TEST_MEMORY_CONFIG) + memory = storage.load() + assert isinstance(memory, dict) + assert memory["version"] == "1.0" def test_save_writes_to_file(self, tmp_path): """Should save memory data to file.""" @@ -103,12 +119,11 @@ class TestFileMemoryStorage: return mock_paths with patch("deerflow.agents.memory.storage.get_paths", side_effect=mock_get_paths): - with patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_path="")): - storage = FileMemoryStorage() - test_memory = {"version": "1.0", "facts": [{"content": "test fact"}]} - result = storage.save(test_memory) - assert result is True - assert memory_file.exists() + storage = FileMemoryStorage(_TEST_MEMORY_CONFIG) + test_memory = {"version": "1.0", "facts": [{"content": "test fact"}]} + result = storage.save(test_memory) + assert result is True + assert memory_file.exists() def test_save_does_not_mutate_caller_dict(self, tmp_path): """save() must not mutate the caller's dict (lastUpdated side-effect).""" @@ -209,18 +224,17 @@ class TestFileMemoryStorage: return mock_paths with patch("deerflow.agents.memory.storage.get_paths", side_effect=mock_get_paths): - with patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_path="")): - storage = FileMemoryStorage() - # First load - memory1 = storage.load() - assert memory1["facts"][0]["content"] == "initial fact" + storage = FileMemoryStorage(_TEST_MEMORY_CONFIG) + # First load + memory1 = storage.load() + assert memory1["facts"][0]["content"] == "initial fact" - # Update file directly - memory_file.write_text('{"version": "1.0", "facts": [{"content": "updated fact"}]}') + # Update file directly + memory_file.write_text('{"version": "1.0", "facts": [{"content": "updated fact"}]}') - # Reload should get updated data - memory2 = storage.reload() - assert memory2["facts"][0]["content"] == "updated fact" + # Reload should get updated data + memory2 = storage.reload() + assert memory2["facts"][0]["content"] == "updated fact" class TestGetMemoryStorage: @@ -237,22 +251,19 @@ class TestGetMemoryStorage: def test_returns_file_memory_storage_by_default(self): """Should return FileMemoryStorage by default.""" - with patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_class="deerflow.agents.memory.storage.FileMemoryStorage")): - storage = get_memory_storage() - assert isinstance(storage, FileMemoryStorage) + storage = get_memory_storage(_TEST_MEMORY_CONFIG) + assert isinstance(storage, FileMemoryStorage) def test_falls_back_to_file_memory_storage_on_error(self): """Should fall back to FileMemoryStorage if configured storage fails to load.""" - with patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_class="non.existent.StorageClass")): - storage = get_memory_storage() - assert isinstance(storage, FileMemoryStorage) + storage = get_memory_storage(_TEST_MEMORY_CONFIG) + assert isinstance(storage, FileMemoryStorage) def test_returns_singleton_instance(self): """Should return the same instance on subsequent calls.""" - with patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_class="deerflow.agents.memory.storage.FileMemoryStorage")): - storage1 = get_memory_storage() - storage2 = get_memory_storage() - assert storage1 is storage2 + storage1 = get_memory_storage(_TEST_MEMORY_CONFIG) + storage2 = get_memory_storage(_TEST_MEMORY_CONFIG) + assert storage1 is storage2 def test_get_memory_storage_thread_safety(self): """Should safely initialize the singleton even with concurrent calls.""" @@ -260,16 +271,15 @@ class TestGetMemoryStorage: def get_storage(): # get_memory_storage is called concurrently from multiple threads while - # get_memory_config is patched once around thread creation. This verifies + # AppConfig.get is patched once around thread creation. This verifies # that the singleton initialization remains thread-safe. - results.append(get_memory_storage()) + results.append(get_memory_storage(_TEST_MEMORY_CONFIG)) - with patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_class="deerflow.agents.memory.storage.FileMemoryStorage")): - threads = [threading.Thread(target=get_storage) for _ in range(10)] - for t in threads: - t.start() - for t in threads: - t.join() + threads = [threading.Thread(target=get_storage) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() # All results should be the exact same instance assert len(results) == 10 @@ -278,13 +288,11 @@ class TestGetMemoryStorage: def test_get_memory_storage_invalid_class_fallback(self): """Should fall back to FileMemoryStorage if the configured class is not actually a class.""" # Using a built-in function instead of a class - with patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_class="os.path.join")): - storage = get_memory_storage() - assert isinstance(storage, FileMemoryStorage) + storage = get_memory_storage(_TEST_MEMORY_CONFIG) + assert isinstance(storage, FileMemoryStorage) def test_get_memory_storage_non_subclass_fallback(self): """Should fall back to FileMemoryStorage if the configured class is not a subclass of MemoryStorage.""" # Using 'dict' as a class that is not a MemoryStorage subclass - with patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_class="builtins.dict")): - storage = get_memory_storage() - assert isinstance(storage, FileMemoryStorage) + storage = get_memory_storage(_TEST_MEMORY_CONFIG) + assert isinstance(storage, FileMemoryStorage) diff --git a/backend/tests/test_memory_storage_user_isolation.py b/backend/tests/test_memory_storage_user_isolation.py index 5dd114b7e..8e5438eff 100644 --- a/backend/tests/test_memory_storage_user_isolation.py +++ b/backend/tests/test_memory_storage_user_isolation.py @@ -1,11 +1,29 @@ -"""Tests for per-user memory storage isolation.""" +# --- Phase 2 config-refactor test helper --- +# Memory APIs now take MemoryConfig / AppConfig explicitly. Tests construct a +# minimal config once and reuse it across call sites. +from deerflow.config.app_config import AppConfig as _TestAppConfig +from deerflow.config.memory_config import MemoryConfig as _TestMemoryConfig +from deerflow.config.sandbox_config import SandboxConfig as _TestSandboxConfig + +_TEST_MEMORY_CONFIG = _TestMemoryConfig(enabled=True) +_TEST_APP_CONFIG = _TestAppConfig(sandbox=_TestSandboxConfig(use="test"), memory=_TEST_MEMORY_CONFIG) +# ------------------------------------------- + +"""Tests for per-user memory storage isolation.""" +import pytest from pathlib import Path from unittest.mock import patch -import pytest - from deerflow.agents.memory.storage import FileMemoryStorage, create_empty_memory +from deerflow.config.app_config import AppConfig +from deerflow.config.memory_config import MemoryConfig +from deerflow.config.sandbox_config import SandboxConfig + + +def _mock_app_config() -> AppConfig: + """Build a minimal AppConfig with default (empty) memory storage_path.""" + return AppConfig(sandbox=SandboxConfig(use="test"), memory=MemoryConfig(storage_path="")) @pytest.fixture @@ -15,7 +33,9 @@ def base_dir(tmp_path: Path) -> Path: @pytest.fixture def storage() -> FileMemoryStorage: - return FileMemoryStorage() + return FileMemoryStorage(_TEST_MEMORY_CONFIG) + + class TestUserIsolatedStorage: @@ -43,7 +63,7 @@ class TestUserIsolatedStorage: paths = Paths(base_dir) with patch("deerflow.agents.memory.storage.get_paths", return_value=paths): - s = FileMemoryStorage() + s = FileMemoryStorage(_TEST_MEMORY_CONFIG) memory = create_empty_memory() s.save(memory, user_id="alice") expected_path = base_dir / "users" / "alice" / "memory.json" @@ -54,7 +74,7 @@ class TestUserIsolatedStorage: paths = Paths(base_dir) with patch("deerflow.agents.memory.storage.get_paths", return_value=paths): - s = FileMemoryStorage() + s = FileMemoryStorage(_TEST_MEMORY_CONFIG) memory_a = create_empty_memory() memory_a["user"]["workContext"]["summary"] = "A" s.save(memory_a, user_id="alice") @@ -67,38 +87,34 @@ class TestUserIsolatedStorage: assert loaded_a["user"]["workContext"]["summary"] == "A" def test_no_user_id_uses_legacy_path(self, base_dir: Path): - from deerflow.config.memory_config import MemoryConfig from deerflow.config.paths import Paths paths = Paths(base_dir) with patch("deerflow.agents.memory.storage.get_paths", return_value=paths): - with patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_path="")): - s = FileMemoryStorage() - memory = create_empty_memory() - s.save(memory, user_id=None) - expected_path = base_dir / "memory.json" - assert expected_path.exists() + s = FileMemoryStorage(_TEST_MEMORY_CONFIG) + memory = create_empty_memory() + s.save(memory, user_id=None) + expected_path = base_dir / "memory.json" + assert expected_path.exists() def test_user_and_legacy_do_not_interfere(self, base_dir: Path): """user_id=None (legacy) and user_id='alice' must use different files and caches.""" - from deerflow.config.memory_config import MemoryConfig from deerflow.config.paths import Paths paths = Paths(base_dir) with patch("deerflow.agents.memory.storage.get_paths", return_value=paths): - with patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_path="")): - s = FileMemoryStorage() + s = FileMemoryStorage(_TEST_MEMORY_CONFIG) - legacy_mem = create_empty_memory() - legacy_mem["user"]["workContext"]["summary"] = "legacy" - s.save(legacy_mem, user_id=None) + legacy_mem = create_empty_memory() + legacy_mem["user"]["workContext"]["summary"] = "legacy" + s.save(legacy_mem, user_id=None) - user_mem = create_empty_memory() - user_mem["user"]["workContext"]["summary"] = "alice" - s.save(user_mem, user_id="alice") + user_mem = create_empty_memory() + user_mem["user"]["workContext"]["summary"] = "alice" + s.save(user_mem, user_id="alice") - assert s.load(user_id=None)["user"]["workContext"]["summary"] == "legacy" - assert s.load(user_id="alice")["user"]["workContext"]["summary"] == "alice" + assert s.load(user_id=None)["user"]["workContext"]["summary"] == "legacy" + assert s.load(user_id="alice")["user"]["workContext"]["summary"] == "alice" def test_user_agent_memory_file_location(self, base_dir: Path): """Per-user per-agent memory uses the user_agent_memory_file path.""" @@ -106,7 +122,7 @@ class TestUserIsolatedStorage: paths = Paths(base_dir) with patch("deerflow.agents.memory.storage.get_paths", return_value=paths): - s = FileMemoryStorage() + s = FileMemoryStorage(_TEST_MEMORY_CONFIG) memory = create_empty_memory() memory["user"]["workContext"]["summary"] = "agent scoped" s.save(memory, "test-agent", user_id="alice") @@ -119,7 +135,7 @@ class TestUserIsolatedStorage: paths = Paths(base_dir) with patch("deerflow.agents.memory.storage.get_paths", return_value=paths): - s = FileMemoryStorage() + s = FileMemoryStorage(_TEST_MEMORY_CONFIG) memory = create_empty_memory() s.save(memory, user_id="alice") # After save, cache should have tuple key @@ -131,7 +147,7 @@ class TestUserIsolatedStorage: paths = Paths(base_dir) with patch("deerflow.agents.memory.storage.get_paths", return_value=paths): - s = FileMemoryStorage() + s = FileMemoryStorage(_TEST_MEMORY_CONFIG) memory = create_empty_memory() memory["user"]["workContext"]["summary"] = "initial" s.save(memory, user_id="alice") diff --git a/backend/tests/test_memory_thread_meta_isolation.py b/backend/tests/test_memory_thread_meta_isolation.py index 25c9298f0..d89034312 100644 --- a/backend/tests/test_memory_thread_meta_isolation.py +++ b/backend/tests/test_memory_thread_meta_isolation.py @@ -6,6 +6,17 @@ the in-memory LangGraph Store backend used when database.backend=memory. from __future__ import annotations +# --- Phase 2 config-refactor test helper --- +# Memory APIs now take MemoryConfig / AppConfig explicitly. Tests construct a +# minimal config once and reuse it across call sites. +from deerflow.config.app_config import AppConfig as _TestAppConfig +from deerflow.config.memory_config import MemoryConfig as _TestMemoryConfig +from deerflow.config.sandbox_config import SandboxConfig as _TestSandboxConfig + +_TEST_MEMORY_CONFIG = _TestMemoryConfig(enabled=True) +_TEST_APP_CONFIG = _TestAppConfig(sandbox=_TestSandboxConfig(use="test"), memory=_TEST_MEMORY_CONFIG) +# ------------------------------------------- + from types import SimpleNamespace import pytest diff --git a/backend/tests/test_memory_updater.py b/backend/tests/test_memory_updater.py index b4fb87a52..3246ab44f 100644 --- a/backend/tests/test_memory_updater.py +++ b/backend/tests/test_memory_updater.py @@ -1,22 +1,32 @@ -import asyncio -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest +from unittest.mock import MagicMock, patch from deerflow.agents.memory.prompt import format_conversation_for_update from deerflow.agents.memory.updater import ( MemoryUpdater, _extract_text, - _run_async_update_sync, clear_memory_data, create_memory_fact, delete_memory_fact, import_memory_data, update_memory_fact, ) +from deerflow.config.app_config import AppConfig from deerflow.config.memory_config import MemoryConfig +from deerflow.config.sandbox_config import SandboxConfig + +# --- Phase 2 config-refactor test helper --- +# Memory APIs now take MemoryConfig / AppConfig explicitly. Tests construct a +# minimal config once and reuse it across call sites. +from deerflow.config.app_config import AppConfig as _TestAppConfig +from deerflow.config.memory_config import MemoryConfig as _TestMemoryConfig +from deerflow.config.sandbox_config import SandboxConfig as _TestSandboxConfig + +_TEST_MEMORY_CONFIG = _TestMemoryConfig(enabled=True) +_TEST_APP_CONFIG = _TestAppConfig(sandbox=_TestSandboxConfig(use="test"), memory=_TEST_MEMORY_CONFIG) +# ------------------------------------------- + def _make_memory(facts: list[dict[str, object]] | None = None) -> dict[str, object]: return { "version": "1.0", @@ -35,15 +45,12 @@ def _make_memory(facts: list[dict[str, object]] | None = None) -> dict[str, obje } -def _memory_config(**overrides: object) -> MemoryConfig: - config = MemoryConfig() - for key, value in overrides.items(): - setattr(config, key, value) - return config +def _memory_config(**overrides: object) -> AppConfig: + return AppConfig(sandbox=SandboxConfig(use="test"), memory=MemoryConfig().model_copy(update=overrides)) def test_apply_updates_skips_existing_duplicate_and_preserves_removals() -> None: - updater = MemoryUpdater() + updater = MemoryUpdater(_memory_config(max_facts=100, fact_confidence_threshold=0.7)) current_memory = _make_memory( facts=[ { @@ -70,19 +77,14 @@ def test_apply_updates_skips_existing_duplicate_and_preserves_removals() -> None {"content": "User likes Python", "category": "preference", "confidence": 0.95}, ], } - - with patch( - "deerflow.agents.memory.updater.get_memory_config", - return_value=_memory_config(max_facts=100, fact_confidence_threshold=0.7), - ): - result = updater._apply_updates(current_memory, update_data, thread_id="thread-b") + result = updater._apply_updates(current_memory, update_data, thread_id="thread-b") assert [fact["content"] for fact in result["facts"]] == ["User likes Python"] assert all(fact["id"] != "fact_remove" for fact in result["facts"]) def test_apply_updates_skips_same_batch_duplicates_and_keeps_source_metadata() -> None: - updater = MemoryUpdater() + updater = MemoryUpdater(_memory_config(max_facts=100, fact_confidence_threshold=0.7)) current_memory = _make_memory() update_data = { "newFacts": [ @@ -91,12 +93,7 @@ def test_apply_updates_skips_same_batch_duplicates_and_keeps_source_metadata() - {"content": "User works on DeerFlow", "category": "context", "confidence": 0.87}, ], } - - with patch( - "deerflow.agents.memory.updater.get_memory_config", - return_value=_memory_config(max_facts=100, fact_confidence_threshold=0.7), - ): - result = updater._apply_updates(current_memory, update_data, thread_id="thread-42") + result = updater._apply_updates(current_memory, update_data, thread_id="thread-42") assert [fact["content"] for fact in result["facts"]] == [ "User prefers dark mode", @@ -107,7 +104,7 @@ def test_apply_updates_skips_same_batch_duplicates_and_keeps_source_metadata() - def test_apply_updates_preserves_threshold_and_max_facts_trimming() -> None: - updater = MemoryUpdater() + updater = MemoryUpdater(_memory_config(max_facts=2, fact_confidence_threshold=0.7)) current_memory = _make_memory( facts=[ { @@ -135,12 +132,7 @@ def test_apply_updates_preserves_threshold_and_max_facts_trimming() -> None: {"content": "User likes noisy logs", "category": "behavior", "confidence": 0.6}, ], } - - with patch( - "deerflow.agents.memory.updater.get_memory_config", - return_value=_memory_config(max_facts=2, fact_confidence_threshold=0.7), - ): - result = updater._apply_updates(current_memory, update_data, thread_id="thread-9") + result = updater._apply_updates(current_memory, update_data, thread_id="thread-9") assert [fact["content"] for fact in result["facts"]] == [ "User likes Python", @@ -151,7 +143,7 @@ def test_apply_updates_preserves_threshold_and_max_facts_trimming() -> None: def test_apply_updates_preserves_source_error() -> None: - updater = MemoryUpdater() + updater = MemoryUpdater(_memory_config(max_facts=100, fact_confidence_threshold=0.7)) current_memory = _make_memory() update_data = { "newFacts": [ @@ -163,19 +155,14 @@ def test_apply_updates_preserves_source_error() -> None: } ] } - - with patch( - "deerflow.agents.memory.updater.get_memory_config", - return_value=_memory_config(max_facts=100, fact_confidence_threshold=0.7), - ): - result = updater._apply_updates(current_memory, update_data, thread_id="thread-correction") + result = updater._apply_updates(current_memory, update_data, thread_id="thread-correction") assert result["facts"][0]["sourceError"] == "The agent previously suggested npm start." assert result["facts"][0]["category"] == "correction" def test_apply_updates_ignores_empty_source_error() -> None: - updater = MemoryUpdater() + updater = MemoryUpdater(_memory_config(max_facts=100, fact_confidence_threshold=0.7)) current_memory = _make_memory() update_data = { "newFacts": [ @@ -187,19 +174,14 @@ def test_apply_updates_ignores_empty_source_error() -> None: } ] } - - with patch( - "deerflow.agents.memory.updater.get_memory_config", - return_value=_memory_config(max_facts=100, fact_confidence_threshold=0.7), - ): - result = updater._apply_updates(current_memory, update_data, thread_id="thread-correction") + result = updater._apply_updates(current_memory, update_data, thread_id="thread-correction") assert "sourceError" not in result["facts"][0] def test_clear_memory_data_resets_all_sections() -> None: with patch("deerflow.agents.memory.updater._save_memory_to_file", return_value=True): - result = clear_memory_data() + result = clear_memory_data(_TEST_MEMORY_CONFIG) assert result["version"] == "1.0" assert result["facts"] == [] @@ -233,7 +215,7 @@ def test_delete_memory_fact_removes_only_matching_fact() -> None: patch("deerflow.agents.memory.updater.get_memory_data", return_value=current_memory), patch("deerflow.agents.memory.updater._save_memory_to_file", return_value=True), ): - result = delete_memory_fact("fact_delete") + result = delete_memory_fact(_TEST_MEMORY_CONFIG, "fact_delete") assert [fact["id"] for fact in result["facts"]] == ["fact_keep"] @@ -243,7 +225,7 @@ def test_create_memory_fact_appends_manual_fact() -> None: patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), patch("deerflow.agents.memory.updater._save_memory_to_file", return_value=True), ): - result = create_memory_fact( + result = create_memory_fact(_TEST_MEMORY_CONFIG, content=" User prefers concise code reviews. ", category="preference", confidence=0.88, @@ -258,7 +240,7 @@ def test_create_memory_fact_appends_manual_fact() -> None: def test_create_memory_fact_rejects_empty_content() -> None: try: - create_memory_fact(content=" ") + create_memory_fact(_TEST_MEMORY_CONFIG, content=" ") except ValueError as exc: assert exc.args == ("content",) else: @@ -268,7 +250,7 @@ def test_create_memory_fact_rejects_empty_content() -> None: def test_create_memory_fact_rejects_invalid_confidence() -> None: for confidence in (-0.1, 1.1, float("nan"), float("inf"), float("-inf")): try: - create_memory_fact(content="User likes tests", confidence=confidence) + create_memory_fact(_TEST_MEMORY_CONFIG, content="User likes tests", confidence=confidence) except ValueError as exc: assert exc.args == ("confidence",) else: @@ -278,7 +260,7 @@ def test_create_memory_fact_rejects_invalid_confidence() -> None: def test_delete_memory_fact_raises_for_unknown_id() -> None: with patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()): try: - delete_memory_fact("fact_missing") + delete_memory_fact(_TEST_MEMORY_CONFIG, "fact_missing") except KeyError as exc: assert exc.args == ("fact_missing",) else: @@ -303,7 +285,7 @@ def test_import_memory_data_saves_and_returns_imported_memory() -> None: mock_storage.load.return_value = imported_memory with patch("deerflow.agents.memory.updater.get_memory_storage", return_value=mock_storage): - result = import_memory_data(imported_memory) + result = import_memory_data(_TEST_MEMORY_CONFIG, imported_memory) mock_storage.save.assert_called_once_with(imported_memory, None, user_id=None) mock_storage.load.assert_called_once_with(None, user_id=None) @@ -336,7 +318,7 @@ def test_update_memory_fact_updates_only_matching_fact() -> None: patch("deerflow.agents.memory.updater.get_memory_data", return_value=current_memory), patch("deerflow.agents.memory.updater._save_memory_to_file", return_value=True), ): - result = update_memory_fact( + result = update_memory_fact(_TEST_MEMORY_CONFIG, fact_id="fact_edit", content="User prefers spaces", category="workflow", @@ -369,7 +351,7 @@ def test_update_memory_fact_preserves_omitted_fields() -> None: patch("deerflow.agents.memory.updater.get_memory_data", return_value=current_memory), patch("deerflow.agents.memory.updater._save_memory_to_file", return_value=True), ): - result = update_memory_fact( + result = update_memory_fact(_TEST_MEMORY_CONFIG, fact_id="fact_edit", content="User prefers spaces", ) @@ -382,7 +364,7 @@ def test_update_memory_fact_preserves_omitted_fields() -> None: def test_update_memory_fact_raises_for_unknown_id() -> None: with patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()): try: - update_memory_fact( + update_memory_fact(_TEST_MEMORY_CONFIG, fact_id="fact_missing", content="User prefers concise code reviews.", category="preference", @@ -414,7 +396,7 @@ def test_update_memory_fact_rejects_invalid_confidence() -> None: return_value=current_memory, ): try: - update_memory_fact( + update_memory_fact(_TEST_MEMORY_CONFIG, fact_id="fact_edit", content="User prefers spaces", confidence=confidence, @@ -527,17 +509,15 @@ class TestUpdateMemoryStructuredResponse: model = MagicMock() response = MagicMock() response.content = content - model.ainvoke = AsyncMock(return_value=response) + model.invoke.return_value = response return model def test_string_response_parses(self): - updater = MemoryUpdater() + updater = MemoryUpdater(_TEST_APP_CONFIG) valid_json = '{"user": {}, "history": {}, "newFacts": [], "factsToRemove": []}' - model = self._make_mock_model(valid_json) with ( - patch.object(updater, "_get_model", return_value=model), - patch("deerflow.agents.memory.updater.get_memory_config", return_value=_memory_config(enabled=True)), + patch.object(updater, "_get_model", return_value=self._make_mock_model(valid_json)), patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), ): @@ -551,17 +531,15 @@ class TestUpdateMemoryStructuredResponse: result = updater.update_memory([msg, ai_msg]) assert result is True - model.ainvoke.assert_awaited_once() def test_list_content_response_parses(self): """LLM response as list-of-blocks should be extracted, not repr'd.""" - updater = MemoryUpdater() + updater = MemoryUpdater(_TEST_APP_CONFIG) valid_json = '{"user": {}, "history": {}, "newFacts": [], "factsToRemove": []}' list_content = [{"type": "text", "text": valid_json}] with ( patch.object(updater, "_get_model", return_value=self._make_mock_model(list_content)), - patch("deerflow.agents.memory.updater.get_memory_config", return_value=_memory_config(enabled=True)), patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), ): @@ -576,38 +554,13 @@ class TestUpdateMemoryStructuredResponse: assert result is True - def test_async_update_memory_uses_ainvoke(self): - updater = MemoryUpdater() - valid_json = '{"user": {}, "history": {}, "newFacts": [], "factsToRemove": []}' - model = self._make_mock_model(valid_json) - - with ( - patch.object(updater, "_get_model", return_value=model), - patch("deerflow.agents.memory.updater.get_memory_config", return_value=_memory_config(enabled=True)), - patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), - patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), - ): - msg = MagicMock() - msg.type = "human" - msg.content = "Hello" - ai_msg = MagicMock() - ai_msg.type = "ai" - ai_msg.content = "Hi there" - ai_msg.tool_calls = [] - result = asyncio.run(updater.aupdate_memory([msg, ai_msg])) - - assert result is True - model.ainvoke.assert_awaited_once() - assert model.ainvoke.await_args.kwargs["config"] == {"run_name": "memory_agent"} - def test_correction_hint_injected_when_detected(self): - updater = MemoryUpdater() + updater = MemoryUpdater(_TEST_APP_CONFIG) valid_json = '{"user": {}, "history": {}, "newFacts": [], "factsToRemove": []}' model = self._make_mock_model(valid_json) with ( patch.object(updater, "_get_model", return_value=model), - patch("deerflow.agents.memory.updater.get_memory_config", return_value=_memory_config(enabled=True)), patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), ): @@ -622,17 +575,16 @@ class TestUpdateMemoryStructuredResponse: result = updater.update_memory([msg, ai_msg], correction_detected=True) assert result is True - prompt = model.ainvoke.await_args.args[0] + prompt = model.invoke.call_args[0][0] assert "Explicit correction signals were detected" in prompt def test_correction_hint_empty_when_not_detected(self): - updater = MemoryUpdater() + updater = MemoryUpdater(_TEST_APP_CONFIG) valid_json = '{"user": {}, "history": {}, "newFacts": [], "factsToRemove": []}' model = self._make_mock_model(valid_json) with ( patch.object(updater, "_get_model", return_value=model), - patch("deerflow.agents.memory.updater.get_memory_config", return_value=_memory_config(enabled=True)), patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), ): @@ -647,95 +599,15 @@ class TestUpdateMemoryStructuredResponse: result = updater.update_memory([msg, ai_msg], correction_detected=False) assert result is True - prompt = model.ainvoke.await_args.args[0] + prompt = model.invoke.call_args[0][0] assert "Explicit correction signals were detected" not in prompt - def test_sync_update_memory_wrapper_works_in_running_loop(self): - updater = MemoryUpdater() - valid_json = '{"user": {}, "history": {}, "newFacts": [], "factsToRemove": []}' - model = self._make_mock_model(valid_json) - - with ( - patch.object(updater, "_get_model", return_value=model), - patch("deerflow.agents.memory.updater.get_memory_config", return_value=_memory_config(enabled=True)), - patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), - patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), - ): - msg = MagicMock() - msg.type = "human" - msg.content = "Hello from loop" - ai_msg = MagicMock() - ai_msg.type = "ai" - ai_msg.content = "Hi" - ai_msg.tool_calls = [] - - async def run_in_loop(): - return updater.update_memory([msg, ai_msg]) - - result = asyncio.run(run_in_loop()) - - assert result is True - model.ainvoke.assert_awaited_once() - - def test_sync_update_memory_returns_false_when_bridge_submit_fails(self): - updater = MemoryUpdater() - - with ( - patch( - "deerflow.agents.memory.updater._SYNC_MEMORY_UPDATER_EXECUTOR.submit", - side_effect=RuntimeError("executor down"), - ), - ): - msg = MagicMock() - msg.type = "human" - msg.content = "Hello from loop" - ai_msg = MagicMock() - ai_msg.type = "ai" - ai_msg.content = "Hi" - ai_msg.tool_calls = [] - - async def run_in_loop(): - return updater.update_memory([msg, ai_msg]) - - result = asyncio.run(run_in_loop()) - - assert result is False - - -class TestRunAsyncUpdateSync: - def test_closes_unawaited_awaitable_when_bridge_fails_before_handoff(self): - class CloseableAwaitable: - def __init__(self): - self.closed = False - - def __await__(self): - pytest.fail("awaitable should not have been awaited") - yield - - def close(self): - self.closed = True - - awaitable = CloseableAwaitable() - - with patch( - "deerflow.agents.memory.updater._SYNC_MEMORY_UPDATER_EXECUTOR.submit", - side_effect=RuntimeError("executor down"), - ): - - async def run_in_loop(): - return _run_async_update_sync(awaitable) - - result = asyncio.run(run_in_loop()) - - assert result is False - assert awaitable.closed is True - class TestFactDeduplicationCaseInsensitive: """Tests that fact deduplication is case-insensitive.""" def test_duplicate_fact_different_case_not_stored(self): - updater = MemoryUpdater() + updater = MemoryUpdater(_memory_config(max_facts=100, fact_confidence_threshold=0.7)) current_memory = _make_memory( facts=[ { @@ -755,19 +627,14 @@ class TestFactDeduplicationCaseInsensitive: {"content": "user prefers python", "category": "preference", "confidence": 0.95}, ], } - - with patch( - "deerflow.agents.memory.updater.get_memory_config", - return_value=_memory_config(max_facts=100, fact_confidence_threshold=0.7), - ): - result = updater._apply_updates(current_memory, update_data, thread_id="thread-b") + result = updater._apply_updates(current_memory, update_data, thread_id="thread-b") # Should still have only 1 fact (duplicate rejected) assert len(result["facts"]) == 1 assert result["facts"][0]["content"] == "User prefers Python" def test_unique_fact_different_case_and_content_stored(self): - updater = MemoryUpdater() + updater = MemoryUpdater(_memory_config(max_facts=100, fact_confidence_threshold=0.7)) current_memory = _make_memory( facts=[ { @@ -786,12 +653,7 @@ class TestFactDeduplicationCaseInsensitive: {"content": "User prefers Go", "category": "preference", "confidence": 0.85}, ], } - - with patch( - "deerflow.agents.memory.updater.get_memory_config", - return_value=_memory_config(max_facts=100, fact_confidence_threshold=0.7), - ): - result = updater._apply_updates(current_memory, update_data, thread_id="thread-b") + result = updater._apply_updates(current_memory, update_data, thread_id="thread-b") assert len(result["facts"]) == 2 @@ -804,17 +666,16 @@ class TestReinforcementHint: model = MagicMock() response = MagicMock() response.content = f"```json\n{json_response}\n```" - model.ainvoke = AsyncMock(return_value=response) + model.invoke.return_value = response return model def test_reinforcement_hint_injected_when_detected(self): - updater = MemoryUpdater() + updater = MemoryUpdater(_TEST_APP_CONFIG) valid_json = '{"user": {}, "history": {}, "newFacts": [], "factsToRemove": []}' model = self._make_mock_model(valid_json) with ( patch.object(updater, "_get_model", return_value=model), - patch("deerflow.agents.memory.updater.get_memory_config", return_value=_memory_config(enabled=True)), patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), ): @@ -829,17 +690,16 @@ class TestReinforcementHint: result = updater.update_memory([msg, ai_msg], reinforcement_detected=True) assert result is True - prompt = model.ainvoke.await_args.args[0] + prompt = model.invoke.call_args[0][0] assert "Positive reinforcement signals were detected" in prompt def test_reinforcement_hint_absent_when_not_detected(self): - updater = MemoryUpdater() + updater = MemoryUpdater(_TEST_APP_CONFIG) valid_json = '{"user": {}, "history": {}, "newFacts": [], "factsToRemove": []}' model = self._make_mock_model(valid_json) with ( patch.object(updater, "_get_model", return_value=model), - patch("deerflow.agents.memory.updater.get_memory_config", return_value=_memory_config(enabled=True)), patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), ): @@ -854,17 +714,16 @@ class TestReinforcementHint: result = updater.update_memory([msg, ai_msg], reinforcement_detected=False) assert result is True - prompt = model.ainvoke.await_args.args[0] + prompt = model.invoke.call_args[0][0] assert "Positive reinforcement signals were detected" not in prompt def test_both_hints_present_when_both_detected(self): - updater = MemoryUpdater() + updater = MemoryUpdater(_TEST_APP_CONFIG) valid_json = '{"user": {}, "history": {}, "newFacts": [], "factsToRemove": []}' model = self._make_mock_model(valid_json) with ( patch.object(updater, "_get_model", return_value=model), - patch("deerflow.agents.memory.updater.get_memory_config", return_value=_memory_config(enabled=True)), patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), ): @@ -879,56 +738,6 @@ class TestReinforcementHint: result = updater.update_memory([msg, ai_msg], correction_detected=True, reinforcement_detected=True) assert result is True - prompt = model.ainvoke.await_args.args[0] + prompt = model.invoke.call_args[0][0] assert "Explicit correction signals were detected" in prompt assert "Positive reinforcement signals were detected" in prompt - - -class TestFinalizeCacheIsolation: - """_finalize_update must not mutate the cached memory object.""" - - def test_deepcopy_prevents_cache_corruption_on_save_failure(self): - """If save() fails, the in-memory snapshot used by _finalize_update - must remain independent of any object the storage layer may still hold in - its cache. The deepcopy in _finalize_update achieves this — the object - passed to _apply_updates is always a fresh copy, never the cache reference. - """ - updater = MemoryUpdater() - original_memory = _make_memory(facts=[{"id": "fact_orig", "content": "original", "category": "context", "confidence": 0.9, "createdAt": "2024-01-01T00:00:00Z", "source": "t1"}]) - - import json as _json - - new_fact_json = _json.dumps( - { - "user": {}, - "history": {}, - "newFacts": [{"content": "new fact", "category": "context", "confidence": 0.9}], - "factsToRemove": [], - } - ) - mock_response = MagicMock() - mock_response.content = new_fact_json - mock_model = AsyncMock() - mock_model.ainvoke = AsyncMock(return_value=mock_response) - - saved_objects: list[dict] = [] - save_mock = MagicMock(side_effect=lambda m, a=None: saved_objects.append(m) or False) # always fails - - with ( - patch.object(updater, "_get_model", return_value=mock_model), - patch("deerflow.agents.memory.updater.get_memory_config", return_value=_memory_config(enabled=True, fact_confidence_threshold=0.7)), - patch("deerflow.agents.memory.updater.get_memory_data", return_value=original_memory), - patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=save_mock)), - ): - msg = MagicMock() - msg.type = "human" - msg.content = "hello" - ai_msg = MagicMock() - ai_msg.type = "ai" - ai_msg.content = "world" - ai_msg.tool_calls = [] - updater.update_memory([msg, ai_msg], thread_id="t1") - - # original_memory must not have been mutated — deepcopy isolates the mutation - assert len(original_memory["facts"]) == 1, "original_memory must not be mutated by _apply_updates" - assert original_memory["facts"][0]["content"] == "original" diff --git a/backend/tests/test_memory_updater_user_isolation.py b/backend/tests/test_memory_updater_user_isolation.py index da8a444fe..f874d1e4b 100644 --- a/backend/tests/test_memory_updater_user_isolation.py +++ b/backend/tests/test_memory_updater_user_isolation.py @@ -1,15 +1,26 @@ -"""Tests for user_id propagation in memory updater.""" +# --- Phase 2 config-refactor test helper --- +# Memory APIs now take MemoryConfig / AppConfig explicitly. Tests construct a +# minimal config once and reuse it across call sites. +from deerflow.config.app_config import AppConfig as _TestAppConfig +from deerflow.config.memory_config import MemoryConfig as _TestMemoryConfig +from deerflow.config.sandbox_config import SandboxConfig as _TestSandboxConfig + +_TEST_MEMORY_CONFIG = _TestMemoryConfig(enabled=True) +_TEST_APP_CONFIG = _TestAppConfig(sandbox=_TestSandboxConfig(use="test"), memory=_TEST_MEMORY_CONFIG) +# ------------------------------------------- + +"""Tests for user_id propagation in memory updater.""" from unittest.mock import MagicMock, patch -from deerflow.agents.memory.updater import _save_memory_to_file, clear_memory_data, get_memory_data +from deerflow.agents.memory.updater import get_memory_data, clear_memory_data, _save_memory_to_file def test_get_memory_data_passes_user_id(): mock_storage = MagicMock() mock_storage.load.return_value = {"version": "1.0"} with patch("deerflow.agents.memory.updater.get_memory_storage", return_value=mock_storage): - get_memory_data(user_id="alice") + get_memory_data(_TEST_MEMORY_CONFIG, user_id="alice") mock_storage.load.assert_called_once_with(None, user_id="alice") @@ -17,7 +28,7 @@ def test_save_memory_passes_user_id(): mock_storage = MagicMock() mock_storage.save.return_value = True with patch("deerflow.agents.memory.updater.get_memory_storage", return_value=mock_storage): - _save_memory_to_file({"version": "1.0"}, user_id="bob") + _save_memory_to_file(_TEST_MEMORY_CONFIG, {"version": "1.0"}, user_id="bob") mock_storage.save.assert_called_once_with({"version": "1.0"}, None, user_id="bob") @@ -25,6 +36,6 @@ def test_clear_memory_data_passes_user_id(): mock_storage = MagicMock() mock_storage.save.return_value = True with patch("deerflow.agents.memory.updater.get_memory_storage", return_value=mock_storage): - clear_memory_data(user_id="charlie") + clear_memory_data(_TEST_MEMORY_CONFIG, user_id="charlie") # Verify save was called with user_id assert mock_storage.save.call_args.kwargs["user_id"] == "charlie" diff --git a/backend/tests/test_migration_user_isolation.py b/backend/tests/test_migration_user_isolation.py index dbb20bdd8..8a07c2130 100644 --- a/backend/tests/test_migration_user_isolation.py +++ b/backend/tests/test_migration_user_isolation.py @@ -1,9 +1,7 @@ """Tests for per-user data migration.""" - import json -from pathlib import Path - import pytest +from pathlib import Path from deerflow.config.paths import Paths @@ -25,7 +23,6 @@ class TestMigrateThreadDirs: (legacy / "file.txt").write_text("hello") from scripts.migrate_user_isolation import migrate_thread_dirs - migrate_thread_dirs(paths, thread_owner_map={"t1": "alice"}) expected = base_dir / "users" / "alice" / "threads" / "t1" / "user-data" / "workspace" / "file.txt" @@ -38,7 +35,6 @@ class TestMigrateThreadDirs: legacy.mkdir(parents=True) from scripts.migrate_user_isolation import migrate_thread_dirs - migrate_thread_dirs(paths, thread_owner_map={}) expected = base_dir / "users" / "default" / "threads" / "t2" @@ -49,7 +45,6 @@ class TestMigrateThreadDirs: new_dir.mkdir(parents=True) from scripts.migrate_user_isolation import migrate_thread_dirs - migrate_thread_dirs(paths, thread_owner_map={"t1": "alice"}) assert new_dir.exists() @@ -63,7 +58,6 @@ class TestMigrateThreadDirs: (dest / "new.txt").write_text("new") from scripts.migrate_user_isolation import migrate_thread_dirs - migrate_thread_dirs(paths, thread_owner_map={"t1": "alice"}) assert (dest / "new.txt").read_text() == "new" @@ -75,7 +69,6 @@ class TestMigrateThreadDirs: legacy.mkdir(parents=True) from scripts.migrate_user_isolation import migrate_thread_dirs - migrate_thread_dirs(paths, thread_owner_map={}) assert not (base_dir / "threads").exists() @@ -85,7 +78,6 @@ class TestMigrateThreadDirs: legacy.mkdir(parents=True) from scripts.migrate_user_isolation import migrate_thread_dirs - report = migrate_thread_dirs(paths, thread_owner_map={"t1": "alice"}, dry_run=True) assert len(report) == 1 @@ -99,7 +91,6 @@ class TestMigrateMemory: legacy_mem.write_text(json.dumps({"version": "1.0", "facts": []})) from scripts.migrate_user_isolation import migrate_memory - migrate_memory(paths, user_id="default") expected = base_dir / "users" / "default" / "memory.json" @@ -115,7 +106,6 @@ class TestMigrateMemory: dest.write_text(json.dumps({"version": "new"})) from scripts.migrate_user_isolation import migrate_memory - migrate_memory(paths, user_id="default") assert json.loads(dest.read_text())["version"] == "new" @@ -123,5 +113,4 @@ class TestMigrateMemory: def test_no_legacy_memory_is_noop(self, base_dir: Path, paths: Paths): from scripts.migrate_user_isolation import migrate_memory - migrate_memory(paths, user_id="default") # should not raise diff --git a/backend/tests/test_model_factory.py b/backend/tests/test_model_factory.py index c8dbe0791..920a4ef38 100644 --- a/backend/tests/test_model_factory.py +++ b/backend/tests/test_model_factory.py @@ -72,8 +72,7 @@ class FakeChatModel(BaseChatModel): def _patch_factory(monkeypatch, app_config: AppConfig, model_class=FakeChatModel): - """Patch get_app_config, resolve_class, and tracing for isolated unit tests.""" - monkeypatch.setattr(factory_module, "get_app_config", lambda: app_config) + """Patch resolve_class and tracing for isolated unit tests.""" monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: model_class) monkeypatch.setattr(factory_module, "build_tracing_callbacks", lambda: []) @@ -88,7 +87,7 @@ def test_uses_first_model_when_name_is_none(monkeypatch): _patch_factory(monkeypatch, cfg) FakeChatModel.captured_kwargs = {} - factory_module.create_chat_model(name=None) + factory_module.create_chat_model(name=None, app_config=cfg) # resolve_class is called — if we reach here without ValueError, the correct model was used assert FakeChatModel.captured_kwargs.get("model") == "alpha" @@ -96,11 +95,10 @@ def test_uses_first_model_when_name_is_none(monkeypatch): def test_raises_when_model_not_found(monkeypatch): cfg = _make_app_config([_make_model("only-model")]) - monkeypatch.setattr(factory_module, "get_app_config", lambda: cfg) monkeypatch.setattr(factory_module, "build_tracing_callbacks", lambda: []) with pytest.raises(ValueError, match="ghost-model"): - factory_module.create_chat_model(name="ghost-model") + factory_module.create_chat_model(name="ghost-model", app_config=cfg) def test_appends_all_tracing_callbacks(monkeypatch): @@ -109,7 +107,7 @@ def test_appends_all_tracing_callbacks(monkeypatch): monkeypatch.setattr(factory_module, "build_tracing_callbacks", lambda: ["smith-callback", "langfuse-callback"]) FakeChatModel.captured_kwargs = {} - model = factory_module.create_chat_model(name="alpha") + model = factory_module.create_chat_model(name="alpha", app_config=cfg) assert model.callbacks == ["smith-callback", "langfuse-callback"] @@ -127,7 +125,7 @@ def test_thinking_enabled_raises_when_not_supported_but_when_thinking_enabled_is _patch_factory(monkeypatch, cfg) with pytest.raises(ValueError, match="does not support thinking"): - factory_module.create_chat_model(name="no-think", thinking_enabled=True) + factory_module.create_chat_model(name="no-think", thinking_enabled=True, app_config=cfg) def test_thinking_enabled_raises_for_empty_when_thinking_enabled_explicitly_set(monkeypatch): @@ -138,7 +136,7 @@ def test_thinking_enabled_raises_for_empty_when_thinking_enabled_explicitly_set( _patch_factory(monkeypatch, cfg) with pytest.raises(ValueError, match="does not support thinking"): - factory_module.create_chat_model(name="no-think-empty", thinking_enabled=True) + factory_module.create_chat_model(name="no-think-empty", thinking_enabled=True, app_config=cfg) def test_thinking_enabled_merges_when_thinking_enabled_settings(monkeypatch): @@ -147,7 +145,7 @@ def test_thinking_enabled_merges_when_thinking_enabled_settings(monkeypatch): _patch_factory(monkeypatch, cfg) FakeChatModel.captured_kwargs = {} - factory_module.create_chat_model(name="thinker", thinking_enabled=True) + factory_module.create_chat_model(name="thinker", thinking_enabled=True, app_config=cfg) assert FakeChatModel.captured_kwargs.get("temperature") == 1.0 assert FakeChatModel.captured_kwargs.get("max_tokens") == 16000 @@ -183,7 +181,7 @@ def test_thinking_disabled_openai_gateway_format(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="openai-gw", thinking_enabled=False) + factory_module.create_chat_model(name="openai-gw", thinking_enabled=False, app_config=cfg) assert captured.get("extra_body") == {"thinking": {"type": "disabled"}} assert captured.get("reasoning_effort") == "minimal" @@ -216,7 +214,7 @@ def test_thinking_disabled_langchain_anthropic_format(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="anthropic-native", thinking_enabled=False) + factory_module.create_chat_model(name="anthropic-native", thinking_enabled=False, app_config=cfg) assert captured.get("thinking") == {"type": "disabled"} assert "extra_body" not in captured @@ -238,7 +236,7 @@ def test_thinking_disabled_no_when_thinking_enabled_does_nothing(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="plain", thinking_enabled=False) + factory_module.create_chat_model(name="plain", thinking_enabled=False, app_config=cfg) assert "extra_body" not in captured assert "thinking" not in captured @@ -278,7 +276,7 @@ def test_when_thinking_disabled_takes_precedence_over_hardcoded_disable(monkeypa monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="custom-disable", thinking_enabled=False) + factory_module.create_chat_model(name="custom-disable", thinking_enabled=False, app_config=cfg) assert captured.get("extra_body") == {"thinking": {"type": "disabled"}} # User overrode the hardcoded "minimal" with "low" @@ -310,7 +308,7 @@ def test_when_thinking_disabled_not_used_when_thinking_enabled(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="wtd-ignored", thinking_enabled=True) + factory_module.create_chat_model(name="wtd-ignored", thinking_enabled=True, app_config=cfg) # when_thinking_enabled should apply, NOT when_thinking_disabled assert captured.get("extra_body") == {"thinking": {"type": "enabled"}} @@ -339,7 +337,7 @@ def test_when_thinking_disabled_without_when_thinking_enabled_still_applies(monk monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="wtd-only", thinking_enabled=False) + factory_module.create_chat_model(name="wtd-only", thinking_enabled=False, app_config=cfg) # when_thinking_disabled is now gated independently of has_thinking_settings assert captured.get("reasoning_effort") == "low" @@ -370,7 +368,7 @@ def test_when_thinking_disabled_excluded_from_model_dump(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="no-leak-wtd", thinking_enabled=True) + factory_module.create_chat_model(name="no-leak-wtd", thinking_enabled=True, app_config=cfg) # when_thinking_disabled value must NOT appear as a raw key assert "when_thinking_disabled" not in captured @@ -394,7 +392,7 @@ def test_reasoning_effort_cleared_when_not_supported(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="no-effort", thinking_enabled=False) + factory_module.create_chat_model(name="no-effort", thinking_enabled=False, app_config=cfg) assert captured.get("reasoning_effort") is None @@ -422,7 +420,7 @@ def test_reasoning_effort_preserved_when_supported(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="effort-model", thinking_enabled=False) + factory_module.create_chat_model(name="effort-model", thinking_enabled=False, app_config=cfg) # When supports_reasoning_effort=True, it should NOT be cleared to None # The disable path sets it to "minimal"; supports_reasoning_effort=True keeps it @@ -458,7 +456,7 @@ def test_thinking_shortcut_enables_thinking_when_thinking_enabled(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="shortcut-model", thinking_enabled=True) + factory_module.create_chat_model(name="shortcut-model", thinking_enabled=True, app_config=cfg) assert captured.get("thinking") == thinking_settings @@ -488,7 +486,7 @@ def test_thinking_shortcut_disables_thinking_when_thinking_disabled(monkeypatch) monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="shortcut-disable", thinking_enabled=False) + factory_module.create_chat_model(name="shortcut-disable", thinking_enabled=False, app_config=cfg) assert captured.get("thinking") == {"type": "disabled"} assert "extra_body" not in captured @@ -520,7 +518,7 @@ def test_thinking_shortcut_merges_with_when_thinking_enabled(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="merge-model", thinking_enabled=True) + factory_module.create_chat_model(name="merge-model", thinking_enabled=True, app_config=cfg) # Both the thinking shortcut and when_thinking_enabled settings should be applied assert captured.get("thinking") == thinking_settings @@ -552,7 +550,7 @@ def test_thinking_shortcut_not_leaked_into_model_when_disabled(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="no-leak", thinking_enabled=False) + factory_module.create_chat_model(name="no-leak", thinking_enabled=False, app_config=cfg) # The disable path should have set thinking to disabled (not the raw enabled shortcut) assert captured.get("thinking") == {"type": "disabled"} @@ -590,7 +588,7 @@ def test_openai_compatible_provider_passes_base_url(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="minimax-m2.5") + factory_module.create_chat_model(name="minimax-m2.5", app_config=cfg) assert captured.get("model") == "MiniMax-M2.5" assert captured.get("base_url") == "https://api.minimax.io/v1" @@ -731,11 +729,11 @@ def test_openai_compatible_provider_multiple_models(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) # Create first model - factory_module.create_chat_model(name="minimax-m2.5") + factory_module.create_chat_model(name="minimax-m2.5", app_config=cfg) assert captured.get("model") == "MiniMax-M2.5" # Create second model - factory_module.create_chat_model(name="minimax-m2.5-highspeed") + factory_module.create_chat_model(name="minimax-m2.5-highspeed", app_config=cfg) assert captured.get("model") == "MiniMax-M2.5-highspeed" @@ -763,7 +761,7 @@ def test_codex_provider_disables_reasoning_when_thinking_disabled(monkeypatch): monkeypatch.setattr(codex_provider_module, "CodexChatModel", FakeCodexChatModel) FakeChatModel.captured_kwargs = {} - factory_module.create_chat_model(name="codex", thinking_enabled=False) + factory_module.create_chat_model(name="codex", thinking_enabled=False, app_config=cfg) assert FakeChatModel.captured_kwargs.get("reasoning_effort") == "none" @@ -783,7 +781,7 @@ def test_codex_provider_preserves_explicit_reasoning_effort(monkeypatch): monkeypatch.setattr(codex_provider_module, "CodexChatModel", FakeCodexChatModel) FakeChatModel.captured_kwargs = {} - factory_module.create_chat_model(name="codex", thinking_enabled=True, reasoning_effort="high") + factory_module.create_chat_model(name="codex", thinking_enabled=True, reasoning_effort="high", app_config=cfg) assert FakeChatModel.captured_kwargs.get("reasoning_effort") == "high" @@ -803,7 +801,7 @@ def test_codex_provider_defaults_reasoning_effort_to_medium(monkeypatch): monkeypatch.setattr(codex_provider_module, "CodexChatModel", FakeCodexChatModel) FakeChatModel.captured_kwargs = {} - factory_module.create_chat_model(name="codex", thinking_enabled=True) + factory_module.create_chat_model(name="codex", thinking_enabled=True, app_config=cfg) assert FakeChatModel.captured_kwargs.get("reasoning_effort") == "medium" @@ -824,7 +822,7 @@ def test_codex_provider_strips_unsupported_max_tokens(monkeypatch): monkeypatch.setattr(codex_provider_module, "CodexChatModel", FakeCodexChatModel) FakeChatModel.captured_kwargs = {} - factory_module.create_chat_model(name="codex", thinking_enabled=True) + factory_module.create_chat_model(name="codex", thinking_enabled=True, app_config=cfg) assert "max_tokens" not in FakeChatModel.captured_kwargs @@ -837,7 +835,7 @@ def test_thinking_disabled_vllm_chat_template_format(monkeypatch): supports_thinking=True, when_thinking_enabled=wte, ) - model.extra_body = {"top_k": 20} + model = model.model_copy(update={"extra_body": {"top_k": 20}}) cfg = _make_app_config([model]) _patch_factory(monkeypatch, cfg) @@ -850,7 +848,7 @@ def test_thinking_disabled_vllm_chat_template_format(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="vllm-qwen", thinking_enabled=False) + factory_module.create_chat_model(name="vllm-qwen", thinking_enabled=False, app_config=cfg) assert captured.get("extra_body") == {"top_k": 20, "chat_template_kwargs": {"thinking": False}} assert captured.get("reasoning_effort") is None @@ -864,7 +862,7 @@ def test_thinking_disabled_vllm_enable_thinking_format(monkeypatch): supports_thinking=True, when_thinking_enabled=wte, ) - model.extra_body = {"top_k": 20} + model = model.model_copy(update={"extra_body": {"top_k": 20}}) cfg = _make_app_config([model]) _patch_factory(monkeypatch, cfg) @@ -877,7 +875,7 @@ def test_thinking_disabled_vllm_enable_thinking_format(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="vllm-qwen-enable", thinking_enabled=False) + factory_module.create_chat_model(name="vllm-qwen-enable", thinking_enabled=False, app_config=cfg) assert captured.get("extra_body") == { "top_k": 20, @@ -911,7 +909,7 @@ def test_stream_usage_injected_for_openai_compatible_model(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="deepseek") + factory_module.create_chat_model(name="deepseek", app_config=cfg) assert captured.get("stream_usage") is True @@ -930,14 +928,25 @@ def test_stream_usage_not_injected_for_non_openai_model(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="claude") + factory_module.create_chat_model(name="claude", app_config=cfg) assert "stream_usage" not in captured def test_stream_usage_not_overridden_when_explicitly_set_in_config(monkeypatch): """If config dumps stream_usage=False, factory should respect it.""" - cfg = _make_app_config([_make_model("deepseek", use="langchain_deepseek:ChatDeepSeek")]) + # Build a ModelConfig with stream_usage=False as an extra field (extra="allow"). + model_with_stream_usage = ModelConfig( + name="deepseek", + display_name="deepseek", + description=None, + use="langchain_deepseek:ChatDeepSeek", + model="deepseek", + supports_thinking=False, + supports_vision=False, + stream_usage=False, + ) + cfg = _make_app_config([model_with_stream_usage]) _patch_factory(monkeypatch, cfg, model_class=_FakeWithStreamUsage) captured: dict = {} @@ -949,17 +958,7 @@ def test_stream_usage_not_overridden_when_explicitly_set_in_config(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - # Simulate config having stream_usage explicitly set by patching model_dump - original_get_model_config = cfg.get_model_config - - def patched_get_model_config(name): - mc = original_get_model_config(name) - mc.stream_usage = False # type: ignore[attr-defined] - return mc - - monkeypatch.setattr(cfg, "get_model_config", patched_get_model_config) - - factory_module.create_chat_model(name="deepseek") + factory_module.create_chat_model(name="deepseek", app_config=cfg) assert captured.get("stream_usage") is False @@ -989,7 +988,7 @@ def test_openai_responses_api_settings_are_passed_to_chatopenai(monkeypatch): monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) - factory_module.create_chat_model(name="gpt-5-responses") + factory_module.create_chat_model(name="gpt-5-responses", app_config=cfg) assert captured.get("use_responses_api") is True assert captured.get("output_version") == "responses/v1" @@ -1030,7 +1029,7 @@ def test_no_duplicate_kwarg_when_reasoning_effort_in_config_and_thinking_disable _patch_factory(monkeypatch, cfg, model_class=CapturingModel) # Must not raise TypeError - factory_module.create_chat_model(name="doubao-model", thinking_enabled=False) + factory_module.create_chat_model(name="doubao-model", thinking_enabled=False, app_config=cfg) # kwargs (runtime) takes precedence: thinking-disabled path sets reasoning_effort=minimal assert captured.get("reasoning_effort") == "minimal" diff --git a/backend/tests/test_paths_user_isolation.py b/backend/tests/test_paths_user_isolation.py index 8f312dcff..e74276a32 100644 --- a/backend/tests/test_paths_user_isolation.py +++ b/backend/tests/test_paths_user_isolation.py @@ -1,8 +1,6 @@ """Tests for user-scoped path resolution in Paths.""" - -from pathlib import Path - import pytest +from pathlib import Path from deerflow.config.paths import Paths diff --git a/backend/tests/test_present_file_tool_core_logic.py b/backend/tests/test_present_file_tool_core_logic.py index 0c064b56b..acaac8a65 100644 --- a/backend/tests/test_present_file_tool_core_logic.py +++ b/backend/tests/test_present_file_tool_core_logic.py @@ -3,14 +3,24 @@ import importlib from types import SimpleNamespace +from deerflow.config.app_config import AppConfig +from deerflow.config.deer_flow_context import DeerFlowContext +from deerflow.config.sandbox_config import SandboxConfig + present_file_tool_module = importlib.import_module("deerflow.tools.builtins.present_file_tool") +def _make_context(thread_id: str) -> DeerFlowContext: + return DeerFlowContext( + app_config=AppConfig(sandbox=SandboxConfig(use="test")), + thread_id=thread_id, + ) + + def _make_runtime(outputs_path: str) -> SimpleNamespace: return SimpleNamespace( state={"thread_data": {"outputs_path": outputs_path}}, - context={"thread_id": "thread-1"}, - config={}, + context=_make_context("thread-1"), ) @@ -51,34 +61,6 @@ def test_present_files_keeps_virtual_outputs_path(tmp_path, monkeypatch): assert result.update["artifacts"] == ["/mnt/user-data/outputs/summary.json"] -def test_present_files_uses_config_thread_id_when_context_missing(tmp_path, monkeypatch): - outputs_dir = tmp_path / "threads" / "thread-from-config" / "user-data" / "outputs" - outputs_dir.mkdir(parents=True) - artifact_path = outputs_dir / "summary.json" - artifact_path.write_text("{}") - - monkeypatch.setattr( - present_file_tool_module, - "get_paths", - lambda: SimpleNamespace(resolve_virtual_path=lambda thread_id, path: artifact_path), - ) - - runtime = SimpleNamespace( - state={"thread_data": {"outputs_path": str(outputs_dir)}}, - context={}, - config={"configurable": {"thread_id": "thread-from-config"}}, - ) - - result = present_file_tool_module.present_file_tool.func( - runtime=runtime, - filepaths=["/mnt/user-data/outputs/summary.json"], - tool_call_id="tc-config", - ) - - assert result.update["artifacts"] == ["/mnt/user-data/outputs/summary.json"] - assert result.update["messages"][0].content == "Successfully presented files" - - def test_present_files_rejects_paths_outside_outputs(tmp_path): outputs_dir = tmp_path / "threads" / "thread-1" / "user-data" / "outputs" workspace_dir = tmp_path / "threads" / "thread-1" / "user-data" / "workspace" diff --git a/backend/tests/test_run_event_store_pagination.py b/backend/tests/test_run_event_store_pagination.py index 14a09610c..ac5ba4c2d 100644 --- a/backend/tests/test_run_event_store_pagination.py +++ b/backend/tests/test_run_event_store_pagination.py @@ -1,5 +1,4 @@ """Tests for paginated list_messages_by_run across all RunEventStore backends.""" - import pytest from deerflow.runtime.events.store.memory import MemoryRunEventStore @@ -15,19 +14,14 @@ async def test_list_messages_by_run_default_returns_all(base_store): store = base_store for i in range(7): await store.put( - thread_id="t1", - run_id="run-a", + thread_id="t1", run_id="run-a", event_type="human_message" if i % 2 == 0 else "ai_message", - category="message", - content=f"msg-a-{i}", + category="message", content=f"msg-a-{i}", ) for i in range(3): await store.put( - thread_id="t1", - run_id="run-b", - event_type="human_message", - category="message", - content=f"msg-b-{i}", + thread_id="t1", run_id="run-b", + event_type="human_message", category="message", content=f"msg-b-{i}", ) await store.put(thread_id="t1", run_id="run-a", event_type="tool_call", category="trace", content="trace") @@ -42,11 +36,9 @@ async def test_list_messages_by_run_with_limit(base_store): store = base_store for i in range(7): await store.put( - thread_id="t1", - run_id="run-a", + thread_id="t1", run_id="run-a", event_type="human_message" if i % 2 == 0 else "ai_message", - category="message", - content=f"msg-a-{i}", + category="message", content=f"msg-a-{i}", ) msgs = await store.list_messages_by_run("t1", "run-a", limit=3) @@ -60,11 +52,9 @@ async def test_list_messages_by_run_after_seq(base_store): store = base_store for i in range(7): await store.put( - thread_id="t1", - run_id="run-a", + thread_id="t1", run_id="run-a", event_type="human_message" if i % 2 == 0 else "ai_message", - category="message", - content=f"msg-a-{i}", + category="message", content=f"msg-a-{i}", ) all_msgs = await store.list_messages_by_run("t1", "run-a") @@ -79,11 +69,9 @@ async def test_list_messages_by_run_before_seq(base_store): store = base_store for i in range(7): await store.put( - thread_id="t1", - run_id="run-a", + thread_id="t1", run_id="run-a", event_type="human_message" if i % 2 == 0 else "ai_message", - category="message", - content=f"msg-a-{i}", + category="message", content=f"msg-a-{i}", ) all_msgs = await store.list_messages_by_run("t1", "run-a") @@ -98,19 +86,13 @@ async def test_list_messages_by_run_does_not_include_other_run(base_store): store = base_store for i in range(7): await store.put( - thread_id="t1", - run_id="run-a", - event_type="human_message", - category="message", - content=f"msg-a-{i}", + thread_id="t1", run_id="run-a", + event_type="human_message", category="message", content=f"msg-a-{i}", ) for i in range(3): await store.put( - thread_id="t1", - run_id="run-b", - event_type="human_message", - category="message", - content=f"msg-b-{i}", + thread_id="t1", run_id="run-b", + event_type="human_message", category="message", content=f"msg-b-{i}", ) msgs = await store.list_messages_by_run("t1", "run-b") diff --git a/backend/tests/test_run_journal.py b/backend/tests/test_run_journal.py index a70d02b9b..b306f59ec 100644 --- a/backend/tests/test_run_journal.py +++ b/backend/tests/test_run_journal.py @@ -62,62 +62,59 @@ class TestLlmCallbacks: j, store = journal_setup run_id = uuid4() j.on_llm_start({}, [], run_id=run_id, tags=["lead_agent"]) - j.on_llm_end(_make_llm_response("Hi"), run_id=run_id, parent_run_id=None, tags=["lead_agent"]) + j.on_llm_end(_make_llm_response("Hi"), run_id=run_id, tags=["lead_agent"]) await j.flush() events = await store.list_events("t1", "r1") - trace_events = [e for e in events if e["event_type"] == "llm.ai.response"] + trace_events = [e for e in events if e["event_type"] == "llm_response"] assert len(trace_events) == 1 - assert trace_events[0]["category"] == "message" + assert trace_events[0]["category"] == "trace" @pytest.mark.anyio async def test_on_llm_end_lead_agent_produces_ai_message(self, journal_setup): j, store = journal_setup run_id = uuid4() j.on_llm_start({}, [], run_id=run_id, tags=["lead_agent"]) - j.on_llm_end(_make_llm_response("Answer"), run_id=run_id, parent_run_id=None, tags=["lead_agent"]) + j.on_llm_end(_make_llm_response("Answer"), run_id=run_id, tags=["lead_agent"]) await j.flush() messages = await store.list_messages("t1") assert len(messages) == 1 - assert messages[0]["event_type"] == "llm.ai.response" + assert messages[0]["event_type"] == "ai_message" # Content is checkpoint-aligned model_dump format assert messages[0]["content"]["type"] == "ai" assert messages[0]["content"]["content"] == "Answer" @pytest.mark.anyio async def test_on_llm_end_with_tool_calls_produces_ai_tool_call(self, journal_setup): - """LLM response with pending tool_calls emits llm.ai.response with tool_calls in content.""" + """LLM response with pending tool_calls should produce ai_tool_call event.""" j, store = journal_setup run_id = uuid4() j.on_llm_end( _make_llm_response("Let me search", tool_calls=[{"id": "call_1", "name": "search", "args": {}}]), run_id=run_id, - parent_run_id=None, tags=["lead_agent"], ) await j.flush() messages = await store.list_messages("t1") assert len(messages) == 1 - assert messages[0]["event_type"] == "llm.ai.response" - assert len(messages[0]["content"]["tool_calls"]) == 1 + assert messages[0]["event_type"] == "ai_tool_call" @pytest.mark.anyio async def test_on_llm_end_subagent_no_ai_message(self, journal_setup): j, store = journal_setup run_id = uuid4() j.on_llm_start({}, [], run_id=run_id, tags=["subagent:research"]) - j.on_llm_end(_make_llm_response("Sub answer"), run_id=run_id, parent_run_id=None, tags=["subagent:research"]) + j.on_llm_end(_make_llm_response("Sub answer"), run_id=run_id, tags=["subagent:research"]) await j.flush() messages = await store.list_messages("t1") - # subagent responses still emit llm.ai.response with category="message" - assert len(messages) == 1 + assert len(messages) == 0 @pytest.mark.anyio async def test_token_accumulation(self, journal_setup): j, store = journal_setup usage1 = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} usage2 = {"input_tokens": 20, "output_tokens": 10, "total_tokens": 30} - j.on_llm_end(_make_llm_response("A", usage=usage1), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"]) - j.on_llm_end(_make_llm_response("B", usage=usage2), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"]) + j.on_llm_end(_make_llm_response("A", usage=usage1), run_id=uuid4(), tags=["lead_agent"]) + j.on_llm_end(_make_llm_response("B", usage=usage2), run_id=uuid4(), tags=["lead_agent"]) assert j._total_input_tokens == 30 assert j._total_output_tokens == 15 assert j._total_tokens == 45 @@ -130,26 +127,26 @@ class TestLlmCallbacks: j.on_llm_end( _make_llm_response("Hi", usage={"input_tokens": 100, "output_tokens": 50, "total_tokens": 0}), run_id=uuid4(), - parent_run_id=None, tags=["lead_agent"], ) assert j._total_tokens == 150 + assert j._lead_agent_tokens == 150 @pytest.mark.anyio async def test_caller_token_classification(self, journal_setup): j, store = journal_setup usage = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} - j.on_llm_end(_make_llm_response("A", usage=usage), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"]) - j.on_llm_end(_make_llm_response("B", usage=usage), run_id=uuid4(), parent_run_id=None, tags=["subagent:research"]) - j.on_llm_end(_make_llm_response("C", usage=usage), run_id=uuid4(), parent_run_id=None, tags=["middleware:summarization"]) - # token tracking not broken by caller type - assert j._total_tokens == 45 - assert j._llm_call_count == 3 + j.on_llm_end(_make_llm_response("A", usage=usage), run_id=uuid4(), tags=["lead_agent"]) + j.on_llm_end(_make_llm_response("B", usage=usage), run_id=uuid4(), tags=["subagent:research"]) + j.on_llm_end(_make_llm_response("C", usage=usage), run_id=uuid4(), tags=["middleware:summarization"]) + assert j._lead_agent_tokens == 15 + assert j._subagent_tokens == 15 + assert j._middleware_tokens == 15 @pytest.mark.anyio async def test_usage_metadata_none_no_crash(self, journal_setup): j, store = journal_setup - j.on_llm_end(_make_llm_response("No usage", usage=None), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"]) + j.on_llm_end(_make_llm_response("No usage", usage=None), run_id=uuid4(), tags=["lead_agent"]) await j.flush() @pytest.mark.anyio @@ -157,106 +154,103 @@ class TestLlmCallbacks: j, store = journal_setup run_id = uuid4() j.on_llm_start({}, [], run_id=run_id, tags=["lead_agent"]) - j.on_llm_end(_make_llm_response("Fast"), run_id=run_id, parent_run_id=None, tags=["lead_agent"]) + j.on_llm_end(_make_llm_response("Fast"), run_id=run_id, tags=["lead_agent"]) await j.flush() events = await store.list_events("t1", "r1") - llm_resp = [e for e in events if e["event_type"] == "llm.ai.response"][0] + llm_resp = [e for e in events if e["event_type"] == "llm_response"][0] assert "latency_ms" in llm_resp["metadata"] assert llm_resp["metadata"]["latency_ms"] is not None class TestLifecycleCallbacks: @pytest.mark.anyio - async def test_chain_start_end_produce_trace_events(self, journal_setup): + async def test_chain_start_end_produce_lifecycle_events(self, journal_setup): j, store = journal_setup j.on_chain_start({}, {}, run_id=uuid4(), parent_run_id=None) - j.on_chain_end({}, run_id=uuid4()) + j.on_chain_end({}, run_id=uuid4(), parent_run_id=None) await asyncio.sleep(0.05) await j.flush() events = await store.list_events("t1", "r1") - types = {e["event_type"] for e in events} - assert "run.start" in types - assert "run.end" in types + types = [e["event_type"] for e in events if e["category"] == "lifecycle"] + assert "run_start" in types + 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_ignored(self, journal_setup): 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) + lifecycle = [e for e in events if e["category"] == "lifecycle"] + assert len(lifecycle) == 0 class TestToolCallbacks: @pytest.mark.anyio - async def test_tool_end_with_tool_message(self, journal_setup): - """on_tool_end with a ToolMessage stores it as llm.tool.result.""" - from langchain_core.messages import ToolMessage - + async def test_tool_start_end_produce_trace(self, journal_setup): j, store = journal_setup - tool_msg = ToolMessage(content="results", tool_call_id="call_1", name="web_search") - j.on_tool_end(tool_msg, run_id=uuid4()) + j.on_tool_start({"name": "web_search"}, "query", run_id=uuid4()) + j.on_tool_end("results", run_id=uuid4(), name="web_search") await j.flush() - messages = await store.list_messages("t1") - assert len(messages) == 1 - assert messages[0]["event_type"] == "llm.tool.result" - assert messages[0]["content"]["type"] == "tool" + events = await store.list_events("t1", "r1") + trace_types = {e["event_type"] for e in events if e["category"] == "trace"} + assert "tool_start" in trace_types + assert "tool_end" in trace_types @pytest.mark.anyio - async def test_tool_end_with_command_unwraps_tool_message(self, journal_setup): - """on_tool_end with Command(update={'messages':[ToolMessage]}) unwraps inner message.""" - from langchain_core.messages import ToolMessage - from langgraph.types import Command - - j, store = journal_setup - inner = ToolMessage(content="file list", tool_call_id="call_2", name="present_files") - cmd = Command(update={"messages": [inner]}) - j.on_tool_end(cmd, run_id=uuid4()) - await j.flush() - messages = await store.list_messages("t1") - assert len(messages) == 1 - assert messages[0]["event_type"] == "llm.tool.result" - assert messages[0]["content"]["content"] == "file list" - - @pytest.mark.anyio - async def test_on_tool_error_no_crash(self, journal_setup): - """on_tool_error should not crash (no event emitted by default).""" + async def test_on_tool_error(self, journal_setup): j, store = journal_setup j.on_tool_error(TimeoutError("timeout"), run_id=uuid4(), name="web_fetch") await j.flush() - # Base implementation does not emit tool_error — just verify no crash events = await store.list_events("t1", "r1") - assert isinstance(events, list) + assert any(e["event_type"] == "tool_error" for e in events) class TestCustomEvents: @pytest.mark.anyio - async def test_on_custom_event_not_implemented(self, journal_setup): - """RunJournal does not implement on_custom_event — no crash expected.""" + async def test_summarization_event(self, journal_setup): j, store = journal_setup - # BaseCallbackHandler.on_custom_event is a no-op by default - j.on_custom_event("task_running", {"task_id": "t1"}, run_id=uuid4()) + j.on_custom_event( + "summarization", + {"summary": "Context was summarized.", "replaced_count": 5, "replaced_message_ids": ["a", "b"]}, + run_id=uuid4(), + ) await j.flush() events = await store.list_events("t1", "r1") - assert isinstance(events, list) + trace = [e for e in events if e["event_type"] == "summarization"] + assert len(trace) == 1 + # Summarization goes to middleware category, not message + mw_events = [e for e in events if e["event_type"] == "middleware:summarize"] + assert len(mw_events) == 1 + assert mw_events[0]["category"] == "middleware" + assert mw_events[0]["content"] == {"role": "system", "content": "Context was summarized."} + # No message events from summarization + messages = await store.list_messages("t1") + assert len(messages) == 0 + + @pytest.mark.anyio + async def test_non_summarization_custom_event(self, journal_setup): + j, store = journal_setup + j.on_custom_event("task_running", {"task_id": "t1", "status": "running"}, run_id=uuid4()) + await j.flush() + events = await store.list_events("t1", "r1") + assert any(e["event_type"] == "task_running" for e in events) class TestBufferFlush: @pytest.mark.anyio async def test_flush_threshold(self, journal_setup): j, store = journal_setup - j._flush_threshold = 2 - # Each on_llm_end emits 1 event - j.on_llm_end(_make_llm_response("A"), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"]) - assert len(j._buffer) == 1 - j.on_llm_end(_make_llm_response("B"), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"]) - # At threshold the buffer should have been flushed asynchronously + j._flush_threshold = 3 + j.on_tool_start({"name": "a"}, "x", run_id=uuid4()) + j.on_tool_start({"name": "b"}, "x", run_id=uuid4()) + assert len(j._buffer) == 2 + j.on_tool_start({"name": "c"}, "x", run_id=uuid4()) await asyncio.sleep(0.1) events = await store.list_events("t1", "r1") - assert len(events) >= 2 + assert len(events) >= 3 @pytest.mark.anyio async def test_events_retained_when_no_loop(self, journal_setup): @@ -272,44 +266,44 @@ class TestBufferFlush: asyncio.get_running_loop = no_loop try: - j._put(event_type="llm.ai.response", category="message", content="test") + j._put(event_type="llm_response", category="trace", content="test") finally: asyncio.get_running_loop = original assert len(j._buffer) == 1 await j.flush() events = await store.list_events("t1", "r1") - assert any(e["event_type"] == "llm.ai.response" for e in events) + assert any(e["event_type"] == "llm_response" for e in events) class TestIdentifyCaller: def test_lead_agent_tag(self, journal_setup): j, _ = journal_setup - assert j._identify_caller(["lead_agent"]) == "lead_agent" + assert j._identify_caller({"tags": ["lead_agent"]}) == "lead_agent" def test_subagent_tag(self, journal_setup): j, _ = journal_setup - assert j._identify_caller(["subagent:research"]) == "subagent:research" + assert j._identify_caller({"tags": ["subagent:research"]}) == "subagent:research" def test_middleware_tag(self, journal_setup): j, _ = journal_setup - assert j._identify_caller(["middleware:summarization"]) == "middleware:summarization" + assert j._identify_caller({"tags": ["middleware:summarization"]}) == "middleware:summarization" def test_no_tags_returns_lead_agent(self, journal_setup): j, _ = journal_setup - assert j._identify_caller([]) == "lead_agent" - assert j._identify_caller(None) == "lead_agent" + assert j._identify_caller({"tags": []}) == "lead_agent" + assert j._identify_caller({}) == "lead_agent" class TestChainErrorCallback: @pytest.mark.anyio async def test_on_chain_error_writes_run_error(self, journal_setup): j, store = journal_setup - j.on_chain_error(ValueError("boom"), run_id=uuid4()) + j.on_chain_error(ValueError("boom"), run_id=uuid4(), parent_run_id=None) await asyncio.sleep(0.05) await j.flush() events = await store.list_events("t1", "r1") - error_events = [e for e in events if e["event_type"] == "run.error"] + error_events = [e for e in events if e["event_type"] == "run_error"] assert len(error_events) == 1 assert "boom" in error_events[0]["content"] assert error_events[0]["metadata"]["error_type"] == "ValueError" @@ -323,7 +317,6 @@ class TestTokenTrackingDisabled: j.on_llm_end( _make_llm_response("X", usage={"input_tokens": 50, "output_tokens": 50, "total_tokens": 100}), run_id=uuid4(), - parent_run_id=None, tags=["lead_agent"], ) data = j.get_completion_data() @@ -332,6 +325,15 @@ class TestTokenTrackingDisabled: class TestConvenienceFields: + @pytest.mark.anyio + async def test_last_ai_message_tracks_latest(self, journal_setup): + j, store = journal_setup + j.on_llm_end(_make_llm_response("First"), run_id=uuid4(), tags=["lead_agent"]) + j.on_llm_end(_make_llm_response("Second"), run_id=uuid4(), tags=["lead_agent"]) + data = j.get_completion_data() + assert data["last_ai_message"] == "Second" + assert data["message_count"] == 2 + @pytest.mark.anyio async def test_first_human_message_via_set(self, journal_setup): j, _ = journal_setup @@ -349,6 +351,613 @@ class TestConvenienceFields: assert data["message_count"] == 5 +class TestUnknownCallerTokens: + @pytest.mark.anyio + async def test_unknown_caller_tokens_go_to_lead(self, journal_setup): + j, store = journal_setup + j.on_llm_end( + _make_llm_response("X", usage={"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}), + run_id=uuid4(), + tags=[], + ) + assert j._lead_agent_tokens == 15 + + +# --------------------------------------------------------------------------- +# SQLite-backed end-to-end test +# --------------------------------------------------------------------------- + + +class TestDbBackedLifecycle: + @pytest.mark.anyio + async def test_full_lifecycle_with_sqlite(self, tmp_path): + """Full lifecycle with SQLite-backed RunRepository + DbRunEventStore.""" + from deerflow.persistence.engine import close_engine, get_session_factory, init_engine + from deerflow.persistence.run import RunRepository + from deerflow.runtime.events.store.db import DbRunEventStore + from deerflow.runtime.runs.manager import RunManager + + url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}" + await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)) + sf = get_session_factory() + + run_store = RunRepository(sf) + event_store = DbRunEventStore(sf) + mgr = RunManager(store=run_store) + + # Create run + record = await mgr.create("t1", "lead_agent") + run_id = record.run_id + + # Write human_message (checkpoint-aligned format) + from langchain_core.messages import HumanMessage + + human_msg = HumanMessage(content="Hello DB") + await event_store.put(thread_id="t1", run_id=run_id, event_type="human_message", category="message", content=human_msg.model_dump()) + + # Simulate journal + journal = RunJournal(run_id, "t1", event_store, flush_threshold=100) + journal.set_first_human_message("Hello DB") + + journal.on_chain_start({}, {}, run_id=uuid4(), parent_run_id=None) + llm_rid = uuid4() + journal.on_llm_start({"name": "test"}, [], run_id=llm_rid, tags=["lead_agent"]) + journal.on_llm_end( + _make_llm_response("DB response", usage={"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}), + run_id=llm_rid, + tags=["lead_agent"], + ) + journal.on_chain_end({}, run_id=uuid4(), parent_run_id=None) + await asyncio.sleep(0.05) + await journal.flush() + + # Verify run persisted + row = await run_store.get(run_id) + assert row is not None + assert row["status"] == "pending" + + # Update completion + completion = journal.get_completion_data() + await run_store.update_run_completion(run_id, status="success", **completion) + row = await run_store.get(run_id) + assert row["status"] == "success" + assert row["total_tokens"] == 15 + + # Verify messages from DB (checkpoint-aligned format) + messages = await event_store.list_messages("t1") + assert len(messages) == 2 + assert messages[0]["event_type"] == "human_message" + assert messages[0]["content"]["type"] == "human" + assert messages[1]["event_type"] == "ai_message" + assert messages[1]["content"]["type"] == "ai" + assert messages[1]["content"]["content"] == "DB response" + + # Verify events from DB + events = await event_store.list_events("t1", run_id) + event_types = {e["event_type"] for e in events} + assert "run_start" in event_types + assert "llm_response" in event_types + assert "run_end" in event_types + + await close_engine() + + +class TestDictContentFlag: + """Verify that content_is_dict metadata flag controls deserialization.""" + + @pytest.mark.anyio + async def test_db_store_str_starting_with_brace_not_deserialized(self, tmp_path): + """Plain string content starting with { should NOT be deserialized.""" + from deerflow.persistence.engine import close_engine, get_session_factory, init_engine + from deerflow.runtime.events.store.db import DbRunEventStore + + url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}" + await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)) + sf = get_session_factory() + store = DbRunEventStore(sf) + + await store.put( + thread_id="t1", + run_id="r1", + event_type="tool_end", + category="trace", + content="{not json, just a string}", + ) + events = await store.list_events("t1", "r1") + assert events[0]["content"] == "{not json, just a string}" + assert isinstance(events[0]["content"], str) + + await close_engine() + + @pytest.mark.anyio + async def test_db_store_str_starting_with_bracket_not_deserialized(self, tmp_path): + """Plain string content like '[1, 2, 3]' should NOT be deserialized.""" + from deerflow.persistence.engine import close_engine, get_session_factory, init_engine + from deerflow.runtime.events.store.db import DbRunEventStore + + url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}" + await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)) + sf = get_session_factory() + store = DbRunEventStore(sf) + + await store.put( + thread_id="t1", + run_id="r1", + event_type="tool_end", + category="trace", + content="[1, 2, 3]", + ) + events = await store.list_events("t1", "r1") + assert events[0]["content"] == "[1, 2, 3]" + assert isinstance(events[0]["content"], str) + + await close_engine() + + +class TestDictContent: + """Verify that store backends accept str | dict content.""" + + @pytest.mark.anyio + async def test_memory_store_dict_content(self): + store = MemoryRunEventStore() + record = await store.put( + thread_id="t1", + run_id="r1", + event_type="ai_message", + category="message", + content={"role": "assistant", "content": "Hello"}, + ) + assert record["content"] == {"role": "assistant", "content": "Hello"} + messages = await store.list_messages("t1") + assert len(messages) == 1 + assert messages[0]["content"] == {"role": "assistant", "content": "Hello"} + + @pytest.mark.anyio + async def test_memory_store_str_content_unchanged(self): + store = MemoryRunEventStore() + record = await store.put( + thread_id="t1", + run_id="r1", + event_type="ai_message", + category="message", + content="plain string", + ) + assert record["content"] == "plain string" + assert isinstance(record["content"], str) + + @pytest.mark.anyio + async def test_db_store_dict_content_roundtrip(self, tmp_path): + """Dict content survives DB roundtrip (JSON serialize on write, deserialize on read).""" + from deerflow.persistence.engine import close_engine, get_session_factory, init_engine + from deerflow.runtime.events.store.db import DbRunEventStore + + url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}" + await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)) + sf = get_session_factory() + store = DbRunEventStore(sf) + + nested = {"role": "assistant", "content": "Hi", "metadata": {"model": "gpt-4", "tokens": [1, 2, 3]}} + record = await store.put( + thread_id="t1", + run_id="r1", + event_type="ai_message", + category="message", + content=nested, + ) + assert record["content"] == nested + + messages = await store.list_messages("t1") + assert len(messages) == 1 + assert messages[0]["content"] == nested + + await close_engine() + + @pytest.mark.anyio + async def test_db_store_trace_dict_truncation(self, tmp_path): + """Large dict trace content is truncated with metadata flag.""" + from deerflow.persistence.engine import close_engine, get_session_factory, init_engine + from deerflow.runtime.events.store.db import DbRunEventStore + + url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}" + await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)) + sf = get_session_factory() + store = DbRunEventStore(sf, max_trace_content=100) + + large_dict = {"role": "assistant", "content": "x" * 200} + record = await store.put( + thread_id="t1", + run_id="r1", + event_type="llm_end", + category="trace", + content=large_dict, + ) + assert record["metadata"].get("content_truncated") is True + # Content should be a truncated string (serialized JSON was too long) + assert isinstance(record["content"], str) + assert len(record["content"]) <= 100 + + await close_engine() + + +class TestCheckpointAlignedHumanMessage: + @pytest.mark.anyio + async def test_human_message_checkpoint_format(self): + """human_message content uses model_dump() checkpoint format.""" + from langchain_core.messages import HumanMessage + + store = MemoryRunEventStore() + human_msg = HumanMessage(content="What is AI?") + await store.put( + thread_id="t1", + run_id="r1", + event_type="human_message", + category="message", + content=human_msg.model_dump(), + metadata={"message_id": "msg_001"}, + ) + messages = await store.list_messages("t1") + assert len(messages) == 1 + assert messages[0]["content"]["type"] == "human" + assert messages[0]["content"]["content"] == "What is AI?" + + +class TestCheckpointAlignedMessageFormat: + @pytest.mark.anyio + async def test_ai_message_checkpoint_format(self, journal_setup): + """ai_message content should be checkpoint-aligned model_dump dict.""" + j, store = journal_setup + j.on_llm_end(_make_llm_response("Answer"), run_id=uuid4(), tags=["lead_agent"]) + await j.flush() + messages = await store.list_messages("t1") + assert len(messages) == 1 + assert messages[0]["content"]["type"] == "ai" + assert messages[0]["content"]["content"] == "Answer" + assert "response_metadata" in messages[0]["content"] + assert "additional_kwargs" in messages[0]["content"] + + @pytest.mark.anyio + async def test_ai_tool_call_event(self, journal_setup): + """LLM response with tool_calls should produce ai_tool_call with model_dump content.""" + j, store = journal_setup + tool_calls = [{"id": "call_1", "name": "search", "args": {"query": "test"}}] + j.on_llm_end( + _make_llm_response("Let me search", tool_calls=tool_calls), + run_id=uuid4(), + tags=["lead_agent"], + ) + await j.flush() + messages = await store.list_messages("t1") + assert len(messages) == 1 + assert messages[0]["event_type"] == "ai_tool_call" + assert messages[0]["content"]["type"] == "ai" + assert messages[0]["content"]["content"] == "Let me search" + assert len(messages[0]["content"]["tool_calls"]) == 1 + tc = messages[0]["content"]["tool_calls"][0] + assert tc["id"] == "call_1" + assert tc["name"] == "search" + + @pytest.mark.anyio + async def test_ai_tool_call_only_from_lead_agent(self, journal_setup): + """ai_tool_call should only be emitted for lead_agent, not subagents.""" + j, store = journal_setup + tool_calls = [{"id": "call_1", "name": "search", "args": {}}] + j.on_llm_end( + _make_llm_response("searching", tool_calls=tool_calls), + run_id=uuid4(), + tags=["subagent:research"], + ) + await j.flush() + messages = await store.list_messages("t1") + assert len(messages) == 0 + + +class TestToolResultMessage: + @pytest.mark.anyio + async def test_tool_end_produces_tool_result_message(self, journal_setup): + j, store = journal_setup + run_id = uuid4() + j.on_tool_start({"name": "web_search"}, '{"query": "test"}', run_id=run_id, tool_call_id="call_abc") + j.on_tool_end("search results here", run_id=run_id, name="web_search", tool_call_id="call_abc") + await j.flush() + messages = await store.list_messages("t1") + assert len(messages) == 1 + assert messages[0]["event_type"] == "tool_result" + # Content is checkpoint-aligned model_dump format + assert messages[0]["content"]["type"] == "tool" + assert messages[0]["content"]["tool_call_id"] == "call_abc" + assert messages[0]["content"]["content"] == "search results here" + assert messages[0]["content"]["name"] == "web_search" + + @pytest.mark.anyio + async def test_tool_result_missing_tool_call_id(self, journal_setup): + j, store = journal_setup + run_id = uuid4() + j.on_tool_start({"name": "bash"}, "ls", run_id=run_id) + j.on_tool_end("file1.txt", run_id=run_id, name="bash") + await j.flush() + messages = await store.list_messages("t1") + assert len(messages) == 1 + assert messages[0]["content"]["type"] == "tool" + + @pytest.mark.anyio + async def test_tool_end_extracts_from_tool_message_object(self, journal_setup): + """When LangChain passes a ToolMessage object as output, extract fields from it.""" + from langchain_core.messages import ToolMessage + + j, store = journal_setup + run_id = uuid4() + tool_msg = ToolMessage( + content="search results", + tool_call_id="call_from_obj", + name="web_search", + status="success", + ) + j.on_tool_end(tool_msg, run_id=run_id) + await j.flush() + + messages = await store.list_messages("t1") + assert len(messages) == 1 + assert messages[0]["content"]["type"] == "tool" + assert messages[0]["content"]["tool_call_id"] == "call_from_obj" + assert messages[0]["content"]["content"] == "search results" + assert messages[0]["content"]["name"] == "web_search" + assert messages[0]["metadata"]["tool_name"] == "web_search" + assert messages[0]["metadata"]["status"] == "success" + + events = await store.list_events("t1", "r1") + tool_end = [e for e in events if e["event_type"] == "tool_end"][0] + assert tool_end["metadata"]["tool_call_id"] == "call_from_obj" + assert tool_end["metadata"]["tool_name"] == "web_search" + + @pytest.mark.anyio + async def test_tool_invoke_end_to_end_unwraps_command(self, journal_setup): + """End-to-end: invoke a real LangChain tool that returns Command(update={'messages':[ToolMessage]}). + + This goes through the real LangChain callback path (tool.invoke -> CallbackManager + -> on_tool_start/on_tool_end), which is what the production agent uses. Mirrors + the ``present_files`` tool shape exactly. + """ + from langchain_core.callbacks import CallbackManager + from langchain_core.messages import ToolMessage + from langchain_core.tools import tool + from langgraph.types import Command + + j, store = journal_setup + + @tool + def fake_present_files(filepaths: list[str]) -> Command: + """Fake present_files that returns a Command with an inner ToolMessage.""" + return Command( + update={ + "artifacts": filepaths, + "messages": [ToolMessage("Successfully presented files", tool_call_id="tc_123")], + }, + ) + + # Real LangChain callback dispatch (matches production agent path) + cm = CallbackManager(handlers=[j]) + fake_present_files.invoke( + {"filepaths": ["/mnt/user-data/outputs/report.md"]}, + config={"callbacks": cm, "run_id": uuid4()}, + ) + await j.flush() + + messages = await store.list_messages("t1") + assert len(messages) == 1, f"expected 1 message event, got {len(messages)}: {messages}" + content = messages[0]["content"] + assert content["type"] == "tool" + # CRITICAL: must be the inner ToolMessage text, not str(Command(...)) + assert content["content"] == "Successfully presented files", ( + f"Command unwrap failed; stored content = {content['content']!r}" + ) + assert "Command(update=" not in str(content["content"]) + + @pytest.mark.anyio + async def test_tool_end_unwraps_command_with_inner_tool_message(self, journal_setup): + """Tools like ``present_files`` return Command(update={'messages': [ToolMessage(...)]}). + + LangGraph unwraps the inner ToolMessage into checkpoint state, so the + event store must do the same — otherwise it captures ``str(Command(...))`` + and the /history response diverges from the real rendered message. + """ + from langchain_core.messages import ToolMessage + from langgraph.types import Command + + j, store = journal_setup + run_id = uuid4() + inner = ToolMessage( + content="Successfully presented files", + tool_call_id="call_present", + name="present_files", + status="success", + ) + cmd = Command(update={"artifacts": ["/mnt/user-data/outputs/report.md"], "messages": [inner]}) + j.on_tool_end(cmd, run_id=run_id) + await j.flush() + + messages = await store.list_messages("t1") + assert len(messages) == 1 + content = messages[0]["content"] + assert content["type"] == "tool" + assert content["content"] == "Successfully presented files" + assert content["tool_call_id"] == "call_present" + assert content["name"] == "present_files" + assert "Command(update=" not in str(content["content"]) + + @pytest.mark.anyio + async def test_tool_message_object_overrides_kwargs(self, journal_setup): + """ToolMessage object fields take priority over kwargs.""" + from langchain_core.messages import ToolMessage + + j, store = journal_setup + run_id = uuid4() + tool_msg = ToolMessage( + content="result", + tool_call_id="call_obj", + name="tool_a", + status="success", + ) + # Pass different values in kwargs — ToolMessage should win + j.on_tool_end(tool_msg, run_id=run_id, name="tool_b", tool_call_id="call_kwarg") + await j.flush() + + messages = await store.list_messages("t1") + assert messages[0]["content"]["tool_call_id"] == "call_obj" + assert messages[0]["content"]["name"] == "tool_a" + assert messages[0]["metadata"]["tool_name"] == "tool_a" + + @pytest.mark.anyio + async def test_tool_message_error_status(self, journal_setup): + """ToolMessage with status='error' propagates status to metadata.""" + from langchain_core.messages import ToolMessage + + j, store = journal_setup + run_id = uuid4() + tool_msg = ToolMessage( + content="something went wrong", + tool_call_id="call_err", + name="web_fetch", + status="error", + ) + j.on_tool_end(tool_msg, run_id=run_id) + await j.flush() + + events = await store.list_events("t1", "r1") + tool_end = [e for e in events if e["event_type"] == "tool_end"][0] + assert tool_end["metadata"]["status"] == "error" + + messages = await store.list_messages("t1") + assert messages[0]["content"]["status"] == "error" + assert messages[0]["metadata"]["status"] == "error" + + @pytest.mark.anyio + async def test_tool_message_fallback_to_cache(self, journal_setup): + """If ToolMessage has empty tool_call_id, fall back to cache from on_tool_start.""" + from langchain_core.messages import ToolMessage + + j, store = journal_setup + run_id = uuid4() + j.on_tool_start({"name": "bash"}, "ls", run_id=run_id, tool_call_id="call_cached") + tool_msg = ToolMessage( + content="file list", + tool_call_id="", + name="bash", + ) + j.on_tool_end(tool_msg, run_id=run_id) + await j.flush() + + messages = await store.list_messages("t1") + assert messages[0]["content"]["tool_call_id"] == "call_cached" + + @pytest.mark.anyio + async def test_tool_error_produces_tool_result_message(self, journal_setup): + j, store = journal_setup + j.on_tool_error(TimeoutError("timeout"), run_id=uuid4(), name="web_fetch", tool_call_id="call_1") + await j.flush() + messages = await store.list_messages("t1") + assert len(messages) == 1 + assert messages[0]["event_type"] == "tool_result" + assert messages[0]["content"]["type"] == "tool" + assert messages[0]["content"]["tool_call_id"] == "call_1" + assert "timeout" in messages[0]["content"]["content"] + assert messages[0]["content"]["status"] == "error" + assert messages[0]["metadata"]["status"] == "error" + + @pytest.mark.anyio + async def test_tool_error_uses_cached_tool_call_id(self, journal_setup): + """on_tool_error should fall back to cached tool_call_id from on_tool_start.""" + j, store = journal_setup + run_id = uuid4() + j.on_tool_start({"name": "web_fetch"}, "url", run_id=run_id, tool_call_id="call_cached") + j.on_tool_error(TimeoutError("timeout"), run_id=run_id, name="web_fetch") + await j.flush() + messages = await store.list_messages("t1") + assert len(messages) == 1 + assert messages[0]["content"]["tool_call_id"] == "call_cached" + + +def _make_base_messages(): + """Create mock LangChain BaseMessages for on_chat_model_start.""" + sys_msg = MagicMock() + sys_msg.content = "You are helpful." + sys_msg.type = "system" + sys_msg.tool_calls = [] + sys_msg.tool_call_id = None + + user_msg = MagicMock() + user_msg.content = "Hello" + user_msg.type = "human" + user_msg.tool_calls = [] + user_msg.tool_call_id = None + + return [sys_msg, user_msg] + + +class TestLlmRequestResponse: + @pytest.mark.anyio + async def test_llm_request_event(self, journal_setup): + j, store = journal_setup + run_id = uuid4() + messages = _make_base_messages() + j.on_chat_model_start({"name": "gpt-4o"}, [messages], run_id=run_id, tags=["lead_agent"]) + await j.flush() + events = await store.list_events("t1", "r1") + req_events = [e for e in events if e["event_type"] == "llm_request"] + assert len(req_events) == 1 + content = req_events[0]["content"] + assert content["model"] == "gpt-4o" + assert len(content["messages"]) == 2 + assert content["messages"][0]["role"] == "system" + assert content["messages"][1]["role"] == "user" + + @pytest.mark.anyio + async def test_llm_response_event(self, journal_setup): + j, store = journal_setup + run_id = uuid4() + j.on_llm_start({}, [], run_id=run_id, tags=["lead_agent"]) + j.on_llm_end( + _make_llm_response("Answer", usage={"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}), + run_id=run_id, + tags=["lead_agent"], + ) + await j.flush() + events = await store.list_events("t1", "r1") + assert not any(e["event_type"] == "llm_end" for e in events) + resp_events = [e for e in events if e["event_type"] == "llm_response"] + assert len(resp_events) == 1 + content = resp_events[0]["content"] + assert "choices" in content + assert content["choices"][0]["message"]["role"] == "assistant" + assert content["choices"][0]["message"]["content"] == "Answer" + assert content["usage"]["prompt_tokens"] == 10 + + @pytest.mark.anyio + async def test_llm_request_response_paired(self, journal_setup): + j, store = journal_setup + run_id = uuid4() + messages = _make_base_messages() + j.on_chat_model_start({"name": "gpt-4o"}, [messages], run_id=run_id, tags=["lead_agent"]) + j.on_llm_end( + _make_llm_response("Hi", usage={"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}), + run_id=run_id, + tags=["lead_agent"], + ) + await j.flush() + events = await store.list_events("t1", "r1") + req = [e for e in events if e["event_type"] == "llm_request"][0] + resp = [e for e in events if e["event_type"] == "llm_response"][0] + assert req["metadata"]["llm_call_index"] == resp["metadata"]["llm_call_index"] + + @pytest.mark.anyio + async def test_no_llm_start_event(self, journal_setup): + j, store = journal_setup + run_id = uuid4() + j.on_llm_start({"name": "test"}, [], run_id=run_id, tags=["lead_agent"]) + await j.flush() + events = await store.list_events("t1", "r1") + assert not any(e["event_type"] == "llm_start" for e in events) + + class TestMiddlewareEvents: @pytest.mark.anyio async def test_record_middleware_uses_middleware_category(self, journal_setup): @@ -370,6 +979,21 @@ class TestMiddlewareEvents: assert mw_events[0]["content"]["action"] == "generate_title" assert mw_events[0]["content"]["changes"]["title"] == "Test Title" + @pytest.mark.anyio + async def test_middleware_events_not_in_messages(self, journal_setup): + """Middleware events should not appear in list_messages().""" + j, store = journal_setup + j.record_middleware( + "title", + name="TitleMiddleware", + hook="after_model", + action="generate_title", + changes={"title": "Test"}, + ) + await j.flush() + messages = await store.list_messages("t1") + assert len(messages) == 0 + @pytest.mark.anyio async def test_middleware_tag_variants(self, journal_setup): """Different middleware tags produce distinct event_types.""" @@ -381,3 +1005,113 @@ class TestMiddlewareEvents: event_types = {e["event_type"] for e in events} assert "middleware:title" in event_types assert "middleware:guardrail" in event_types + + +class TestFullRunSequence: + @pytest.mark.anyio + async def test_complete_run_event_sequence(self): + """Simulate a full run: user -> LLM -> tool_call -> tool_result -> LLM -> final reply. + + All message events use checkpoint-aligned model_dump format. + """ + from langchain_core.messages import HumanMessage + + store = MemoryRunEventStore() + j = RunJournal("r1", "t1", store, flush_threshold=100) + + # 1. Human message (written by worker, using model_dump format) + human_msg = HumanMessage(content="Search for quantum computing") + await store.put( + thread_id="t1", + run_id="r1", + event_type="human_message", + category="message", + content=human_msg.model_dump(), + ) + j.set_first_human_message("Search for quantum computing") + + # 2. Run start + j.on_chain_start({}, {}, run_id=uuid4(), parent_run_id=None) + + # 3. First LLM call -> tool_calls + llm1_id = uuid4() + sys_msg = MagicMock(content="You are helpful.", type="system", tool_calls=[], tool_call_id=None) + user_msg = MagicMock(content="Search for quantum computing", type="human", tool_calls=[], tool_call_id=None) + j.on_chat_model_start({"name": "gpt-4o"}, [[sys_msg, user_msg]], run_id=llm1_id, tags=["lead_agent"]) + j.on_llm_end( + _make_llm_response( + "Let me search", + tool_calls=[{"id": "call_1", "name": "web_search", "args": {"query": "quantum computing"}}], + usage={"input_tokens": 100, "output_tokens": 20, "total_tokens": 120}, + ), + run_id=llm1_id, + tags=["lead_agent"], + ) + + # 4. Tool execution + tool_id = uuid4() + j.on_tool_start({"name": "web_search"}, '{"query": "quantum computing"}', run_id=tool_id, tool_call_id="call_1") + j.on_tool_end("Quantum computing results...", run_id=tool_id, name="web_search", tool_call_id="call_1") + + # 5. Middleware: title generation + j.record_middleware("title", name="TitleMiddleware", hook="after_model", action="generate_title", changes={"title": "Quantum Computing"}) + + # 6. Second LLM call -> final reply + llm2_id = uuid4() + j.on_chat_model_start({"name": "gpt-4o"}, [[sys_msg, user_msg]], run_id=llm2_id, tags=["lead_agent"]) + j.on_llm_end( + _make_llm_response( + "Here are the results about quantum computing...", + usage={"input_tokens": 200, "output_tokens": 100, "total_tokens": 300}, + ), + run_id=llm2_id, + tags=["lead_agent"], + ) + + # 7. Run end + j.on_chain_end({}, run_id=uuid4(), parent_run_id=None) + await asyncio.sleep(0.05) + await j.flush() + + # Verify message sequence + messages = await store.list_messages("t1") + msg_types = [m["event_type"] for m in messages] + assert msg_types == ["human_message", "ai_tool_call", "tool_result", "ai_message"] + + # Verify checkpoint-aligned format: all messages use "type" not "role" + assert messages[0]["content"]["type"] == "human" + assert messages[0]["content"]["content"] == "Search for quantum computing" + assert messages[1]["content"]["type"] == "ai" + assert "tool_calls" in messages[1]["content"] + assert messages[2]["content"]["type"] == "tool" + assert messages[2]["content"]["tool_call_id"] == "call_1" + assert messages[3]["content"]["type"] == "ai" + assert messages[3]["content"]["content"] == "Here are the results about quantum computing..." + + # Verify trace events + events = await store.list_events("t1", "r1") + trace_types = [e["event_type"] for e in events if e["category"] == "trace"] + assert "llm_request" in trace_types + assert "llm_response" in trace_types + assert "tool_start" in trace_types + assert "tool_end" in trace_types + assert "llm_start" not in trace_types + assert "llm_end" not in trace_types + + # Verify middleware events are in their own category + mw_events = [e for e in events if e["category"] == "middleware"] + assert len(mw_events) == 1 + assert mw_events[0]["event_type"] == "middleware:title" + + # Verify token accumulation + data = j.get_completion_data() + assert data["total_tokens"] == 420 # 120 + 300 + assert data["llm_call_count"] == 2 + assert data["lead_agent_tokens"] == 420 + assert data["message_count"] == 1 # only final ai_message counts + assert data["last_ai_message"] == "Here are the results about quantum computing..." + + # Verify all message contents are checkpoint-aligned dicts with "type" field + for m in messages: + assert isinstance(m["content"], dict) + assert "type" in m["content"] diff --git a/backend/tests/test_runs_api_endpoints.py b/backend/tests/test_runs_api_endpoints.py index 1826e4d8e..e6b73d865 100644 --- a/backend/tests/test_runs_api_endpoints.py +++ b/backend/tests/test_runs_api_endpoints.py @@ -1,14 +1,15 @@ """Tests for GET /api/runs/{run_id}/messages and GET /api/runs/{run_id}/feedback endpoints.""" - from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch +import pytest from _router_auth_helpers import make_authed_test_app from fastapi.testclient import TestClient from app.gateway.routers import runs + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -112,8 +113,7 @@ def test_run_messages_passes_after_seq_to_event_store(): response = client.get("/api/runs/run-3/messages?after_seq=5") assert response.status_code == 200 event_store.list_messages_by_run.assert_awaited_once_with( - "thread-3", - "run-3", + "thread-3", "run-3", limit=51, # default limit(50) + 1 before_seq=None, after_seq=5, @@ -133,8 +133,7 @@ def test_run_messages_respects_custom_limit(): response = client.get("/api/runs/run-4/messages?limit=10") assert response.status_code == 200 event_store.list_messages_by_run.assert_awaited_once_with( - "thread-4", - "run-4", + "thread-4", "run-4", limit=11, # 10 + 1 before_seq=None, after_seq=None, @@ -154,8 +153,7 @@ def test_run_messages_passes_before_seq_to_event_store(): response = client.get("/api/runs/run-5/messages?before_seq=10") assert response.status_code == 200 event_store.list_messages_by_run.assert_awaited_once_with( - "thread-5", - "run-5", + "thread-5", "run-5", limit=51, before_seq=10, after_seq=None, diff --git a/backend/tests/test_sandbox_search_tools.py b/backend/tests/test_sandbox_search_tools.py index 88e87a783..9c7ec1990 100644 --- a/backend/tests/test_sandbox_search_tools.py +++ b/backend/tests/test_sandbox_search_tools.py @@ -14,6 +14,10 @@ def _make_runtime(tmp_path): workspace.mkdir() uploads.mkdir() outputs.mkdir() + from deerflow.config.app_config import AppConfig + from deerflow.config.deer_flow_context import DeerFlowContext + from deerflow.config.sandbox_config import SandboxConfig + return SimpleNamespace( state={ "sandbox": {"sandbox_id": "local"}, @@ -23,7 +27,10 @@ def _make_runtime(tmp_path): "outputs_path": str(outputs), }, }, - context={"thread_id": "thread-1"}, + context=DeerFlowContext( + app_config=AppConfig(sandbox=SandboxConfig(use="test")), + thread_id="thread-1", + ), ) @@ -103,8 +110,6 @@ def test_grep_tool_truncates_results(tmp_path, monkeypatch) -> None: (workspace / "main.py").write_text("TODO one\nTODO two\nTODO three\n", encoding="utf-8") monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local")) - # Prevent config.yaml tool config from overriding the caller-supplied max_results=2. - monkeypatch.setattr("deerflow.sandbox.tools.get_app_config", lambda: SimpleNamespace(get_tool_config=lambda name: None)) result = grep_tool.func( runtime=runtime, @@ -324,10 +329,6 @@ def test_glob_tool_honors_smaller_requested_max_results(tmp_path, monkeypatch) - (workspace / "c.py").write_text("print('c')\n", encoding="utf-8") monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local")) - monkeypatch.setattr( - "deerflow.sandbox.tools.get_app_config", - lambda: SimpleNamespace(get_tool_config=lambda name: SimpleNamespace(model_extra={"max_results": 50})), - ) result = glob_tool.func( runtime=runtime, diff --git a/backend/tests/test_sandbox_tools_security.py b/backend/tests/test_sandbox_tools_security.py index 8c67cd50a..e74d00d3e 100644 --- a/backend/tests/test_sandbox_tools_security.py +++ b/backend/tests/test_sandbox_tools_security.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest +from deerflow.config.app_config import AppConfig from deerflow.sandbox.tools import ( VIRTUAL_PATH_PREFIX, _apply_cwd_prefix, @@ -34,6 +35,53 @@ _THREAD_DATA = { } +def _make_app_config( + *, + skills_container_path: str = "/mnt/skills", + skills_host_path: str | None = None, + mounts=None, + mcp_servers=None, + tool_config_map=None, +) -> SimpleNamespace: + """Build a lightweight AppConfig stand-in used by tests. + + Only the attributes accessed by the helpers under test are populated; + everything else is omitted to keep the fake minimal and explicit. + """ + skills_path = Path(skills_host_path) if skills_host_path is not None else None + skills_cfg = SimpleNamespace( + container_path=skills_container_path, + get_skills_path=lambda: skills_path if skills_path is not None else Path("/nonexistent-skills-root-12345"), + ) + sandbox_cfg = SimpleNamespace(mounts=list(mounts) if mounts else [], bash_output_max_chars=20000) + extensions_cfg = SimpleNamespace(mcp_servers=dict(mcp_servers) if mcp_servers else {}) + tool_config_map = dict(tool_config_map or {}) + return SimpleNamespace( + skills=skills_cfg, + sandbox=sandbox_cfg, + extensions=extensions_cfg, + get_tool_config=lambda name: tool_config_map.get(name), + ) + + +_DEFAULT_APP_CONFIG = _make_app_config() + + +def _make_ctx(thread_id: str = "thread-1", *, app_config=_DEFAULT_APP_CONFIG, sandbox_key: str | None = None): + """Build a DeerFlowContext-like object with extra attributes allowed. + + ``resolve_context`` only checks ``isinstance(ctx, DeerFlowContext)``; for + tests that need additional attributes (``sandbox_key``) we use a subclass + created at runtime. + """ + from deerflow.config.deer_flow_context import DeerFlowContext as _DFC + + ctx = _DFC(app_config=app_config, thread_id=thread_id) + if sandbox_key is not None: + object.__setattr__(ctx, "sandbox_key", sandbox_key) + return ctx + + # ---------- replace_virtual_path ---------- @@ -85,7 +133,7 @@ def test_replace_virtual_path_preserves_windows_style_for_nested_subdir_trailing def test_replace_virtual_paths_in_command_preserves_trailing_slash() -> None: """Trailing slash on a virtual path inside a command must be preserved.""" cmd = """python -c "output_dir = '/mnt/user-data/workspace/'; print(output_dir + 'some_file.txt')\"""" - result = replace_virtual_paths_in_command(cmd, _THREAD_DATA) + result = replace_virtual_paths_in_command(cmd, _THREAD_DATA, _DEFAULT_APP_CONFIG) assert "/tmp/deer-flow/threads/t1/user-data/workspace/" in result, f"Trailing slash lost in: {result!r}" @@ -94,7 +142,7 @@ def test_replace_virtual_paths_in_command_preserves_trailing_slash() -> None: def test_mask_local_paths_in_output_hides_host_paths() -> None: output = "Created: /tmp/deer-flow/threads/t1/user-data/workspace/result.txt" - masked = mask_local_paths_in_output(output, _THREAD_DATA) + masked = mask_local_paths_in_output(output, _THREAD_DATA, _DEFAULT_APP_CONFIG) assert "/tmp/deer-flow/threads/t1/user-data" not in masked assert "/mnt/user-data/workspace/result.txt" in masked @@ -107,7 +155,7 @@ def test_mask_local_paths_in_output_hides_skills_host_paths() -> None: patch("deerflow.sandbox.tools._get_skills_host_path", return_value="/home/user/deer-flow/skills"), ): output = "Reading: /home/user/deer-flow/skills/public/bootstrap/SKILL.md" - masked = mask_local_paths_in_output(output, _THREAD_DATA) + masked = mask_local_paths_in_output(output, _THREAD_DATA, _DEFAULT_APP_CONFIG) assert "/home/user/deer-flow/skills" not in masked assert "/mnt/skills/public/bootstrap/SKILL.md" in masked @@ -143,12 +191,12 @@ def test_reject_path_traversal_allows_normal_paths() -> None: def test_validate_local_tool_path_rejects_non_virtual_path() -> None: with pytest.raises(PermissionError, match="Only paths under"): - validate_local_tool_path("/Users/someone/config.yaml", _THREAD_DATA) + validate_local_tool_path("/Users/someone/config.yaml", _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_tool_path_rejects_non_virtual_path_mentions_configured_mounts() -> None: with pytest.raises(PermissionError, match="configured mount paths"): - validate_local_tool_path("/Users/someone/config.yaml", _THREAD_DATA) + validate_local_tool_path("/Users/someone/config.yaml", _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_tool_path_prioritizes_user_data_before_custom_mounts() -> None: @@ -158,42 +206,41 @@ def test_validate_local_tool_path_prioritizes_user_data_before_custom_mounts() - VolumeMountConfig(host_path="/tmp/host-user-data", container_path=VIRTUAL_PATH_PREFIX, read_only=False), ] with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=mounts): - validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/workspace/file.txt", _THREAD_DATA, read_only=True) + validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/workspace/file.txt", _THREAD_DATA, _DEFAULT_APP_CONFIG, read_only=True) with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=mounts): with pytest.raises(PermissionError, match="path traversal"): - validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/workspace/../../etc/passwd", _THREAD_DATA, read_only=True) + validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/workspace/../../etc/passwd", _THREAD_DATA, _DEFAULT_APP_CONFIG, read_only=True) def test_validate_local_tool_path_rejects_bare_virtual_root() -> None: """The bare /mnt/user-data root without trailing slash is not a valid sub-path.""" with pytest.raises(PermissionError, match="Only paths under"): - validate_local_tool_path(VIRTUAL_PATH_PREFIX, _THREAD_DATA) + validate_local_tool_path(VIRTUAL_PATH_PREFIX, _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_tool_path_allows_user_data_paths() -> None: # Should not raise — user-data paths are always allowed - validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/workspace/file.txt", _THREAD_DATA) - validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/uploads/doc.pdf", _THREAD_DATA) - validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/outputs/result.csv", _THREAD_DATA) + validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/workspace/file.txt", _THREAD_DATA, _DEFAULT_APP_CONFIG) + validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/uploads/doc.pdf", _THREAD_DATA, _DEFAULT_APP_CONFIG) + validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/outputs/result.csv", _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_tool_path_allows_user_data_write() -> None: # read_only=False (default) should still work for user-data paths - validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/workspace/file.txt", _THREAD_DATA, read_only=False) + validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/workspace/file.txt", _THREAD_DATA, _DEFAULT_APP_CONFIG, read_only=False) def test_validate_local_tool_path_rejects_traversal_in_user_data() -> None: """Path traversal via .. in user-data paths must be rejected.""" with pytest.raises(PermissionError, match="path traversal"): - validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/workspace/../../etc/passwd", _THREAD_DATA) + validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/workspace/../../etc/passwd", _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_tool_path_rejects_traversal_in_skills() -> None: """Path traversal via .. in skills paths must be rejected.""" - with patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"): - with pytest.raises(PermissionError, match="path traversal"): - validate_local_tool_path("/mnt/skills/../../etc/passwd", _THREAD_DATA, read_only=True) + with pytest.raises(PermissionError, match="path traversal"): + validate_local_tool_path("/mnt/skills/../../etc/passwd", _THREAD_DATA, _DEFAULT_APP_CONFIG, read_only=True) def test_validate_local_tool_path_rejects_none_thread_data() -> None: @@ -201,7 +248,7 @@ def test_validate_local_tool_path_rejects_none_thread_data() -> None: from deerflow.sandbox.exceptions import SandboxRuntimeError with pytest.raises(SandboxRuntimeError): - validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/workspace/file.txt", None) + validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/workspace/file.txt", None, _DEFAULT_APP_CONFIG) # ---------- _resolve_skills_path ---------- @@ -209,32 +256,26 @@ def test_validate_local_tool_path_rejects_none_thread_data() -> None: def test_resolve_skills_path_resolves_correctly() -> None: """Skills virtual path should resolve to host path.""" - with ( - patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"), - patch("deerflow.sandbox.tools._get_skills_host_path", return_value="/home/user/deer-flow/skills"), - ): - resolved = _resolve_skills_path("/mnt/skills/public/bootstrap/SKILL.md") - assert resolved == "/home/user/deer-flow/skills/public/bootstrap/SKILL.md" + cfg = _make_app_config(skills_host_path="/home/user/deer-flow/skills") + # Force get_skills_path().exists() to be True without touching the FS + cfg.skills.get_skills_path = lambda: type("_P", (), {"exists": lambda self: True, "__str__": lambda self: "/home/user/deer-flow/skills"})() + resolved = _resolve_skills_path("/mnt/skills/public/bootstrap/SKILL.md", cfg) + assert resolved == "/home/user/deer-flow/skills/public/bootstrap/SKILL.md" def test_resolve_skills_path_resolves_root() -> None: """Skills container root should resolve to host skills directory.""" - with ( - patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"), - patch("deerflow.sandbox.tools._get_skills_host_path", return_value="/home/user/deer-flow/skills"), - ): - resolved = _resolve_skills_path("/mnt/skills") - assert resolved == "/home/user/deer-flow/skills" + cfg = _make_app_config(skills_host_path="/home/user/deer-flow/skills") + cfg.skills.get_skills_path = lambda: type("_P", (), {"exists": lambda self: True, "__str__": lambda self: "/home/user/deer-flow/skills"})() + resolved = _resolve_skills_path("/mnt/skills", cfg) + assert resolved == "/home/user/deer-flow/skills" def test_resolve_skills_path_raises_when_not_configured() -> None: """Should raise FileNotFoundError when skills directory is not available.""" - with ( - patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"), - patch("deerflow.sandbox.tools._get_skills_host_path", return_value=None), - ): - with pytest.raises(FileNotFoundError, match="Skills directory not available"): - _resolve_skills_path("/mnt/skills/public/bootstrap/SKILL.md") + # Default app config has no host path configured → _get_skills_host_path returns None + with pytest.raises(FileNotFoundError, match="Skills directory not available"): + _resolve_skills_path("/mnt/skills/public/bootstrap/SKILL.md", _DEFAULT_APP_CONFIG) # ---------- _resolve_and_validate_user_data_path ---------- @@ -249,7 +290,7 @@ def test_resolve_and_validate_user_data_path_resolves_correctly(tmp_path: Path) "uploads_path": str(tmp_path / "uploads"), "outputs_path": str(tmp_path / "outputs"), } - resolved = _resolve_and_validate_user_data_path("/mnt/user-data/workspace/hello.txt", thread_data) + resolved = _resolve_and_validate_user_data_path("/mnt/user-data/workspace/hello.txt", thread_data, _DEFAULT_APP_CONFIG) assert resolved == str(workspace / "hello.txt") @@ -264,7 +305,7 @@ def test_resolve_and_validate_user_data_path_blocks_traversal(tmp_path: Path) -> } # This path resolves outside the allowed roots with pytest.raises(PermissionError): - _resolve_and_validate_user_data_path("/mnt/user-data/workspace/../../../etc/passwd", thread_data) + _resolve_and_validate_user_data_path("/mnt/user-data/workspace/../../../etc/passwd", thread_data, _DEFAULT_APP_CONFIG) # ---------- replace_virtual_paths_in_command ---------- @@ -277,7 +318,7 @@ def test_replace_virtual_paths_in_command_replaces_skills_paths() -> None: patch("deerflow.sandbox.tools._get_skills_host_path", return_value="/home/user/deer-flow/skills"), ): cmd = "cat /mnt/skills/public/bootstrap/SKILL.md" - result = replace_virtual_paths_in_command(cmd, _THREAD_DATA) + result = replace_virtual_paths_in_command(cmd, _THREAD_DATA, _DEFAULT_APP_CONFIG) assert "/mnt/skills" not in result assert "/home/user/deer-flow/skills/public/bootstrap/SKILL.md" in result @@ -289,7 +330,7 @@ def test_replace_virtual_paths_in_command_replaces_both() -> None: patch("deerflow.sandbox.tools._get_skills_host_path", return_value="/home/user/skills"), ): cmd = "cat /mnt/skills/public/SKILL.md > /mnt/user-data/workspace/out.txt" - result = replace_virtual_paths_in_command(cmd, _THREAD_DATA) + result = replace_virtual_paths_in_command(cmd, _THREAD_DATA, _DEFAULT_APP_CONFIG) assert "/mnt/skills" not in result assert "/mnt/user-data" not in result assert "/home/user/skills/public/SKILL.md" in result @@ -301,30 +342,27 @@ def test_replace_virtual_paths_in_command_replaces_both() -> None: def test_validate_local_bash_command_paths_blocks_host_paths() -> None: with pytest.raises(PermissionError, match="Unsafe absolute paths"): - validate_local_bash_command_paths("cat /etc/passwd", _THREAD_DATA) + validate_local_bash_command_paths("cat /etc/passwd", _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_bash_command_paths_allows_https_urls() -> None: """URLs like https://github.com/... must not be flagged as unsafe absolute paths.""" validate_local_bash_command_paths( "cd /mnt/user-data/workspace && git clone https://github.com/CherryHQ/cherry-studio.git", - _THREAD_DATA, - ) + _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_bash_command_paths_allows_http_urls() -> None: """HTTP URLs must not be flagged as unsafe absolute paths.""" validate_local_bash_command_paths( "curl http://example.com/file.tar.gz -o /mnt/user-data/workspace/file.tar.gz", - _THREAD_DATA, - ) + _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_bash_command_paths_allows_virtual_and_system_paths() -> None: validate_local_bash_command_paths( "/bin/echo ok > /mnt/user-data/workspace/out.txt && cat /dev/null", - _THREAD_DATA, - ) + _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_bash_command_paths_blocks_traversal_in_user_data() -> None: @@ -332,8 +370,7 @@ def test_validate_local_bash_command_paths_blocks_traversal_in_user_data() -> No with pytest.raises(PermissionError, match="path traversal"): validate_local_bash_command_paths( "cat /mnt/user-data/workspace/../../etc/passwd", - _THREAD_DATA, - ) + _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_bash_command_paths_blocks_traversal_in_skills() -> None: @@ -342,21 +379,20 @@ def test_validate_local_bash_command_paths_blocks_traversal_in_skills() -> None: with pytest.raises(PermissionError, match="path traversal"): validate_local_bash_command_paths( "cat /mnt/skills/../../etc/passwd", - _THREAD_DATA, - ) + _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_bash_tool_rejects_host_bash_when_local_sandbox_default(monkeypatch) -> None: runtime = SimpleNamespace( state={"sandbox": {"sandbox_id": "local"}, "thread_data": _THREAD_DATA.copy()}, - context={"thread_id": "thread-1"}, + context=_make_ctx("thread-1"), ) monkeypatch.setattr( "deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: SimpleNamespace(execute_command=lambda command: pytest.fail("host bash should not execute")), ) - monkeypatch.setattr("deerflow.sandbox.tools.is_host_bash_allowed", lambda: False) + monkeypatch.setattr("deerflow.sandbox.tools.is_host_bash_allowed", lambda *a, **k: False) result = bash_tool.func( runtime=runtime, @@ -371,33 +407,32 @@ def test_bash_tool_rejects_host_bash_when_local_sandbox_default(monkeypatch) -> def test_is_skills_path_recognises_default_prefix() -> None: - with patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"): - assert _is_skills_path("/mnt/skills") is True - assert _is_skills_path("/mnt/skills/public/bootstrap/SKILL.md") is True - assert _is_skills_path("/mnt/skills-extra/foo") is False - assert _is_skills_path("/mnt/user-data/workspace") is False + assert _is_skills_path("/mnt/skills", _DEFAULT_APP_CONFIG) is True + assert _is_skills_path("/mnt/skills/public/bootstrap/SKILL.md", _DEFAULT_APP_CONFIG) is True + assert _is_skills_path("/mnt/skills-extra/foo", _DEFAULT_APP_CONFIG) is False + assert _is_skills_path("/mnt/user-data/workspace", _DEFAULT_APP_CONFIG) is False def test_validate_local_tool_path_allows_skills_read_only() -> None: """read_file / ls should be able to access /mnt/skills paths.""" - with patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"): - # Should not raise - validate_local_tool_path( - "/mnt/skills/public/bootstrap/SKILL.md", - _THREAD_DATA, - read_only=True, - ) + # Should not raise — default app config uses /mnt/skills as container path + validate_local_tool_path( + "/mnt/skills/public/bootstrap/SKILL.md", + _THREAD_DATA, + _DEFAULT_APP_CONFIG, + read_only=True, + ) def test_validate_local_tool_path_blocks_skills_write() -> None: """write_file / str_replace must NOT write to skills paths.""" - with patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"): - with pytest.raises(PermissionError, match="Write access to skills path is not allowed"): - validate_local_tool_path( - "/mnt/skills/public/bootstrap/SKILL.md", - _THREAD_DATA, - read_only=False, - ) + with pytest.raises(PermissionError, match="Write access to skills path is not allowed"): + validate_local_tool_path( + "/mnt/skills/public/bootstrap/SKILL.md", + _THREAD_DATA, + _DEFAULT_APP_CONFIG, + read_only=False, + ) def test_validate_local_bash_command_paths_allows_skills_path() -> None: @@ -405,8 +440,7 @@ def test_validate_local_bash_command_paths_allows_skills_path() -> None: with patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"): validate_local_bash_command_paths( "cat /mnt/skills/public/bootstrap/SKILL.md", - _THREAD_DATA, - ) + _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_bash_command_paths_allows_urls() -> None: @@ -414,40 +448,35 @@ def test_validate_local_bash_command_paths_allows_urls() -> None: # HTTPS URLs validate_local_bash_command_paths( "curl -X POST https://example.com/api/v1/risk/check", - _THREAD_DATA, - ) + _THREAD_DATA, _DEFAULT_APP_CONFIG) # HTTP URLs validate_local_bash_command_paths( "curl http://localhost:8080/health", - _THREAD_DATA, - ) + _THREAD_DATA, _DEFAULT_APP_CONFIG) # URLs with query strings validate_local_bash_command_paths( "curl https://api.example.com/v2/search?q=test", - _THREAD_DATA, - ) + _THREAD_DATA, _DEFAULT_APP_CONFIG) # FTP URLs validate_local_bash_command_paths( "curl ftp://ftp.example.com/pub/file.tar.gz", - _THREAD_DATA, - ) + _THREAD_DATA, _DEFAULT_APP_CONFIG) # URL mixed with valid virtual path validate_local_bash_command_paths( "curl https://example.com/data -o /mnt/user-data/workspace/data.json", - _THREAD_DATA, - ) + _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_bash_command_paths_blocks_file_urls() -> None: """file:// URLs should be treated as unsafe and blocked.""" with pytest.raises(PermissionError): - validate_local_bash_command_paths("curl file:///etc/passwd", _THREAD_DATA) + validate_local_bash_command_paths("curl file:///etc/passwd", _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_bash_command_paths_blocks_file_urls_case_insensitive() -> None: """file:// URL detection should be case-insensitive.""" with pytest.raises(PermissionError): - validate_local_bash_command_paths("curl FILE:///etc/shadow", _THREAD_DATA) + validate_local_bash_command_paths("curl FILE:///etc/shadow", _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_bash_command_paths_blocks_file_urls_mixed_with_valid() -> None: @@ -455,35 +484,36 @@ def test_validate_local_bash_command_paths_blocks_file_urls_mixed_with_valid() - with pytest.raises(PermissionError): validate_local_bash_command_paths( "curl file:///etc/passwd -o /mnt/user-data/workspace/out.txt", - _THREAD_DATA, - ) + _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_bash_command_paths_still_blocks_other_paths() -> None: """Paths outside virtual and system prefixes must still be blocked.""" with patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"): with pytest.raises(PermissionError, match="Unsafe absolute paths"): - validate_local_bash_command_paths("cat /etc/shadow", _THREAD_DATA) + validate_local_bash_command_paths("cat /etc/shadow", _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_tool_path_skills_custom_container_path() -> None: """Skills with a custom container_path in config should also work.""" - with patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/custom/skills"): - # Should not raise + custom_config = _make_app_config(skills_container_path="/custom/skills") + # Should not raise + validate_local_tool_path( + "/custom/skills/public/my-skill/SKILL.md", + _THREAD_DATA, + custom_config, + read_only=True, + ) + + # The default /mnt/skills should not match since container path is /custom/skills + with pytest.raises(PermissionError, match="Only paths under"): validate_local_tool_path( - "/custom/skills/public/my-skill/SKILL.md", + "/mnt/skills/public/bootstrap/SKILL.md", _THREAD_DATA, + custom_config, read_only=True, ) - # The default /mnt/skills should not match since container path is /custom/skills - with pytest.raises(PermissionError, match="Only paths under"): - validate_local_tool_path( - "/mnt/skills/public/bootstrap/SKILL.md", - _THREAD_DATA, - read_only=True, - ) - # ---------- ACP workspace path tests ---------- @@ -500,6 +530,7 @@ def test_validate_local_tool_path_allows_acp_workspace_read_only() -> None: validate_local_tool_path( "/mnt/acp-workspace/hello_world.py", _THREAD_DATA, + _DEFAULT_APP_CONFIG, read_only=True, ) @@ -510,6 +541,7 @@ def test_validate_local_tool_path_blocks_acp_workspace_write() -> None: validate_local_tool_path( "/mnt/acp-workspace/hello_world.py", _THREAD_DATA, + _DEFAULT_APP_CONFIG, read_only=False, ) @@ -518,8 +550,7 @@ def test_validate_local_bash_command_paths_allows_acp_workspace() -> None: """bash commands referencing /mnt/acp-workspace should be allowed.""" validate_local_bash_command_paths( "cp /mnt/acp-workspace/hello_world.py /mnt/user-data/outputs/hello_world.py", - _THREAD_DATA, - ) + _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_bash_command_paths_blocks_traversal_in_acp_workspace() -> None: @@ -527,8 +558,7 @@ def test_validate_local_bash_command_paths_blocks_traversal_in_acp_workspace() - with pytest.raises(PermissionError, match="path traversal"): validate_local_bash_command_paths( "cat /mnt/acp-workspace/../../etc/passwd", - _THREAD_DATA, - ) + _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_resolve_acp_workspace_path_resolves_correctly(tmp_path: Path) -> None: @@ -570,7 +600,7 @@ def test_replace_virtual_paths_in_command_replaces_acp_workspace() -> None: acp_host = "/home/user/.deer-flow/acp-workspace" with patch("deerflow.sandbox.tools._get_acp_workspace_host_path", return_value=acp_host): cmd = "cp /mnt/acp-workspace/hello.py /mnt/user-data/outputs/hello.py" - result = replace_virtual_paths_in_command(cmd, _THREAD_DATA) + result = replace_virtual_paths_in_command(cmd, _THREAD_DATA, _DEFAULT_APP_CONFIG) assert "/mnt/acp-workspace" not in result assert f"{acp_host}/hello.py" in result assert "/tmp/deer-flow/threads/t1/user-data/outputs/hello.py" in result @@ -581,7 +611,7 @@ def test_mask_local_paths_in_output_hides_acp_workspace_host_paths() -> None: acp_host = "/home/user/.deer-flow/acp-workspace" with patch("deerflow.sandbox.tools._get_acp_workspace_host_path", return_value=acp_host): output = f"Copied: {acp_host}/hello.py" - masked = mask_local_paths_in_output(output, _THREAD_DATA) + masked = mask_local_paths_in_output(output, _THREAD_DATA, _DEFAULT_APP_CONFIG) assert acp_host not in masked assert "/mnt/acp-workspace/hello.py" in masked @@ -617,39 +647,37 @@ def test_apply_cwd_prefix_quotes_path_with_spaces() -> None: def test_validate_local_bash_command_paths_allows_mcp_filesystem_paths() -> None: """Bash commands referencing MCP filesystem server paths should be allowed.""" + from deerflow.config.app_config import AppConfig from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig + from deerflow.config.sandbox_config import SandboxConfig - mock_config = ExtensionsConfig( - mcp_servers={ - "filesystem": McpServerConfig( - enabled=True, - command="npx", - args=["-y", "@modelcontextprotocol/server-filesystem", "/mnt/d/workspace"], - ) - } - ) - with patch("deerflow.config.extensions_config.get_extensions_config", return_value=mock_config): - # Should not raise - MCP filesystem paths are allowed - validate_local_bash_command_paths("ls /mnt/d/workspace", _THREAD_DATA) - validate_local_bash_command_paths("cat /mnt/d/workspace/subdir/file.txt", _THREAD_DATA) - - # Path traversal should still be blocked - with pytest.raises(PermissionError, match="path traversal"): - validate_local_bash_command_paths("cat /mnt/d/workspace/../../etc/passwd", _THREAD_DATA) - - # Disabled servers should not expose paths - disabled_config = ExtensionsConfig( - mcp_servers={ - "filesystem": McpServerConfig( - enabled=False, - command="npx", - args=["-y", "@modelcontextprotocol/server-filesystem", "/mnt/d/workspace"], - ) - } + def _mcp_app_config(enabled: bool) -> AppConfig: + return AppConfig( + sandbox=SandboxConfig(use="test"), + extensions=ExtensionsConfig( + mcp_servers={ + "filesystem": McpServerConfig( + enabled=enabled, + command="npx", + args=["-y", "@modelcontextprotocol/server-filesystem", "/mnt/d/workspace"], + ) + } + ), ) - with patch("deerflow.config.extensions_config.get_extensions_config", return_value=disabled_config): - with pytest.raises(PermissionError, match="Unsafe absolute paths"): - validate_local_bash_command_paths("ls /mnt/d/workspace", _THREAD_DATA) + + enabled_cfg = _mcp_app_config(True) + # Should not raise - MCP filesystem paths are allowed + validate_local_bash_command_paths("ls /mnt/d/workspace", _THREAD_DATA, enabled_cfg) + validate_local_bash_command_paths("cat /mnt/d/workspace/subdir/file.txt", _THREAD_DATA, enabled_cfg) + + # Path traversal should still be blocked + with pytest.raises(PermissionError, match="path traversal"): + validate_local_bash_command_paths("cat /mnt/d/workspace/../../etc/passwd", _THREAD_DATA, enabled_cfg) + + # Disabled servers should not expose paths + disabled_cfg = _mcp_app_config(False) + with pytest.raises(PermissionError, match="Unsafe absolute paths"): + validate_local_bash_command_paths("ls /mnt/d/workspace", _THREAD_DATA, disabled_cfg) # ---------- Custom mount path tests ---------- @@ -667,12 +695,12 @@ def _mock_custom_mounts(): def test_is_custom_mount_path_recognises_configured_mounts() -> None: with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): - assert _is_custom_mount_path("/mnt/code-read") is True - assert _is_custom_mount_path("/mnt/code-read/src/main.py") is True - assert _is_custom_mount_path("/mnt/data") is True - assert _is_custom_mount_path("/mnt/data/file.txt") is True - assert _is_custom_mount_path("/mnt/code-read-extra/foo") is False - assert _is_custom_mount_path("/mnt/other") is False + assert _is_custom_mount_path("/mnt/code-read", _DEFAULT_APP_CONFIG) is True + assert _is_custom_mount_path("/mnt/code-read/src/main.py", _DEFAULT_APP_CONFIG) is True + assert _is_custom_mount_path("/mnt/data", _DEFAULT_APP_CONFIG) is True + assert _is_custom_mount_path("/mnt/data/file.txt", _DEFAULT_APP_CONFIG) is True + assert _is_custom_mount_path("/mnt/code-read-extra/foo", _DEFAULT_APP_CONFIG) is False + assert _is_custom_mount_path("/mnt/other", _DEFAULT_APP_CONFIG) is False def test_get_custom_mount_for_path_returns_longest_prefix() -> None: @@ -683,7 +711,7 @@ def test_get_custom_mount_for_path_returns_longest_prefix() -> None: VolumeMountConfig(host_path="/home/user/code", container_path="/mnt/code", read_only=True), ] with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=mounts): - mount = _get_custom_mount_for_path("/mnt/code/file.py") + mount = _get_custom_mount_for_path("/mnt/code/file.py", _DEFAULT_APP_CONFIG) assert mount is not None assert mount.container_path == "/mnt/code" @@ -691,90 +719,72 @@ def test_get_custom_mount_for_path_returns_longest_prefix() -> None: def test_validate_local_tool_path_allows_custom_mount_read() -> None: """read_file / ls should be able to access custom mount paths.""" with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): - validate_local_tool_path("/mnt/code-read/src/main.py", _THREAD_DATA, read_only=True) - validate_local_tool_path("/mnt/data/file.txt", _THREAD_DATA, read_only=True) + validate_local_tool_path("/mnt/code-read/src/main.py", _THREAD_DATA, _DEFAULT_APP_CONFIG, read_only=True) + validate_local_tool_path("/mnt/data/file.txt", _THREAD_DATA, _DEFAULT_APP_CONFIG, read_only=True) def test_validate_local_tool_path_blocks_read_only_mount_write() -> None: """write_file / str_replace must NOT write to read-only custom mounts.""" with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): with pytest.raises(PermissionError, match="Write access to read-only mount is not allowed"): - validate_local_tool_path("/mnt/code-read/src/main.py", _THREAD_DATA, read_only=False) + validate_local_tool_path("/mnt/code-read/src/main.py", _THREAD_DATA, _DEFAULT_APP_CONFIG, read_only=False) def test_validate_local_tool_path_allows_writable_mount_write() -> None: """write_file / str_replace should succeed on writable custom mounts.""" with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): - validate_local_tool_path("/mnt/data/file.txt", _THREAD_DATA, read_only=False) + validate_local_tool_path("/mnt/data/file.txt", _THREAD_DATA, _DEFAULT_APP_CONFIG, read_only=False) def test_validate_local_tool_path_blocks_traversal_in_custom_mount() -> None: """Path traversal via .. in custom mount paths must be rejected.""" with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): with pytest.raises(PermissionError, match="path traversal"): - validate_local_tool_path("/mnt/code-read/../../etc/passwd", _THREAD_DATA, read_only=True) + validate_local_tool_path("/mnt/code-read/../../etc/passwd", _THREAD_DATA, _DEFAULT_APP_CONFIG, read_only=True) def test_validate_local_bash_command_paths_allows_custom_mount() -> None: """bash commands referencing custom mount paths should be allowed.""" with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): - validate_local_bash_command_paths("cat /mnt/code-read/src/main.py", _THREAD_DATA) - validate_local_bash_command_paths("ls /mnt/data", _THREAD_DATA) + validate_local_bash_command_paths("cat /mnt/code-read/src/main.py", _THREAD_DATA, _DEFAULT_APP_CONFIG) + validate_local_bash_command_paths("ls /mnt/data", _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_bash_command_paths_blocks_traversal_in_custom_mount() -> None: """Bash commands with traversal in custom mount paths should be blocked.""" with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): with pytest.raises(PermissionError, match="path traversal"): - validate_local_bash_command_paths("cat /mnt/code-read/../../etc/passwd", _THREAD_DATA) + validate_local_bash_command_paths("cat /mnt/code-read/../../etc/passwd", _THREAD_DATA, _DEFAULT_APP_CONFIG) def test_validate_local_bash_command_paths_still_blocks_non_mount_paths() -> None: """Paths not matching any custom mount should still be blocked.""" with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): with pytest.raises(PermissionError, match="Unsafe absolute paths"): - validate_local_bash_command_paths("cat /etc/shadow", _THREAD_DATA) + validate_local_bash_command_paths("cat /etc/shadow", _THREAD_DATA, _DEFAULT_APP_CONFIG) -def test_get_custom_mounts_caching(monkeypatch, tmp_path) -> None: - """_get_custom_mounts should cache after first successful load.""" - # Clear any existing cache - if hasattr(_get_custom_mounts, "_cached"): - monkeypatch.delattr(_get_custom_mounts, "_cached") - - # Use real directories so host_path.exists() filtering passes +def test_get_custom_mounts_reads_from_app_config(tmp_path) -> None: + """_get_custom_mounts should read directly from the supplied AppConfig.""" dir_a = tmp_path / "code-read" dir_a.mkdir() dir_b = tmp_path / "data" dir_b.mkdir() - from deerflow.config.sandbox_config import SandboxConfig, VolumeMountConfig + from deerflow.config.sandbox_config import VolumeMountConfig mounts = [ VolumeMountConfig(host_path=str(dir_a), container_path="/mnt/code-read", read_only=True), VolumeMountConfig(host_path=str(dir_b), container_path="/mnt/data", read_only=False), ] - mock_sandbox = SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider", mounts=mounts) - mock_config = SimpleNamespace(sandbox=mock_sandbox) - - with patch("deerflow.config.get_app_config", return_value=mock_config): - result = _get_custom_mounts() - assert len(result) == 2 - - # After caching, should return cached value even without mock - assert hasattr(_get_custom_mounts, "_cached") - assert len(_get_custom_mounts()) == 2 - - # Cleanup - monkeypatch.delattr(_get_custom_mounts, "_cached") + cfg = _make_app_config(mounts=mounts) + result = _get_custom_mounts(cfg) + assert len(result) == 2 -def test_get_custom_mounts_filters_nonexistent_host_path(monkeypatch, tmp_path) -> None: +def test_get_custom_mounts_filters_nonexistent_host_path(tmp_path) -> None: """_get_custom_mounts should only return mounts whose host_path exists.""" - if hasattr(_get_custom_mounts, "_cached"): - monkeypatch.delattr(_get_custom_mounts, "_cached") - - from deerflow.config.sandbox_config import SandboxConfig, VolumeMountConfig + from deerflow.config.sandbox_config import VolumeMountConfig existing_dir = tmp_path / "existing" existing_dir.mkdir() @@ -783,22 +793,16 @@ def test_get_custom_mounts_filters_nonexistent_host_path(monkeypatch, tmp_path) VolumeMountConfig(host_path=str(existing_dir), container_path="/mnt/existing", read_only=True), VolumeMountConfig(host_path="/nonexistent/path/12345", container_path="/mnt/ghost", read_only=False), ] - mock_sandbox = SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider", mounts=mounts) - mock_config = SimpleNamespace(sandbox=mock_sandbox) - - with patch("deerflow.config.get_app_config", return_value=mock_config): - result = _get_custom_mounts() - assert len(result) == 1 - assert result[0].container_path == "/mnt/existing" - - # Cleanup - monkeypatch.delattr(_get_custom_mounts, "_cached") + cfg = _make_app_config(mounts=mounts) + result = _get_custom_mounts(cfg) + assert len(result) == 1 + assert result[0].container_path == "/mnt/existing" def test_get_custom_mount_for_path_boundary_no_false_prefix_match() -> None: """_get_custom_mount_for_path must not match /mnt/code-read-extra for /mnt/code-read.""" with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): - mount = _get_custom_mount_for_path("/mnt/code-read-extra/foo") + mount = _get_custom_mount_for_path("/mnt/code-read-extra/foo", _DEFAULT_APP_CONFIG) assert mount is None @@ -829,8 +833,8 @@ def test_str_replace_parallel_updates_should_preserve_both_edits(monkeypatch) -> sandbox = SharedSandbox() runtimes = [ - SimpleNamespace(state={}, context={"thread_id": "thread-1"}, config={}), - SimpleNamespace(state={}, context={"thread_id": "thread-1"}, config={}), + SimpleNamespace(state={}, context=_make_ctx("thread-1"), config={}), + SimpleNamespace(state={}, context=_make_ctx("thread-1"), config={}), ] failures: list[BaseException] = [] @@ -905,14 +909,14 @@ def test_str_replace_parallel_updates_in_isolated_sandboxes_should_not_share_pat "sandbox-b": IsolatedSandbox("sandbox-b", shared_state), } runtimes = [ - SimpleNamespace(state={}, context={"thread_id": "thread-1", "sandbox_key": "sandbox-a"}, config={}), - SimpleNamespace(state={}, context={"thread_id": "thread-2", "sandbox_key": "sandbox-b"}, config={}), + SimpleNamespace(state={}, context=_make_ctx("thread-1", sandbox_key="sandbox-a"), config={}), + SimpleNamespace(state={}, context=_make_ctx("thread-2", sandbox_key="sandbox-b"), config={}), ] failures: list[BaseException] = [] monkeypatch.setattr( "deerflow.sandbox.tools.ensure_sandbox_initialized", - lambda runtime: sandboxes[runtime.context["sandbox_key"]], + lambda runtime: sandboxes[runtime.context.sandbox_key], ) monkeypatch.setattr("deerflow.sandbox.tools.ensure_thread_directories_exist", lambda runtime: None) monkeypatch.setattr("deerflow.sandbox.tools.is_local_sandbox", lambda runtime: False) @@ -972,8 +976,8 @@ def test_str_replace_and_append_on_same_path_should_preserve_both_updates(monkey sandbox = SharedSandbox() runtimes = [ - SimpleNamespace(state={}, context={"thread_id": "thread-1"}, config={}), - SimpleNamespace(state={}, context={"thread_id": "thread-1"}, config={}), + SimpleNamespace(state={}, context=_make_ctx("thread-1"), config={}), + SimpleNamespace(state={}, context=_make_ctx("thread-1"), config={}), ] failures: list[BaseException] = [] diff --git a/backend/tests/test_security_scanner.py b/backend/tests/test_security_scanner.py index 088cb2c11..c7e060c7e 100644 --- a/backend/tests/test_security_scanner.py +++ b/backend/tests/test_security_scanner.py @@ -29,10 +29,9 @@ async def test_scan_skill_content_passes_run_name_to_model(monkeypatch): @pytest.mark.anyio async def test_scan_skill_content_blocks_when_model_unavailable(monkeypatch): config = SimpleNamespace(skill_evolution=SimpleNamespace(moderation_model_name=None)) - monkeypatch.setattr("deerflow.skills.security_scanner.get_app_config", lambda: config) monkeypatch.setattr("deerflow.skills.security_scanner.create_chat_model", lambda **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))) - result = await scan_skill_content("---\nname: demo-skill\ndescription: demo\n---\n", executable=False) + result = await scan_skill_content(config, "---\nname: demo-skill\ndescription: demo\n---\n", executable=False) assert result.decision == "block" assert "manual review required" in result.reason diff --git a/backend/tests/test_skill_manage_tool.py b/backend/tests/test_skill_manage_tool.py index 1b16fb48f..81bf49766 100644 --- a/backend/tests/test_skill_manage_tool.py +++ b/backend/tests/test_skill_manage_tool.py @@ -4,9 +4,20 @@ from types import SimpleNamespace import anyio import pytest +from deerflow.config.app_config import AppConfig +from deerflow.config.deer_flow_context import DeerFlowContext +from deerflow.config.sandbox_config import SandboxConfig + skill_manage_module = importlib.import_module("deerflow.tools.skill_manage_tool") +def _make_context(thread_id: str, app_config: object | None = None) -> DeerFlowContext: + return DeerFlowContext( + app_config=app_config if app_config is not None else AppConfig(sandbox=SandboxConfig(use="test")), + thread_id=thread_id, + ) + + def _skill_content(name: str, description: str = "Demo skill") -> str: return f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n" @@ -23,18 +34,15 @@ def test_skill_manage_create_and_patch(monkeypatch, tmp_path): skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"), skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None), ) - monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) - monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config) - monkeypatch.setattr("deerflow.skills.security_scanner.get_app_config", lambda: config) refresh_calls = [] - async def _refresh(): + async def _refresh(*a, **k): refresh_calls.append("refresh") monkeypatch.setattr(skill_manage_module, "refresh_skills_system_prompt_cache_async", _refresh) monkeypatch.setattr(skill_manage_module, "scan_skill_content", lambda *args, **kwargs: _async_result("allow", "ok")) - runtime = SimpleNamespace(context={"thread_id": "thread-1"}, config={"configurable": {"thread_id": "thread-1"}}) + runtime = SimpleNamespace(context=_make_context("thread-1", config), config={"configurable": {"thread_id": "thread-1"}}) result = anyio.run( skill_manage_module.skill_manage_tool.coroutine, @@ -67,17 +75,14 @@ def test_skill_manage_patch_replaces_single_occurrence_by_default(monkeypatch, t skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"), skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None), ) - monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) - monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config) - monkeypatch.setattr("deerflow.skills.security_scanner.get_app_config", lambda: config) - async def _refresh(): + async def _refresh(*a, **k): return None monkeypatch.setattr(skill_manage_module, "refresh_skills_system_prompt_cache_async", _refresh) monkeypatch.setattr(skill_manage_module, "scan_skill_content", lambda *args, **kwargs: _async_result("allow", "ok")) - runtime = SimpleNamespace(context={"thread_id": "thread-1"}, config={"configurable": {"thread_id": "thread-1"}}) + runtime = SimpleNamespace(context=_make_context("thread-1", config), config={"configurable": {"thread_id": "thread-1"}}) content = _skill_content("demo-skill", "Demo skill") + "\nRepeated: Demo skill\n" anyio.run(skill_manage_module.skill_manage_tool.coroutine, runtime, "create", "demo-skill", content) @@ -107,10 +112,8 @@ def test_skill_manage_rejects_public_skill_patch(monkeypatch, tmp_path): skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"), skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None), ) - monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) - monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config) - runtime = SimpleNamespace(context={}, config={"configurable": {}}) + runtime = SimpleNamespace(context=_make_context("", config), config={"configurable": {}}) with pytest.raises(ValueError, match="built-in skill"): anyio.run( @@ -131,17 +134,15 @@ def test_skill_manage_sync_wrapper_supported(monkeypatch, tmp_path): skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"), skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None), ) - monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) - monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config) refresh_calls = [] - async def _refresh(): + async def _refresh(*a, **k): refresh_calls.append("refresh") monkeypatch.setattr(skill_manage_module, "refresh_skills_system_prompt_cache_async", _refresh) monkeypatch.setattr(skill_manage_module, "scan_skill_content", lambda *args, **kwargs: _async_result("allow", "ok")) - runtime = SimpleNamespace(context={"thread_id": "thread-sync"}, config={"configurable": {"thread_id": "thread-sync"}}) + runtime = SimpleNamespace(context=_make_context("thread-sync", config), config={"configurable": {"thread_id": "thread-sync"}}) result = skill_manage_module.skill_manage_tool.func( runtime=runtime, action="create", @@ -159,17 +160,14 @@ def test_skill_manage_rejects_support_path_traversal(monkeypatch, tmp_path): skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"), skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None), ) - monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) - monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config) - monkeypatch.setattr("deerflow.skills.security_scanner.get_app_config", lambda: config) - async def _refresh(): + async def _refresh(*a, **k): return None monkeypatch.setattr(skill_manage_module, "refresh_skills_system_prompt_cache_async", _refresh) monkeypatch.setattr(skill_manage_module, "scan_skill_content", lambda *args, **kwargs: _async_result("allow", "ok")) - runtime = SimpleNamespace(context={"thread_id": "thread-1"}, config={"configurable": {"thread_id": "thread-1"}}) + runtime = SimpleNamespace(context=_make_context("thread-1", config), config={"configurable": {"thread_id": "thread-1"}}) anyio.run(skill_manage_module.skill_manage_tool.coroutine, runtime, "create", "demo-skill", _skill_content("demo-skill")) with pytest.raises(ValueError, match="parent-directory traversal|selected support directory"): diff --git a/backend/tests/test_skills_custom_router.py b/backend/tests/test_skills_custom_router.py index e78eb54d7..10d8214bd 100644 --- a/backend/tests/test_skills_custom_router.py +++ b/backend/tests/test_skills_custom_router.py @@ -7,6 +7,9 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from app.gateway.routers import skills as skills_router +from deerflow.config.app_config import AppConfig +from deerflow.config.extensions_config import ExtensionsConfig +from deerflow.config.sandbox_config import SandboxConfig from deerflow.skills.manager import get_skill_history_file from deerflow.skills.types import Skill @@ -44,17 +47,16 @@ def test_custom_skills_router_lifecycle(monkeypatch, tmp_path): skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"), skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None), ) - monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) - monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config) monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", lambda *args, **kwargs: _async_scan("allow", "ok")) refresh_calls = [] - async def _refresh(): + async def _refresh(*a, **k): refresh_calls.append("refresh") monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh) app = FastAPI() + app.state.config = config app.include_router(skills_router.router) with TestClient(app) as client: @@ -94,14 +96,12 @@ def test_custom_skill_rollback_blocked_by_scanner(monkeypatch, tmp_path): skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"), skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None), ) - monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) - monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config) - get_skill_history_file("demo-skill").write_text( + get_skill_history_file("demo-skill", config).write_text( '{"action":"human_edit","prev_content":' + json.dumps(original_content) + ',"new_content":' + json.dumps(edited_content) + "}\n", encoding="utf-8", ) - async def _refresh(): + async def _refresh(*a, **k): return None monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh) @@ -114,6 +114,7 @@ def test_custom_skill_rollback_blocked_by_scanner(monkeypatch, tmp_path): monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", _scan) app = FastAPI() + app.state.config = config app.include_router(skills_router.router) with TestClient(app) as client: @@ -136,17 +137,16 @@ def test_custom_skill_delete_preserves_history_and_allows_restore(monkeypatch, t skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"), skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None), ) - monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) - monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config) monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", lambda *args, **kwargs: _async_scan("allow", "ok")) refresh_calls = [] - async def _refresh(): + async def _refresh(*a, **k): refresh_calls.append("refresh") monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh) app = FastAPI() + app.state.config = config app.include_router(skills_router.router) with TestClient(app) as client: @@ -238,23 +238,25 @@ def test_update_skill_refreshes_prompt_cache_before_return(monkeypatch, tmp_path enabled_state = {"value": True} refresh_calls = [] - def _load_skills(*, enabled_only: bool): + def _load_skills(*a, enabled_only: bool = False, **k): skill = _make_skill("demo-skill", enabled=enabled_state["value"]) if enabled_only and not skill.enabled: return [] return [skill] - async def _refresh(): + async def _refresh(*a, **k): refresh_calls.append("refresh") enabled_state["value"] = False + _app_cfg = AppConfig(sandbox=SandboxConfig(use="test"), extensions=ExtensionsConfig(mcp_servers={}, skills={})) + monkeypatch.setattr("app.gateway.routers.skills.load_skills", _load_skills) - monkeypatch.setattr("app.gateway.routers.skills.get_extensions_config", lambda: SimpleNamespace(mcp_servers={}, skills={})) - monkeypatch.setattr("app.gateway.routers.skills.reload_extensions_config", lambda: None) + monkeypatch.setattr(AppConfig, "from_file", staticmethod(lambda: _app_cfg)) monkeypatch.setattr(skills_router.ExtensionsConfig, "resolve_config_path", staticmethod(lambda: config_path)) monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh) app = FastAPI() + app.state.config = _app_cfg app.include_router(skills_router.router) with TestClient(app) as client: diff --git a/backend/tests/test_skills_loader.py b/backend/tests/test_skills_loader.py index 7d885444d..efc614c7b 100644 --- a/backend/tests/test_skills_loader.py +++ b/backend/tests/test_skills_loader.py @@ -27,7 +27,7 @@ def test_load_skills_discovers_nested_skills_and_sets_container_paths(tmp_path: _write_skill(skills_root / "public" / "parent" / "child-skill", "child-skill", "Child skill") _write_skill(skills_root / "custom" / "team" / "helper", "team-helper", "Team helper") - skills = load_skills(skills_path=skills_root, use_config=False, enabled_only=False) + skills = load_skills(skills_path=skills_root, enabled_only=False) by_name = {skill.name: skill for skill in skills} assert {"root-skill", "child-skill", "team-helper"} <= set(by_name) @@ -57,7 +57,7 @@ def test_load_skills_skips_hidden_directories(tmp_path: Path): "Hidden skill", ) - skills = load_skills(skills_path=skills_root, use_config=False, enabled_only=False) + skills = load_skills(skills_path=skills_root, enabled_only=False) names = {skill.name for skill in skills} assert "ok-skill" in names @@ -69,7 +69,7 @@ def test_load_skills_prefers_custom_over_public_with_same_name(tmp_path: Path): _write_skill(skills_root / "public" / "shared-skill", "shared-skill", "Public version") _write_skill(skills_root / "custom" / "shared-skill", "shared-skill", "Custom version") - skills = load_skills(skills_path=skills_root, use_config=False, enabled_only=False) + skills = load_skills(skills_path=skills_root, enabled_only=False) shared = next(skill for skill in skills if skill.name == "shared-skill") assert shared.category == "custom" diff --git a/backend/tests/test_stream_bridge.py b/backend/tests/test_stream_bridge.py index efd5e7923..55b463812 100644 --- a/backend/tests/test_stream_bridge.py +++ b/backend/tests/test_stream_bridge.py @@ -6,6 +6,7 @@ import re import anyio import pytest +from deerflow.config.app_config import AppConfig from deerflow.runtime import END_SENTINEL, HEARTBEAT_SENTINEL, MemoryStreamBridge, make_stream_bridge # --------------------------------------------------------------------------- @@ -331,6 +332,9 @@ async def test_concurrent_tasks_end_sentinel(): @pytest.mark.anyio async def test_make_stream_bridge_defaults(): - """make_stream_bridge() with no config yields a MemoryStreamBridge.""" - async with make_stream_bridge() as bridge: + """make_stream_bridge with a config lacking stream_bridge yields a MemoryStreamBridge.""" + from deerflow.config.sandbox_config import SandboxConfig + + config = AppConfig(sandbox=SandboxConfig(use="test")) + async with make_stream_bridge(config) as bridge: assert isinstance(bridge, MemoryStreamBridge) diff --git a/backend/tests/test_subagent_executor.py b/backend/tests/test_subagent_executor.py index a6a62c2b6..43ffb0663 100644 --- a/backend/tests/test_subagent_executor.py +++ b/backend/tests/test_subagent_executor.py @@ -21,6 +21,8 @@ from unittest.mock import MagicMock, patch import pytest +_TEST_APP_CONFIG = MagicMock(name="TestAppConfig") + # Module names that need to be mocked to break circular imports _MOCKED_MODULE_NAMES = [ "deerflow.agents", @@ -203,7 +205,7 @@ class TestAsyncExecutionPath: config=base_config, tools=[], thread_id="test-thread", - trace_id="test-trace", + trace_id="test-trace", app_config=_TEST_APP_CONFIG, ) with patch.object(executor, "_create_agent", return_value=mock_agent): @@ -232,7 +234,7 @@ class TestAsyncExecutionPath: executor = SubagentExecutor( config=base_config, tools=[], - thread_id="test-thread", + thread_id="test-thread", app_config=_TEST_APP_CONFIG, ) with patch.object(executor, "_create_agent", return_value=mock_agent): @@ -259,7 +261,7 @@ class TestAsyncExecutionPath: executor = SubagentExecutor( config=base_config, tools=[], - thread_id="test-thread", + thread_id="test-thread", app_config=_TEST_APP_CONFIG, ) with patch.object(executor, "_create_agent", return_value=mock_agent): @@ -285,7 +287,7 @@ class TestAsyncExecutionPath: executor = SubagentExecutor( config=base_config, tools=[], - thread_id="test-thread", + thread_id="test-thread", app_config=_TEST_APP_CONFIG, ) with patch.object(executor, "_create_agent", return_value=mock_agent): @@ -306,7 +308,7 @@ class TestAsyncExecutionPath: executor = SubagentExecutor( config=base_config, tools=[], - thread_id="test-thread", + thread_id="test-thread", app_config=_TEST_APP_CONFIG, ) with patch.object(executor, "_create_agent", return_value=mock_agent): @@ -327,7 +329,7 @@ class TestAsyncExecutionPath: executor = SubagentExecutor( config=base_config, tools=[], - thread_id="test-thread", + thread_id="test-thread", app_config=_TEST_APP_CONFIG, ) with patch.object(executor, "_create_agent", return_value=mock_agent): @@ -348,7 +350,7 @@ class TestAsyncExecutionPath: executor = SubagentExecutor( config=base_config, tools=[], - thread_id="test-thread", + thread_id="test-thread", app_config=_TEST_APP_CONFIG, ) with patch.object(executor, "_create_agent", return_value=mock_agent): @@ -384,7 +386,7 @@ class TestSyncExecutionPath: executor = SubagentExecutor( config=base_config, tools=[], - thread_id="test-thread", + thread_id="test-thread", app_config=_TEST_APP_CONFIG, ) with patch.object(executor, "_create_agent", return_value=mock_agent): @@ -419,7 +421,7 @@ class TestSyncExecutionPath: executor = SubagentExecutor( config=base_config, tools=[], - thread_id="test-thread", + thread_id="test-thread", app_config=_TEST_APP_CONFIG, ) with patch.object(executor, "_create_agent", return_value=mock_agent): @@ -456,7 +458,7 @@ class TestSyncExecutionPath: executor = SubagentExecutor( config=base_config, tools=[], - thread_id="test-thread", + thread_id="test-thread", app_config=_TEST_APP_CONFIG, ) with patch.object(executor, "_create_agent", return_value=mock_agent): @@ -477,7 +479,7 @@ class TestSyncExecutionPath: executor = SubagentExecutor( config=base_config, tools=[], - thread_id="test-thread", + thread_id="test-thread", app_config=_TEST_APP_CONFIG, ) with patch.object(executor, "_aexecute") as mock_aexecute: @@ -511,7 +513,7 @@ class TestSyncExecutionPath: executor = SubagentExecutor( config=base_config, tools=[], - thread_id="test-thread", + thread_id="test-thread", app_config=_TEST_APP_CONFIG, ) with patch.object(executor, "_create_agent", return_value=mock_agent): @@ -565,7 +567,7 @@ class TestAsyncToolSupport: executor = SubagentExecutor( config=base_config, tools=[], - thread_id="test-thread", + thread_id="test-thread", app_config=_TEST_APP_CONFIG, ) with patch.object(executor, "_create_agent", return_value=mock_agent): @@ -602,7 +604,7 @@ class TestAsyncToolSupport: executor = SubagentExecutor( config=base_config, tools=[], - thread_id="test-thread", + thread_id="test-thread", app_config=_TEST_APP_CONFIG, ) with patch.object(executor, "_create_agent", return_value=mock_agent): @@ -648,7 +650,7 @@ class TestThreadSafety: executor = SubagentExecutor( config=base_config, tools=[], - thread_id=f"thread-{task_id}", + thread_id=f"thread-{task_id}", app_config=_TEST_APP_CONFIG, ) with patch.object(executor, "_create_agent", return_value=mock_agent): @@ -858,7 +860,7 @@ class TestCooperativeCancellation: executor = SubagentExecutor( config=base_config, tools=[], - thread_id="test-thread", + thread_id="test-thread", app_config=_TEST_APP_CONFIG, ) with patch.object(executor, "_create_agent", return_value=mock_agent): @@ -898,7 +900,7 @@ class TestCooperativeCancellation: executor = SubagentExecutor( config=base_config, tools=[], - thread_id="test-thread", + thread_id="test-thread", app_config=_TEST_APP_CONFIG, ) with patch.object(executor, "_create_agent", return_value=mock_agent): @@ -977,7 +979,7 @@ class TestCooperativeCancellation: config=short_config, tools=[], thread_id="test-thread", - trace_id="test-trace", + trace_id="test-trace", app_config=_TEST_APP_CONFIG, ) # Wrap _scheduler_pool.submit so we know when run_task finishes diff --git a/backend/tests/test_subagent_prompt_security.py b/backend/tests/test_subagent_prompt_security.py index 015206877..d4a920f93 100644 --- a/backend/tests/test_subagent_prompt_security.py +++ b/backend/tests/test_subagent_prompt_security.py @@ -1,29 +1,35 @@ """Tests for subagent availability and prompt exposure under local bash hardening.""" from deerflow.agents.lead_agent import prompt as prompt_module +from deerflow.config.app_config import AppConfig +from deerflow.config.sandbox_config import SandboxConfig from deerflow.subagents import registry as registry_module -def test_get_available_subagent_names_hides_bash_when_host_bash_disabled(monkeypatch) -> None: - monkeypatch.setattr(registry_module, "is_host_bash_allowed", lambda: False) +def _config() -> AppConfig: + return AppConfig(sandbox=SandboxConfig(use="test")) - names = registry_module.get_available_subagent_names() + +def test_get_available_subagent_names_hides_bash_when_host_bash_disabled(monkeypatch) -> None: + monkeypatch.setattr(registry_module, "is_host_bash_allowed", lambda *a, **k: False) + + names = registry_module.get_available_subagent_names(_config()) assert names == ["general-purpose"] def test_get_available_subagent_names_keeps_bash_when_allowed(monkeypatch) -> None: - monkeypatch.setattr(registry_module, "is_host_bash_allowed", lambda: True) + monkeypatch.setattr(registry_module, "is_host_bash_allowed", lambda *a, **k: True) - names = registry_module.get_available_subagent_names() + names = registry_module.get_available_subagent_names(_config()) assert names == ["general-purpose", "bash"] def test_build_subagent_section_hides_bash_examples_when_unavailable(monkeypatch) -> None: - monkeypatch.setattr(prompt_module, "get_available_subagent_names", lambda: ["general-purpose"]) + monkeypatch.setattr(prompt_module, "get_available_subagent_names", lambda *a, **k: ["general-purpose"]) - section = prompt_module._build_subagent_section(3) + section = prompt_module._build_subagent_section(3, _config()) # When bash is not available, it should not appear at all (aligned with Codex: # unavailable roles are omitted, not listed as disabled) @@ -34,9 +40,9 @@ def test_build_subagent_section_hides_bash_examples_when_unavailable(monkeypatch def test_build_subagent_section_includes_bash_when_available(monkeypatch) -> None: - monkeypatch.setattr(prompt_module, "get_available_subagent_names", lambda: ["general-purpose", "bash"]) + monkeypatch.setattr(prompt_module, "get_available_subagent_names", lambda *a, **k: ["general-purpose", "bash"]) - section = prompt_module._build_subagent_section(3) + section = prompt_module._build_subagent_section(3, _config()) assert "For command execution (git, build, test, deploy operations)" in section assert 'bash("npm test")' in section diff --git a/backend/tests/test_subagent_skills_config.py b/backend/tests/test_subagent_skills_config.py deleted file mode 100644 index f121ccf25..000000000 --- a/backend/tests/test_subagent_skills_config.py +++ /dev/null @@ -1,596 +0,0 @@ -"""Tests for subagent per-agent skill configuration and custom subagent types. - -Covers: -- SubagentConfig.skills field -- SubagentOverrideConfig.skills field -- CustomSubagentConfig model validation -- SubagentsAppConfig.custom_agents and get_skills_for() -- Registry: custom agent lookup, skills override, merged available names -- Skills filter passthrough in task_tool config assembly -""" - -import pytest - -from deerflow.config.subagents_config import ( - CustomSubagentConfig, - SubagentOverrideConfig, - SubagentsAppConfig, - get_subagents_app_config, - load_subagents_config_from_dict, -) -from deerflow.subagents.config import SubagentConfig - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _reset_subagents_config(**kwargs) -> None: - """Reset global subagents config to a known state.""" - load_subagents_config_from_dict(kwargs) - - -# --------------------------------------------------------------------------- -# SubagentConfig.skills field -# --------------------------------------------------------------------------- - - -class TestSubagentConfigSkills: - def test_default_skills_is_none(self): - config = SubagentConfig(name="test", description="test", system_prompt="test") - assert config.skills is None - - def test_skills_whitelist(self): - config = SubagentConfig( - name="test", - description="test", - system_prompt="test", - skills=["data-analysis", "visualization"], - ) - assert config.skills == ["data-analysis", "visualization"] - - def test_skills_empty_list_means_no_skills(self): - config = SubagentConfig( - name="test", - description="test", - system_prompt="test", - skills=[], - ) - assert config.skills == [] - - -# --------------------------------------------------------------------------- -# SubagentOverrideConfig.skills field -# --------------------------------------------------------------------------- - - -class TestSubagentOverrideConfigSkills: - def test_default_skills_is_none(self): - override = SubagentOverrideConfig() - assert override.skills is None - - def test_skills_whitelist(self): - override = SubagentOverrideConfig(skills=["web-search", "data-analysis"]) - assert override.skills == ["web-search", "data-analysis"] - - def test_skills_empty_list(self): - override = SubagentOverrideConfig(skills=[]) - assert override.skills == [] - - def test_skills_coexists_with_other_fields(self): - override = SubagentOverrideConfig( - timeout_seconds=300, - model="gpt-5", - skills=["my-skill"], - ) - assert override.timeout_seconds == 300 - assert override.model == "gpt-5" - assert override.skills == ["my-skill"] - - -# --------------------------------------------------------------------------- -# CustomSubagentConfig model -# --------------------------------------------------------------------------- - - -class TestCustomSubagentConfig: - def test_minimal_valid(self): - config = CustomSubagentConfig( - description="A test agent", - system_prompt="You are a test agent.", - ) - assert config.description == "A test agent" - assert config.system_prompt == "You are a test agent." - assert config.tools is None - assert config.disallowed_tools == ["task", "ask_clarification", "present_files"] - assert config.skills is None - assert config.model == "inherit" - assert config.max_turns == 50 - assert config.timeout_seconds == 900 - - def test_full_configuration(self): - config = CustomSubagentConfig( - description="Data analysis specialist", - system_prompt="You are a data analysis subagent.", - tools=["bash", "read_file", "write_file"], - disallowed_tools=["task"], - skills=["data-analysis", "visualization"], - model="qwen3:32b", - max_turns=80, - timeout_seconds=600, - ) - assert config.tools == ["bash", "read_file", "write_file"] - assert config.skills == ["data-analysis", "visualization"] - assert config.model == "qwen3:32b" - assert config.max_turns == 80 - assert config.timeout_seconds == 600 - - def test_skills_empty_list_no_skills(self): - config = CustomSubagentConfig( - description="test", - system_prompt="test", - skills=[], - ) - assert config.skills == [] - - def test_rejects_zero_max_turns(self): - with pytest.raises(ValueError): - CustomSubagentConfig( - description="test", - system_prompt="test", - max_turns=0, - ) - - def test_rejects_zero_timeout(self): - with pytest.raises(ValueError): - CustomSubagentConfig( - description="test", - system_prompt="test", - timeout_seconds=0, - ) - - -# --------------------------------------------------------------------------- -# SubagentsAppConfig.custom_agents and get_skills_for() -# --------------------------------------------------------------------------- - - -class TestSubagentsAppConfigCustomAgents: - def test_default_custom_agents_empty(self): - config = SubagentsAppConfig() - assert config.custom_agents == {} - - def test_custom_agents_loaded(self): - config = SubagentsAppConfig( - custom_agents={ - "analysis": CustomSubagentConfig( - description="Analysis agent", - system_prompt="You analyze data.", - skills=["data-analysis"], - ), - } - ) - assert "analysis" in config.custom_agents - assert config.custom_agents["analysis"].skills == ["data-analysis"] - - def test_multiple_custom_agents(self): - config = SubagentsAppConfig( - custom_agents={ - "analysis": CustomSubagentConfig( - description="Analysis", - system_prompt="analyze", - skills=["data-analysis"], - ), - "researcher": CustomSubagentConfig( - description="Research", - system_prompt="research", - skills=["web-search"], - ), - } - ) - assert len(config.custom_agents) == 2 - - -class TestGetSkillsFor: - def test_returns_none_when_no_override(self): - config = SubagentsAppConfig() - assert config.get_skills_for("general-purpose") is None - assert config.get_skills_for("unknown") is None - - def test_returns_skills_whitelist(self): - config = SubagentsAppConfig( - agents={ - "general-purpose": SubagentOverrideConfig(skills=["web-search", "coding"]), - } - ) - assert config.get_skills_for("general-purpose") == ["web-search", "coding"] - - def test_returns_empty_list_for_no_skills(self): - config = SubagentsAppConfig( - agents={ - "bash": SubagentOverrideConfig(skills=[]), - } - ) - assert config.get_skills_for("bash") == [] - - def test_returns_none_for_unrelated_agent(self): - config = SubagentsAppConfig( - agents={ - "bash": SubagentOverrideConfig(skills=["web-search"]), - } - ) - assert config.get_skills_for("general-purpose") is None - - def test_returns_none_when_skills_not_set(self): - config = SubagentsAppConfig( - agents={ - "bash": SubagentOverrideConfig(timeout_seconds=300), - } - ) - assert config.get_skills_for("bash") is None - - -# --------------------------------------------------------------------------- -# load_subagents_config_from_dict with skills and custom_agents -# --------------------------------------------------------------------------- - - -class TestLoadSubagentsConfigWithSkills: - def teardown_method(self): - _reset_subagents_config() - - def test_load_with_skills_override(self): - load_subagents_config_from_dict( - { - "timeout_seconds": 900, - "agents": { - "general-purpose": {"skills": ["web-search", "data-analysis"]}, - }, - } - ) - cfg = get_subagents_app_config() - assert cfg.get_skills_for("general-purpose") == ["web-search", "data-analysis"] - - def test_load_with_empty_skills(self): - load_subagents_config_from_dict( - { - "timeout_seconds": 900, - "agents": { - "bash": {"skills": []}, - }, - } - ) - cfg = get_subagents_app_config() - assert cfg.get_skills_for("bash") == [] - - def test_load_with_custom_agents(self): - load_subagents_config_from_dict( - { - "timeout_seconds": 900, - "custom_agents": { - "analysis": { - "description": "Data analysis specialist", - "system_prompt": "You are a data analysis subagent.", - "skills": ["data-analysis", "visualization"], - "tools": ["bash", "read_file"], - "max_turns": 80, - "timeout_seconds": 600, - }, - }, - } - ) - cfg = get_subagents_app_config() - assert "analysis" in cfg.custom_agents - custom = cfg.custom_agents["analysis"] - assert custom.skills == ["data-analysis", "visualization"] - assert custom.tools == ["bash", "read_file"] - assert custom.max_turns == 80 - assert custom.timeout_seconds == 600 - - def test_load_with_both_overrides_and_custom(self): - load_subagents_config_from_dict( - { - "timeout_seconds": 900, - "agents": { - "general-purpose": {"skills": ["web-search"]}, - }, - "custom_agents": { - "analysis": { - "description": "Analysis", - "system_prompt": "Analyze.", - "skills": ["data-analysis"], - }, - }, - } - ) - cfg = get_subagents_app_config() - assert cfg.get_skills_for("general-purpose") == ["web-search"] - assert cfg.custom_agents["analysis"].skills == ["data-analysis"] - - -# --------------------------------------------------------------------------- -# Registry: custom agent lookup -# --------------------------------------------------------------------------- - - -class TestRegistryCustomAgentLookup: - def teardown_method(self): - _reset_subagents_config() - - def test_custom_agent_found(self): - from deerflow.subagents.registry import get_subagent_config - - load_subagents_config_from_dict( - { - "custom_agents": { - "analysis": { - "description": "Data analysis specialist", - "system_prompt": "You are a data analysis subagent.", - "skills": ["data-analysis"], - "tools": ["bash", "read_file"], - "max_turns": 80, - "timeout_seconds": 600, - }, - }, - } - ) - config = get_subagent_config("analysis") - assert config is not None - assert config.name == "analysis" - assert config.skills == ["data-analysis"] - assert config.tools == ["bash", "read_file"] - assert config.max_turns == 80 - assert config.timeout_seconds == 600 - assert config.model == "inherit" - - def test_custom_agent_not_found(self): - from deerflow.subagents.registry import get_subagent_config - - _reset_subagents_config() - assert get_subagent_config("nonexistent") is None - - def test_builtin_takes_priority_over_custom(self): - """If a custom agent has the same name as a builtin, builtin wins.""" - from deerflow.subagents.builtins import BUILTIN_SUBAGENTS - from deerflow.subagents.registry import get_subagent_config - - load_subagents_config_from_dict( - { - "custom_agents": { - "general-purpose": { - "description": "Custom override attempt", - "system_prompt": "Should not be used", - }, - }, - } - ) - config = get_subagent_config("general-purpose") - # Should get the builtin description, not the custom one - assert config.description == BUILTIN_SUBAGENTS["general-purpose"].description - - def test_custom_agent_with_override(self): - """Per-agent overrides also apply to custom agents.""" - from deerflow.subagents.registry import get_subagent_config - - load_subagents_config_from_dict( - { - "custom_agents": { - "analysis": { - "description": "Analysis", - "system_prompt": "Analyze.", - "timeout_seconds": 600, - }, - }, - "agents": { - "analysis": {"timeout_seconds": 300, "skills": ["overridden-skill"]}, - }, - } - ) - config = get_subagent_config("analysis") - assert config is not None - assert config.timeout_seconds == 300 # Override applied - assert config.skills == ["overridden-skill"] # Override applied - - -# --------------------------------------------------------------------------- -# Registry: skills override on builtin agents -# --------------------------------------------------------------------------- - - -class TestRegistrySkillsOverride: - def teardown_method(self): - _reset_subagents_config() - - def test_skills_override_applied_to_builtin(self): - from deerflow.subagents.registry import get_subagent_config - - load_subagents_config_from_dict( - { - "agents": { - "general-purpose": {"skills": ["web-search", "data-analysis"]}, - }, - } - ) - config = get_subagent_config("general-purpose") - assert config.skills == ["web-search", "data-analysis"] - - def test_empty_skills_override(self): - from deerflow.subagents.registry import get_subagent_config - - load_subagents_config_from_dict( - { - "agents": { - "bash": {"skills": []}, - }, - } - ) - config = get_subagent_config("bash") - assert config.skills == [] - - def test_no_skills_override_keeps_default(self): - from deerflow.subagents.registry import get_subagent_config - - _reset_subagents_config() - config = get_subagent_config("general-purpose") - assert config.skills is None # Default: inherit all - - def test_skills_override_does_not_mutate_builtin(self): - from deerflow.subagents.builtins import BUILTIN_SUBAGENTS - from deerflow.subagents.registry import get_subagent_config - - load_subagents_config_from_dict( - { - "agents": { - "general-purpose": {"skills": ["web-search"]}, - }, - } - ) - _ = get_subagent_config("general-purpose") - assert BUILTIN_SUBAGENTS["general-purpose"].skills is None - - -# --------------------------------------------------------------------------- -# Registry: get_available_subagent_names merges custom types -# --------------------------------------------------------------------------- - - -class TestRegistryAvailableNames: - def teardown_method(self): - _reset_subagents_config() - - def test_includes_builtin_names(self): - from deerflow.subagents.registry import get_subagent_names - - _reset_subagents_config() - names = get_subagent_names() - assert "general-purpose" in names - assert "bash" in names - - def test_includes_custom_names(self): - from deerflow.subagents.registry import get_subagent_names - - load_subagents_config_from_dict( - { - "custom_agents": { - "analysis": { - "description": "Analysis", - "system_prompt": "Analyze.", - }, - "researcher": { - "description": "Research", - "system_prompt": "Research.", - }, - }, - } - ) - names = get_subagent_names() - assert "general-purpose" in names - assert "bash" in names - assert "analysis" in names - assert "researcher" in names - - def test_no_duplicates_when_custom_name_matches_builtin(self): - from deerflow.subagents.registry import get_subagent_names - - load_subagents_config_from_dict( - { - "custom_agents": { - "general-purpose": { - "description": "Duplicate name", - "system_prompt": "test", - }, - }, - } - ) - names = get_subagent_names() - assert names.count("general-purpose") == 1 - - -# --------------------------------------------------------------------------- -# Registry: list_subagents includes custom agents -# --------------------------------------------------------------------------- - - -class TestRegistryListSubagentsWithCustom: - def teardown_method(self): - _reset_subagents_config() - - def test_list_includes_custom_agents(self): - from deerflow.subagents.registry import list_subagents - - load_subagents_config_from_dict( - { - "custom_agents": { - "analysis": { - "description": "Analysis", - "system_prompt": "Analyze.", - "skills": ["data-analysis"], - }, - }, - } - ) - configs = list_subagents() - names = {c.name for c in configs} - assert "general-purpose" in names - assert "bash" in names - assert "analysis" in names - - def test_list_custom_agent_has_correct_skills(self): - from deerflow.subagents.registry import list_subagents - - load_subagents_config_from_dict( - { - "custom_agents": { - "analysis": { - "description": "Analysis", - "system_prompt": "Analyze.", - "skills": ["data-analysis", "visualization"], - }, - }, - } - ) - by_name = {c.name: c for c in list_subagents()} - assert by_name["analysis"].skills == ["data-analysis", "visualization"] - - -# --------------------------------------------------------------------------- -# Skills filter passthrough: verify config.skills is used in task_tool assembly -# --------------------------------------------------------------------------- - - -class TestSkillsFilterPassthrough: - """Test that SubagentConfig.skills is correctly passed to get_skills_prompt_section.""" - - def test_none_skills_passes_none_to_prompt(self): - """When config.skills is None, available_skills=None should be passed (inherit all).""" - config = SubagentConfig( - name="test", - description="test", - system_prompt="test", - skills=None, - ) - # Verify: set(None) would raise, so the code must check for None first - available = set(config.skills) if config.skills is not None else None - assert available is None - - def test_empty_skills_passes_empty_set(self): - """When config.skills is [], available_skills=set() should be passed (no skills).""" - config = SubagentConfig( - name="test", - description="test", - system_prompt="test", - skills=[], - ) - available = set(config.skills) if config.skills is not None else None - assert available == set() - - def test_skills_whitelist_passes_correct_set(self): - """When config.skills has values, those should be passed as available_skills.""" - config = SubagentConfig( - name="test", - description="test", - system_prompt="test", - skills=["data-analysis", "web-search"], - ) - available = set(config.skills) if config.skills is not None else None - assert available == {"data-analysis", "web-search"} diff --git a/backend/tests/test_subagent_timeout_config.py b/backend/tests/test_subagent_timeout_config.py index b20bbe7a9..d3f695534 100644 --- a/backend/tests/test_subagent_timeout_config.py +++ b/backend/tests/test_subagent_timeout_config.py @@ -3,7 +3,7 @@ Covers: - SubagentsAppConfig / SubagentOverrideConfig model validation and defaults - get_timeout_for() / get_max_turns_for() resolution logic -- load_subagents_config_from_dict() and get_subagents_app_config() singleton +- AppConfig.subagents field access - registry.get_subagent_config() applies config overrides - registry.list_subagents() applies overrides for all agents - Polling timeout calculation in task_tool is consistent with config @@ -11,32 +11,28 @@ Covers: import pytest +from deerflow.config.app_config import AppConfig +from deerflow.config.sandbox_config import SandboxConfig from deerflow.config.subagents_config import ( SubagentOverrideConfig, SubagentsAppConfig, - get_subagents_app_config, - load_subagents_config_from_dict, ) -from deerflow.subagents.config import SubagentConfig - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- -def _reset_subagents_config( +def _make_config( timeout_seconds: int = 900, *, max_turns: int | None = None, agents: dict | None = None, -) -> None: - """Reset global subagents config to a known state.""" - load_subagents_config_from_dict( - { - "timeout_seconds": timeout_seconds, - "max_turns": max_turns, - "agents": agents or {}, - } +) -> AppConfig: + """Build an AppConfig with the given subagents settings.""" + return AppConfig( + sandbox=SandboxConfig(use="test"), + subagents=SubagentsAppConfig( + timeout_seconds=timeout_seconds, + max_turns=max_turns, + agents={k: SubagentOverrideConfig(**v) for k, v in (agents or {}).items()}, + ), ) @@ -50,523 +46,131 @@ class TestSubagentOverrideConfig: override = SubagentOverrideConfig() assert override.timeout_seconds is None assert override.max_turns is None - assert override.model is None - def test_explicit_value(self): - override = SubagentOverrideConfig(timeout_seconds=300, max_turns=42, model="gpt-5.4") - assert override.timeout_seconds == 300 - assert override.max_turns == 42 - assert override.model == "gpt-5.4" - - def test_model_accepts_any_non_empty_string(self): - """Model name is a free-form non-empty string; cross-reference validation - against the `models:` section happens at registry lookup time.""" - override = SubagentOverrideConfig(model="any-arbitrary-model-name") - assert override.model == "any-arbitrary-model-name" - - def test_rejects_zero(self): - with pytest.raises(ValueError): - SubagentOverrideConfig(timeout_seconds=0) - with pytest.raises(ValueError): - SubagentOverrideConfig(max_turns=0) - - def test_rejects_negative(self): - with pytest.raises(ValueError): - SubagentOverrideConfig(timeout_seconds=-1) - with pytest.raises(ValueError): - SubagentOverrideConfig(max_turns=-1) - - def test_rejects_empty_model(self): - """Empty-string model would silently bypass the `is not None` check and - reach `create_chat_model(name="")` as a runtime error. Reject at load time - instead, symmetric with the `ge=1` guard on timeout_seconds / max_turns.""" - with pytest.raises(ValueError): - SubagentOverrideConfig(model="") - - def test_minimum_valid_value(self): - override = SubagentOverrideConfig(timeout_seconds=1, max_turns=1) - assert override.timeout_seconds == 1 - assert override.max_turns == 1 - - -# --------------------------------------------------------------------------- -# SubagentsAppConfig – defaults and validation -# --------------------------------------------------------------------------- - - -class TestSubagentsAppConfigDefaults: - def test_default_timeout(self): - config = SubagentsAppConfig() - assert config.timeout_seconds == 900 - - def test_default_max_turns_override_is_none(self): - config = SubagentsAppConfig() - assert config.max_turns is None - - def test_default_agents_empty(self): - config = SubagentsAppConfig() - assert config.agents == {} - - def test_custom_global_runtime_overrides(self): - config = SubagentsAppConfig(timeout_seconds=1800, max_turns=120) - assert config.timeout_seconds == 1800 - assert config.max_turns == 120 - - def test_rejects_zero_timeout(self): - with pytest.raises(ValueError): - SubagentsAppConfig(timeout_seconds=0) - with pytest.raises(ValueError): - SubagentsAppConfig(max_turns=0) + def test_explicit_values(self): + override = SubagentOverrideConfig(timeout_seconds=120, max_turns=50) + assert override.timeout_seconds == 120 + assert override.max_turns == 50 def test_rejects_negative_timeout(self): - with pytest.raises(ValueError): - SubagentsAppConfig(timeout_seconds=-60) - with pytest.raises(ValueError): - SubagentsAppConfig(max_turns=-60) + with pytest.raises(Exception): + SubagentOverrideConfig(timeout_seconds=-1) + + def test_rejects_zero_timeout(self): + with pytest.raises(Exception): + SubagentOverrideConfig(timeout_seconds=0) # --------------------------------------------------------------------------- -# SubagentsAppConfig resolution helpers +# SubagentsAppConfig model # --------------------------------------------------------------------------- -class TestRuntimeResolution: - def test_returns_global_default_when_no_override(self): +class TestSubagentsAppConfig: + def test_default_timeout_is_900(self): + config = SubagentsAppConfig() + assert config.timeout_seconds == 900 + assert config.max_turns is None + assert config.agents == {} + + def test_custom_defaults(self): + config = SubagentsAppConfig(timeout_seconds=300, max_turns=50) + assert config.timeout_seconds == 300 + assert config.max_turns == 50 + + +# --------------------------------------------------------------------------- +# get_timeout_for / get_max_turns_for +# --------------------------------------------------------------------------- + + +class TestTimeoutResolution: + def test_global_timeout_for_unknown_agent(self): config = SubagentsAppConfig(timeout_seconds=600) + assert config.get_timeout_for("unknown") == 600 + + def test_per_agent_timeout_overrides_global(self): + config = SubagentsAppConfig( + timeout_seconds=600, + agents={"bash": SubagentOverrideConfig(timeout_seconds=120)}, + ) + assert config.get_timeout_for("bash") == 120 assert config.get_timeout_for("general-purpose") == 600 + + def test_per_agent_override_none_falls_back_to_global(self): + config = SubagentsAppConfig( + timeout_seconds=600, + agents={"bash": SubagentOverrideConfig(timeout_seconds=None)}, + ) assert config.get_timeout_for("bash") == 600 - assert config.get_timeout_for("unknown-agent") == 600 - assert config.get_max_turns_for("general-purpose", 100) == 100 + + +class TestMaxTurnsResolution: + def test_builtin_default_when_no_override(self): + config = SubagentsAppConfig() assert config.get_max_turns_for("bash", 60) == 60 - def test_returns_per_agent_override_when_set(self): - config = SubagentsAppConfig( - timeout_seconds=900, - max_turns=120, - agents={"bash": SubagentOverrideConfig(timeout_seconds=300, max_turns=80)}, - ) - assert config.get_timeout_for("bash") == 300 - assert config.get_max_turns_for("bash", 60) == 80 + def test_global_max_turns_overrides_builtin(self): + config = SubagentsAppConfig(max_turns=100) + assert config.get_max_turns_for("bash", 60) == 100 - def test_other_agents_still_use_global_default(self): + def test_per_agent_max_turns_overrides_global(self): config = SubagentsAppConfig( - timeout_seconds=900, - max_turns=140, - agents={"bash": SubagentOverrideConfig(timeout_seconds=300, max_turns=80)}, + max_turns=100, + agents={"bash": SubagentOverrideConfig(max_turns=30)}, ) - assert config.get_timeout_for("general-purpose") == 900 - assert config.get_max_turns_for("general-purpose", 100) == 140 + assert config.get_max_turns_for("bash", 60) == 30 + assert config.get_max_turns_for("general-purpose", 60) == 100 - def test_agent_with_none_override_falls_back_to_global(self): + def test_per_agent_override_none_falls_back(self): config = SubagentsAppConfig( - timeout_seconds=900, - max_turns=150, - agents={"general-purpose": SubagentOverrideConfig(timeout_seconds=None, max_turns=None)}, + max_turns=100, + agents={"bash": SubagentOverrideConfig(max_turns=None)}, ) - assert config.get_timeout_for("general-purpose") == 900 - assert config.get_max_turns_for("general-purpose", 100) == 150 - - def test_multiple_per_agent_overrides(self): - config = SubagentsAppConfig( - timeout_seconds=900, - max_turns=120, - agents={ - "general-purpose": SubagentOverrideConfig(timeout_seconds=1800, max_turns=200), - "bash": SubagentOverrideConfig(timeout_seconds=120, max_turns=80), - }, - ) - assert config.get_timeout_for("general-purpose") == 1800 - assert config.get_timeout_for("bash") == 120 - assert config.get_max_turns_for("general-purpose", 100) == 200 - assert config.get_max_turns_for("bash", 60) == 80 - - def test_get_model_for_returns_none_when_no_override(self): - """No per-agent model override -> returns None so callers fall back to builtin/parent.""" - config = SubagentsAppConfig(timeout_seconds=900) - assert config.get_model_for("general-purpose") is None - assert config.get_model_for("bash") is None - assert config.get_model_for("unknown-agent") is None - - def test_get_model_for_returns_override_when_set(self): - config = SubagentsAppConfig( - timeout_seconds=900, - agents={ - "general-purpose": SubagentOverrideConfig(model="qwen3.5-35b-a3b"), - "bash": SubagentOverrideConfig(model="gpt-5.4"), - }, - ) - assert config.get_model_for("general-purpose") == "qwen3.5-35b-a3b" - assert config.get_model_for("bash") == "gpt-5.4" - - def test_get_model_for_returns_none_for_omitted_agent(self): - """An agent not listed in overrides returns None even when other agents have model overrides.""" - config = SubagentsAppConfig( - timeout_seconds=900, - agents={"bash": SubagentOverrideConfig(model="gpt-5.4")}, - ) - assert config.get_model_for("general-purpose") is None - - def test_get_model_for_handles_explicit_none(self): - """Explicit model=None in the override is equivalent to no override.""" - config = SubagentsAppConfig( - timeout_seconds=900, - agents={"bash": SubagentOverrideConfig(timeout_seconds=300, model=None)}, - ) - assert config.get_model_for("bash") is None - # Timeout override is still applied even when model is None. - assert config.get_timeout_for("bash") == 300 + assert config.get_max_turns_for("bash", 60) == 100 # --------------------------------------------------------------------------- -# load_subagents_config_from_dict / get_subagents_app_config singleton +# AppConfig.subagents # --------------------------------------------------------------------------- -class TestLoadSubagentsConfig: - def teardown_method(self): - """Restore defaults after each test.""" - _reset_subagents_config() - +class TestAppConfigSubagents: def test_load_global_timeout(self): - load_subagents_config_from_dict({"timeout_seconds": 300, "max_turns": 120}) - assert get_subagents_app_config().timeout_seconds == 300 - assert get_subagents_app_config().max_turns == 120 + cfg = _make_config(timeout_seconds=300, max_turns=120) + sub = cfg.subagents + assert sub.timeout_seconds == 300 + assert sub.max_turns == 120 def test_load_with_per_agent_overrides(self): - load_subagents_config_from_dict( - { - "timeout_seconds": 900, - "max_turns": 120, - "agents": { - "general-purpose": {"timeout_seconds": 1800, "max_turns": 200}, - "bash": {"timeout_seconds": 60, "max_turns": 80}, - }, - } + cfg = _make_config( + timeout_seconds=900, + max_turns=120, + agents={ + "general-purpose": {"timeout_seconds": 1800, "max_turns": 200}, + "bash": {"timeout_seconds": 60, "max_turns": 80}, + }, ) - cfg = get_subagents_app_config() - assert cfg.get_timeout_for("general-purpose") == 1800 - assert cfg.get_timeout_for("bash") == 60 - assert cfg.get_max_turns_for("general-purpose", 100) == 200 - assert cfg.get_max_turns_for("bash", 60) == 80 + sub = cfg.subagents + assert sub.get_timeout_for("general-purpose") == 1800 + assert sub.get_timeout_for("bash") == 60 + assert sub.get_max_turns_for("general-purpose", 100) == 200 + assert sub.get_max_turns_for("bash", 60) == 80 def test_load_partial_override(self): - load_subagents_config_from_dict( - { - "timeout_seconds": 600, - "agents": {"bash": {"timeout_seconds": 120, "max_turns": 70}}, - } + cfg = _make_config( + timeout_seconds=600, + agents={"bash": {"timeout_seconds": 120, "max_turns": 70}}, ) - cfg = get_subagents_app_config() - assert cfg.get_timeout_for("general-purpose") == 600 - assert cfg.get_timeout_for("bash") == 120 - assert cfg.get_max_turns_for("general-purpose", 100) == 100 - assert cfg.get_max_turns_for("bash", 60) == 70 + sub = cfg.subagents + assert sub.get_timeout_for("general-purpose") == 600 + assert sub.get_timeout_for("bash") == 120 + assert sub.get_max_turns_for("general-purpose", 100) == 100 + assert sub.get_max_turns_for("bash", 60) == 70 - def test_load_with_model_overrides(self): - load_subagents_config_from_dict( - { - "timeout_seconds": 900, - "agents": { - "general-purpose": {"model": "qwen3.5-35b-a3b"}, - "bash": {"model": "gpt-5.4", "timeout_seconds": 300}, - }, - } - ) - cfg = get_subagents_app_config() - assert cfg.get_model_for("general-purpose") == "qwen3.5-35b-a3b" - assert cfg.get_model_for("bash") == "gpt-5.4" - # Other override fields on the same agent must still load correctly. - assert cfg.get_timeout_for("bash") == 300 - - def test_load_empty_dict_uses_defaults(self): - load_subagents_config_from_dict({}) - cfg = get_subagents_app_config() - assert cfg.timeout_seconds == 900 - assert cfg.max_turns is None - assert cfg.agents == {} - - def test_load_replaces_previous_config(self): - load_subagents_config_from_dict({"timeout_seconds": 100, "max_turns": 90}) - assert get_subagents_app_config().timeout_seconds == 100 - assert get_subagents_app_config().max_turns == 90 - - load_subagents_config_from_dict({"timeout_seconds": 200, "max_turns": 110}) - assert get_subagents_app_config().timeout_seconds == 200 - assert get_subagents_app_config().max_turns == 110 - - def test_singleton_returns_same_instance_between_calls(self): - load_subagents_config_from_dict({"timeout_seconds": 777, "max_turns": 123}) - assert get_subagents_app_config() is get_subagents_app_config() - - -# --------------------------------------------------------------------------- -# registry.get_subagent_config – runtime overrides applied -# --------------------------------------------------------------------------- - - -class TestRegistryGetSubagentConfig: - def teardown_method(self): - _reset_subagents_config() - - def test_returns_none_for_unknown_agent(self): - from deerflow.subagents.registry import get_subagent_config - - assert get_subagent_config("nonexistent") is None - - def test_returns_config_for_builtin_agents(self): - from deerflow.subagents.registry import get_subagent_config - - assert get_subagent_config("general-purpose") is not None - assert get_subagent_config("bash") is not None - - def test_default_timeout_preserved_when_no_config(self): - from deerflow.subagents.registry import get_subagent_config - - _reset_subagents_config(timeout_seconds=900) - config = get_subagent_config("general-purpose") - assert config.timeout_seconds == 900 - assert config.max_turns == 100 - - def test_global_timeout_override_applied(self): - from deerflow.subagents.registry import get_subagent_config - - _reset_subagents_config(timeout_seconds=1800, max_turns=140) - config = get_subagent_config("general-purpose") - assert config.timeout_seconds == 1800 - assert config.max_turns == 140 - - def test_per_agent_runtime_override_applied(self): - from deerflow.subagents.registry import get_subagent_config - - load_subagents_config_from_dict( - { - "timeout_seconds": 900, - "max_turns": 120, - "agents": {"bash": {"timeout_seconds": 120, "max_turns": 80}}, - } - ) - bash_config = get_subagent_config("bash") - assert bash_config.timeout_seconds == 120 - assert bash_config.max_turns == 80 - - def test_per_agent_override_does_not_affect_other_agents(self): - from deerflow.subagents.registry import get_subagent_config - - load_subagents_config_from_dict( - { - "timeout_seconds": 900, - "max_turns": 120, - "agents": {"bash": {"timeout_seconds": 120, "max_turns": 80}}, - } - ) - gp_config = get_subagent_config("general-purpose") - assert gp_config.timeout_seconds == 900 - assert gp_config.max_turns == 120 - - def test_per_agent_model_override_applied(self): - from deerflow.subagents.registry import get_subagent_config - - load_subagents_config_from_dict( - { - "timeout_seconds": 900, - "agents": {"bash": {"model": "gpt-5.4-mini"}}, - } - ) - bash_config = get_subagent_config("bash") - assert bash_config.model == "gpt-5.4-mini" - - def test_omitted_model_keeps_builtin_value(self): - """When config.yaml has no `model` field for an agent, the builtin default must be preserved.""" - from deerflow.subagents.builtins import BUILTIN_SUBAGENTS - from deerflow.subagents.registry import get_subagent_config - - builtin_bash_model = BUILTIN_SUBAGENTS["bash"].model - load_subagents_config_from_dict( - { - "timeout_seconds": 900, - "agents": {"bash": {"timeout_seconds": 300}}, - } - ) - bash_config = get_subagent_config("bash") - assert bash_config.model == builtin_bash_model - - def test_explicit_null_model_keeps_builtin_value(self): - """An explicit `model: null` in config.yaml is equivalent to omission — builtin wins.""" - from deerflow.subagents.builtins import BUILTIN_SUBAGENTS - from deerflow.subagents.registry import get_subagent_config - - builtin_bash_model = BUILTIN_SUBAGENTS["bash"].model - load_subagents_config_from_dict( - { - "timeout_seconds": 900, - "agents": {"bash": {"model": None}}, - } - ) - bash_config = get_subagent_config("bash") - assert bash_config.model == builtin_bash_model - - def test_model_override_does_not_affect_other_agents(self): - from deerflow.subagents.builtins import BUILTIN_SUBAGENTS - from deerflow.subagents.registry import get_subagent_config - - builtin_gp_model = BUILTIN_SUBAGENTS["general-purpose"].model - load_subagents_config_from_dict( - { - "timeout_seconds": 900, - "agents": {"bash": {"model": "gpt-5.4"}}, - } - ) - gp_config = get_subagent_config("general-purpose") - assert gp_config.model == builtin_gp_model - - def test_model_override_preserves_other_fields(self): - """Applying a model override must leave timeout_seconds / max_turns / name intact.""" - from deerflow.subagents.builtins import BUILTIN_SUBAGENTS - from deerflow.subagents.registry import get_subagent_config - - original = BUILTIN_SUBAGENTS["bash"] - load_subagents_config_from_dict( - { - "timeout_seconds": 900, - "agents": {"bash": {"model": "gpt-5.4-mini"}}, - } - ) - overridden = get_subagent_config("bash") - assert overridden.model == "gpt-5.4-mini" - assert overridden.name == original.name - assert overridden.description == original.description - # No timeout / max_turns override was set, so they use global default / builtin. - assert overridden.timeout_seconds == 900 - assert overridden.max_turns == original.max_turns - - def test_model_override_does_not_mutate_builtin(self): - """Registry must return a new object, leaving the builtin default intact.""" - from deerflow.subagents.builtins import BUILTIN_SUBAGENTS - from deerflow.subagents.registry import get_subagent_config - - original_bash_model = BUILTIN_SUBAGENTS["bash"].model - load_subagents_config_from_dict( - { - "timeout_seconds": 900, - "agents": {"bash": {"model": "gpt-5.4-mini"}}, - } - ) - _ = get_subagent_config("bash") - assert BUILTIN_SUBAGENTS["bash"].model == original_bash_model - - def test_builtin_config_object_is_not_mutated(self): - """Registry must return a new object, leaving the builtin default intact.""" - from deerflow.subagents.builtins import BUILTIN_SUBAGENTS - from deerflow.subagents.registry import get_subagent_config - - original_timeout = BUILTIN_SUBAGENTS["bash"].timeout_seconds - original_max_turns = BUILTIN_SUBAGENTS["bash"].max_turns - load_subagents_config_from_dict({"timeout_seconds": 42, "max_turns": 88}) - - returned = get_subagent_config("bash") - assert returned.timeout_seconds == 42 - assert returned.max_turns == 88 - assert BUILTIN_SUBAGENTS["bash"].timeout_seconds == original_timeout - assert BUILTIN_SUBAGENTS["bash"].max_turns == original_max_turns - - def test_config_preserves_other_fields(self): - """Applying runtime overrides must not change other SubagentConfig fields.""" - from deerflow.subagents.builtins import BUILTIN_SUBAGENTS - from deerflow.subagents.registry import get_subagent_config - - _reset_subagents_config(timeout_seconds=300, max_turns=140) - original = BUILTIN_SUBAGENTS["general-purpose"] - overridden = get_subagent_config("general-purpose") - - assert overridden.name == original.name - assert overridden.description == original.description - assert overridden.max_turns == 140 - assert overridden.model == original.model - assert overridden.tools == original.tools - assert overridden.disallowed_tools == original.disallowed_tools - - -# --------------------------------------------------------------------------- -# registry.list_subagents – all agents get overrides -# --------------------------------------------------------------------------- - - -class TestRegistryListSubagents: - def teardown_method(self): - _reset_subagents_config() - - def test_lists_both_builtin_agents(self): - from deerflow.subagents.registry import list_subagents - - names = {cfg.name for cfg in list_subagents()} - assert "general-purpose" in names - assert "bash" in names - - def test_all_returned_configs_get_global_override(self): - from deerflow.subagents.registry import list_subagents - - _reset_subagents_config(timeout_seconds=123, max_turns=77) - for cfg in list_subagents(): - assert cfg.timeout_seconds == 123, f"{cfg.name} has wrong timeout" - assert cfg.max_turns == 77, f"{cfg.name} has wrong max_turns" - - def test_per_agent_overrides_reflected_in_list(self): - from deerflow.subagents.registry import list_subagents - - load_subagents_config_from_dict( - { - "timeout_seconds": 900, - "max_turns": 120, - "agents": { - "general-purpose": {"timeout_seconds": 1800, "max_turns": 200}, - "bash": {"timeout_seconds": 60, "max_turns": 80}, - }, - } - ) - by_name = {cfg.name: cfg for cfg in list_subagents()} - assert by_name["general-purpose"].timeout_seconds == 1800 - assert by_name["bash"].timeout_seconds == 60 - assert by_name["general-purpose"].max_turns == 200 - assert by_name["bash"].max_turns == 80 - - -# --------------------------------------------------------------------------- -# Polling timeout calculation (logic extracted from task_tool) -# --------------------------------------------------------------------------- - - -class TestPollingTimeoutCalculation: - """Verify the formula (timeout_seconds + 60) // 5 is correct for various inputs.""" - - @pytest.mark.parametrize( - "timeout_seconds, expected_max_polls", - [ - (900, 192), # default 15 min → (900+60)//5 = 192 - (300, 72), # 5 min → (300+60)//5 = 72 - (1800, 372), # 30 min → (1800+60)//5 = 372 - (60, 24), # 1 min → (60+60)//5 = 24 - (1, 12), # minimum → (1+60)//5 = 12 - ], - ) - def test_polling_timeout_formula(self, timeout_seconds: int, expected_max_polls: int): - dummy_config = SubagentConfig( - name="test", - description="test", - system_prompt="test", - timeout_seconds=timeout_seconds, - ) - max_poll_count = (dummy_config.timeout_seconds + 60) // 5 - assert max_poll_count == expected_max_polls - - def test_polling_timeout_exceeds_execution_timeout(self): - """Safety-net polling window must always be longer than the execution timeout.""" - for timeout_seconds in [60, 300, 900, 1800]: - dummy_config = SubagentConfig( - name="test", - description="test", - system_prompt="test", - timeout_seconds=timeout_seconds, - ) - max_poll_count = (dummy_config.timeout_seconds + 60) // 5 - polling_window_seconds = max_poll_count * 5 - assert polling_window_seconds > timeout_seconds + def test_load_empty_uses_defaults(self): + cfg = _make_config() + sub = cfg.subagents + assert sub.timeout_seconds == 900 + assert sub.max_turns is None + assert sub.agents == {} diff --git a/backend/tests/test_task_tool_core_logic.py b/backend/tests/test_task_tool_core_logic.py index 1ae008df2..253d1cd49 100644 --- a/backend/tests/test_task_tool_core_logic.py +++ b/backend/tests/test_task_tool_core_logic.py @@ -8,6 +8,9 @@ from unittest.mock import MagicMock import pytest +from deerflow.config.app_config import AppConfig +from deerflow.config.deer_flow_context import DeerFlowContext +from deerflow.config.sandbox_config import SandboxConfig from deerflow.subagents.config import SubagentConfig # Use module import so tests can patch the exact symbols referenced inside task_tool(). @@ -24,6 +27,13 @@ class FakeSubagentStatus(Enum): TIMED_OUT = "timed_out" +def _make_context(thread_id: str) -> DeerFlowContext: + return DeerFlowContext( + app_config=AppConfig(sandbox=SandboxConfig(use="test")), + thread_id=thread_id, + ) + + def _make_runtime() -> SimpleNamespace: # Minimal ToolRuntime-like object; task_tool only reads these three attributes. return SimpleNamespace( @@ -35,7 +45,7 @@ def _make_runtime() -> SimpleNamespace: "outputs_path": "/tmp/outputs", }, }, - context={"thread_id": "thread-1"}, + context=_make_context("thread-1"), config={"metadata": {"model_name": "ark-model", "trace_id": "trace-1"}}, ) @@ -83,11 +93,11 @@ class _DummyScheduledTask: def test_task_tool_returns_error_for_unknown_subagent(monkeypatch): - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: None) - monkeypatch.setattr(task_tool_module, "get_available_subagent_names", lambda: ["general-purpose"]) + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda *a, **k: None) + monkeypatch.setattr(task_tool_module, "get_available_subagent_names", lambda *a, **k: ["general-purpose"]) result = _run_task_tool( - runtime=None, + runtime=_make_runtime(), description="执行任务", prompt="do work", subagent_type="general-purpose", @@ -98,8 +108,8 @@ def test_task_tool_returns_error_for_unknown_subagent(monkeypatch): def test_task_tool_rejects_bash_subagent_when_host_bash_disabled(monkeypatch): - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: _make_subagent_config()) - monkeypatch.setattr(task_tool_module, "is_host_bash_allowed", lambda: False) + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda *a, **k: _make_subagent_config()) + monkeypatch.setattr(task_tool_module, "is_host_bash_allowed", lambda *a, **k: False) result = _run_task_tool( runtime=_make_runtime(), @@ -142,9 +152,8 @@ def test_task_tool_emits_running_and_completed_events(monkeypatch): monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus) monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor) - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - - monkeypatch.setattr(task_tool_module, "get_background_task_result", lambda _: next(responses)) + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda *a, **k: config) + monkeypatch.setattr(task_tool_module, "get_background_task_result", lambda *a, **k: next(responses)) monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) # task_tool lazily imports from deerflow.tools at call time, so patch that module-level function. @@ -165,225 +174,20 @@ def test_task_tool_emits_running_and_completed_events(monkeypatch): assert captured["executor_kwargs"]["thread_id"] == "thread-1" assert captured["executor_kwargs"]["parent_model"] == "ark-model" assert captured["executor_kwargs"]["config"].max_turns == 7 - # Skills are no longer appended to system_prompt; they are loaded per-session - # by SubagentExecutor and injected as conversation items (Codex pattern). - assert captured["executor_kwargs"]["config"].system_prompt == "Base system prompt" + # Skills are now loaded per-session by SubagentExecutor (mirroring Codex's pattern); + # task_tool no longer appends them to ``system_prompt`` here. + assert "Skills Appendix" not in captured["executor_kwargs"]["config"].system_prompt - get_available_tools.assert_called_once_with(model_name="ark-model", groups=None, subagent_enabled=False) + from unittest.mock import ANY + + get_available_tools.assert_called_once_with(model_name="ark-model", groups=ANY, subagent_enabled=False, app_config=ANY) event_types = [e["type"] for e in events] assert event_types == ["task_started", "task_running", "task_running", "task_completed"] assert events[-1]["result"] == "all done" -def test_task_tool_propagates_tool_groups_to_subagent(monkeypatch): - """Verify tool_groups from parent metadata are passed to get_available_tools(groups=...).""" - config = _make_subagent_config() - parent_tool_groups = ["file:read", "file:write", "bash"] - runtime = SimpleNamespace( - state={ - "sandbox": {"sandbox_id": "local"}, - "thread_data": {"workspace_path": "/tmp/workspace"}, - }, - context={"thread_id": "thread-1"}, - config={"metadata": {"model_name": "ark-model", "trace_id": "trace-1", "tool_groups": parent_tool_groups}}, - ) - events = [] - get_available_tools = MagicMock(return_value=["tool-a"]) - - class DummyExecutor: - def __init__(self, **kwargs): - pass - - def execute_async(self, prompt, task_id=None): - return task_id or "generated-task-id" - - monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus) - monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor) - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr( - task_tool_module, - "get_background_task_result", - lambda _: _make_result(FakeSubagentStatus.COMPLETED, result="done"), - ) - monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) - monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) - monkeypatch.setattr("deerflow.tools.get_available_tools", get_available_tools) - - output = _run_task_tool( - runtime=runtime, - description="执行任务", - prompt="file work only", - subagent_type="general-purpose", - tool_call_id="tc-groups", - ) - - assert output == "Task Succeeded. Result: done" - # The key assertion: groups should be propagated from parent metadata - get_available_tools.assert_called_once_with(model_name="ark-model", groups=parent_tool_groups, subagent_enabled=False) - - -def test_task_tool_inherits_parent_skill_allowlist_for_default_subagent(monkeypatch): - config = _make_subagent_config() - runtime = _make_runtime() - runtime.config["metadata"]["available_skills"] = ["safe-skill"] - events = [] - captured = {} - - class DummyExecutor: - def __init__(self, **kwargs): - captured["config"] = kwargs["config"] - - def execute_async(self, prompt, task_id=None): - return task_id or "generated-task-id" - - monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus) - monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor) - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr( - task_tool_module, - "get_background_task_result", - lambda _: _make_result(FakeSubagentStatus.COMPLETED, result="done"), - ) - monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) - monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) - monkeypatch.setattr("deerflow.tools.get_available_tools", MagicMock(return_value=[])) - - output = _run_task_tool( - runtime=runtime, - description="执行任务", - prompt="use skills", - subagent_type="general-purpose", - tool_call_id="tc-skills", - ) - - assert output == "Task Succeeded. Result: done" - assert captured["config"].skills == ["safe-skill"] - - -def test_task_tool_intersects_parent_and_subagent_skill_allowlists(monkeypatch): - config = _make_subagent_config() - config = SubagentConfig( - name=config.name, - description=config.description, - system_prompt=config.system_prompt, - max_turns=config.max_turns, - timeout_seconds=config.timeout_seconds, - skills=["safe-skill", "other-skill"], - ) - runtime = _make_runtime() - runtime.config["metadata"]["available_skills"] = ["safe-skill"] - events = [] - captured = {} - - class DummyExecutor: - def __init__(self, **kwargs): - captured["config"] = kwargs["config"] - - def execute_async(self, prompt, task_id=None): - return task_id or "generated-task-id" - - monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus) - monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor) - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr( - task_tool_module, - "get_background_task_result", - lambda _: _make_result(FakeSubagentStatus.COMPLETED, result="done"), - ) - monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) - monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) - monkeypatch.setattr("deerflow.tools.get_available_tools", MagicMock(return_value=[])) - - output = _run_task_tool( - runtime=runtime, - description="执行任务", - prompt="use skills", - subagent_type="general-purpose", - tool_call_id="tc-skills-intersection", - ) - - assert output == "Task Succeeded. Result: done" - assert captured["config"].skills == ["safe-skill"] - - -def test_task_tool_no_tool_groups_passes_none(monkeypatch): - """Verify that when metadata has no tool_groups, groups=None is passed (backward compat).""" - config = _make_subagent_config() - # Default _make_runtime() has no tool_groups in metadata - runtime = _make_runtime() - events = [] - get_available_tools = MagicMock(return_value=[]) - - class DummyExecutor: - def __init__(self, **kwargs): - pass - - def execute_async(self, prompt, task_id=None): - return task_id or "generated-task-id" - - monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus) - monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor) - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr( - task_tool_module, - "get_background_task_result", - lambda _: _make_result(FakeSubagentStatus.COMPLETED, result="ok"), - ) - monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) - monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) - monkeypatch.setattr("deerflow.tools.get_available_tools", get_available_tools) - - output = _run_task_tool( - runtime=runtime, - description="执行任务", - prompt="normal work", - subagent_type="general-purpose", - tool_call_id="tc-no-groups", - ) - - assert output == "Task Succeeded. Result: ok" - # No tool_groups in metadata → groups=None (default behavior preserved) - get_available_tools.assert_called_once_with(model_name="ark-model", groups=None, subagent_enabled=False) - - -def test_task_tool_runtime_none_passes_groups_none(monkeypatch): - """Verify that when runtime is None, groups=None is passed (e.g., unknown subagent path exits early, but tools still load correctly).""" - config = _make_subagent_config() - events = [] - get_available_tools = MagicMock(return_value=[]) - - class DummyExecutor: - def __init__(self, **kwargs): - pass - - def execute_async(self, prompt, task_id=None): - return task_id or "generated-task-id" - - monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus) - monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor) - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr( - task_tool_module, - "get_background_task_result", - lambda _: _make_result(FakeSubagentStatus.COMPLETED, result="ok"), - ) - monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) - monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) - monkeypatch.setattr("deerflow.tools.get_available_tools", get_available_tools) - - output = _run_task_tool( - runtime=None, - description="执行任务", - prompt="no runtime", - subagent_type="general-purpose", - tool_call_id="tc-no-runtime", - ) - - assert output == "Task Succeeded. Result: ok" - # runtime is None → metadata is empty dict → groups=None - get_available_tools.assert_called_once_with(model_name=None, groups=None, subagent_enabled=False) - +def test_task_tool_returns_failed_message(monkeypatch): config = _make_subagent_config() events = [] @@ -393,12 +197,11 @@ def test_task_tool_runtime_none_passes_groups_none(monkeypatch): "SubagentExecutor", type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda *a, **k: config) monkeypatch.setattr( task_tool_module, "get_background_task_result", - lambda _: _make_result(FakeSubagentStatus.FAILED, error="subagent crashed"), + lambda *a, **k: _make_result(FakeSubagentStatus.FAILED, error="subagent crashed"), ) monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) @@ -427,12 +230,11 @@ def test_task_tool_returns_timed_out_message(monkeypatch): "SubagentExecutor", type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda *a, **k: config) monkeypatch.setattr( task_tool_module, "get_background_task_result", - lambda _: _make_result(FakeSubagentStatus.TIMED_OUT, error="timeout"), + lambda *a, **k: _make_result(FakeSubagentStatus.TIMED_OUT, error="timeout"), ) monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) @@ -463,12 +265,11 @@ def test_task_tool_polling_safety_timeout(monkeypatch): "SubagentExecutor", type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda *a, **k: config) monkeypatch.setattr( task_tool_module, "get_background_task_result", - lambda _: _make_result(FakeSubagentStatus.RUNNING, ai_messages=[]), + lambda *a, **k: _make_result(FakeSubagentStatus.RUNNING, ai_messages=[]), ) monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) @@ -499,12 +300,11 @@ def test_cleanup_called_on_completed(monkeypatch): "SubagentExecutor", type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda *a, **k: config) monkeypatch.setattr( task_tool_module, "get_background_task_result", - lambda _: _make_result(FakeSubagentStatus.COMPLETED, result="done"), + lambda *a, **k: _make_result(FakeSubagentStatus.COMPLETED, result="done"), ) monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) @@ -539,12 +339,11 @@ def test_cleanup_called_on_failed(monkeypatch): "SubagentExecutor", type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda *a, **k: config) monkeypatch.setattr( task_tool_module, "get_background_task_result", - lambda _: _make_result(FakeSubagentStatus.FAILED, error="error"), + lambda *a, **k: _make_result(FakeSubagentStatus.FAILED, error="error"), ) monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) @@ -579,12 +378,11 @@ def test_cleanup_called_on_timed_out(monkeypatch): "SubagentExecutor", type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda *a, **k: config) monkeypatch.setattr( task_tool_module, "get_background_task_result", - lambda _: _make_result(FakeSubagentStatus.TIMED_OUT, error="timeout"), + lambda *a, **k: _make_result(FakeSubagentStatus.TIMED_OUT, error="timeout"), ) monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) @@ -626,12 +424,11 @@ def test_cleanup_not_called_on_polling_safety_timeout(monkeypatch): "SubagentExecutor", type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda *a, **k: config) monkeypatch.setattr( task_tool_module, "get_background_task_result", - lambda _: _make_result(FakeSubagentStatus.RUNNING, ai_messages=[]), + lambda *a, **k: _make_result(FakeSubagentStatus.RUNNING, ai_messages=[]), ) monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) @@ -679,8 +476,7 @@ def test_cleanup_scheduled_on_cancellation(monkeypatch): "SubagentExecutor", type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda *a, **k: config) monkeypatch.setattr(task_tool_module, "get_background_task_result", get_result) monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) monkeypatch.setattr(task_tool_module.asyncio, "sleep", cancel_on_first_sleep) @@ -730,12 +526,11 @@ def test_cancelled_cleanup_stops_after_timeout(monkeypatch): "SubagentExecutor", type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda *a, **k: config) monkeypatch.setattr( task_tool_module, "get_background_task_result", - lambda _: _make_result(FakeSubagentStatus.RUNNING, ai_messages=[]), + lambda *a, **k: _make_result(FakeSubagentStatus.RUNNING, ai_messages=[]), ) monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) monkeypatch.setattr(task_tool_module.asyncio, "sleep", cancel_on_first_sleep) @@ -785,12 +580,11 @@ def test_cancellation_calls_request_cancel(monkeypatch): "SubagentExecutor", type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda *a, **k: config) monkeypatch.setattr( task_tool_module, "get_background_task_result", - lambda _: _make_result(FakeSubagentStatus.RUNNING, ai_messages=[]), + lambda *a, **k: _make_result(FakeSubagentStatus.RUNNING, ai_messages=[]), ) monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) monkeypatch.setattr(task_tool_module.asyncio, "sleep", cancel_on_first_sleep) @@ -843,9 +637,8 @@ def test_task_tool_returns_cancelled_message(monkeypatch): "SubagentExecutor", type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) - monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - - monkeypatch.setattr(task_tool_module, "get_background_task_result", lambda _: next(responses)) + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda *a, **k: config) + monkeypatch.setattr(task_tool_module, "get_background_task_result", lambda *a, **k: next(responses)) monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: []) diff --git a/backend/tests/test_thread_data_middleware.py b/backend/tests/test_thread_data_middleware.py index ef3e440f7..4cc289b2d 100644 --- a/backend/tests/test_thread_data_middleware.py +++ b/backend/tests/test_thread_data_middleware.py @@ -1,58 +1,55 @@ import pytest -from langgraph.runtime import Runtime from deerflow.agents.middlewares.thread_data_middleware import ThreadDataMiddleware +from deerflow.config.app_config import AppConfig +from deerflow.config.deer_flow_context import DeerFlowContext +from deerflow.config.sandbox_config import SandboxConfig def _as_posix(path: str) -> str: return path.replace("\\", "/") +def _make_context(thread_id: str) -> DeerFlowContext: + return DeerFlowContext( + app_config=AppConfig(sandbox=SandboxConfig(use="test")), + thread_id=thread_id, + ) + + class TestThreadDataMiddleware: def test_before_agent_returns_paths_when_thread_id_present_in_context(self, tmp_path): middleware = ThreadDataMiddleware(base_dir=str(tmp_path), lazy_init=True) + from langgraph.runtime import Runtime - result = middleware.before_agent(state={}, runtime=Runtime(context={"thread_id": "thread-123"})) + result = middleware.before_agent(state={}, runtime=Runtime(context=_make_context("thread-123"))) assert result is not None assert _as_posix(result["thread_data"]["workspace_path"]).endswith("threads/thread-123/user-data/workspace") assert _as_posix(result["thread_data"]["uploads_path"]).endswith("threads/thread-123/user-data/uploads") assert _as_posix(result["thread_data"]["outputs_path"]).endswith("threads/thread-123/user-data/outputs") - def test_before_agent_uses_thread_id_from_configurable_when_context_is_none(self, tmp_path, monkeypatch): + def test_before_agent_uses_thread_id_from_context(self, tmp_path): middleware = ThreadDataMiddleware(base_dir=str(tmp_path), lazy_init=True) - runtime = Runtime(context=None) - monkeypatch.setattr( - "deerflow.agents.middlewares.thread_data_middleware.get_config", - lambda: {"configurable": {"thread_id": "thread-from-config"}}, - ) + from langgraph.runtime import Runtime - result = middleware.before_agent(state={}, runtime=runtime) + result = middleware.before_agent(state={}, runtime=Runtime(context=_make_context("thread-from-config"))) assert result is not None assert _as_posix(result["thread_data"]["workspace_path"]).endswith("threads/thread-from-config/user-data/workspace") - assert runtime.context is None - def test_before_agent_uses_thread_id_from_configurable_when_context_missing_thread_id(self, tmp_path, monkeypatch): + def test_before_agent_uses_thread_id_from_typed_context(self, tmp_path): middleware = ThreadDataMiddleware(base_dir=str(tmp_path), lazy_init=True) - runtime = Runtime(context={}) - monkeypatch.setattr( - "deerflow.agents.middlewares.thread_data_middleware.get_config", - lambda: {"configurable": {"thread_id": "thread-from-config"}}, - ) + from langgraph.runtime import Runtime - result = middleware.before_agent(state={}, runtime=runtime) + result = middleware.before_agent(state={}, runtime=Runtime(context=_make_context("thread-from-dict"))) assert result is not None - assert _as_posix(result["thread_data"]["uploads_path"]).endswith("threads/thread-from-config/user-data/uploads") - assert runtime.context == {} + assert _as_posix(result["thread_data"]["uploads_path"]).endswith("threads/thread-from-dict/user-data/uploads") - def test_before_agent_raises_clear_error_when_thread_id_missing_everywhere(self, tmp_path, monkeypatch): + def test_before_agent_raises_clear_error_when_thread_id_missing(self, tmp_path): middleware = ThreadDataMiddleware(base_dir=str(tmp_path), lazy_init=True) - monkeypatch.setattr( - "deerflow.agents.middlewares.thread_data_middleware.get_config", - lambda: {"configurable": {}}, - ) + from langgraph.runtime import Runtime - with pytest.raises(ValueError, match="Thread ID is required in runtime context or config.configurable"): - middleware.before_agent(state={}, runtime=Runtime(context=None)) + with pytest.raises(ValueError, match="Thread ID is required"): + middleware.before_agent(state={}, runtime=Runtime(context=_make_context(""))) diff --git a/backend/tests/test_thread_run_messages_pagination.py b/backend/tests/test_thread_run_messages_pagination.py index 00e354a34..f00100cad 100644 --- a/backend/tests/test_thread_run_messages_pagination.py +++ b/backend/tests/test_thread_run_messages_pagination.py @@ -1,14 +1,15 @@ """Tests for paginated GET /api/threads/{thread_id}/runs/{run_id}/messages endpoint.""" - from __future__ import annotations from unittest.mock import AsyncMock, MagicMock +import pytest from _router_auth_helpers import make_authed_test_app from fastapi.testclient import TestClient from app.gateway.routers import thread_runs + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -77,8 +78,7 @@ def test_after_seq_forwarded_to_event_store(): response = client.get("/api/threads/thread-3/runs/run-3/messages?after_seq=5") assert response.status_code == 200 event_store.list_messages_by_run.assert_awaited_once_with( - "thread-3", - "run-3", + "thread-3", "run-3", limit=51, # default limit(50) + 1 before_seq=None, after_seq=5, @@ -94,8 +94,7 @@ def test_before_seq_forwarded_to_event_store(): response = client.get("/api/threads/thread-4/runs/run-4/messages?before_seq=10") assert response.status_code == 200 event_store.list_messages_by_run.assert_awaited_once_with( - "thread-4", - "run-4", + "thread-4", "run-4", limit=51, before_seq=10, after_seq=None, @@ -111,8 +110,7 @@ def test_custom_limit_forwarded_to_event_store(): response = client.get("/api/threads/thread-5/runs/run-5/messages?limit=10") assert response.status_code == 200 event_store.list_messages_by_run.assert_awaited_once_with( - "thread-5", - "run-5", + "thread-5", "run-5", limit=11, # 10 + 1 before_seq=None, after_seq=None, diff --git a/backend/tests/test_title_generation.py b/backend/tests/test_title_generation.py index 53b0a5010..ba5b8e856 100644 --- a/backend/tests/test_title_generation.py +++ b/backend/tests/test_title_generation.py @@ -3,7 +3,7 @@ import pytest from deerflow.agents.middlewares.title_middleware import TitleMiddleware -from deerflow.config.title_config import TitleConfig, get_title_config, set_title_config +from deerflow.config.title_config import TitleConfig class TestTitleConfig: @@ -44,21 +44,6 @@ class TestTitleConfig: with pytest.raises(ValueError): TitleConfig(max_chars=201) - def test_get_set_config(self): - """Test global config getter and setter.""" - original_config = get_title_config() - - # Set new config - new_config = TitleConfig(enabled=False, max_words=10) - set_title_config(new_config) - - # Verify it was set - assert get_title_config().enabled is False - assert get_title_config().max_words == 10 - - # Restore original config - set_title_config(original_config) - class TestTitleMiddleware: """Tests for TitleMiddleware.""" @@ -68,23 +53,3 @@ class TestTitleMiddleware: middleware = TitleMiddleware() assert middleware is not None assert middleware.state_schema is not None - - # TODO: Add integration tests with mock Runtime - # def test_should_generate_title(self): - # """Test title generation trigger logic.""" - # pass - - # def test_generate_title(self): - # """Test title generation.""" - # pass - - # def test_after_agent_hook(self): - # """Test after_agent hook.""" - # pass - - -# TODO: Add integration tests -# - Test with real LangGraph runtime -# - Test title persistence with checkpointer -# - Test fallback behavior when LLM fails -# - Test concurrent title generation diff --git a/backend/tests/test_title_middleware_core_logic.py b/backend/tests/test_title_middleware_core_logic.py index afd10f2b3..06415ef49 100644 --- a/backend/tests/test_title_middleware_core_logic.py +++ b/backend/tests/test_title_middleware_core_logic.py @@ -1,38 +1,32 @@ """Core behavior tests for TitleMiddleware.""" import asyncio +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock from langchain_core.messages import AIMessage, HumanMessage from deerflow.agents.middlewares import title_middleware as title_middleware_module from deerflow.agents.middlewares.title_middleware import TitleMiddleware -from deerflow.config.title_config import TitleConfig, get_title_config, set_title_config +from deerflow.config.app_config import AppConfig +from deerflow.config.deer_flow_context import DeerFlowContext +from deerflow.config.sandbox_config import SandboxConfig +from deerflow.config.title_config import TitleConfig -def _clone_title_config(config: TitleConfig) -> TitleConfig: - # Avoid mutating shared global config objects across tests. - return TitleConfig(**config.model_dump()) +def _make_title_config(**overrides) -> TitleConfig: + return TitleConfig(**overrides) -def _set_test_title_config(**overrides) -> TitleConfig: - config = _clone_title_config(get_title_config()) - for key, value in overrides.items(): - setattr(config, key, value) - set_title_config(config) - return config +def _make_runtime(**title_overrides) -> SimpleNamespace: + """Build a runtime whose context carries a DeerFlowContext with the given TitleConfig.""" + app_config = AppConfig(sandbox=SandboxConfig(use="test"), title=TitleConfig(**title_overrides)) + ctx = DeerFlowContext(app_config=app_config, thread_id="t1") + return SimpleNamespace(context=ctx) class TestTitleMiddlewareCoreLogic: - def setup_method(self): - # Title config is a global singleton; snapshot and restore for test isolation. - self._original = _clone_title_config(get_title_config()) - - def teardown_method(self): - set_title_config(self._original) - def test_should_generate_title_for_first_complete_exchange(self): - _set_test_title_config(enabled=True) middleware = TitleMiddleware() state = { "messages": [ @@ -41,27 +35,24 @@ class TestTitleMiddlewareCoreLogic: ] } - assert middleware._should_generate_title(state) is True + assert middleware._should_generate_title(state, _make_title_config(enabled=True)) is True def test_should_not_generate_title_when_disabled_or_already_set(self): middleware = TitleMiddleware() - _set_test_title_config(enabled=False) disabled_state = { "messages": [HumanMessage(content="Q"), AIMessage(content="A")], "title": None, } - assert middleware._should_generate_title(disabled_state) is False + assert middleware._should_generate_title(disabled_state, _make_title_config(enabled=False)) is False - _set_test_title_config(enabled=True) titled_state = { "messages": [HumanMessage(content="Q"), AIMessage(content="A")], "title": "Existing Title", } - assert middleware._should_generate_title(titled_state) is False + assert middleware._should_generate_title(titled_state, _make_title_config(enabled=True)) is False def test_should_not_generate_title_after_second_user_turn(self): - _set_test_title_config(enabled=True) middleware = TitleMiddleware() state = { "messages": [ @@ -72,10 +63,9 @@ class TestTitleMiddlewareCoreLogic: ] } - assert middleware._should_generate_title(state) is False + assert middleware._should_generate_title(state, _make_title_config(enabled=True)) is False def test_generate_title_uses_async_model_and_respects_max_chars(self, monkeypatch): - _set_test_title_config(max_chars=12) middleware = TitleMiddleware() model = MagicMock() model.ainvoke = AsyncMock(return_value=AIMessage(content="短标题")) @@ -87,11 +77,13 @@ class TestTitleMiddlewareCoreLogic: AIMessage(content="好的,先确认需求"), ] } - result = asyncio.run(middleware._agenerate_title_result(state)) + result = asyncio.run(middleware._agenerate_title_result(state, AppConfig(sandbox=SandboxConfig(use="test"), title=_make_title_config(max_chars=12)))) title = result["title"] assert title == "短标题" - title_middleware_module.create_chat_model.assert_called_once_with(thinking_enabled=False) + from unittest.mock import ANY + + title_middleware_module.create_chat_model.assert_called_once_with(thinking_enabled=False, app_config=ANY) model.ainvoke.assert_awaited_once() assert model.ainvoke.await_args.kwargs["config"] == { "run_name": "title_agent", @@ -99,7 +91,6 @@ class TestTitleMiddlewareCoreLogic: } def test_generate_title_normalizes_structured_message_content(self, monkeypatch): - _set_test_title_config(max_chars=20) middleware = TitleMiddleware() model = MagicMock() model.ainvoke = AsyncMock(return_value=AIMessage(content="请帮我总结这段代码")) @@ -112,13 +103,12 @@ class TestTitleMiddlewareCoreLogic: ] } - result = asyncio.run(middleware._agenerate_title_result(state)) + result = asyncio.run(middleware._agenerate_title_result(state, AppConfig(sandbox=SandboxConfig(use="test"), title=_make_title_config(max_chars=20)))) title = result["title"] assert title == "请帮我总结这段代码" def test_generate_title_fallback_for_long_message(self, monkeypatch): - _set_test_title_config(max_chars=20) middleware = TitleMiddleware() model = MagicMock() model.ainvoke = AsyncMock(side_effect=RuntimeError("model unavailable")) @@ -130,7 +120,7 @@ class TestTitleMiddlewareCoreLogic: AIMessage(content="收到"), ] } - result = asyncio.run(middleware._agenerate_title_result(state)) + result = asyncio.run(middleware._agenerate_title_result(state, AppConfig(sandbox=SandboxConfig(use="test"), title=_make_title_config(max_chars=20)))) title = result["title"] # Assert behavior (truncated fallback + ellipsis) without overfitting exact text. @@ -141,25 +131,24 @@ class TestTitleMiddlewareCoreLogic: middleware = TitleMiddleware() monkeypatch.setattr(middleware, "_agenerate_title_result", AsyncMock(return_value={"title": "异步标题"})) - result = asyncio.run(middleware.aafter_model({"messages": []}, runtime=MagicMock())) + result = asyncio.run(middleware.aafter_model({"messages": []}, runtime=_make_runtime())) assert result == {"title": "异步标题"} monkeypatch.setattr(middleware, "_agenerate_title_result", AsyncMock(return_value=None)) - assert asyncio.run(middleware.aafter_model({"messages": []}, runtime=MagicMock())) is None + assert asyncio.run(middleware.aafter_model({"messages": []}, runtime=_make_runtime())) is None def test_after_model_sync_delegates_to_sync_helper(self, monkeypatch): middleware = TitleMiddleware() monkeypatch.setattr(middleware, "_generate_title_result", MagicMock(return_value={"title": "同步标题"})) - result = middleware.after_model({"messages": []}, runtime=MagicMock()) + result = middleware.after_model({"messages": []}, runtime=_make_runtime()) assert result == {"title": "同步标题"} monkeypatch.setattr(middleware, "_generate_title_result", MagicMock(return_value=None)) - assert middleware.after_model({"messages": []}, runtime=MagicMock()) is None + assert middleware.after_model({"messages": []}, runtime=_make_runtime()) is None def test_sync_generate_title_uses_fallback_without_model(self): """Sync path avoids LLM calls and derives a local fallback title.""" - _set_test_title_config(max_chars=20) middleware = TitleMiddleware() state = { @@ -168,12 +157,11 @@ class TestTitleMiddlewareCoreLogic: AIMessage(content="好的"), ] } - result = middleware._generate_title_result(state) + result = middleware._generate_title_result(state, _make_title_config(max_chars=20)) assert result == {"title": "请帮我写测试"} def test_sync_generate_title_respects_fallback_truncation(self): """Sync fallback path still respects max_chars truncation rules.""" - _set_test_title_config(max_chars=50) middleware = TitleMiddleware() state = { @@ -182,7 +170,7 @@ class TestTitleMiddlewareCoreLogic: AIMessage(content="回复"), ] } - result = middleware._generate_title_result(state) + result = middleware._generate_title_result(state, _make_title_config(max_chars=50)) assert result["title"].endswith("...") assert result["title"].startswith("这是一个非常长的问题描述") diff --git a/backend/tests/test_token_usage.py b/backend/tests/test_token_usage.py index bec9e9ac3..977756157 100644 --- a/backend/tests/test_token_usage.py +++ b/backend/tests/test_token_usage.py @@ -154,8 +154,7 @@ class TestStreamUsageIntegration: """Test that stream() emits usage_metadata in messages-tuple and end events.""" def _make_client(self): - with patch("deerflow.client.get_app_config", return_value=_mock_app_config()): - return DeerFlowClient() + return DeerFlowClient() def test_stream_emits_usage_in_messages_tuple(self): """messages-tuple AI event should include usage_metadata when present.""" diff --git a/backend/tests/test_tool_deduplication.py b/backend/tests/test_tool_deduplication.py index 35ec0bea6..505cd91c1 100644 --- a/backend/tests/test_tool_deduplication.py +++ b/backend/tests/test_tool_deduplication.py @@ -56,27 +56,25 @@ def _make_minimal_config(tools): return config -@patch("deerflow.tools.tools.get_app_config") @patch("deerflow.tools.tools.is_host_bash_allowed", return_value=True) @patch("deerflow.tools.tools.reset_deferred_registry") -def test_no_duplicates_returned(mock_reset, mock_bash, mock_cfg): +def test_no_duplicates_returned(mock_reset, mock_bash): """get_available_tools() never returns two tools with the same name.""" - mock_cfg.return_value = _make_minimal_config([]) + cfg = _make_minimal_config([]) # Patch the builtin tools so we control exactly what comes back. with patch("deerflow.tools.tools.BUILTIN_TOOLS", [_tool_alpha, _tool_alpha_dup, _tool_beta]): - result = get_available_tools(include_mcp=False) + result = get_available_tools(include_mcp=False, app_config=cfg) names = [t.name for t in result] assert len(names) == len(set(names)), f"Duplicate names detected: {names}" -@patch("deerflow.tools.tools.get_app_config") @patch("deerflow.tools.tools.is_host_bash_allowed", return_value=True) @patch("deerflow.tools.tools.reset_deferred_registry") -def test_first_occurrence_wins(mock_reset, mock_bash, mock_cfg): +def test_first_occurrence_wins(mock_reset, mock_bash): """When duplicates exist, the first occurrence is kept.""" - mock_cfg.return_value = _make_minimal_config([]) + cfg = _make_minimal_config([]) sentinel_alpha = MagicMock(spec=BaseTool, name="_sentinel") sentinel_alpha.name = _tool_alpha.name # same name @@ -84,23 +82,22 @@ def test_first_occurrence_wins(mock_reset, mock_bash, mock_cfg): sentinel_alpha_dup.name = _tool_alpha.name # same name — should be dropped with patch("deerflow.tools.tools.BUILTIN_TOOLS", [sentinel_alpha, sentinel_alpha_dup, _tool_beta]): - result = get_available_tools(include_mcp=False) + result = get_available_tools(include_mcp=False, app_config=cfg) returned_alpha = next(t for t in result if t.name == _tool_alpha.name) assert returned_alpha is sentinel_alpha -@patch("deerflow.tools.tools.get_app_config") @patch("deerflow.tools.tools.is_host_bash_allowed", return_value=True) @patch("deerflow.tools.tools.reset_deferred_registry") -def test_duplicate_triggers_warning(mock_reset, mock_bash, mock_cfg, caplog): +def test_duplicate_triggers_warning(mock_reset, mock_bash, caplog): """A warning is logged for every skipped duplicate.""" import logging - mock_cfg.return_value = _make_minimal_config([]) + cfg = _make_minimal_config([]) with patch("deerflow.tools.tools.BUILTIN_TOOLS", [_tool_alpha, _tool_alpha_dup]): with caplog.at_level(logging.WARNING, logger="deerflow.tools.tools"): - get_available_tools(include_mcp=False) + get_available_tools(include_mcp=False, app_config=cfg) assert any("Duplicate tool name" in r.message for r in caplog.records), "Expected a duplicate-tool warning in log output" diff --git a/backend/tests/test_tool_search.py b/backend/tests/test_tool_search.py index 428bfec3d..705a35339 100644 --- a/backend/tests/test_tool_search.py +++ b/backend/tests/test_tool_search.py @@ -8,7 +8,7 @@ import pytest from langchain_core.messages import ToolMessage from langchain_core.tools import tool as langchain_tool -from deerflow.config.tool_search_config import ToolSearchConfig, load_tool_search_config_from_dict +from deerflow.config.tool_search_config import ToolSearchConfig from deerflow.tools.builtins.tool_search import ( DeferredToolRegistry, get_deferred_registry, @@ -64,12 +64,12 @@ class TestToolSearchConfig: config = ToolSearchConfig(enabled=True) assert config.enabled is True - def test_load_from_dict(self): - config = load_tool_search_config_from_dict({"enabled": True}) + def test_validate_from_dict(self): + config = ToolSearchConfig.model_validate({"enabled": True}) assert config.enabled is True - def test_load_from_empty_dict(self): - config = load_tool_search_config_from_dict({}) + def test_validate_from_empty_dict(self): + config = ToolSearchConfig.model_validate({}) assert config.enabled is False @@ -266,48 +266,42 @@ class TestToolSearchTool: class TestDeferredToolsPromptSection: - @pytest.fixture(autouse=True) - def _mock_app_config(self, monkeypatch): + @pytest.fixture + def mock_config(self): """Provide a minimal AppConfig mock so tests don't need config.yaml.""" from unittest.mock import MagicMock from deerflow.config.tool_search_config import ToolSearchConfig - mock_config = MagicMock() - mock_config.tool_search = ToolSearchConfig() # disabled by default - monkeypatch.setattr("deerflow.config.get_app_config", lambda: mock_config) + config = MagicMock() + config.tool_search = ToolSearchConfig() # disabled by default + return config - def test_empty_when_disabled(self): + def test_empty_when_disabled(self, mock_config): from deerflow.agents.lead_agent.prompt import get_deferred_tools_prompt_section # tool_search.enabled defaults to False - section = get_deferred_tools_prompt_section() + section = get_deferred_tools_prompt_section(mock_config) assert section == "" - def test_empty_when_enabled_but_no_registry(self, monkeypatch): + def test_empty_when_enabled_but_no_registry(self, mock_config): from deerflow.agents.lead_agent.prompt import get_deferred_tools_prompt_section - from deerflow.config import get_app_config - - monkeypatch.setattr(get_app_config().tool_search, "enabled", True) - section = get_deferred_tools_prompt_section() + mock_config.tool_search = ToolSearchConfig(enabled=True) + section = get_deferred_tools_prompt_section(mock_config) assert section == "" - def test_empty_when_enabled_but_empty_registry(self, monkeypatch): + def test_empty_when_enabled_but_empty_registry(self, mock_config): from deerflow.agents.lead_agent.prompt import get_deferred_tools_prompt_section - from deerflow.config import get_app_config - - monkeypatch.setattr(get_app_config().tool_search, "enabled", True) + mock_config.tool_search = ToolSearchConfig(enabled=True) set_deferred_registry(DeferredToolRegistry()) - section = get_deferred_tools_prompt_section() + section = get_deferred_tools_prompt_section(mock_config) assert section == "" - def test_lists_tool_names(self, registry, monkeypatch): + def test_lists_tool_names(self, registry, mock_config): from deerflow.agents.lead_agent.prompt import get_deferred_tools_prompt_section - from deerflow.config import get_app_config - - monkeypatch.setattr(get_app_config().tool_search, "enabled", True) + mock_config.tool_search = ToolSearchConfig(enabled=True) set_deferred_registry(registry) - section = get_deferred_tools_prompt_section() + section = get_deferred_tools_prompt_section(mock_config) assert "" in section assert "" in section assert "github_create_issue" in section diff --git a/backend/tests/test_uploads_middleware_core_logic.py b/backend/tests/test_uploads_middleware_core_logic.py index 6e39cda46..89a04a15d 100644 --- a/backend/tests/test_uploads_middleware_core_logic.py +++ b/backend/tests/test_uploads_middleware_core_logic.py @@ -13,7 +13,10 @@ from unittest.mock import MagicMock from langchain_core.messages import AIMessage, HumanMessage from deerflow.agents.middlewares.uploads_middleware import UploadsMiddleware +from deerflow.config.app_config import AppConfig +from deerflow.config.deer_flow_context import DeerFlowContext from deerflow.config.paths import Paths +from deerflow.config.sandbox_config import SandboxConfig THREAD_ID = "thread-abc123" @@ -23,13 +26,20 @@ THREAD_ID = "thread-abc123" # --------------------------------------------------------------------------- +def _make_context(thread_id: str) -> DeerFlowContext: + return DeerFlowContext( + app_config=AppConfig(sandbox=SandboxConfig(use="test")), + thread_id=thread_id, + ) + + def _middleware(tmp_path: Path) -> UploadsMiddleware: return UploadsMiddleware(base_dir=str(tmp_path)) def _runtime(thread_id: str | None = THREAD_ID) -> MagicMock: rt = MagicMock() - rt.context = {"thread_id": thread_id} + rt.context = _make_context(thread_id or "") return rt diff --git a/backend/tests/test_user_context.py b/backend/tests/test_user_context.py index 111ffb679..8c7cbd13c 100644 --- a/backend/tests/test_user_context.py +++ b/backend/tests/test_user_context.py @@ -10,8 +10,8 @@ from types import SimpleNamespace import pytest from deerflow.runtime.user_context import ( - DEFAULT_USER_ID, CurrentUser, + DEFAULT_USER_ID, get_current_user, get_effective_user_id, require_current_user, @@ -100,7 +100,6 @@ def test_effective_user_id_returns_user_id_when_set(): def test_effective_user_id_coerces_to_str(): """User.id might be a UUID object; must come back as str.""" import uuid - uid = uuid.uuid4() user = SimpleNamespace(id=uid) diff --git a/backend/uv.lock b/backend/uv.lock index e0ea058c0..485dcbb02 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -262,6 +262,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -878,8 +918,8 @@ requires-dist = [ { name = "langchain-ollama", marker = "extra == 'ollama'", specifier = ">=0.3.0" }, { name = "langchain-openai", specifier = ">=1.2.1" }, { name = "langfuse", specifier = ">=3.4.1" }, - { name = "langgraph", specifier = ">=1.1.9" }, - { name = "langgraph-api", specifier = ">=0.8.1" }, + { name = "langgraph", specifier = ">=1.0.6,<1.0.10" }, + { name = "langgraph-api", specifier = ">=0.7.0,<0.8.0" }, { name = "langgraph-checkpoint-postgres", marker = "extra == 'postgres'", specifier = ">=3.0.5" }, { name = "langgraph-checkpoint-sqlite", specifier = ">=3.0.3" }, { name = "langgraph-cli", specifier = ">=0.4.24" }, @@ -926,6 +966,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + [[package]] name = "docstring-parser" version = "0.18.0" @@ -1268,6 +1317,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, ] +[[package]] +name = "greenlet" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" }, + { url = "https://files.pythonhosted.org/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/44/57/eae2cac10421feae6c0987e3dc106c6d86262b1cb379e171b017aba893a6/greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d", size = 624901, upload-time = "2026-04-08T16:40:38.981Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8a/0e73c9b94f31d1cc257fe79a0eff621674141cdae7d6d00f40de378a1e42/greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077", size = 423927, upload-time = "2026-04-08T16:43:05.293Z" }, + { url = "https://files.pythonhosted.org/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" }, + { url = "https://files.pythonhosted.org/packages/b7/47/6c41314bac56e71436ce551c7fbe3cc830ed857e6aa9708dbb9c65142eb6/greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e", size = 235599, upload-time = "2026-04-08T15:52:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" }, + { url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", size = 628455, upload-time = "2026-04-08T16:40:40.698Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" }, + { url = "https://files.pythonhosted.org/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", size = 426238, upload-time = "2026-04-08T16:43:06.865Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" }, + { url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" }, + { url = "https://files.pythonhosted.org/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" }, + { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, + { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, + { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" }, + { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, + { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, + { url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" }, + { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, + { url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, +] + [[package]] name = "grpcio" version = "1.78.0" @@ -1914,6 +2010,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/87/b0f98b33a67204bca9d5619bcd9574222f6b025cf3c125eedcec9a50ecbc/langgraph_checkpoint_postgres-3.0.5-py3-none-any.whl", hash = "sha256:86d7040a88fd70087eaafb72251d796696a0a2d856168f5c11ef620771411552", size = 42907, upload-time = "2026-03-18T21:25:28.75Z" }, ] +[[package]] +name = "langgraph-checkpoint-postgres" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langgraph-checkpoint" }, + { name = "orjson" }, + { name = "psycopg" }, + { name = "psycopg-pool" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7a/8f439966643d32111248a225e6cb33a182d07c90de780c4dbfc1e0377832/langgraph_checkpoint_postgres-3.0.5.tar.gz", hash = "sha256:a8fd7278a63f4f849b5cbc7884a15ca8f41e7d5f7467d0a66b31e8c24492f7eb", size = 127856, upload-time = "2026-03-18T21:25:29.785Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/87/b0f98b33a67204bca9d5619bcd9574222f6b025cf3c125eedcec9a50ecbc/langgraph_checkpoint_postgres-3.0.5-py3-none-any.whl", hash = "sha256:86d7040a88fd70087eaafb72251d796696a0a2d856168f5c11ef620771411552", size = 42907, upload-time = "2026-03-18T21:25:28.75Z" }, +] + [[package]] name = "langgraph-checkpoint-sqlite" version = "3.0.3" @@ -3170,6 +3281,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" }, + { url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" }, + { url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" }, + { url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" }, + { url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" }, + { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, + { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, + { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, + { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, + { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" }, + { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" }, + { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" }, + { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, +] + +[[package]] +name = "psycopg-pool" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/9a/9470d013d0d50af0da9c4251614aeb3c1823635cab3edc211e3839db0bcf/psycopg_pool-3.3.0.tar.gz", hash = "sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5", size = 31606, upload-time = "2025-12-01T11:34:33.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" }, +] + [[package]] name = "pyasn1" version = "0.6.3" @@ -3996,6 +4177,57 @@ asyncio = [ { name = "greenlet" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.49" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" }, + { url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" }, + { url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" }, + { url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" }, + { url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, + { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, + { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, + { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, + { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, + { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + [[package]] name = "sqlite-vec" version = "0.1.9" diff --git a/backend/uv.toml b/backend/uv.toml new file mode 100644 index 000000000..7884c96f1 --- /dev/null +++ b/backend/uv.toml @@ -0,0 +1 @@ +index-url = "https://pypi.org/simple" diff --git a/config.example.yaml b/config.example.yaml index 0b72ab80f..366e755ed 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -816,14 +816,10 @@ skill_evolution: # Unified storage backend for LangGraph checkpointer and DeerFlow # application data (runs, threads metadata, feedback, etc.). # -# backend: memory -- No persistence, data lost on restart +# backend: memory -- No persistence, data lost on restart (default) # backend: sqlite -- Single-node deployment, files in sqlite_dir # backend: postgres -- Production multi-node deployment # -# If this section is omitted or empty in config.yaml, DeerFlow uses: -# backend: sqlite -# sqlite_dir: .deer-flow/data -# # SQLite mode uses a single deerflow.db file with WAL journal mode # for both checkpointer and application data. # diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 82cb62425..cc1c819b4 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -110,6 +110,58 @@ services: - deer-flow restart: unless-stopped + # ── LangGraph Server ─────────────────────────────────────────────────────── + # TODO: switch to langchain/langgraph-api (licensed) once a license key is available. + # For now, use `langgraph dev` (no license required) with the standard backend image. + langgraph: + build: + context: ../ + dockerfile: backend/Dockerfile + args: + APT_MIRROR: ${APT_MIRROR:-} + UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:0.7.20} + UV_INDEX_URL: ${UV_INDEX_URL:-https://pypi.org/simple} + UV_EXTRAS: ${UV_EXTRAS:-} + container_name: deer-flow-langgraph + command: sh -c 'cd /app/backend && args="--no-browser --no-reload --host 0.0.0.0 --port 2024 --n-jobs-per-worker $${LANGGRAPH_JOBS_PER_WORKER:-10}" && if [ "$${LANGGRAPH_ALLOW_BLOCKING:-0}" = "1" ]; then args="$$args --allow-blocking"; fi && uv run langgraph dev $$args' + volumes: + - ${DEER_FLOW_CONFIG_PATH}:/app/backend/config.yaml:ro + - ${DEER_FLOW_EXTENSIONS_CONFIG_PATH}:/app/backend/extensions_config.json:ro + - ${DEER_FLOW_HOME}:/app/backend/.deer-flow + - ../skills:/app/skills:ro + - ../backend/.langgraph_api:/app/backend/.langgraph_api + # DooD: same as gateway + - ${DEER_FLOW_DOCKER_SOCKET}:/var/run/docker.sock + # CLI auth directories for auto-auth (Claude Code + Codex CLI) + - type: bind + source: ${HOME:?HOME must be set}/.claude + target: /root/.claude + read_only: true + bind: + create_host_path: true + - type: bind + source: ${HOME:?HOME must be set}/.codex + target: /root/.codex + read_only: true + bind: + create_host_path: true + environment: + - CI=true + - DEER_FLOW_HOME=/app/backend/.deer-flow + - DEER_FLOW_CONFIG_PATH=/app/backend/config.yaml + - DEER_FLOW_EXTENSIONS_CONFIG_PATH=/app/backend/extensions_config.json + - DEER_FLOW_HOST_BASE_DIR=${DEER_FLOW_HOME} + - DEER_FLOW_HOST_SKILLS_PATH=${DEER_FLOW_REPO_ROOT}/skills + - DEER_FLOW_SANDBOX_HOST=host.docker.internal + # LangSmith tracing: set LANGSMITH_TRACING=true and LANGSMITH_API_KEY in .env to enable. + env_file: + - ../.env + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - deer-flow + restart: unless-stopped + # ── Sandbox Provisioner (optional, Kubernetes mode) ──────────────────────── provisioner: build: diff --git a/docs/CONFIG_DESIGN.zh.md b/docs/CONFIG_DESIGN.zh.md new file mode 100644 index 000000000..448bf2c2f --- /dev/null +++ b/docs/CONFIG_DESIGN.zh.md @@ -0,0 +1,301 @@ +# DeerFlow 配置系统设计 + +> 对应实现:[PR #2271](https://github.com/bytedance/deer-flow/pull/2271) · RFC [#1811](https://github.com/bytedance/deer-flow/issues/1811) · 归档 spec:[config-refactor-design](./plans/2026-04-12-config-refactor-design.md) + +## 1. 为什么要重构 + +重构前的 `deerflow/config/` 有三个结构性问题,凑在一起就是"全局可变状态 + 副作用耦合"的经典反模式: + +| 问题 | 具体表现 | +|------|----------| +| 双重真相 | 每个 sub-config 同时是 `AppConfig` 字段**和**模块级全局(`_memory_config` / `_title_config` …)。consumer 不知道该信哪个 | +| 副作用耦合 | `AppConfig.from_file()` 顺便 mutate 8 个 sub-module 的 globals(通过 `load_*_from_dict()`) | +| 隔离不完整 | 原有的 `ContextVar` 只罩住 `AppConfig` 本体,8 个 sub-config globals 漏在外面 | + +从类型论视角看:config 本应是一个**纯值对象(value object)**——构造一次、不变、可复制——但上面这套设计让它变成了"带全局状态的活对象",于是 test mutation、async 边界、热更新都会互相污染。 + +## 2. 核心设计原则 + +> **Config is a value object, not live shared state.** +> 构造一次,不可变,没有 reload。新 config = 新对象 + 重建 agent。 + +这一条原则推导出后面所有决策: + +- 全部 config model `frozen=True` → 非法状态不可表示 +- `from_file()` 是纯函数 → 无副作用 +- 没有 "热加载"语义 → 改变配置等于"拿到新对象",由调用方决定要不要换进程全局 + +## 3. 四层分层 + +```mermaid +graph TB + subgraph L1 ["第 1 层 数据模型 — 冻结的 ADT"] + direction LR + AppConfig["AppConfig frozen=True"] + Sub["MemoryConfig TitleConfig SummarizationConfig ... 全部 frozen"] + AppConfig --> Sub + end + + subgraph L2 ["第 2 层 Lifecycle — AppConfig.current"] + direction LR + Override["_override ContextVar per-context"] + Global["_global ClassVar process-singleton"] + Auto["auto-load from file with warning"] + Override --> Global + Global --> Auto + end + + subgraph L3 ["第 3 层 Per-invocation context — DeerFlowContext"] + direction LR + Ctx["frozen dataclass app_config thread_id agent_name"] + Resolve["resolve_context legacy bridge"] + Ctx --> Resolve + end + + subgraph L4 ["第 4 层 访问模式 — 按 caller 类型分流"] + direction LR + Typed["typed middleware runtime.context.app_config.xxx"] + Legacy["dict-legacy resolve_context runtime"] + NonAgent["非 agent 路径 AppConfig.current"] + end + + L1 --> L2 + L2 --> L3 + L3 --> L4 + + classDef morandiBlue fill:#B5C4D1,stroke:#6A7A8C,color:#2E3A47 + classDef morandiGreen fill:#C4D1B5,stroke:#7A8C6A,color:#2E3A47 + classDef morandiPurple fill:#C9BED1,stroke:#7E6A8C,color:#2E3A47 + classDef morandiGrey fill:#CFCFCF,stroke:#7A7A7A,color:#2E3A47 + class L1 morandiBlue + class L2 morandiGreen + class L3 morandiPurple + class L4 morandiGrey +``` + +### 3.1 第 1 层:冻结的 ADT + +所有 config model 都是 Pydantic `frozen=True`。 + +```python +class MemoryConfig(BaseModel): + model_config = ConfigDict(frozen=True) + enabled: bool = True + storage_path: str | None = None + ... + +class AppConfig(BaseModel): + model_config = ConfigDict(extra="allow", frozen=True) + memory: MemoryConfig + title: TitleConfig + ... +``` + +改 config 用 copy-on-write: + +```python +new_config = config.model_copy(update={"memory": new_memory_config}) +``` + +**从类型论视角**:这就是个 product type(record),所有字段组合起来才是一个完整的 `AppConfig`。冻结意味着 `AppConfig` 是**指称透明**的——同样的输入永远拿到同样的对象。 + +### 3.2 第 2 层:Lifecycle — `AppConfig.current()` + +这层是整个设计最值得讲的一块。它不是一个简单的单 `ContextVar`,而是**三层 fallback**: + +```python +class AppConfig(BaseModel): + ... + + # 进程级单例。GIL 下原子指针交换,无需锁 + _global: ClassVar[AppConfig | None] = None + + # Per-context override,用于测试隔离和多 client + _override: ClassVar[ContextVar[AppConfig]] = ContextVar("deerflow_app_config_override") + + @classmethod + def init(cls, config: AppConfig) -> None: + """设置进程全局。对所有后续 async task 可见""" + cls._global = config + + @classmethod + def set_override(cls, config: AppConfig) -> Token[AppConfig]: + """Per-context 覆盖。返回 Token 给 reset_override()""" + return cls._override.set(config) + + @classmethod + def reset_override(cls, token: Token[AppConfig]) -> None: + cls._override.reset(token) + + @classmethod + def current(cls) -> AppConfig: + """优先级:per-context override > 进程全局 > 自动从文件加载(warning)""" + try: + return cls._override.get() + except LookupError: + pass + if cls._global is not None: + return cls._global + logger.warning("AppConfig.current() called before init(); auto-loading from file. ...") + config = cls.from_file() + cls._global = config + return config +``` + +**为什么是三层,不是一层?** + +| 原因 | 解释 | +|------|------| +| 单 ContextVar 行不通 | Gateway 收到 `PUT /mcp/config` reload config,下一个请求在**全新的 async context** 里跑——ContextVar 的值传不过去。只能用进程级变量 | +| 保留 ContextVar override | 测试需要 per-test scope config,`Token`-based reset 保证干净恢复。多 client 场景如果真出现也能靠它 | +| Auto-load fallback | 有些 call site 历史上没调 `init()`(内部脚本、import-time 触发的测试)。加 warning 保证信号不丢,但不硬崩 | + +**Scala 视角的映射**: + +- `_global` = 进程级 `var`,脏,但别无选择 +- `_override` = `Option[ContextVar]` 形式的 reader monad 层 +- `current()` = fallback chain `override.orElse(global).orElse(autoLoad)`,和 `Option.orElse` 思路一致 + +**为什么 `_global` 没加锁?** + +因为读和写都是单个指针赋值(assignment of class attribute),在 CPython 的 GIL 下是原子的。如果将来改成 read-modify-write(比如 "如果没 init 就 init 成 X"),再加 `threading.Lock`。现在不加是因为——不需要。 + +### 3.3 第 3 层:`DeerFlowContext` — per-invocation typed context + +```python +# deerflow/config/deer_flow_context.py +@dataclass(frozen=True) +class DeerFlowContext: + """Typed, immutable, per-invocation context injected via LangGraph Runtime""" + app_config: AppConfig + thread_id: str + agent_name: str | None = None +``` + +为什么不把 `thread_id` 也放进 `AppConfig`? + +- `AppConfig` 是**配置**——进程启动时确定,所有请求共享 +- `thread_id` 是**每次调用变的运行时身份**——必须 per-invocation + +两者是不同的 category,混在一起就是把静态配置和动态 identity 耦合。 + +**注入路径**: + +```python +# Gateway worker(主路径) +deer_flow_context = DeerFlowContext( + app_config=AppConfig.current(), + thread_id=thread_id, +) +agent.astream(input, config=config, context=deer_flow_context) + +# DeerFlowClient +AppConfig.init(AppConfig.from_file(config_path)) +context = DeerFlowContext(app_config=AppConfig.current(), thread_id=thread_id) +agent.stream(input, config=config, context=context) +``` + +LangGraph 的 `Runtime` 会把 `context=...` 的值注入到 `Runtime[DeerFlowContext].context` 里。Middleware 拿到的就是 typed 的 `DeerFlowContext`。 + +**不进 context 的东西**:`sandbox_id`——它是 mid-execution 才 acquire 的**可变运行时状态**,正确的归宿是 `ThreadState.sandbox`(state channel,有 reducer),不是 context。原先 `sandbox/tools.py` 里 3 处 `runtime.context["sandbox_id"] = ...` 的写法全部删除。 + +### 3.4 第 4 层:访问模式按 caller 类型分流 + +三种 caller,三种模式: + +| Caller 类型 | 访问模式 | 例子 | +|-------------|----------|------| +| Typed middleware(签名写 `Runtime[DeerFlowContext]`) | `runtime.context.app_config.xxx` 直读,无包装 | `memory_middleware` / `title_middleware` / `thread_data_middleware` 等 | +| 可能遇到 dict context 的 tool | `resolve_context(runtime).xxx` | `sandbox/tools.py`(dict-legacy 路径)/ `task_tool.py`(bash subagent gate) | +| 非 agent 路径(Gateway router、CLI、factory) | `AppConfig.current().xxx` | `app/gateway/routers/*` / `reset_admin.py` / `models/factory.py` | + +**关键简化**(commit `a934a822`):原本所有 middleware 都走 `resolve_context()`,后来发现既然签名已经是 `Runtime[DeerFlowContext]`,包装就是冗余防御,直接 `runtime.context.app_config.xxx` 就行。同时也把 `title_middleware` 里每个 helper 的 `title_config=None` fallback 都删掉了——**required parameter 不给 default**,让类型系统强制 caller 传对。 + +这对应 Scala / FP 的两个信条: +- **让非法状态不可表示**(`Option[TitleConfig]` 改成 `TitleConfig` required) +- **Let-it-crash**(config 解析失败是真 bug,surface 出来比吞掉退化更好) + +## 4. `resolve_context()` 的三种分支 + +`resolve_context()` 自己还在,处理三种 runtime.context 形状: + +```python +def resolve_context(runtime: Any) -> DeerFlowContext: + ctx = getattr(runtime, "context", None) + + # 1. typed 路径(Gateway、Client)— 直接返回 + if isinstance(ctx, DeerFlowContext): + return ctx + + # 2. dict-legacy 路径(老测试、第三方 invoke)— 桥接 + if isinstance(ctx, dict): + thread_id = ctx.get("thread_id", "") + if not thread_id: + logger.warning("...empty thread_id...") + return DeerFlowContext( + app_config=AppConfig.current(), + thread_id=thread_id, + agent_name=ctx.get("agent_name"), + ) + + # 3. 完全没 context — fall back 到 LangGraph configurable + cfg = get_config().get("configurable", {}) + return DeerFlowContext( + app_config=AppConfig.current(), + thread_id=cfg.get("thread_id", ""), + agent_name=cfg.get("agent_name"), + ) +``` + +空 thread_id 会 warn,不会硬崩——在这里 warn 比 crash 合理,因为 `thread_id` 缺失只影响文件路径(落到空字符串目录),不会让整个 agent 跑崩。 + +## 5. Gateway config 热更新流程 + +历史上 Gateway 用 `reload_*_config()` 带 mtime 检测。现在改成: + +``` +写 extensions_config.json → AppConfig.init(AppConfig.from_file()) → 下一个请求看到新值 +``` + +**没有**:mtime 检测、自动刷新、`reload_*()` 函数。 + +哲学很简单:**结构性变化(模型、tools、middleware 链)需要重建 agent;运行时变化(`memory.enabled` 这种 flag)下一次 invocation 从 `AppConfig.current()` 取值就自动生效**。不需要给 config 做"活对象"语义。 + +## 6. 从原计划的分歧 + +三处关键分歧(详情见 [归档 spec §7](./plans/2026-04-12-config-refactor-design.md#7-divergence-from-original-plan)): + +| 分歧 | 原计划 | Shipped | 原因 | +|------|--------|---------|------| +| Lifecycle 存储 | 单 ContextVar,`ConfigNotInitializedError` 硬崩 | 3 层 fallback,auto-load + warning | ContextVar 跨 async 边界传不过去 | +| 模块位置 | 新建 `context.py` | Lifecycle 放在 `AppConfig` 自身 classmethod | 减一层模块耦合 | +| Middleware 访问 | 处处 `resolve_context()` | typed middleware 直读 `runtime.context.xxx` | 类型收紧后防御性包装是 noise | + +## 7. 从 Scala / Actor 视角的几点观察 + +- **`AppConfig` 就是个 case class / ADT**。`frozen=True` 相当于 Scala 的 final case class:构造完就不动。改动靠 `model_copy(update=…)`,对应 Scala 的 `copy(…)`。 +- **`DeerFlowContext` 是 typed reader**。Middleware 接收 `Runtime[DeerFlowContext]`,本质是 `Kleisli[DeerFlowContext, State, Result]`——依赖注入,类型化。比 `RunnableConfig.configurable: dict[str, Any]` 强太多。 +- **`resolve_context()` 是适配层**。存在是因为有三种不同形状的上游输入;在纯 FP 眼里这是个 `X => DeerFlowContext` 的 total function,通过 pattern match 三种 case 把世界收敛回 typed 的那条路径。 +- **Let-it-crash 的体现**:commit `a934a822` 干掉 middleware 里 `try/except resolve_context(...)`,干掉 `TitleConfig | None` 的 defensive fallback。Config 解析失败就让它抛出去,别吞成"degraded mode"——actor supervision 会处理,吞错反而藏 bug。 +- **进程 global 的妥协**:`_global: ClassVar` 是这套设计里唯一违背纯值的地方。但在 Python async + HTTP server 的语境里,你没别的办法跨 request 把"新 config"传给所有 task。承认妥协、限制范围(只在 lifecycle 层一个变量)、周边全部 immutable——这就是工程意义上的"合理妥协"。 + +## 8. Cheat sheet + +想访问 config,怎么办?按你写代码的位置看: + +| 我在写什么 | 用什么 | +|------------|--------| +| Typed middleware(签名 `Runtime[DeerFlowContext]`) | `runtime.context.app_config.xxx` | +| Typed tool(`ToolRuntime[DeerFlowContext]`) | `runtime.context.xxx` | +| 可能被老调用方以 dict context 调到的 tool | `resolve_context(runtime).xxx` | +| Gateway router、CLI、factory、测试 helper | `AppConfig.current().xxx` | +| 启动时初始化 | `AppConfig.init(AppConfig.from_file(path))` | +| 测试里想临时改 config | `token = AppConfig.set_override(cfg)` / `AppConfig.reset_override(token)` | +| Gateway 写完新 `extensions_config.json` 之后 | `AppConfig.init(AppConfig.from_file())`,然后让 agent 重建(如果结构变了) | + +不要: +- ~~`get_memory_config()` / `get_title_config()` 等旧 getter~~(已删) +- ~~`reload_app_config()` / `reset_app_config()`~~(已删) +- ~~`_memory_config` 等模块级 global~~(已删) +- ~~`runtime.context["sandbox_id"] = ...`~~(走 `runtime.state["sandbox"]`) +- ~~防御性 `try/except resolve_context(...)`~~(让它崩) diff --git a/docs/plans/2026-04-12-config-refactor-design.md b/docs/plans/2026-04-12-config-refactor-design.md new file mode 100644 index 000000000..3a56866b4 --- /dev/null +++ b/docs/plans/2026-04-12-config-refactor-design.md @@ -0,0 +1,414 @@ +# Design: Eliminate Global Mutable State in Configuration System + +> Implements [#1811](https://github.com/bytedance/deer-flow/issues/1811) · Tracked in [#2151](https://github.com/bytedance/deer-flow/issues/2151) +> +> **Phase 1 (shipped):** [PR #2271](https://github.com/bytedance/deer-flow/pull/2271) — frozen config tree, purify `from_file()`, 3-tier `AppConfig.current()` lifecycle, `DeerFlowContext` for agent execution path. +> +> **Phase 2 (proposed):** eliminate the remaining implicit-state surface (`_global` / `_override` / `current()`) via pure explicit parameter passing. See §8. + +## Problem + +`deerflow/config/` had three structural issues: + +1. **Dual source of truth** — each sub-config existed both as an `AppConfig` field and a module-level global (e.g. `_memory_config`). Consumers didn't know which to trust. +2. **Side-effect coupling** — `AppConfig.from_file()` silently mutated 8 sub-module globals via `load_*_from_dict()` calls. +3. **Incomplete isolation** — `ContextVar` only scoped `AppConfig`, not the 8 sub-config globals. + +## Design Principle + +**Config is a value object, not live shared state.** Constructed once, immutable, no reload. New config = new object + rebuild agent. + +## Solution + +### 1. Frozen AppConfig (full tree) + +All config models set `frozen=True`, including `DatabaseConfig` and `RunEventsConfig` (added late in review). No mutation after construction. + +```python +class MemoryConfig(BaseModel): + model_config = ConfigDict(frozen=True) + +class AppConfig(BaseModel): + model_config = ConfigDict(extra="allow", frozen=True) + memory: MemoryConfig + title: TitleConfig + ... +``` + +Changes use copy-on-write: `config.model_copy(update={...})`. + +### 2. Pure `from_file()` + +`AppConfig.from_file()` is a pure function — returns a frozen object, no side effects. All 8 `load_*_from_dict()` calls and their imports were removed. + +### 3. Deleted sub-module globals + +Every sub-config module's global state was deleted: + +| Deleted | Files | +|---------|-------| +| `_memory_config`, `get_memory_config()`, `set_memory_config()`, `load_memory_config_from_dict()` | `memory_config.py` | +| `_title_config`, `get_title_config()`, `set_title_config()`, `load_title_config_from_dict()` | `title_config.py` | +| Same pattern | `summarization_config.py`, `subagents_config.py`, `guardrails_config.py`, `tool_search_config.py`, `checkpointer_config.py`, `stream_bridge_config.py`, `acp_config.py` | +| `_extensions_config`, `reload_extensions_config()`, `reset_extensions_config()`, `set_extensions_config()` | `extensions_config.py` | +| `reload_app_config()`, `reset_app_config()`, `set_app_config()`, mtime detection, `push/pop_current_app_config()` | `app_config.py` | + +Consumers migrated from `get_memory_config()` → `AppConfig.current().memory` (~100 call-sites). + +### 4. Lifecycle: 3-tier `AppConfig.current()` + +The original plan called for a single `ContextVar` with hard-fail on uninitialized access. The shipped lifecycle is a **3-tier fallback** attached to `AppConfig` itself (no separate `context.py` module). The divergence is explained in §7. + +```python +# app_config.py +class AppConfig(BaseModel): + ... + + # Process-global singleton. Atomic pointer swap under the GIL, + # so no lock is needed for current read/write patterns. + _global: ClassVar[AppConfig | None] = None + + # Per-context override (tests, multi-client scenarios). + _override: ClassVar[ContextVar[AppConfig]] = ContextVar("deerflow_app_config_override") + + @classmethod + def init(cls, config: AppConfig) -> None: + """Set the process-global. Visible to all subsequent async tasks.""" + cls._global = config + + @classmethod + def set_override(cls, config: AppConfig) -> Token[AppConfig]: + """Per-context override. Returns Token for reset_override().""" + return cls._override.set(config) + + @classmethod + def reset_override(cls, token: Token[AppConfig]) -> None: + cls._override.reset(token) + + @classmethod + def current(cls) -> AppConfig: + """Priority: per-context override > process-global > auto-load from file.""" + try: + return cls._override.get() + except LookupError: + pass + if cls._global is not None: + return cls._global + logger.warning( + "AppConfig.current() called before init(); auto-loading from file. " + "Call AppConfig.init() at process startup to surface config errors early." + ) + config = cls.from_file() + cls._global = config + return config +``` + +**Why three tiers and not one:** + +- **Process-global** is required because `ContextVar` doesn't propagate config updates across async request boundaries. Gateway receives a `PUT /mcp/config` on one request, reloads config, and the next request — in a fresh async context — must see the new value. A plain class variable (`_global`) does this; a `ContextVar` does not. +- **Per-context override** is retained for test isolation and multi-client scenarios. A test can scope its config without mutating the process singleton. `reset_override()` restores the previous state deterministically via `Token`. +- **Auto-load fallback** is a backward-compatibility escape hatch with a warning. Call sites that skipped explicit `init()` (legacy or test) still work, but the warning surfaces the miss. + +### 5. Per-invocation context: `DeerFlowContext` + +Lives in `deerflow/config/deer_flow_context.py` (not `context.py` as originally planned — the name was reserved to avoid implying a lifecycle module). + +```python +@dataclass(frozen=True) +class DeerFlowContext: + """Typed, immutable, per-invocation context injected via LangGraph Runtime.""" + app_config: AppConfig + thread_id: str + agent_name: str | None = None +``` + +**Fields:** + +| Field | Type | Source | Mutability | +|-------|------|--------|-----------| +| `app_config` | `AppConfig` | `AppConfig.current()` at run start | Immutable per-run | +| `thread_id` | `str` | Caller-provided | Immutable per-run | +| `agent_name` | `str \| None` | Caller-provided (bootstrap only) | Immutable per-run | + +**Not in context:** `sandbox_id` is mutable runtime state (lazy-acquired mid-execution). It flows through `ThreadState.sandbox` (state channel), not context. All 3 `runtime.context["sandbox_id"] = ...` writes in `sandbox/tools.py` were removed; `SandboxMiddleware.after_agent` reads from `state["sandbox"]` only. + +**Construction per entry point:** + +```python +# Gateway runtime (worker.py) — primary path +deer_flow_context = DeerFlowContext( + app_config=AppConfig.current(), + thread_id=thread_id, +) +agent.astream(input, config=config, context=deer_flow_context) + +# DeerFlowClient (client.py) +AppConfig.init(AppConfig.from_file(config_path)) +context = DeerFlowContext(app_config=AppConfig.current(), thread_id=thread_id) +agent.stream(input, config=config, context=context) + +# LangGraph Server — legacy path, context=None or dict, fallback via resolve_context() +``` + +### 6. Access pattern by caller type + +The shipped code stratifies callers by what `runtime.context` type they see, and tightened middleware access over time: + +| Caller type | Access pattern | Examples | +|-------------|---------------|----------| +| Typed middleware (declares `Runtime[DeerFlowContext]`) | `runtime.context.app_config.xxx` — direct field access, no wrapper | `memory_middleware`, `title_middleware`, `thread_data_middleware`, `uploads_middleware`, `loop_detection_middleware` | +| Tools that may see legacy dict context | `resolve_context(runtime).xxx` | `sandbox/tools.py` (bash-guard gate, sandbox config), `task_tool.py` (bash subagent gate) | +| Tools with typed runtime | `runtime.context.xxx` directly | `present_file_tool.py`, `setup_agent_tool.py`, `skill_manage_tool.py` | +| Non-agent paths (Gateway routers, CLI, factories) | `AppConfig.current().xxx` | `app/gateway/routers/*`, `reset_admin.py`, `models/factory.py` | + +**Middleware hardening** (late commit `a934a822`): the original plan had middlewares call `resolve_context(runtime)` everywhere. In practice, once the middleware signature was typed as `Runtime[DeerFlowContext]`, the wrapper became defensive noise. The commit removed: +- `try/except` wrappers around `resolve_context(...)` in middlewares and sandbox tools +- Optional `title_config=None` fallback on every `_build_title_prompt` / `_format_for_title_model` helper; they now take `TitleConfig` as a **required parameter** +- Ad-hoc `get_config()` fallback chains in `memory_middleware` + +Dropping the swallowed-exception layer means config-resolution bugs surface as errors instead of silently degrading — aligning with let-it-crash. + +`resolve_context()` itself still exists and handles three cases: + +```python +def resolve_context(runtime: Any) -> DeerFlowContext: + ctx = getattr(runtime, "context", None) + if isinstance(ctx, DeerFlowContext): + return ctx # typed path (Gateway, Client) + if isinstance(ctx, dict): + return DeerFlowContext( # legacy dict path (with warning if empty thread_id) + app_config=AppConfig.current(), + thread_id=ctx.get("thread_id", ""), + agent_name=ctx.get("agent_name"), + ) + # Final fallback: LangGraph configurable (e.g. LangGraph Server) + cfg = get_config().get("configurable", {}) + return DeerFlowContext( + app_config=AppConfig.current(), + thread_id=cfg.get("thread_id", ""), + agent_name=cfg.get("agent_name"), + ) +``` + +### 7. Divergence from original plan + +Two material divergences from the original design, both driven by implementation feedback: + +**7.1 Lifecycle: `ContextVar` → process-global + `ContextVar` override** + +*Original:* single `ContextVar` in a new `context.py` module. `get_app_config()` raises `ConfigNotInitializedError` if unset. + +*Shipped:* process-global `AppConfig._global` (primary) + `ContextVar` override (scoped) + auto-load with warning (fallback). + +*Why:* a `ContextVar` set by Gateway startup is not visible to subsequent requests that spawn fresh async contexts. `PUT /mcp/config` must update config such that the next incoming request sees the new value in *its* async task — this requires process-wide state. ContextVar is retained for test isolation (`reset_override()` works cleanly per test via `Token`) and for per-client scoping if ever needed. + +The `ConfigNotInitializedError` was replaced by a warning + auto-load. The hard error caught more legitimate bugs but also broke call sites that historically worked without explicit init (internal scripts, test fixtures during import-time). The warning preserves the signal without breaking backward compatibility; `backend/tests/conftest.py` now has an autouse fixture that sets `_global` to a minimal `AppConfig` so tests never hit auto-load. + +**7.2 Module name: `context.py` → lifecycle on `AppConfig`, `deer_flow_context.py` for the invocation context** + +*Original:* lifecycle and `DeerFlowContext` both in `deerflow/config/context.py`. + +*Shipped:* lifecycle is classmethods on `AppConfig` itself (`init`, `current`, `set_override`, `reset_override`). `DeerFlowContext` and `resolve_context()` live in `deerflow/config/deer_flow_context.py`. + +*Why:* the lifecycle operates on `AppConfig` directly — putting it on the class removes one level of module coupling. The per-invocation context is conceptually separate (it's agent-execution plumbing, not config lifecycle) so it got its own file with a distinguishing name. + +**7.3 Client lifecycle: `init() + set_override()` → `init()` only** + +*Original (never finalized):* `DeerFlowClient.__init__` called both `init()` (process-global) and `set_override()` so two clients with different configs wouldn't clobber each other. + +*Shipped:* `init()` only. + +*Why (commit `a934a822`):* `set_override()` leaked overrides across test boundaries because the `ContextVar` wasn't reset between client instances. Single-client is the common case, and tests use the autouse fixture for isolation. Multi-client scoping can be added back with explicit `set_override()` if the need arises. + +## What doesn't change + +- `config.yaml` schema +- `extensions_config.json` loading +- External API behavior (Gateway, DeerFlowClient) + +## Migration scope (Phase 1, actual) + +- ~100 call-sites: `get_*_config()` → `AppConfig.current().xxx` +- 6 runtime-path migrations: middlewares + sandbox tools read from `runtime.context` or `resolve_context()` +- 3 deleted sandbox_id writes in `sandbox/tools.py` +- ~100 test locations updated; `conftest.py` autouse fixture added +- New tests: `test_config_frozen.py`, `test_deer_flow_context.py`, `test_app_config_reload.py` +- Gateway update flow: `reload_*` → `AppConfig.init(AppConfig.from_file())` +- Dependency: langgraph `Runtime` / `ToolRuntime` (already available at target version) + +## 8. Phase 2: pure explicit parameter passing + +Phase 1 shipped a working 3-tier `AppConfig.current()` lifecycle. The remaining implicit-state surface is: + +- `AppConfig._global: ClassVar` — process-level singleton +- `AppConfig._override: ClassVar[ContextVar]` — per-context override +- `AppConfig.current()` — fallback-chain reader with auto-load warning + +Phase 2 proposes removing all three. `AppConfig` reduces to a pure Pydantic value object with `from_file()` as its only factory. All consumers receive `AppConfig` as an explicit parameter, either through a typed constructor, a function signature, or LangGraph `Runtime[DeerFlowContext]`. + +### 8.1 Motivation + +Phase 1 addressed the **data side** of the problem: config is now a frozen ADT, sub-module globals deleted, `from_file()` pure. The **access side** still relies on implicit ambient lookup: + +```python +# Today (Phase 1 shipped): +def _get_memory_prompt() -> str: + config = AppConfig.current().memory # implicit global lookup + ... + +# Target (Phase 2): +def _get_memory_prompt(config: MemoryConfig) -> str: # explicit dependency + ... +``` + +Three concrete benefits: + +| Benefit | What it buys | +|---------|-------------| +| Referential transparency | A function's result depends only on its inputs. Testing becomes parameter substitution, no `patch.object(AppConfig, "current")` chains | +| Dependency visibility | A function signature declares what config it needs. No "this deep helper secretly reads `.memory`" surprises | +| True multi-config isolation | Two `DeerFlowClient` instances with different configs can run in the same process without any ambient shared state to contend over | + +The cost (Phase 1 wouldn't have made this smaller): ~97 production call sites + ~91 test mock sites need touching, plus signature changes for helpers that now accept `config` as a parameter. + +### 8.2 Non-agent call paths and their target APIs + +Phase 1 got the agent-execution path right (`runtime.context.app_config.xxx`). The unsolved paths split into four categories: + +**FastAPI Gateway** → `Depends(get_config)` + +```python +# app/gateway/app.py — at startup +app.state.config = AppConfig.from_file() + +# app/gateway/deps.py +def get_config(request: Request) -> AppConfig: + return request.app.state.config + +# app/gateway/routers/models.py +@router.get("/models") +def list_models(config: AppConfig = Depends(get_config)): + ... + +# app/gateway/routers/mcp.py — config reload replaces AppConfig.init() +@router.put("/config") +def update_mcp(..., request: Request): + ... + request.app.state.config = AppConfig.from_file() +``` + +`app.state.config` is a FastAPI-owned attribute on the app object, not a module-level global. Scoped to the app's lifetime, only written at startup and config-reload. + +**`DeerFlowClient`** → constructor-captured config + +```python +class DeerFlowClient: + def __init__(self, config_path: str | None = None, config: AppConfig | None = None): + self._config = config or AppConfig.from_file(config_path) + + def chat(self, message: str, thread_id: str) -> str: + context = DeerFlowContext(app_config=self._config, thread_id=thread_id) + ... +``` + +Multiple `DeerFlowClient` instances are now first-class — each owns its config, nothing shared. + +**Agent construction (`make_lead_agent`, `_build_middlewares`, prompt helpers)** → threaded through + +```python +def make_lead_agent(config: RunnableConfig, app_config: AppConfig): + middlewares = _build_middlewares(app_config, runtime_config=config) + ... + +def _build_middlewares(app_config: AppConfig, runtime_config: RunnableConfig): + if app_config.token_usage.enabled: + middlewares.append(TokenUsageMiddleware()) + ... +``` + +Every helper that reads config is now on a function-signature chain from `make_lead_agent`. + +**Background threads (memory debounce Timer, queue consumers)** → closure-captured + +```python +def MemoryQueue.add(self, conversation, user_id, config: MemoryConfig): + # capture config at enqueue time + def _flush(): + self._updater.update(conversation, user_id, config) + self._timer = Timer(config.debounce_seconds, _flush) + self._timer.start() +``` + +The captured config lives in the closure, not in a contextvar the thread can't see. + +### 8.3 Target `AppConfig` shape + +```python +class AppConfig(BaseModel): + model_config = ConfigDict(extra="allow", frozen=True) + + log_level: str = "info" + memory: MemoryConfig = Field(default_factory=MemoryConfig) + ... # same fields as Phase 1 + + @classmethod + def from_file(cls, config_path: str | None = None) -> Self: + """Pure factory. Reads file, returns frozen object. No side effects.""" + ... + + @classmethod + def resolve_config_path(cls, config_path: str | None = None) -> Path: + """Unchanged from Phase 1.""" + ... + + def get_model_config(self, name: str) -> ModelConfig | None: + """Unchanged.""" + ... + + # Removed: + # - _global: ClassVar + # - _override: ClassVar[ContextVar] + # - init(), set_override(), reset_override(), current() +``` + +### 8.4 `DeerFlowContext` and `resolve_context()` after Phase 2 + +`DeerFlowContext` is unchanged — it's already Phase 2-compliant. + +`resolve_context()` simplifies: the "fall back to `AppConfig.current()`" branch goes away. The dict-context legacy path either constructs `DeerFlowContext` with an explicitly-passed `AppConfig` (fed by caller) or is deleted if no dict-context callers remain. + +```python +def resolve_context(runtime: Any) -> DeerFlowContext: + ctx = getattr(runtime, "context", None) + if isinstance(ctx, DeerFlowContext): + return ctx + raise RuntimeError( + "runtime.context is not a DeerFlowContext. All callers must construct " + "and inject one explicitly; there is no global fallback." + ) +``` + +Let-it-crash: if Phase 2 is done correctly, every caller constructs a typed context. If one doesn't, fail loudly. + +### 8.5 Trade-off acknowledgment + +The three cases where ambient lookup is genuinely tempting (and why we reject them): + +| Tempting case | Why ambient looks easier | Why we still reject it | +|---------------|-------------------------|------------------------| +| Deep helper in `memory/storage.py` needs `memory.storage_path` | Just threaded through 4 call layers | That's exactly the dependency chain you want visible. It's either there or it's hiding | +| Community tool factory reading API keys from config | "Each tool factory doesn't want to take config" | Each tool factory literally needs the config. Passing it is the honest signature | +| Test that wants to "override just one field globally" | `patch.object(AppConfig, "current")` is one line | Tests constructing their own `AppConfig` is one fixture — and that fixture becomes infrastructure for all future tests | + +The rejection is consistent: **an explicit parameter is strictly more honest than an implicit global lookup**, in every case. + +### 8.6 Scope + +- ~97 production call sites: `AppConfig.current()` → parameter +- ~91 test mock sites: `patch.object(AppConfig, "current")` / `AppConfig._global = ...` → fixture injection +- ~30 FastAPI endpoints gain `config: AppConfig = Depends(get_config)` +- ~15 factory / helper functions gain `config: AppConfig` parameter +- Delete from `app_config.py`: `_global`, `_override`, `init`, `current`, `set_override`, `reset_override` +- Simplify `resolve_context()`: remove `AppConfig.current()` fallback + +Implementation plan: see [2026-04-12-config-refactor-plan.md §Phase 2](./2026-04-12-config-refactor-plan.md#phase-2-pure-explicit-parameter-passing). diff --git a/docs/plans/2026-04-12-config-refactor-plan.md b/docs/plans/2026-04-12-config-refactor-plan.md new file mode 100644 index 000000000..18a275b36 --- /dev/null +++ b/docs/plans/2026-04-12-config-refactor-plan.md @@ -0,0 +1,1208 @@ +# Config Refactor Implementation Plan — Shipped + +> **Status:** Shipped in [PR #2271](https://github.com/bytedance/deer-flow/pull/2271). All tasks complete. This document is an implementation log; for the shipped architecture see [design doc](./2026-04-12-config-refactor-design.md). +> +> **Goal:** Eliminate global mutable state in the configuration system — frozen `AppConfig`, pure `from_file()`, process-global + ContextVar-override lifecycle, `Runtime[DeerFlowContext]` propagation. +> +> **Tech Stack:** Pydantic v2 (`frozen=True`, `model_copy`), Python `contextvars.ContextVar` + `Token`, LangGraph `Runtime` / `ToolRuntime`. +> +> **Issues:** [#2151](https://github.com/bytedance/deer-flow/issues/2151) (implementation), [#1811](https://github.com/bytedance/deer-flow/issues/1811) (RFC) + +## Post-mortem — divergences from the original plan + +The implementation diverged from the original task-by-task plan in three places. The rationale lives in the design doc §7; here is the commit trail. + +| Divergence | Original plan | Shipped | Triggering commit | +|------------|--------------|---------|-------------------| +| Lifecycle storage | Single `ContextVar` in new `context.py`, raises `ConfigNotInitializedError` | 3-tier: `AppConfig._global` (process singleton) + `_override: ContextVar` + auto-load-with-warning fallback | `7a11e925` ("use process-global + ContextVar override"), refined in `4df595b0` | +| Module / API shape | Top-level `get_app_config()` / `init_app_config()` in `context.py` | Classmethods on `AppConfig` (`current`, `init`, `set_override`, `reset_override`); `DeerFlowContext` + `resolve_context` in `deer_flow_context.py` | Same commits + `9040e49e` (call-site migration) | +| Middleware access | `resolve_context(runtime)` in every middleware and tool | Typed middleware reads `runtime.context.xxx` directly; `resolve_context()` only in dict-legacy callers; defensive `try/except` wrappers removed | `a934a822` ("simplify runtime context access") | + +**Core insight:** ContextVar alone could not propagate config changes across Gateway request boundaries; process-global fixed that. The override ContextVar was kept for test/multi-client isolation. Hard-fail on uninitialized access (`ConfigNotInitializedError`) was dropped in favor of warning + auto-load to preserve backward compatibility, and tests use an autouse fixture in `backend/tests/conftest.py` to avoid the auto-load path. + +--- + +## File Structure (shipped) + +### New files + +| File | Responsibility | +|------|---------------| +| `deerflow/config/deer_flow_context.py` | `DeerFlowContext` frozen dataclass + `resolve_context()` helper | + +The originally-planned `deerflow/config/context.py` was never created. Lifecycle (`init`, `current`, `set_override`, `reset_override`) is on `AppConfig` itself in `app_config.py`. + +### Modified files (config layer) + +| File | Change | +|------|--------| +| `deerflow/config/app_config.py` | `frozen=True`, purify `from_file()`, delete mtime/reload/reset/push/pop; add classmethods `init`/`current`/`set_override`/`reset_override` with `_global` ClassVar and `_override` ContextVar | +| `deerflow/config/memory_config.py` | `frozen=True`, delete all globals and loader functions | +| `deerflow/config/title_config.py` | Same pattern | +| `deerflow/config/summarization_config.py` | Same pattern | +| `deerflow/config/subagents_config.py` | Same pattern | +| `deerflow/config/guardrails_config.py` | Same pattern (also delete `reset_guardrails_config`) | +| `deerflow/config/tool_search_config.py` | Same pattern | +| `deerflow/config/checkpointer_config.py` | Same pattern | +| `deerflow/config/stream_bridge_config.py` | Same pattern | +| `deerflow/config/acp_config.py` | Same pattern | +| `deerflow/config/extensions_config.py` | `frozen=True`, delete globals (`_extensions_config`, `reload_extensions_config`, `reset_extensions_config`, `set_extensions_config`) | +| `deerflow/config/database_config.py` | `frozen=True` (added in `4df595b0` review round) | +| `deerflow/config/run_events_config.py` | `frozen=True` (same) | +| `deerflow/config/tracing_config.py` | `frozen=True`, unchanged exports | +| `deerflow/config/__init__.py` | Removed deleted getter exports; no new re-exports needed since API is now on `AppConfig` | + +### Modified files (production consumers) + +| File | Change | +|------|--------| +| `deerflow/agents/lead_agent/agent.py` | `get_summarization_config()` → `AppConfig.current().summarization` | +| `deerflow/agents/lead_agent/prompt.py` | `get_memory_config()` → `AppConfig.current().memory`; ACP agents derived from `AppConfig.current()` | +| `deerflow/agents/middlewares/memory_middleware.py` | Reads `runtime.context.app_config.memory` directly (typed `Runtime[DeerFlowContext]`) | +| `deerflow/agents/middlewares/title_middleware.py` | `after_model` / `aafter_model` read `runtime.context.app_config.title`; helpers take `TitleConfig` as required parameter | +| `deerflow/agents/middlewares/tool_error_handling_middleware.py` | `get_guardrails_config()` → `AppConfig.current().guardrails` | +| `deerflow/agents/middlewares/loop_detection_middleware.py` | Reads `runtime.context.thread_id` directly | +| `deerflow/agents/middlewares/thread_data_middleware.py` | Reads `runtime.context.thread_id` directly | +| `deerflow/agents/middlewares/uploads_middleware.py` | Reads `runtime.context.thread_id` directly | +| `deerflow/agents/memory/updater.py` / `queue.py` / `storage.py` | `get_memory_config()` → `AppConfig.current().memory` | +| `deerflow/runtime/checkpointer/provider.py` / `async_provider.py` | `get_checkpointer_config()` → `AppConfig.current().checkpointer` | +| `deerflow/runtime/store/provider.py` / `async_provider.py` | Same pattern | +| `deerflow/runtime/stream_bridge/async_provider.py` | `get_stream_bridge_config()` → `AppConfig.current().stream_bridge` | +| `deerflow/runtime/runs/worker.py` | Constructs `DeerFlowContext(app_config=AppConfig.current(), thread_id=thread_id)` and passes via `agent.astream(context=...)` | +| `deerflow/subagents/registry.py` | `get_subagents_app_config()` → `AppConfig.current().subagents` | +| `deerflow/sandbox/middleware.py` | Reads `runtime.context.thread_id`; removed `runtime.context["sandbox_id"]` read path | +| `deerflow/sandbox/tools.py` | Removed 3× `runtime.context["sandbox_id"] = ...` writes; state now flows through `runtime.state["sandbox"]`; sandbox-config access via `resolve_context(runtime).app_config.sandbox` where dict-context fallback may still apply | +| `deerflow/sandbox/local/local_sandbox_provider.py` / `sandbox_provider.py` / `security.py` | `get_app_config()` → `AppConfig.current()` | +| `deerflow/community/*/tools.py` (tavily, jina_ai, firecrawl, exa, ddg_search, image_search, infoquest, aio_sandbox) | `get_app_config()` → `AppConfig.current()` | +| `deerflow/skills/loader.py` / `manager.py` / `security_scanner.py` | Same pattern | +| `deerflow/tools/builtins/*.py` | Typed tools read `runtime.context.xxx`; `task_tool.py` uses `resolve_context()` for bash-subagent guard | +| `deerflow/tools/tools.py` / `skill_manage_tool.py` | ACP agents derived from `AppConfig.current()`; skill manage reads `runtime.context.thread_id` | +| `deerflow/models/factory.py` | `get_app_config()` → `AppConfig.current()` | +| `deerflow/utils/file_conversion.py` | Same | +| `deerflow/client.py` | `AppConfig.init(AppConfig.from_file(config_path))`; constructs `DeerFlowContext` at invoke time. Earlier iterations used `set_override()`; removed in `a934a822` | +| `app/gateway/app.py` | `AppConfig.init(AppConfig.from_file())` at startup | +| `app/gateway/deps.py` / `auth/reset_admin.py` | `get_app_config()` → `AppConfig.current()` | +| `app/gateway/routers/mcp.py` / `skills.py` | Construct new config + `AppConfig.init()` instead of `reload_extensions_config()` | +| `app/gateway/routers/memory.py` / `models.py` | `get_memory_config()` → `AppConfig.current().memory`, etc. | +| `app/channels/service.py` | `get_app_config()` → `AppConfig.current()` | +| `backend/CLAUDE.md` | Config Lifecycle + `DeerFlowContext` sections updated | + +### Modified files (tests) + +~100 test locations updated. Patterns: + +- `@patch("...get_memory_config")` → `@patch.object(AppConfig, "current", ...)` returning a frozen `AppConfig` with the desired sub-config +- Tests that mutated `AppConfig` instances now construct fresh ones or use `model_copy(update={...})` +- `backend/tests/conftest.py` gained an autouse `_auto_app_config` fixture that sets `AppConfig._global` to a minimal config for every test + +New test files: +- `backend/tests/test_config_frozen.py` — verifies every config model rejects mutation +- `backend/tests/test_deer_flow_context.py` — verifies `DeerFlowContext` construction, defaults, and `resolve_context()` for all three input shapes +- `backend/tests/test_app_config_reload.py` — verifies lifecycle: `init()` visibility across contexts, `set_override()` + `reset_override()` with `Token`, auto-load warning + +--- + +## Task log + +All tasks complete. Checkboxes below reflect the shipped state. For detailed step-by-step TDD sequence, see the commit history on `refactor/config-deerflow-context`. + +### Task 1: Freeze all sub-config models + +- [x] Write `test_config_frozen.py` parameterized over every config model +- [x] Add `model_config = ConfigDict(frozen=True)` (or `extra="allow", frozen=True`) to every model +- [x] Add frozen=True to `DatabaseConfig`, `RunEventsConfig` in review round (`4df595b0`) +- [x] Fix tests that mutated config objects — use `model_copy(update={...})` or fresh instances + +### Task 2: Freeze `AppConfig` + +- [x] Extend `test_config_frozen.py` with `test_app_config_is_frozen` +- [x] Change `AppConfig.model_config` to `ConfigDict(extra="allow", frozen=True)` + +### Task 3: Purify `from_file()` + +- [x] Write test verifying no `load_*_from_dict()` calls happen during `from_file()` +- [x] Remove all 8 `load_*_from_dict()` calls and their imports from `app_config.py` + +### Task 4: Replace `app_config.py` lifecycle + +**Diverged from original plan.** See post-mortem for rationale. + +- [x] ~~Create `deerflow/config/context.py`~~ → Lifecycle added directly to `AppConfig` as classmethods +- [x] Add `_global: ClassVar[AppConfig | None]` for process-global storage (atomic pointer swap under GIL, no lock) +- [x] Add `_override: ClassVar[ContextVar[AppConfig]]` for per-context override +- [x] Implement `init()`, `current()`, `set_override()` (returns `Token`), `reset_override()` +- [x] `current()` priority order: override → global → auto-load-with-warning +- [x] Delete old lifecycle: `get_app_config`, `reload_app_config`, `reset_app_config`, `set_app_config`, `peek_current_app_config`, `push_current_app_config`, `pop_current_app_config`, `_load_and_cache_app_config`, mtime globals +- [x] Write `test_app_config_reload.py` covering init/override/reset/auto-load paths + +Commits: `7a11e925` (initial process-global + override), `4df595b0` (harden: `Token` return, auto-load warning, doc `_global` lock-free rationale). + +### Task 5: Migrate call sites to `AppConfig.current()` + +- [x] ~100 `get_app_config()` / `get_memory_config()` / `get_title_config()` / ... call sites migrated to `AppConfig.current().xxx` +- [x] Tests that patched module-level getters migrated to `patch.object(AppConfig, "current", ...)` +- [x] Update `deerflow/config/__init__.py` — removed deleted getter exports + +Commits: `9040e49e` (bulk migration), `82fdabd7` (deps.py + reset_admin.py follow-up), `6c0c2ecf` (test mocks update), `faec3bf9` (runtime-path migration). + +### Task 6: Delete sub-config module globals (memory / title / summarization) + +- [x] Delete `_memory_config`, `get_memory_config`, `set_memory_config`, `load_memory_config_from_dict` from `memory_config.py` +- [x] Delete analogous globals from `title_config.py`, `summarization_config.py` +- [x] Migrate 6 production consumers of `get_memory_config`, 1 of `get_title_config`, 1 of `get_summarization_config` +- [x] Fix tests that patched the deleted getters + +### Task 7: Delete remaining sub-config module globals + +- [x] `subagents_config.py` — delete globals; migrate `subagents/registry.py` +- [x] `guardrails_config.py` — delete globals + `reset_guardrails_config`; migrate `tool_error_handling_middleware.py` +- [x] `tool_search_config.py` — delete globals (no production consumers) +- [x] `checkpointer_config.py` — delete globals; migrate 2 consumers in runtime/ +- [x] `stream_bridge_config.py` — delete globals; migrate 1 consumer +- [x] `acp_config.py` — delete globals; migrate 2 consumers (`agents/lead_agent/prompt.py`, `tools/tools.py`) +- [x] `extensions_config.py` — delete globals + `reload_extensions_config`/`reset_extensions_config`/`set_extensions_config`; migrate 4 consumers (`sandbox/tools.py`, `client.py`, `gateway/routers/mcp.py`, `gateway/routers/skills.py`) + +### Task 8: Update `__init__.py` exports + +- [x] Remove deleted-getter exports; keep type exports (`AppConfig`, `ExtensionsConfig`, `MemoryConfig`, etc.) +- [x] `tracing_config` re-exports preserved (still function-based, no lifecycle change) + +### Task 9: Gateway config update flow + +- [x] `app/gateway/routers/mcp.py`: write extensions_config.json → `AppConfig.init(AppConfig.from_file())` +- [x] `app/gateway/routers/skills.py`: same pattern +- [x] `deerflow/client.py`: `update_mcp_config()` and `update_skill()` reuse the same pattern (now via `AppConfig.current().extensions` + `init(AppConfig.from_file())`) + +### Task 10: Create `DeerFlowContext` + +- [x] Create `deerflow/config/deer_flow_context.py` with `DeerFlowContext` frozen dataclass +- [x] Fields: `app_config: AppConfig`, `thread_id: str`, `agent_name: str | None = None` +- [x] Typed via `TYPE_CHECKING` import to avoid circular dependency +- [x] Wire into `create_agent(context_schema=DeerFlowContext)` in `lead_agent/agent.py` +- [x] Wire into `DeerFlowClient.stream(context=...)` + +### Task 11: Add `resolve_context()` helper + +- [x] Handle typed context (Gateway/Client path): return `runtime.context` directly +- [x] Handle dict context (legacy/tests): construct `DeerFlowContext` from dict keys; warn on empty `thread_id` +- [x] Handle missing context (LangGraph Server): fall back to `get_config().get("configurable", {})`; warn on empty `thread_id` +- [x] Write `test_deer_flow_context.py` covering all three paths + +### Task 12: Remove `sandbox_id` from `runtime.context` + +- [x] Delete 3× `runtime.context["sandbox_id"] = sandbox_id` writes in `sandbox/tools.py` +- [x] Delete context-based release path in `sandbox/middleware.py:after_agent` +- [x] Sandbox state flows exclusively through `runtime.state["sandbox"] = {"sandbox_id": ...}` + +### Task 13: Wire `DeerFlowContext` into Gateway runtime and client + +- [x] `deerflow/runtime/runs/worker.py`: construct `DeerFlowContext(app_config=AppConfig.current(), thread_id=thread_id)`, pass via `agent.astream(context=...)`; remove dict-context injection +- [x] `deerflow/client.py`: call `AppConfig.init(AppConfig.from_file(config_path))` in `__init__` / `_reload_config()`; construct `DeerFlowContext` at invoke time + +### Task 14: Migrate middleware/tools from dict access to typed access + +Originally planned as "replace with `resolve_context()`". Shipped as: typed middleware reads `runtime.context.xxx` directly; `resolve_context()` only where dict-context may still appear. + +- [x] `thread_data_middleware`, `uploads_middleware`, `memory_middleware`, `loop_detection_middleware`: `runtime.context.thread_id` direct read +- [x] `sandbox/middleware.py`: same +- [x] `present_file_tool`, `setup_agent_tool`, `skill_manage_tool`: same pattern (typed `ToolRuntime`) +- [x] `task_tool.py`: keep `resolve_context()` for bash-subagent guard (uses `app_config`) +- [x] `sandbox/tools.py`: keep `resolve_context()` for sandbox config + thread_id in dict-legacy paths + +Commit: `a934a822`. + +### Task 15: Middleware reads config from Runtime + +- [x] `memory_middleware`: `runtime.context.app_config.memory` — no wrapper, no `try/except` +- [x] `title_middleware`: `runtime.context.app_config.title` passed as required parameter to helpers; no `TitleConfig | None` fallback +- [x] `tool_error_handling_middleware`: reads from `AppConfig.current().guardrails` (lives outside per-invocation context) + +Commit: `a934a822`. + +### Task 16: Final cleanup and verification + +- [x] Grep verified: no remaining `runtime.context.get(...)` / `runtime.context[...]` patterns in production code (the pattern exists in `app/channels/wechat.py` but is unrelated — it's a channel-token helper, not LangGraph runtime) +- [x] Grep verified: no remaining `get_memory_config` / `get_title_config` / `get_summarization_config` / `get_subagents_app_config` / `get_guardrails_config` / `get_tool_search_config` / `get_checkpointer_config` / `get_stream_bridge_config` / `get_acp_agents` / `reload_*` / `reset_*` / `set_extensions_config` / `push_current_app_config` / `pop_current_app_config` / `load_*_from_dict` references +- [x] Full test suite passes (`make test` — 2376 passed per PR description) +- [x] CI green (backend-unit-tests) +- [x] `backend/CLAUDE.md` updated with new Config Lifecycle and `DeerFlowContext` sections + +--- + +## Follow-ups (not in Phase 1 PR) + +- Consider re-exporting `DeerFlowContext` / `resolve_context` from `deerflow.config.__init__` for ergonomic imports. +- `app/channels/wechat.py` uses `_resolve_context_token` — unrelated naming collision with `resolve_context()`. No action required but worth noting for future readers. +- **Phase 2** (below) subsumes the auto-load-warning concern: `AppConfig.current()` goes away entirely rather than getting its warning promoted to error. + +--- + +# Phase 2: Pure explicit parameter passing + +> **Status:** Shipped. P2-1..P2-5 landed first with `AppConfig.current()` kept as a transition fallback; P2-6..P2-10 landed together in commit `84dccef2` to eliminate the fallback and delete the ambient-lookup surface entirely. `AppConfig` is now a pure Pydantic value object with no process-global state and no classmethod accessors. +> +> **Design:** [§8 of the design doc](./2026-04-12-config-refactor-design.md#8-phase-2-pure-explicit-parameter-passing) + +## Shipped commits + +| Commit | Task | Category | What changed | +|--------|------|----------|--------------| +| `c45157e0` | P2-1 | infrastructure | `get_config` FastAPI dependency, `app.state.config` populated at startup | +| `70323e05` | P2-2 | G (Gateway) | 6 routers migrated to `Depends(get_config)`; reload paths dual-write `app.state.config` + `AppConfig.init()` | +| `f8738d1e` | P2-3 | H (Client) | `DeerFlowClient.__init__(config=...)` captures config locally; multi-client isolation test pins invariant | +| `23b424e7` | P2-4 | B (Agent construction) | `make_lead_agent`, `_build_middlewares`, `_resolve_model_name`, `build_lead_runtime_middlewares` accept optional `app_config` | +| `74b7a7ef` | P2-5 (partial) | D (Runtime) | `RunContext` gains `app_config` field; Worker builds `DeerFlowContext` from it; Gateway `deps.get_run_context` populates it. Standalone providers (checkpointer/store/stream_bridge) already accept optional config from Phase 1 | +| `84dccef2` | P2-6..P2-10 | C+E+F+I + deletion | Memory closure-captures `MemoryConfig`; sandbox/skills/community/factories/tools thread `app_config` end-to-end; `resolve_context()` rejects non-typed runtime.context; `AppConfig.current()` removed; `get_sandbox_provider(app_config)` required; `make_lead_agent` LangGraph-Server bootstrap path loads via `AppConfig.from_file()`. All 2337 non-e2e tests pass. | + +## Completed tasks (P2-6 through P2-10) + +All landed in `84dccef2`. + +### P2-6: Memory subsystem closure-captured config (Category C) — shipped +- [x] `MemoryConfig` captured at enqueue time so the Timer thread survives the ContextVar boundary. +- [x] `deerflow/agents/memory/{queue,updater,storage}.py` no longer read any process-global. + +### P2-7: Sandbox / skills / factories / tools / community (Categories E+F) — shipped +- [x] `sandbox/tools.py` helpers take `app_config` explicitly; the `_cached` attribute trick is gone. +- [x] `sandbox/security.py`, `sandbox/sandbox_provider.py`, `sandbox/local/local_sandbox_provider.py`, `community/aio_sandbox/aio_sandbox_provider.py` all require `app_config`. +- [x] `skills/manager.py` + `skills/loader.py` + `agents/lead_agent/prompt.py` cache refresh thread `app_config` through the worker thread via closure. +- [x] Community tools (tavily, jina, firecrawl, exa, ddg, image_search, infoquest, aio_sandbox) read `resolve_context(runtime).app_config`. +- [x] `subagents/registry.py` (`get_subagent_config`, `list_subagents`, `get_available_subagent_names`) take `app_config`. +- [x] `models/factory.py::create_chat_model` and `tools/tools.py::get_available_tools` require `app_config`. + +### P2-8: Test fixtures (Category I) — shipped +- [x] `conftest.py` autouse fixture no longer monkey-patches `AppConfig.current`; it only stubs `from_file()` so tests don't need a real `config.yaml`. +- [x] ~90 call sites migrated: `patch.object(AppConfig, "current", ...)` removed where production no longer calls it (≈56 sites), and for the remaining ~10 files whose tests called `AppConfig.current()` themselves, the tests now hold the config in a local variable and pass it explicitly. +- [x] `test_deer_flow_context.py` updated to assert that `resolve_context()` raises on dict/None contexts. +- [x] `grep -rn 'AppConfig\.current' backend/tests` is clean. + +### P2-9: Simplify `resolve_context()` — shipped +- [x] `resolve_context(runtime)` returns `runtime.context` when it is a `DeerFlowContext`; any other shape raises `RuntimeError` pointing at the composition root that should have attached the typed context. +- [x] The dict-context and `get_config().configurable` fallbacks are deleted. + +### P2-10: Delete `AppConfig` lifecycle — shipped +- [x] `AppConfig.current()` classmethod removed. +- [x] `_global` / `_override` / `init` / `set_override` / `reset_override` already gone as of Phase 1; nothing left to delete on the ambient side. +- [x] LangGraph Server bootstrap uses `AppConfig.from_file()` inside `make_lead_agent` — a pure load, not an ambient lookup. +- [x] `backend/CLAUDE.md` Config Lifecycle section rewritten to describe the explicit-parameter design. +- [x] `app/gateway/deps.py` docstrings no longer mention `AppConfig.current()`. +- [x] Production grep confirms zero `AppConfig.current()` call sites in `backend/packages` or `backend/app`. + +## Rationale + +Phase 1 fixed the **data side** (frozen ADT, no sub-module globals, pure `from_file`). Phase 2 fixes the **access side** (no ambient lookup). Together they make `AppConfig` referentially transparent: a function's result depends only on its inputs, nothing ambient. + +## Scope + +- ~97 production call sites: `AppConfig.current()` → parameter +- ~91 test mock sites: `patch.object(AppConfig, "current")` / `AppConfig._global = ...` → fixture injection +- ~30 FastAPI endpoints: add `config: AppConfig = Depends(get_config)` +- ~15 factory/helper functions: add `config: AppConfig` parameter +- Delete Phase 1 lifecycle from `app_config.py` + +## Ordering rule + +`AppConfig._global` can only be deleted **after** every caller is migrated. Tasks run in this order: + +1. Introduce new primitives alongside the old ones (Task P2-1) +2. Migrate call sites category by category (Tasks P2-2 through P2-9) +3. Delete the old lifecycle (Task P2-10) + +Each category task is independently mergeable. After a category is migrated, grep confirms the old callers in that category are gone but the old lifecycle still exists (other categories may still use it). + +## File structure (Phase 2) + +### Modified files + +| File | Change | +|------|--------| +| `app/gateway/app.py` | Store config on `app.state.config` at startup; remove `AppConfig.init()` call | +| `app/gateway/deps.py` | Add `get_config(request: Request) -> AppConfig`; remove `AppConfig.current()` uses | +| `app/gateway/routers/*.py` | Add `config: AppConfig = Depends(get_config)` to each endpoint; remove `AppConfig.current()` | +| `app/gateway/auth/reset_admin.py` | Take `config: AppConfig` parameter | +| `app/channels/service.py` | Take `config: AppConfig` parameter | +| `deerflow/client.py` | Remove `AppConfig.init()` call; store `self._config = AppConfig.from_file(...)`; all methods read `self._config` | +| `deerflow/agents/lead_agent/agent.py` | `make_lead_agent(runtime_config, app_config)`, `_build_middlewares(app_config, ...)`, pass down through every helper | +| `deerflow/agents/lead_agent/prompt.py` | Every helper takes config (or the specific sub-config slice it needs) as a parameter | +| `deerflow/agents/middlewares/tool_error_handling_middleware.py` | Take guardrails config at construction | +| `deerflow/agents/memory/queue.py` | Capture `MemoryConfig` at enqueue; Timer closure reads from capture | +| `deerflow/agents/memory/updater.py` | Constructor takes `MemoryConfig`; store on `self` | +| `deerflow/agents/memory/storage.py` | Constructor takes `MemoryConfig`; store on `self` | +| `deerflow/runtime/runs/worker.py` | Receive `AppConfig` from `RunManager`; build `DeerFlowContext` from parameter | +| `deerflow/runtime/checkpointer/provider.py` / `async_provider.py` | Constructor takes `CheckpointerConfig \| None` | +| `deerflow/runtime/store/provider.py` / `async_provider.py` | Constructor takes relevant config | +| `deerflow/runtime/stream_bridge/async_provider.py` | Constructor takes `StreamBridgeConfig \| None` | +| `deerflow/sandbox/*.py`, `deerflow/skills/*.py` | Helpers take config parameter | +| `deerflow/community/*/tools.py` | Factory takes config parameter | +| `deerflow/models/factory.py` | `create_chat_model(name, config, thinking_enabled=False)` | +| `deerflow/tools/tools.py` | `get_available_tools(config, ...)` | +| `deerflow/subagents/registry.py` | Helper takes `SubagentsAppConfig` | +| `deerflow/config/deer_flow_context.py` | Simplify `resolve_context()`: typed-only; raise on non-DeerFlowContext | +| `deerflow/config/app_config.py` | **Delete** `_global`, `_override`, `init`, `current`, `set_override`, `reset_override` | +| `backend/tests/conftest.py` | Replace `_auto_app_config` autouse fixture with per-test `test_config` fixture returning `AppConfig` | +| `backend/tests/test_*.py` | Replace `patch.object(AppConfig, "current", ...)` with passing different `AppConfig` instances | +| `backend/CLAUDE.md` | Update Config Lifecycle section to describe pure-parameter design | + +### New files + +None. Phase 2 is a pure refactor — same file set. + +--- + +## Task P2-1: Add FastAPI `Depends(get_config)` infrastructure + +Introduce the new FastAPI DI primitive. Old `AppConfig.current()` still works; this task only adds the new path. + +**Files:** +- Modify: `backend/app/gateway/app.py` +- Modify: `backend/app/gateway/deps.py` +- Test: `backend/tests/test_gateway_deps_config.py` (new) + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/test_gateway_deps_config.py +from fastapi import FastAPI, Depends +from fastapi.testclient import TestClient +from deerflow.config.app_config import AppConfig +from deerflow.config.sandbox_config import SandboxConfig +from app.gateway.deps import get_config + + +def test_get_config_returns_app_state_config(): + app = FastAPI() + cfg = AppConfig(sandbox=SandboxConfig(use="test")) + app.state.config = cfg + + @app.get("/probe") + def probe(c: AppConfig = Depends(get_config)): + return {"same": c is cfg} + + client = TestClient(app) + assert client.get("/probe").json() == {"same": True} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_gateway_deps_config.py -v +``` +Expected: FAIL — `get_config` doesn't exist or returns the wrong thing. + +- [ ] **Step 3: Add `get_config` to `deps.py`** + +```python +# backend/app/gateway/deps.py +from fastapi import Request +from deerflow.config.app_config import AppConfig + + +def get_config(request: Request) -> AppConfig: + """FastAPI dependency that returns the app-scoped AppConfig.""" + return request.app.state.config +``` + +- [ ] **Step 4: Wire startup in `app.py`** + +In `backend/app/gateway/app.py`, at startup (existing `AppConfig.init` call site), add: + +```python +app.state.config = AppConfig.from_file() +# Keep AppConfig.init() for now — other callers still use AppConfig.current() +AppConfig.init(app.state.config) +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_gateway_deps_config.py -v +``` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/gateway/deps.py backend/app/gateway/app.py backend/tests/test_gateway_deps_config.py +git commit -m "feat(config): add FastAPI get_config dependency reading from app.state" +``` + +--- + +## Task P2-2 (Category G): Migrate FastAPI routers to `Depends(get_config)` + +**Files:** +- Modify: `backend/app/gateway/routers/models.py` (2 calls) +- Modify: `backend/app/gateway/routers/mcp.py` (3 calls) +- Modify: `backend/app/gateway/routers/memory.py` (2 calls) +- Modify: `backend/app/gateway/routers/skills.py` (1 call) +- Modify: `backend/app/gateway/auth/reset_admin.py` (1 call) +- Modify: `backend/app/channels/service.py` (1 call) + +**Pattern for each endpoint:** + +```python +# Before +from deerflow.config.app_config import AppConfig + +@router.get("/models") +def list_models(): + models = AppConfig.current().models + ... + +# After +from fastapi import Depends +from app.gateway.deps import get_config + +@router.get("/models") +def list_models(config: AppConfig = Depends(get_config)): + models = config.models + ... +``` + +**For `mcp.py` / `skills.py` runtime config reload:** + +```python +# Before +AppConfig.init(AppConfig.from_file()) + +# After +request.app.state.config = AppConfig.from_file() +# Keep the AppConfig.init() call alongside for now — other consumers still need it +AppConfig.init(request.app.state.config) +``` + +- [ ] **Step 1: Migrate `models.py`** + +Replace 2 `AppConfig.current()` reads with `config: AppConfig = Depends(get_config)` parameter. + +- [ ] **Step 2: Migrate `mcp.py`** — 3 reads + 1 reload write + +- [ ] **Step 3: Migrate `memory.py`** — 2 reads + +- [ ] **Step 4: Migrate `skills.py`** — 1 read + 1 reload write + +- [ ] **Step 5: Migrate `auth/reset_admin.py`** + +`reset_admin.py` is a CLI-like entry. Signature changes to `reset_admin(config: AppConfig)`. Caller in `cli.py` (or wherever it's invoked) constructs config at top. + +- [ ] **Step 6: Migrate `app/channels/service.py`** + +Constructor or `start_channel_service(config: AppConfig)` — pass config from `app.py` where it's called. + +- [ ] **Step 7: Run full gateway test suite** + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_gateway_*.py tests/test_channels_*.py -v +``` + +- [ ] **Step 8: Grep verify Category G complete** + +```bash +cd backend && grep -rn "AppConfig\.current()" app/gateway/ app/channels/ +``` +Expected: no matches. + +- [ ] **Step 9: Commit** + +```bash +git add backend/app/gateway/ backend/app/channels/ backend/tests/ +git commit -m "refactor(config): migrate gateway routers and channels to Depends(get_config)" +``` + +--- + +## Task P2-3 (Category H): `DeerFlowClient` constructor-captured config + +**Files:** +- Modify: `backend/packages/harness/deerflow/client.py` (7 `current()` + 2 `init()` calls) +- Modify: `backend/tests/test_client.py`, `backend/tests/test_client_e2e.py` + +**Pattern:** + +```python +# Before +class DeerFlowClient: + def __init__(self, config_path: str | None = None): + if config_path is not None: + AppConfig.init(AppConfig.from_file(config_path)) + self._app_config = AppConfig.current() + + def some_method(self): + ext = AppConfig.current().extensions + ... + +# After +class DeerFlowClient: + def __init__( + self, + config_path: str | None = None, + config: AppConfig | None = None, + ): + self._config = config or AppConfig.from_file(config_path) + + def some_method(self): + ext = self._config.extensions + ... + + def _reload_config(self): + # Mutate self._config with model_copy or rebuild from file + self._config = AppConfig.from_file(...) +``` + +- [ ] **Step 1: Update constructor signature** + +Add `config: AppConfig | None = None` parameter. Construct `self._config` locally, not via `AppConfig.init() + current()`. + +- [ ] **Step 2: Replace all 7 `AppConfig.current()` calls with `self._config`** + +- [ ] **Step 3: Update `_reload_config()` to rebuild `self._config`** + +- [ ] **Step 4: Write test for multi-client isolation** + +```python +# backend/tests/test_client_multi_isolation.py +from deerflow.client import DeerFlowClient +from deerflow.config.app_config import AppConfig +from deerflow.config.sandbox_config import SandboxConfig +from deerflow.config.memory_config import MemoryConfig + + +def test_two_clients_different_configs_do_not_contend(): + cfg_a = AppConfig(sandbox=SandboxConfig(use="test"), memory=MemoryConfig(enabled=True)) + cfg_b = AppConfig(sandbox=SandboxConfig(use="test"), memory=MemoryConfig(enabled=False)) + + client_a = DeerFlowClient(config=cfg_a) + client_b = DeerFlowClient(config=cfg_b) + + assert client_a._config.memory.enabled is True + assert client_b._config.memory.enabled is False + # Verify mutation of one client's config does not affect the other + # (impossible because frozen, but verify via identity too) + assert client_a._config is cfg_a + assert client_b._config is cfg_b +``` + +- [ ] **Step 5: Run test to verify multi-client works** + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_client_multi_isolation.py -v +``` + +- [ ] **Step 6: Update existing client tests** + +Replace `AppConfig.init(MagicMock(...))` patterns in `test_client.py` with constructing `AppConfig` instances and passing via `DeerFlowClient(config=cfg)`. + +- [ ] **Step 7: Run full client test suite** + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_client*.py -v +``` + +- [ ] **Step 8: Grep verify Category H complete** + +```bash +cd backend && grep -n "AppConfig\.current()\|AppConfig\.init(" packages/harness/deerflow/client.py +``` +Expected: no matches. + +- [ ] **Step 9: Commit** + +```bash +git add backend/packages/harness/deerflow/client.py backend/tests/ +git commit -m "refactor(config): DeerFlowClient captures config in constructor" +``` + +--- + +## Task P2-4 (Category B): Agent construction — thread `AppConfig` from `make_lead_agent` + +**Files:** +- Modify: `backend/packages/harness/deerflow/agents/lead_agent/agent.py` (5 calls) +- Modify: `backend/packages/harness/deerflow/agents/lead_agent/prompt.py` (5 calls) +- Modify: `backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py` (1 call) + +**Pattern:** + +```python +# Before +def make_lead_agent(config: RunnableConfig) -> CompiledStateGraph: + app_config = AppConfig.current() + model_name = _resolve_runtime_model_name(config) + ... + +def _build_middlewares(config, runtime_config): + if AppConfig.current().token_usage.enabled: + ... + +# After +def make_lead_agent(config: RunnableConfig, app_config: AppConfig) -> CompiledStateGraph: + model_name = _resolve_runtime_model_name(config, app_config) + ... + +def _build_middlewares(app_config: AppConfig, runtime_config: RunnableConfig): + if app_config.token_usage.enabled: + ... +``` + +- [ ] **Step 1: Update `make_lead_agent` signature and internal calls** + +Add `app_config: AppConfig` parameter. Replace all 5 `AppConfig.current()` calls with `app_config.xxx`. + +- [ ] **Step 2: Update `_build_middlewares`, `_create_*_middleware` helpers** + +Thread `app_config` through each helper that previously called `AppConfig.current()`. + +- [ ] **Step 3: Update `prompt.py` helpers** + +Every function that previously called `AppConfig.current()` now takes the relevant config slice as a parameter. Caller (either `apply_prompt_template` or `make_lead_agent`) provides it. + +- [ ] **Step 4: Update `tool_error_handling_middleware.py`** + +Guardrail config is needed at middleware construction. Pass `GuardrailsConfig` to the middleware's `__init__`. + +- [ ] **Step 5: Update the two call sites of `make_lead_agent`** + +- `backend/langgraph.json` (or wherever LangGraph Server registers the agent) — the registration function wraps `make_lead_agent` and must supply `app_config`. If LangGraph Server doesn't support injecting extra args, wrap: + + ```python + def _lead_agent_for_langgraph(config: RunnableConfig): + return make_lead_agent(config, AppConfig.from_file()) + ``` + + (LangGraph Server still reads config from file — there's no central config broker in that process yet.) + +- `backend/packages/harness/deerflow/client.py` — already has `self._config`, pass it: `make_lead_agent(config, self._config)`. + +- [ ] **Step 6: Run agent tests** + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_lead_agent*.py -v +``` + +- [ ] **Step 7: Grep verify Category B complete** + +```bash +cd backend && grep -n "AppConfig\.current()" packages/harness/deerflow/agents/lead_agent/ packages/harness/deerflow/agents/middlewares/ +``` +Expected: no matches. + +- [ ] **Step 8: Commit** + +```bash +git add backend/packages/harness/deerflow/agents/ backend/langgraph.json backend/packages/harness/deerflow/client.py backend/tests/ +git commit -m "refactor(config): thread AppConfig through lead agent construction" +``` + +--- + +## Task P2-5 (Category D): Runtime infrastructure takes config at construction + +**Files:** +- Modify: `deerflow/runtime/checkpointer/provider.py` (2 calls), `async_provider.py` (1 call) +- Modify: `deerflow/runtime/store/provider.py` (2 calls), `async_provider.py` (1 call) +- Modify: `deerflow/runtime/stream_bridge/async_provider.py` (1 call) +- Modify: `deerflow/runtime/runs/worker.py` (1 call) + +**Pattern:** + +```python +# Before +class CheckpointerProvider: + def get(self): + config = AppConfig.current().checkpointer + ... + +# After +class CheckpointerProvider: + def __init__(self, config: CheckpointerConfig | None): + self._config = config + + def get(self): + config = self._config + ... +``` + +Callers construct these providers at startup (from `app/gateway/app.py` or `DeerFlowClient.__init__`) with the relevant config slice. + +- [ ] **Step 1: Update `CheckpointerProvider` constructor + `get_checkpointer_provider()` factory** + +The factory may need to go from a module-level singleton getter to one that accepts config. Alternatively, the factory stays but takes config as parameter. + +- [ ] **Step 2: Update `StoreProvider` analogously** + +- [ ] **Step 3: Update `StreamBridgeProvider` analogously** + +- [ ] **Step 4: Update `worker.py`** + +`Worker` already receives a `RunManager`; `RunManager` receives config at construction time (from Gateway `app.py`) and forwards to `Worker`. Replace `AppConfig.current()` in worker with the injected config. + +- [ ] **Step 5: Update `RunManager` construction in `app/gateway/app.py`** + +Pass `app.state.config` into `RunManager(..., config=app.state.config)`. + +- [ ] **Step 6: Run runtime tests** + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_checkpointer*.py tests/test_store*.py tests/test_stream_bridge*.py tests/test_worker*.py -v +``` + +- [ ] **Step 7: Grep verify Category D complete** + +```bash +cd backend && grep -rn "AppConfig\.current()" packages/harness/deerflow/runtime/ +``` +Expected: no matches. + +- [ ] **Step 8: Commit** + +```bash +git add backend/packages/harness/deerflow/runtime/ backend/app/gateway/app.py backend/tests/ +git commit -m "refactor(config): runtime providers take config at construction" +``` + +--- + +## Task P2-6 (Category C): Memory subsystem — closure-captured config + +**Files:** +- Modify: `deerflow/agents/memory/queue.py` (2 calls) +- Modify: `deerflow/agents/memory/updater.py` (3 calls) +- Modify: `deerflow/agents/memory/storage.py` (3 calls) + +This category is the trickiest because the Timer callback runs on a thread without Runtime. Config must be captured at enqueue time into the closure. + +**Pattern:** + +```python +# Before — config read from ambient state on Timer thread +class MemoryQueue: + def add(self, conversation, user_id): + config = AppConfig.current().memory # may not exist on Timer thread + if not config.enabled: + return + # schedule Timer ... + +# After — config captured at enqueue time +class MemoryQueue: + def __init__(self, updater: MemoryUpdater, config: MemoryConfig): + self._updater = updater + self._config = config + + def add(self, conversation, user_id): + config = self._config # captured at construction + if not config.enabled: + return + # Timer callback closes over `config` and `conversation` + def _flush(): + self._updater.update(conversation, user_id, config) + self._timer = Timer(config.debounce_seconds, _flush) + self._timer.start() +``` + +- [ ] **Step 1: Add `MemoryConfig` parameter to `MemoryStorage.__init__`** + +Replace all 3 `AppConfig.current().memory` reads with `self._config.memory` field accesses. + +- [ ] **Step 2: Add `MemoryConfig` parameter to `MemoryUpdater.__init__`** + +Same pattern. + +- [ ] **Step 3: Add `MemoryConfig` parameter to `MemoryQueue.__init__`** + +Same pattern. Timer callbacks close over `self._config`. + +- [ ] **Step 4: Update the factory / caller path** + +`MemoryMiddleware` (the consumer) currently constructs `MemoryQueue` lazily. Now it must get `MemoryConfig` from `runtime.context.app_config.memory` in `before_model`, and construct the queue with that config. Cache construction by config identity if re-construction on every invocation is too expensive. + +Alternatively: `MemoryMiddleware.__init__(config: MemoryConfig)` and the config is supplied at middleware-chain construction time (from `make_lead_agent` → `_build_middlewares`). + +- [ ] **Step 5: Write regression test for Timer thread** + +```python +# backend/tests/test_memory_queue_timer_captures_config.py +def test_timer_callback_uses_captured_config(): + """Verify Timer callback reads config from closure, not ambient state.""" + cfg = MemoryConfig(enabled=True, debounce_seconds=0.01, ...) + updater = MagicMock() + queue = MemoryQueue(updater=updater, config=cfg) + + queue.add(conversation=..., user_id="u1") + time.sleep(0.05) + + # Verify updater was called with the captured cfg, not a re-read from AppConfig + assert updater.update.called +``` + +- [ ] **Step 6: Run memory tests** + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_memory*.py -v +``` + +- [ ] **Step 7: Grep verify Category C complete** + +```bash +cd backend && grep -rn "AppConfig\.current()" packages/harness/deerflow/agents/memory/ +``` +Expected: no matches. + +- [ ] **Step 8: Commit** + +```bash +git add backend/packages/harness/deerflow/agents/memory/ backend/tests/ +git commit -m "refactor(config): memory subsystem captures config at construction/enqueue" +``` + +--- + +## Task P2-7 (Category E+F): Sandbox / skills / factories / tools / community — parameter threading + +This is the largest mechanical task by file count. All follow the same pattern: add `config: AppConfig` (or a sub-config slice) to the function signature, replace `AppConfig.current()` with the parameter. + +**Files:** +- `deerflow/sandbox/local/local_sandbox_provider.py` (1), `sandbox_provider.py` (1), `security.py` (2) +- `deerflow/sandbox/tools.py` (5 — these already use `resolve_context()`; no change) +- `deerflow/skills/loader.py` (1), `manager.py` (1), `security_scanner.py` (1) +- `deerflow/models/factory.py` (1) +- `deerflow/tools/tools.py` (2) +- `deerflow/subagents/registry.py` (1) +- `deerflow/utils/file_conversion.py` (1) +- `deerflow/community/aio_sandbox/aio_sandbox_provider.py` (2) +- `deerflow/community/tavily/tools.py` (2) +- `deerflow/community/jina_ai/tools.py` (1) +- `deerflow/community/infoquest/tools.py` (3) +- `deerflow/community/image_search/tools.py` (1) +- `deerflow/community/firecrawl/tools.py` (2) +- `deerflow/community/exa/tools.py` (2) +- `deerflow/community/ddg_search/tools.py` (1) + +**Pattern:** + +```python +# Before +def get_available_tools(groups, include_mcp=True, model_name=None, subagent_enabled=False): + config = AppConfig.current() + ... + +# After +def get_available_tools( + app_config: AppConfig, + groups=None, + include_mcp=True, + model_name=None, + subagent_enabled=False, +): + config = app_config + ... +``` + +**Caller responsibility:** whoever calls `get_available_tools()` must have `AppConfig` in scope. For agent construction that's `make_lead_agent(config, app_config)` from Task P2-4. For factory tools registered via `use:` strings in config, the `tools.py` resolution pass threads `app_config` through. + +- [ ] **Step 1: Update `deerflow/models/factory.py`** + +`create_chat_model(name, thinking_enabled=False)` → `create_chat_model(name, app_config, thinking_enabled=False)`. Every caller (agent.py, client.py memory-updater internal model setup) passes `app_config`. + +- [ ] **Step 2: Update `deerflow/tools/tools.py`** + +`get_available_tools(...)` signature gains `app_config: AppConfig`. Community tool resolution inside it also threads config. + +- [ ] **Step 3: Update `deerflow/subagents/registry.py`** + +- [ ] **Step 4: Update `deerflow/sandbox/*.py` (non-tools)** + +Provider construction takes config. `security.py` helpers take config parameter. + +- [ ] **Step 5: Update `deerflow/skills/*.py`** + +Loader / manager / scanner take config parameter. + +- [ ] **Step 6: Update `deerflow/utils/file_conversion.py`** + +- [ ] **Step 7: Update community tool factories** + +Each `community//tools.py` factory now accepts `app_config`. The `tools.py` resolution pass (Step 2) supplies it when instantiating. + +- [ ] **Step 8: Run affected test files** + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_tool*.py tests/test_skill*.py tests/test_sandbox*.py tests/test_community*.py tests/test_*tool*.py -v +``` + +- [ ] **Step 9: Grep verify Category E+F complete** + +```bash +cd backend && grep -rn "AppConfig\.current()" packages/harness/deerflow/{sandbox,skills,models,tools,subagents,utils,community}/ +``` +Expected: no matches (except `sandbox/tools.py` may retain `resolve_context()` calls for dict-legacy paths — those are fine). + +- [ ] **Step 10: Commit** + +```bash +git add backend/packages/harness/deerflow/ backend/tests/ +git commit -m "refactor(config): thread AppConfig through sandbox/skills/factories/tools" +``` + +--- + +## Task P2-8 (Category I): Test fixtures + +**Files:** +- Modify: `backend/tests/conftest.py` +- Modify: ~18 test files using `patch.object(AppConfig, "current")` or `AppConfig._global = ...` + +**Pattern:** + +```python +# Before — conftest.py autouse fixture +@pytest.fixture(autouse=True) +def _auto_app_config(): + previous_global = AppConfig._global + AppConfig._global = AppConfig(sandbox=SandboxConfig(use="test")) + try: + yield + finally: + AppConfig._global = previous_global + + +# Before — test using it +def test_something(): + with patch.object(AppConfig, "current", return_value=AppConfig(...)): + result = function_under_test() + +# After — conftest.py fixture returns config +@pytest.fixture +def test_config() -> AppConfig: + """Minimal AppConfig for tests that need one.""" + return AppConfig(sandbox=SandboxConfig(use="test")) + + +# After — test passes config explicitly +def test_something(test_config): + overridden = test_config.model_copy(update={"memory": MemoryConfig(enabled=False)}) + result = function_under_test(config=overridden) +``` + +- [ ] **Step 1: Update `conftest.py`** + +Replace `_auto_app_config` autouse fixture with a non-autouse `test_config` fixture. The autouse is no longer needed because `AppConfig.current()` no longer exists after P2-10. + +**Note:** Do not remove autouse yet. Tests that still call `AppConfig.current()` (pre-migration) would break. Instead: +- Add the new `test_config` fixture +- Keep autouse for now so old tests still work +- Remove autouse only in Task P2-10 alongside deletion of `current()` + +- [ ] **Step 2: Migrate tests by module, starting with most isolated** + +For each test file using `patch.object(AppConfig, "current", ...)`: +- Replace with fixture injection: `def test_xxx(test_config)` and pass `test_config` (or a `model_copy(update=...)` variant) into the function under test. + +Per-file migration order (smallest blast radius first): +1. `test_memory_updater.py` (14 occurrences) — Memory subsystem already took config parameter in P2-6 +2. `test_client.py` (20 occurrences) — Client already took config in P2-3 +3. `test_checkpointer.py` (11 occurrences) — Providers took config in P2-5 +4. `test_memory_storage.py` (10 occurrences) +5. Remaining files + +- [ ] **Step 3: Verify all tests pass after each file migration** + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/.py -v +``` + +- [ ] **Step 4: Commit after each file (keeps diffs reviewable)** + +```bash +git commit -m "refactor(tests): migrate to explicit config fixture" +``` + +- [ ] **Step 5: Final grep verify** + +```bash +cd backend && grep -rn "patch\.object(AppConfig, \"current\"" tests/ +cd backend && grep -rn "AppConfig\._global" tests/ +``` +Expected: no matches. + +--- + +## Task P2-9: Simplify `resolve_context()` + +**Files:** +- Modify: `backend/packages/harness/deerflow/config/deer_flow_context.py` +- Test: `backend/tests/test_deer_flow_context.py` + +After P2-2 through P2-8, every caller that invokes `resolve_context()` either passes a typed `DeerFlowContext` or a dict. The dict path's `AppConfig.current()` fallback is no longer reachable if all construction sites are explicit. + +- [ ] **Step 1: Update `test_deer_flow_context.py` to expect hard failure on non-DeerFlowContext** + +```python +def test_resolve_context_raises_on_missing_context(): + runtime = MagicMock() + runtime.context = None + with pytest.raises(RuntimeError, match="not a DeerFlowContext"): + resolve_context(runtime) + +def test_resolve_context_raises_on_dict_context(): + runtime = MagicMock() + runtime.context = {"thread_id": "t1"} + with pytest.raises(RuntimeError, match="not a DeerFlowContext"): + resolve_context(runtime) +``` + +- [ ] **Step 2: Simplify `resolve_context()`** + +```python +def resolve_context(runtime: Any) -> DeerFlowContext: + ctx = getattr(runtime, "context", None) + if isinstance(ctx, DeerFlowContext): + return ctx + raise RuntimeError( + "runtime.context is not a DeerFlowContext. Every caller must " + "construct and inject one explicitly; there is no global fallback." + ) +``` + +- [ ] **Step 3: Run `test_deer_flow_context.py`** + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_deer_flow_context.py -v +``` + +- [ ] **Step 4: Run full test suite to catch any missed dict-context callers** + +```bash +cd backend && PYTHONPATH=. uv run pytest -v +``` + +If failures surface, they indicate a caller that was still relying on dict-context fallback. Fix by constructing proper `DeerFlowContext`. + +- [ ] **Step 5: Commit** + +```bash +git add backend/packages/harness/deerflow/config/deer_flow_context.py backend/tests/test_deer_flow_context.py +git commit -m "refactor(config): resolve_context requires typed DeerFlowContext" +``` + +--- + +## Task P2-10: Delete `AppConfig` lifecycle + +**Files:** +- Modify: `backend/packages/harness/deerflow/config/app_config.py` +- Modify: `backend/tests/conftest.py` (remove `_auto_app_config` autouse fixture) +- Modify: `backend/tests/test_app_config_reload.py` (delete or rewrite as pure `from_file()` test) +- Modify: `backend/CLAUDE.md` (update Config Lifecycle section) + +Final deletion. Grep must show no callers of `AppConfig.current()`, `AppConfig.init()`, `AppConfig.set_override()`, `AppConfig.reset_override()` in production or tests. + +- [ ] **Step 1: Final grep — verify no callers remain** + +```bash +cd backend && grep -rn "AppConfig\.\(current\|init\|set_override\|reset_override\)" packages/ app/ tests/ +``` +Expected: no matches (except the `app_config.py` definitions themselves). + +If any match, return to the relevant Category task and finish the migration. + +- [ ] **Step 2: Delete from `app_config.py`** + +Remove: +- `_global: ClassVar[AppConfig | None]` +- `_override: ClassVar[ContextVar[AppConfig]]` +- `init()`, `set_override()`, `reset_override()`, `current()` +- The comment block `"# -- Lifecycle (process-global + per-context override) --"` +- Unused imports: `ContextVar`, `Token`, `ClassVar` + +The class reduces to: Pydantic fields + `from_file()`, `resolve_config_path()`, `resolve_env_variables()`, `_check_config_version()`, `get_model_config()`, `get_tool_config()`, `get_tool_group_config()`. + +- [ ] **Step 3: Remove `_auto_app_config` autouse fixture from `conftest.py`** + +Keep only the explicit `test_config` fixture (non-autouse). + +- [ ] **Step 4: Delete or rewrite `test_app_config_reload.py`** + +The tests covered `init` / `set_override` / auto-load, all of which are gone. Rewrite as a single test: + +```python +def test_from_file_is_pure(tmp_path): + config_file = tmp_path / "config.yaml" + config_file.write_text("config_version: 6\nsandbox:\n use: test\n") + + result1 = AppConfig.from_file(str(config_file)) + result2 = AppConfig.from_file(str(config_file)) + + # Different objects (Pydantic doesn't intern) + assert result1 is not result2 + # But equal values + assert result1 == result2 + # Frozen — cannot mutate + with pytest.raises(ValidationError): + result1.log_level = "debug" +``` + +- [ ] **Step 5: Update `backend/CLAUDE.md`** + +Rewrite the "Config Lifecycle" section: + +```markdown +**Config Lifecycle**: All config models are `frozen=True` (immutable after construction). `AppConfig.from_file()` is a pure function — no side effects. There is no process-global or ContextVar — every consumer receives `AppConfig` as an explicit parameter. + +- `app/gateway/app.py` loads config at startup and stores on `app.state.config`; routers access via `Depends(get_config)` +- `DeerFlowClient.__init__(config_path=..., config=...)` captures config as `self._config` +- Agent execution path: `DeerFlowContext(app_config=..., thread_id=...)` injected via LangGraph `Runtime[DeerFlowContext]` +- Background threads (memory debounce Timer): config captured at enqueue time in closure +- Tests: use the `test_config` fixture or construct `AppConfig` directly +``` + +- [ ] **Step 6: Run full test suite** + +```bash +cd backend && PYTHONPATH=. uv run pytest -v +``` +Expected: all pass. + +- [ ] **Step 7: Run linter** + +```bash +cd backend && make lint +``` + +- [ ] **Step 8: Commit** + +```bash +git add backend/packages/harness/deerflow/config/app_config.py backend/tests/conftest.py backend/tests/test_app_config_reload.py backend/CLAUDE.md +git commit -m "refactor(config): delete AppConfig process-global and ContextVar lifecycle" +``` + +--- + +## Verification — Phase 2 complete + +- [ ] **No global lookup remains** + +```bash +cd backend && grep -rn "AppConfig\.current()\|AppConfig\._global\|AppConfig\._override\|AppConfig\.init(\|AppConfig\.set_override(\|AppConfig\.reset_override(" packages/ app/ tests/ +``` +Expected: no matches. + +- [ ] **`AppConfig` is a pure value object** + +Read `backend/packages/harness/deerflow/config/app_config.py`. It should contain: Pydantic fields, `from_file()`, `resolve_config_path()`, `resolve_env_variables()`, `_check_config_version()`, `get_model_config()`, `get_tool_config()`, `get_tool_group_config()`. Nothing else. + +- [ ] **Multi-client isolation works** + +`tests/test_client_multi_isolation.py` passes — two clients with different configs coexist. + +- [ ] **Full test suite green** + +```bash +cd backend && PYTHONPATH=. uv run pytest -v && make lint +``` + +- [ ] **Commit log tells the story** + +```bash +git log --oneline refactor/explicit-config-p2 +``` +Shows ~10 commits, each scoped to one Category. diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 99ec72c39..180c590e7 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -55,7 +55,7 @@ import { DropdownMenuLabel, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; -import { fetch } from "@/core/api/fetcher"; +import { fetchWithAuth } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; @@ -403,16 +403,19 @@ export function InputBox({ setFollowupsLoading(true); setFollowups([]); - fetch(`${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - messages: recent, - n: 3, - model_name: context.model_name ?? undefined, - }), - signal: controller.signal, - }) + fetchWithAuth( + `${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: recent, + n: 3, + model_name: context.model_name ?? undefined, + }), + signal: controller.signal, + }, + ) .then(async (res) => { if (!res.ok) { return { suggestions: [] as string[] }; diff --git a/frontend/src/components/workspace/settings/account-settings-page.tsx b/frontend/src/components/workspace/settings/account-settings-page.tsx index 6d3df844d..c00d6961e 100644 --- a/frontend/src/components/workspace/settings/account-settings-page.tsx +++ b/frontend/src/components/workspace/settings/account-settings-page.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { fetch, getCsrfHeaders } from "@/core/api/fetcher"; +import { fetchWithAuth, getCsrfHeaders } from "@/core/api/fetcher"; import { useAuth } from "@/core/auth/AuthProvider"; import { parseAuthError } from "@/core/auth/types"; @@ -36,7 +36,7 @@ export function AccountSettingsPage() { setLoading(true); try { - const res = await fetch("/api/v1/auth/change-password", { + const res = await fetchWithAuth("/api/v1/auth/change-password", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/frontend/src/content/en/application/configuration.mdx b/frontend/src/content/en/application/configuration.mdx index 3eeca52a1..fb0fedc89 100644 --- a/frontend/src/content/en/application/configuration.mdx +++ b/frontend/src/content/en/application/configuration.mdx @@ -11,10 +11,10 @@ DeerFlow App is configured through two files and a set of environment variables. ## Configuration files -| File | Purpose | -| ------------------------ | --------------------------------------------------------------------------------------- | -| `config.yaml` | Backend configuration: models, sandbox, tools, skills, memory, and all Harness settings | -| `extensions_config.json` | MCP servers and skill enable/disable state (managed by the App UI and Gateway API) | +| File | Purpose | +|---|---| +| `config.yaml` | Backend configuration: models, sandbox, tools, skills, memory, and all Harness settings | +| `extensions_config.json` | MCP servers and skill enable/disable state (managed by the App UI and Gateway API) | Frontend environment variables control the Next.js build and runtime behavior. @@ -144,7 +144,6 @@ sandbox: ``` Install: `cd backend && uv add 'deerflow-harness[aio-sandbox]'` - ```yaml @@ -162,7 +161,7 @@ Configure which tools the agent has access to. The defaults use DuckDuckGo (no A ```yaml tools: # Web search (choose one) - - use: deerflow.community.ddg_search.tools:web_search_tool # default, no key required + - use: deerflow.community.ddg_search.tools:web_search_tool # default, no key required # - use: deerflow.community.tavily.tools:web_search_tool # api_key: $TAVILY_API_KEY @@ -189,7 +188,7 @@ By default, DeerFlow uses an SQLite checkpointer for thread state persistence: ```yaml checkpointer: type: sqlite - connection_string: checkpoints.db # stored in backend/.deer-flow/ + connection_string: checkpoints.db # stored in backend/.deer-flow/ ``` For production deployments with multiple processes: @@ -225,12 +224,12 @@ memory: Set these before running `pnpm build` or starting the frontend in production: -| Variable | Required | Description | -| --------------------- | -------------------------- | ---------------------------------------------------------------- | -| `BETTER_AUTH_SECRET` | **Required** in production | Secret for session signing. Use `openssl rand -base64 32`. | -| `BETTER_AUTH_URL` | Recommended | Public-facing base URL (e.g., `https://your-domain.com`) | -| `SKIP_ENV_VALIDATION` | Optional | Set to `1` to skip env validation during build (not recommended) | -| `NEXT_PUBLIC_API_URL` | Optional | Override the API base URL for the frontend | +| Variable | Required | Description | +|---|---|---| +| `BETTER_AUTH_SECRET` | **Required** in production | Secret for session signing. Use `openssl rand -base64 32`. | +| `BETTER_AUTH_URL` | Recommended | Public-facing base URL (e.g., `https://your-domain.com`) | +| `SKIP_ENV_VALIDATION` | Optional | Set to `1` to skip env validation during build (not recommended) | +| `NEXT_PUBLIC_API_URL` | Optional | Override the API base URL for the frontend | In development, set these in a `.env` file at the repo root: @@ -269,12 +268,6 @@ make config-upgrade ``` - - + + diff --git a/frontend/src/content/en/application/deployment-guide.mdx b/frontend/src/content/en/application/deployment-guide.mdx index 04b3599c0..8d5542dbb 100644 --- a/frontend/src/content/en/application/deployment-guide.mdx +++ b/frontend/src/content/en/application/deployment-guide.mdx @@ -1,5 +1,5 @@ --- -title: Deployment Guide +title: Deployment Guide description: "This guide covers all supported deployment methods for DeerFlow App: local development, Docker Compose, and production with Kubernetes-managed sandboxes." --- diff --git a/frontend/src/content/en/application/index.mdx b/frontend/src/content/en/application/index.mdx index 2cb15a911..f61f18775 100644 --- a/frontend/src/content/en/application/index.mdx +++ b/frontend/src/content/en/application/index.mdx @@ -17,15 +17,15 @@ DeerFlow App is the reference implementation of what a production DeerFlow exper ## What the App provides -| Capability | Description | -| ----------------------- | ---------------------------------------------------------------------------------------------------- | -| **Web workspace** | Browser-based conversation UI with support for threads, artifacts, file uploads, and skill selection | -| **Custom agents** | Create and manage named agents with different models, skills, and tool sets | -| **Thread management** | Persistent conversation threads with checkpointing and history | -| **Streaming responses** | Real-time token streaming with thinking steps and tool call visibility | -| **Artifact viewer** | In-browser preview and download of files and outputs produced by the agent | -| **Extensions UI** | Enable/disable MCP servers and skills without editing config files | -| **Gateway API** | FastAPI-based REST API that bridges the frontend and the LangGraph runtime | +| Capability | Description | +|---|---| +| **Web workspace** | Browser-based conversation UI with support for threads, artifacts, file uploads, and skill selection | +| **Custom agents** | Create and manage named agents with different models, skills, and tool sets | +| **Thread management** | Persistent conversation threads with checkpointing and history | +| **Streaming responses** | Real-time token streaming with thinking steps and tool call visibility | +| **Artifact viewer** | In-browser preview and download of files and outputs produced by the agent | +| **Extensions UI** | Enable/disable MCP servers and skills without editing config files | +| **Gateway API** | FastAPI-based REST API that bridges the frontend and the LangGraph runtime | ## Architecture @@ -58,18 +58,15 @@ The DeerFlow App runs as four services behind a single nginx reverse proxy: ## Technology stack -| Layer | Technology | -| ----------------- | -------------------------------------------------------------------- | -| Frontend | Next.js 16, React 19, TypeScript, pnpm | -| Gateway | FastAPI, Python 3.12, uvicorn | -| Agent runtime | LangGraph, LangChain, DeerFlow Harness | -| Reverse proxy | nginx | +| Layer | Technology | +|---|---| +| Frontend | Next.js 16, React 19, TypeScript, pnpm | +| Gateway | FastAPI, Python 3.12, uvicorn | +| Agent runtime | LangGraph, LangChain, DeerFlow Harness | +| Reverse proxy | nginx | | State persistence | LangGraph Server (default) + optional SQLite/PostgreSQL checkpointer | - + diff --git a/frontend/src/content/en/application/operations-and-troubleshooting.mdx b/frontend/src/content/en/application/operations-and-troubleshooting.mdx index 8b21cf4b4..e12f4d4f1 100644 --- a/frontend/src/content/en/application/operations-and-troubleshooting.mdx +++ b/frontend/src/content/en/application/operations-and-troubleshooting.mdx @@ -13,12 +13,12 @@ This page covers day-to-day operational tasks and solutions to common problems w All services write logs to the `logs/` directory when started with `make dev`: -| File | Service | -| -------------------- | ------------------------------------ | +| File | Service | +|---|---| | `logs/langgraph.log` | LangGraph / DeerFlow Harness runtime | -| `logs/gateway.log` | FastAPI Gateway API | -| `logs/frontend.log` | Next.js frontend dev server | -| `logs/nginx.log` | nginx reverse proxy | +| `logs/gateway.log` | FastAPI Gateway API | +| `logs/frontend.log` | Next.js frontend dev server | +| `logs/nginx.log` | nginx reverse proxy | Tail logs in real time: @@ -30,7 +30,7 @@ tail -f logs/gateway.log Adjust the runtime log level in `config.yaml`: ```yaml -log_level: debug # debug | info | warning | error +log_level: debug # debug | info | warning | error ``` ## Health checks @@ -171,9 +171,6 @@ make dev Individual service restart scripts are in `scripts/`. For targeted restarts, you can kill and relaunch individual processes manually using the PIDs in the log files. - + diff --git a/frontend/src/content/en/application/quick-start.mdx b/frontend/src/content/en/application/quick-start.mdx index 5ecfb3a26..d9b4a3e7d 100644 --- a/frontend/src/content/en/application/quick-start.mdx +++ b/frontend/src/content/en/application/quick-start.mdx @@ -24,13 +24,13 @@ make check Required: -| Tool | Minimum version | -| ------- | ------------------ | -| Python | 3.12 | -| uv | latest | -| Node.js | 22 | -| pnpm | 10 | -| nginx | any recent version | +| Tool | Minimum version | +|---|---| +| Python | 3.12 | +| uv | latest | +| Node.js | 22 | +| pnpm | 10 | +| nginx | any recent version | On macOS, install with `brew install python uv node pnpm nginx`. On Linux, use your distribution's package manager. @@ -87,7 +87,6 @@ make dev ``` This starts: - - LangGraph server on port `2024` - Gateway API on port `8001` - Frontend on port `3000` @@ -111,12 +110,12 @@ make stop Log files: -| Service | Log file | -| --------- | -------------------- | +| Service | Log file | +|---|---| | LangGraph | `logs/langgraph.log` | -| Gateway | `logs/gateway.log` | -| Frontend | `logs/frontend.log` | -| nginx | `logs/nginx.log` | +| Gateway | `logs/gateway.log` | +| Frontend | `logs/frontend.log` | +| nginx | `logs/nginx.log` | If something is not working, check the log files first. Most startup errors @@ -125,9 +124,6 @@ Log files: - + diff --git a/frontend/src/content/en/application/workspace-usage.mdx b/frontend/src/content/en/application/workspace-usage.mdx index 686614aa7..8589c624c 100644 --- a/frontend/src/content/en/application/workspace-usage.mdx +++ b/frontend/src/content/en/application/workspace-usage.mdx @@ -74,9 +74,6 @@ If you have created custom agents, use the **Agent** selector in the input bar t Custom agents may have different models, skills, tool sets, and system prompts. See [Agents and Threads](/docs/application/agents-and-threads) for how to create and manage custom agents. - + diff --git a/frontend/src/content/en/harness/configuration.mdx b/frontend/src/content/en/harness/configuration.mdx index 89de12716..8043e2beb 100644 --- a/frontend/src/content/en/harness/configuration.mdx +++ b/frontend/src/content/en/harness/configuration.mdx @@ -81,7 +81,7 @@ models: use: langchain_openai:ChatOpenAI model: gpt-4o api_key: $OPENAI_API_KEY - some_provider_specific_option: value # passed through to ChatOpenAI constructor + some_provider_specific_option: value # passed through to ChatOpenAI constructor ``` ## Configuration version @@ -104,27 +104,27 @@ This merges new fields from `config.example.yaml` into your existing `config.yam The following table maps each top-level `config.yaml` section to its documentation page: -| Section | Description | Documentation | -| ----------------- | ------------------------------------------------ | -------------------------------------------------------- | -| `log_level` | Logging level (`debug`/`info`/`warning`/`error`) | — | -| `models` | Available LLM models | [Lead Agent](/docs/harness/lead-agent) | -| `token_usage` | Token tracking per model call | [Middlewares](/docs/harness/middlewares) | -| `tools` | Available agent tools | [Tools](/docs/harness/tools) | -| `tool_groups` | Named groups of tools | [Tools](/docs/harness/tools) | -| `tool_search` | Deferred/on-demand tool loading | [Tools](/docs/harness/tools) | -| `sandbox` | Sandbox provider and options | [Sandbox](/docs/harness/sandbox) | -| `skills` | Skills directory and container path | [Skills](/docs/harness/skills) | -| `skill_evolution` | Agent-managed skill creation | [Skills](/docs/harness/skills) | -| `subagents` | Subagent timeouts and max turns | [Subagents](/docs/harness/subagents) | -| `acp_agents` | External ACP agent integrations | [Subagents](/docs/harness/subagents) | -| `memory` | Cross-session memory storage | [Memory](/docs/harness/memory) | -| `summarization` | Conversation summarization | [Middlewares](/docs/harness/middlewares) | -| `title` | Automatic thread title generation | [Middlewares](/docs/harness/middlewares) | -| `checkpointer` | Thread state persistence | [Agents & Threads](/docs/application/agents-and-threads) | -| `guardrails` | Tool call authorization | — | -| `stream_bridge` | Streaming configuration | — | -| `uploads` | File upload settings (PDF converter) | — | -| `channels` | IM channel integrations (Feishu, Slack, etc.) | — | +| Section | Description | Documentation | +|---|---|---| +| `log_level` | Logging level (`debug`/`info`/`warning`/`error`) | — | +| `models` | Available LLM models | [Lead Agent](/docs/harness/lead-agent) | +| `token_usage` | Token tracking per model call | [Middlewares](/docs/harness/middlewares) | +| `tools` | Available agent tools | [Tools](/docs/harness/tools) | +| `tool_groups` | Named groups of tools | [Tools](/docs/harness/tools) | +| `tool_search` | Deferred/on-demand tool loading | [Tools](/docs/harness/tools) | +| `sandbox` | Sandbox provider and options | [Sandbox](/docs/harness/sandbox) | +| `skills` | Skills directory and container path | [Skills](/docs/harness/skills) | +| `skill_evolution` | Agent-managed skill creation | [Skills](/docs/harness/skills) | +| `subagents` | Subagent timeouts and max turns | [Subagents](/docs/harness/subagents) | +| `acp_agents` | External ACP agent integrations | [Subagents](/docs/harness/subagents) | +| `memory` | Cross-session memory storage | [Memory](/docs/harness/memory) | +| `summarization` | Conversation summarization | [Middlewares](/docs/harness/middlewares) | +| `title` | Automatic thread title generation | [Middlewares](/docs/harness/middlewares) | +| `checkpointer` | Thread state persistence | [Agents & Threads](/docs/application/agents-and-threads) | +| `guardrails` | Tool call authorization | — | +| `stream_bridge` | Streaming configuration | — | +| `uploads` | File upload settings (PDF converter) | — | +| `channels` | IM channel integrations (Feishu, Slack, etc.) | — | ## Minimal config to get started @@ -157,12 +157,6 @@ tools: Start from `config.example.yaml` in the repository root and uncomment the sections you need. - - + + diff --git a/frontend/src/content/en/harness/customization.mdx b/frontend/src/content/en/harness/customization.mdx index 2f538aeb6..9d8c6b934 100644 --- a/frontend/src/content/en/harness/customization.mdx +++ b/frontend/src/content/en/harness/customization.mdx @@ -170,9 +170,6 @@ guardrails: For custom guardrail logic, implement a class with `evaluate()` and `aevaluate()` methods and reference it via `use:`. - + diff --git a/frontend/src/content/en/harness/design-principles.mdx b/frontend/src/content/en/harness/design-principles.mdx index e741f6d74..bda534217 100644 --- a/frontend/src/content/en/harness/design-principles.mdx +++ b/frontend/src/content/en/harness/design-principles.mdx @@ -110,15 +110,15 @@ Environment variable interpolation (`api_key: $OPENAI_API_KEY`) keeps secrets ou ## Summary -| Principle | What it means in practice | -| --------------------------- | -------------------------------------------------------------- | -| Harness, not framework | Ready-to-run runtime with all the infrastructure already wired | -| Long-horizon first | Architecture assumes multi-step, multi-tool, multi-turn tasks | -| Middleware over inheritance | Behavior is composed from small, isolated plugins | -| Skills for specialization | Domain capability injected on demand, keeping the base clean | -| Sandbox for execution | Isolated workspace for real file and command work | -| Context engineering | Active management of what the agent sees to stay effective | -| Config-driven | All key behaviors are controlled through `config.yaml` | +| Principle | What it means in practice | +|---|---| +| Harness, not framework | Ready-to-run runtime with all the infrastructure already wired | +| Long-horizon first | Architecture assumes multi-step, multi-tool, multi-turn tasks | +| Middleware over inheritance | Behavior is composed from small, isolated plugins | +| Skills for specialization | Domain capability injected on demand, keeping the base clean | +| Sandbox for execution | Isolated workspace for real file and command work | +| Context engineering | Active management of what the agent sees to stay effective | +| Config-driven | All key behaviors are controlled through `config.yaml` | diff --git a/frontend/src/content/en/harness/lead-agent.mdx b/frontend/src/content/en/harness/lead-agent.mdx index ee89888c0..4aafa80a1 100644 --- a/frontend/src/content/en/harness/lead-agent.mdx +++ b/frontend/src/content/en/harness/lead-agent.mdx @@ -141,9 +141,9 @@ The same Lead Agent runtime powers both the default agent and any custom agents Custom agents are created through the DeerFlow App UI or via the `/api/agents` endpoint. Their configuration is stored in `agents/{name}/config.yaml` relative to the backend directory. - When a custom agent is selected in a thread, the Lead Agent loads that agent's - config at runtime. Switching models or skills for a specific agent does not - require restarting the server. + When a custom agent is selected in a thread, the Lead Agent loads that + agent's config at runtime. Switching models or skills for a specific agent + does not require restarting the server. ## Bootstrap mode diff --git a/frontend/src/content/en/harness/mcp.mdx b/frontend/src/content/en/harness/mcp.mdx index 0e43aa235..0ea0f186d 100644 --- a/frontend/src/content/en/harness/mcp.mdx +++ b/frontend/src/content/en/harness/mcp.mdx @@ -44,7 +44,6 @@ The default location is the project root (same directory as `config.yaml`). The ``` Each server entry supports: - - `command`: the executable to run (e.g., `npx`, `uvx`, `python`) - `args`: command arguments as an array - `enabled`: whether the server is active (can be toggled without removing the entry) @@ -93,7 +92,6 @@ With tool search enabled, MCP tools are listed by name in the system prompt but Some MCP servers require OAuth authentication. DeerFlow's `mcp/oauth.py` handles the OAuth flow for servers that declare OAuth requirements in their capability headers. When an OAuth-protected MCP server is connected, DeerFlow will: - 1. Detect the OAuth requirement from the server's capability headers 2. Build the appropriate authorization headers using `get_initial_oauth_headers()` 3. Wrap tool calls with an OAuth interceptor via `build_oauth_tool_interceptor()` diff --git a/frontend/src/content/en/harness/middlewares.mdx b/frontend/src/content/en/harness/middlewares.mdx index 70c499925..fca391cf3 100644 --- a/frontend/src/content/en/harness/middlewares.mdx +++ b/frontend/src/content/en/harness/middlewares.mdx @@ -89,7 +89,7 @@ title: enabled: true max_words: 6 max_chars: 60 - model_name: null # use default model + model_name: null # use default model ``` --- @@ -159,7 +159,7 @@ summarization: # Trigger conditions — summarization runs when ANY threshold is met trigger: - - type: tokens # trigger when context exceeds N tokens + - type: tokens # trigger when context exceeds N tokens value: 15564 # - type: messages # trigger when there are more than N messages # value: 50 @@ -169,7 +169,7 @@ summarization: # How much recent history to keep after summarization keep: type: messages - value: 10 # keep the 10 most recent messages + value: 10 # keep the 10 most recent messages # Alternative: keep by tokens # type: tokens # value: 3000 @@ -182,7 +182,6 @@ summarization: ``` **Trigger types**: - - `tokens`: triggers when the total token count in the conversation exceeds `value`. - `messages`: triggers when the number of messages exceeds `value`. - `fraction`: triggers when the context reaches `value` fraction of the model's maximum input token limit. @@ -190,7 +189,6 @@ summarization: Multiple triggers can be listed; summarization runs when **any** of them fires. **Keep types**: - - `messages`: keep the last `value` messages after summarization. - `tokens`: keep up to `value` tokens of recent history. - `fraction`: keep up to `value` fraction of the model's max input token limit. diff --git a/frontend/src/content/en/harness/quick-start.mdx b/frontend/src/content/en/harness/quick-start.mdx index b253a122c..5327e6773 100644 --- a/frontend/src/content/en/harness/quick-start.mdx +++ b/frontend/src/content/en/harness/quick-start.mdx @@ -85,15 +85,15 @@ agent = create_deerflow_agent( Common parameters: -| Parameter | Description | -| ------------------ | ----------------------------------------------- | -| `tools` | Additional tools available to the agent | -| `system_prompt` | Custom system prompt | -| `features` | Enable or replace built-in runtime features | +| Parameter | Description | +|---|---| +| `tools` | Additional tools available to the agent | +| `system_prompt` | Custom system prompt | +| `features` | Enable or replace built-in runtime features | | `extra_middleware` | Insert custom middleware into the default chain | -| `plan_mode` | Enable Todo-style task tracking | -| `checkpointer` | Persist agent state across runs | -| `name` | Logical agent name | +| `plan_mode` | Enable Todo-style task tracking | +| `checkpointer` | Persist agent state across runs | +| `name` | Logical agent name | ## When to use DeerFlowClient instead @@ -109,10 +109,7 @@ Use `DeerFlowClient` when you want the higher-level embedded app interface, such ## Next steps - + diff --git a/frontend/src/content/en/harness/sandbox.mdx b/frontend/src/content/en/harness/sandbox.mdx index b1ba74b13..203e5c44d 100644 --- a/frontend/src/content/en/harness/sandbox.mdx +++ b/frontend/src/content/en/harness/sandbox.mdx @@ -9,8 +9,8 @@ import { Callout, Cards, Tabs } from "nextra/components"; The sandbox is the isolated workspace where the agent does file and - command-based work. It is what makes DeerFlow capable of real action, not just - conversation. + command-based work. It is what makes DeerFlow capable of real action, not + just conversation. The sandbox gives the Lead Agent a controlled environment where it can read files, write outputs, run shell commands, and produce artifacts. Without a sandbox, the agent can only generate text. With a sandbox, it can write and execute code, process data files, generate charts, and build deliverables. @@ -29,7 +29,7 @@ Commands run directly on the host machine's filesystem. There is no container is ```yaml sandbox: use: deerflow.sandbox.local:LocalSandboxProvider - allow_host_bash: false # default; set to true only for fully trusted workflows + allow_host_bash: false # default; set to true only for fully trusted workflows ``` ### Container-based AIO Sandbox @@ -83,10 +83,10 @@ The provisioner service is included in `docker/docker-compose-dev.yaml` and mana The sandbox uses path mappings to bridge the host filesystem and the container's virtual filesystem. Two key mappings are always configured: -| Host path | Container path | Access | -| ------------------------------------------- | -------------------------------------------- | ---------- | -| `skills/` (from `skills.path`) | `/mnt/skills` (from `skills.container_path`) | Read-only | -| `.deer-flow/threads/{thread_id}/user-data/` | `/mnt/user-data/` | Read-write | +| Host path | Container path | Access | +|---|---|---| +| `skills/` (from `skills.path`) | `/mnt/skills` (from `skills.container_path`) | Read-only | +| `.deer-flow/threads/{thread_id}/user-data/` | `/mnt/user-data/` | Read-write | The skills directory is always mounted read-only. Threads write their working data (uploads, outputs, intermediate files) to `/mnt/user-data/`. @@ -136,7 +136,7 @@ The `LocalSandbox` runs commands directly on the host. By default, the `bash` to ```yaml sandbox: - allow_host_bash: true # Dangerous: grants the agent shell access to your machine + allow_host_bash: true # Dangerous: grants the agent shell access to your machine ``` Even without `bash`, the agent can still read and write files through the dedicated file tools. diff --git a/frontend/src/content/en/harness/skills.mdx b/frontend/src/content/en/harness/skills.mdx index 09f8b0d43..45f232b37 100644 --- a/frontend/src/content/en/harness/skills.mdx +++ b/frontend/src/content/en/harness/skills.mdx @@ -44,23 +44,23 @@ The `SKILL.md` file is the authoritative definition of the skill. It is parsed b DeerFlow ships with the following public skills: -| Skill | Description | -| ------------------------------ | -------------------------------------------------------------------------------- | -| `deep-research` | Multi-step research with source gathering, cross-checking, and structured output | -| `data-analysis` | Data exploration, statistical analysis, and insight generation | -| `chart-visualization` | Chart and graph creation from data | -| `ppt-generation` | Presentation slide generation | -| `image-generation` | AI image generation workflows | -| `code-documentation` | Automated code documentation generation | -| `newsletter-generation` | Newsletter content creation | -| `podcast-generation` | Podcast script and outline generation | -| `academic-paper-review` | Structured academic paper analysis | -| `consulting-analysis` | Business consulting frameworks and analysis | -| `systematic-literature-review` | Literature review methodology and synthesis | -| `github-deep-research` | Repository and code deep-dive research | -| `frontend-design` | Frontend design and UI workflow | -| `web-design-guidelines` | Web design standards and review | -| `video-generation` | Video content planning and generation | +| Skill | Description | +|---|---| +| `deep-research` | Multi-step research with source gathering, cross-checking, and structured output | +| `data-analysis` | Data exploration, statistical analysis, and insight generation | +| `chart-visualization` | Chart and graph creation from data | +| `ppt-generation` | Presentation slide generation | +| `image-generation` | AI image generation workflows | +| `code-documentation` | Automated code documentation generation | +| `newsletter-generation` | Newsletter content creation | +| `podcast-generation` | Podcast script and outline generation | +| `academic-paper-review` | Structured academic paper analysis | +| `consulting-analysis` | Business consulting frameworks and analysis | +| `systematic-literature-review` | Literature review methodology and synthesis | +| `github-deep-research` | Repository and code deep-dive research | +| `frontend-design` | Frontend design and UI workflow | +| `web-design-guidelines` | Web design standards and review | +| `video-generation` | Video content planning and generation | ## Skill lifecycle @@ -139,8 +139,8 @@ DeerFlow includes an optional **skill evolution** feature that allows the agent ```yaml skill_evolution: - enabled: false # Set to true to allow agent-managed skill creation - moderation_model_name: null # Model for security scanning (null = use default) + enabled: false # Set to true to allow agent-managed skill creation + moderation_model_name: null # Model for security scanning (null = use default) ``` diff --git a/frontend/src/content/en/harness/subagents.mdx b/frontend/src/content/en/harness/subagents.mdx index 6da63cf00..193f1aa0c 100644 --- a/frontend/src/content/en/harness/subagents.mdx +++ b/frontend/src/content/en/harness/subagents.mdx @@ -75,10 +75,10 @@ subagents: # Optional: per-agent overrides agents: general-purpose: - timeout_seconds: 1800 # 30 minutes for complex tasks + timeout_seconds: 1800 # 30 minutes for complex tasks max_turns: 160 bash: - timeout_seconds: 300 # 5 minutes for quick commands + timeout_seconds: 300 # 5 minutes for quick commands max_turns: 80 ``` @@ -122,8 +122,8 @@ The Lead Agent invokes ACP agents through the `invoke_acp_agent` built-in tool. ACP agents run as child processes managed by DeerFlow. They communicate over the ACP wire protocol. The standard CLI tools (like the plain `claude` or - `codex` commands) are not ACP-compatible by default — use the adapter packages - listed above or a compatible ACP wrapper. + `codex` commands) are not ACP-compatible by default — use the adapter + packages listed above or a compatible ACP wrapper. ## Custom agents as subagents diff --git a/frontend/src/content/en/harness/tools.mdx b/frontend/src/content/en/harness/tools.mdx index f9cf9a234..005fa3ed3 100644 --- a/frontend/src/content/en/harness/tools.mdx +++ b/frontend/src/content/en/harness/tools.mdx @@ -78,15 +78,15 @@ Searches for tools by name or description and loads them into the agent's contex The following tools interact with the sandbox filesystem. They require a sandbox to be configured and active. -| Tool | Description | -| ------------- | --------------------------------------------------------------------------------- | -| `ls` | List files in a directory | -| `read_file` | Read file contents | -| `glob` | Find files matching a pattern | -| `grep` | Search file contents | -| `write_file` | Write content to a file | -| `str_replace` | Replace a string in a file | -| `bash` | Execute a shell command (requires `allow_host_bash: true` or a container sandbox) | +| Tool | Description | +|---|---| +| `ls` | List files in a directory | +| `read_file` | Read file contents | +| `glob` | Find files matching a pattern | +| `grep` | Search file contents | +| `write_file` | Write content to a file | +| `str_replace` | Replace a string in a file | +| `bash` | Execute a shell command (requires `allow_host_bash: true` or a container sandbox) | These are configured in `config.yaml` under `tools:`: @@ -98,7 +98,7 @@ tools: - use: deerflow.sandbox.tools:grep_tool - use: deerflow.sandbox.tools:write_file_tool - use: deerflow.sandbox.tools:str_replace_tool - - use: deerflow.sandbox.tools:bash_tool # requires host bash or container sandbox + - use: deerflow.sandbox.tools:bash_tool # requires host bash or container sandbox ``` ## Community tools @@ -124,7 +124,6 @@ tools: High-quality search with structured results. Requires a [Tavily](https://tavily.com) API key. Install: `cd backend && uv add 'deerflow-harness[tavily]'` - ```yaml @@ -135,7 +134,6 @@ tools: Semantic search with neural retrieval. Requires an [Exa](https://exa.ai) API key. Install: `cd backend && uv add 'deerflow-harness[exa]'` - ```yaml @@ -154,7 +152,6 @@ tools: Firecrawl-powered search and crawl. Requires a [Firecrawl](https://firecrawl.dev) API key. Install: `cd backend && uv add 'deerflow-harness[firecrawl]'` - @@ -165,10 +162,9 @@ Install: `cd backend && uv add 'deerflow-harness[firecrawl]'` ```yaml tools: - use: deerflow.community.jina_ai.tools:web_fetch_tool - api_key: $JINA_API_KEY # optional; anonymous usage has rate limits + api_key: $JINA_API_KEY # optional; anonymous usage has rate limits ``` -Converts web pages to clean Markdown. Works without an API key at reduced rate -limits. +Converts web pages to clean Markdown. Works without an API key at reduced rate limits. ```yaml diff --git a/frontend/src/content/en/tutorials/first-conversation.mdx b/frontend/src/content/en/tutorials/first-conversation.mdx index 64776fd42..271e806d2 100644 --- a/frontend/src/content/en/tutorials/first-conversation.mdx +++ b/frontend/src/content/en/tutorials/first-conversation.mdx @@ -35,7 +35,6 @@ Press Enter to send. ### Watch the agent work You will see the agent start working: - - Expand the **thinking steps** to see which tools it is calling - Watch search results stream in - Wait for the final report to be generated @@ -43,7 +42,6 @@ You will see the agent start working: ### Interact with the result Once the report is generated, you can: - - Ask for more detail on a specific section - Ask to export the report as a file (the agent will use the `present_files` tool) - Ask to create a chart based on the research findings @@ -53,7 +51,6 @@ Once the report is generated, you can: ## What just happened The agent used the DeerFlow Harness to: - 1. Receive your message and add it to the thread state 2. Run the middleware chain (memory injection, title generation) 3. Call the LLM, which decided to search the web diff --git a/frontend/src/content/en/tutorials/use-tools-and-skills.mdx b/frontend/src/content/en/tutorials/use-tools-and-skills.mdx index 2a12051f4..dc84a6317 100644 --- a/frontend/src/content/en/tutorials/use-tools-and-skills.mdx +++ b/frontend/src/content/en/tutorials/use-tools-and-skills.mdx @@ -33,7 +33,6 @@ tools: Enable skills through the DeerFlow app's extensions panel, or edit `extensions_config.json` directly. **Via the app UI:** - 1. Open the DeerFlow app 2. Click the Extensions/Skills icon in the sidebar 3. Find `deep-research` and toggle it on diff --git a/frontend/src/content/en/tutorials/work-with-memory.mdx b/frontend/src/content/en/tutorials/work-with-memory.mdx index c7aa047db..8b101aaf4 100644 --- a/frontend/src/content/en/tutorials/work-with-memory.mdx +++ b/frontend/src/content/en/tutorials/work-with-memory.mdx @@ -32,7 +32,6 @@ Memory works automatically through `MemoryMiddleware`: ## Example **First conversation:** - ``` I am a Python backend developer primarily using FastAPI and PostgreSQL. My team follows PEP 8 and prefers type annotations everywhere. @@ -40,7 +39,6 @@ Please remember this for future code suggestions. ``` **Later conversation** (no need to repeat background): - ``` Help me write a user authentication module ``` diff --git a/frontend/src/content/zh/application/agents-and-threads.mdx b/frontend/src/content/zh/application/agents-and-threads.mdx index 6ad982fed..0900c733a 100644 --- a/frontend/src/content/zh/application/agents-and-threads.mdx +++ b/frontend/src/content/zh/application/agents-and-threads.mdx @@ -8,8 +8,7 @@ import { Callout, Cards, Steps } from "nextra/components"; # Agent 与线程 - Agent - 是配置单元——它们定义了一组能力。线程是对话实例,带有持久化状态和历史记录。 + Agent 是配置单元——它们定义了一组能力。线程是对话实例,带有持久化状态和历史记录。 ## 自定义 Agent @@ -116,8 +115,5 @@ checkpointer: - + diff --git a/frontend/src/content/zh/application/configuration.mdx b/frontend/src/content/zh/application/configuration.mdx index 639eeaec5..a0cb9c7cc 100644 --- a/frontend/src/content/zh/application/configuration.mdx +++ b/frontend/src/content/zh/application/configuration.mdx @@ -46,18 +46,17 @@ models: 启用扩展思考: ```yaml -- name: claude-extended-thinking - use: langchain_anthropic:ChatAnthropic - model: claude-sonnet-4-5 - api_key: $ANTHROPIC_API_KEY - max_tokens: 16000 - thinking_enabled: true - extra_body: - thinking: - type: enabled - budget_tokens: 10000 + - name: claude-extended-thinking + use: langchain_anthropic:ChatAnthropic + model: claude-sonnet-4-5 + api_key: $ANTHROPIC_API_KEY + max_tokens: 16000 + thinking_enabled: true + extra_body: + thinking: + type: enabled + budget_tokens: 10000 ``` - ```yaml @@ -104,7 +103,6 @@ models: ```bash ollama pull llama3.3 ``` - @@ -164,11 +162,11 @@ checkpointer: 前端通过 `.env.local`(本地开发)或 Docker Compose 环境中的环境变量配置。 -| 变量 | 必需 | 描述 | -| --------------------- | ---------- | -------------------------------------------------- | -| `BETTER_AUTH_SECRET` | 是(生产) | 会话管理的密钥(最少 32 个字符) | -| `BETTER_AUTH_URL` | 推荐 | 你的应用公开 URL(例如 `https://your-domain.com`) | -| `SKIP_ENV_VALIDATION` | 否 | 设为 `1` 跳过构建时环境变量验证 | +| 变量 | 必需 | 描述 | +|---|---|---| +| `BETTER_AUTH_SECRET` | 是(生产) | 会话管理的密钥(最少 32 个字符) | +| `BETTER_AUTH_URL` | 推荐 | 你的应用公开 URL(例如 `https://your-domain.com`) | +| `SKIP_ENV_VALIDATION` | 否 | 设为 `1` 跳过构建时环境变量验证 | 本地开发: @@ -178,8 +176,7 @@ BETTER_AUTH_SECRET=local-dev-secret-at-least-32-chars ``` - 不要在生产中使用 SKIP_ENV_VALIDATION=1。为{" "} - BETTER_AUTH_SECRET 设置一个真实值。 + 不要在生产中使用 SKIP_ENV_VALIDATION=1。为 BETTER_AUTH_SECRET 设置一个真实值。 ## extensions_config.json @@ -210,12 +207,12 @@ BETTER_AUTH_SECRET=local-dev-secret-at-least-32-chars 这些变量在 DeerFlow 进程中设置(通过 `.env`、Docker 环境或 shell): -| 变量 | 默认值 | 描述 | -| ----------------------- | ---------------- | ------------------------------------------------ | -| `DEER_FLOW_CONFIG_PATH` | 自动发现 | `config.yaml` 的绝对路径 | -| `LOG_LEVEL` | `info` | 日志详细程度(`debug`/`info`/`warning`/`error`) | -| `DEER_FLOW_ROOT` | 仓库根目录 | 用于 Docker 中的技能和线程挂载 | -| `LANGGRAPH_UPSTREAM` | `langgraph:2024` | nginx 代理的 LangGraph 地址 | +| 变量 | 默认值 | 描述 | +|---|---|---| +| `DEER_FLOW_CONFIG_PATH` | 自动发现 | `config.yaml` 的绝对路径 | +| `LOG_LEVEL` | `info` | 日志详细程度(`debug`/`info`/`warning`/`error`) | +| `DEER_FLOW_ROOT` | 仓库根目录 | 用于 Docker 中的技能和线程挂载 | +| `LANGGRAPH_UPSTREAM` | `langgraph:2024` | nginx 代理的 LangGraph 地址 | diff --git a/frontend/src/content/zh/application/deployment-guide.mdx b/frontend/src/content/zh/application/deployment-guide.mdx index 59eceece2..ff75c21b0 100644 --- a/frontend/src/content/zh/application/deployment-guide.mdx +++ b/frontend/src/content/zh/application/deployment-guide.mdx @@ -21,15 +21,14 @@ make dev 启动的服务: -| 服务 | 端口 | 描述 | -| ----------- | ---- | ----------------------- | -| LangGraph | 2024 | DeerFlow Harness 运行时 | -| Gateway API | 8001 | FastAPI 后端 | -| 前端 | 3000 | Next.js 界面 | -| nginx | 2026 | 统一反向代理 | +| 服务 | 端口 | 描述 | +|---|---|---| +| LangGraph | 2024 | DeerFlow Harness 运行时 | +| Gateway API | 8001 | FastAPI 后端 | +| 前端 | 3000 | Next.js 界面 | +| nginx | 2026 | 统一反向代理 | 访问地址:**http://localhost:2026** - ```bash @@ -37,7 +36,6 @@ make stop ``` 停止所有四个服务。即使某个服务没有运行也可以安全执行。 - ``` @@ -48,11 +46,9 @@ logs/nginx.log # nginx 访问/错误日志 ``` 实时追踪日志: - ```bash tail -f logs/langgraph.log ``` - @@ -90,8 +86,7 @@ BETTER_AUTH_SECRET=your-secret-here-min-32-chars `docker-compose*.yaml` 文件包含 `env_file: ../.env` 指令,会自动加载该文件。 - 在部署前始终将 BETTER_AUTH_SECRET{" "} - 设置为强随机字符串。没有它,前端构建会使用一个公开已知的默认值。 + 在部署前始终将 BETTER_AUTH_SECRET 设置为强随机字符串。没有它,前端构建会使用一个公开已知的默认值。 ### 数据持久化 @@ -109,11 +104,11 @@ BETTER_AUTH_SECRET=your-secret-here-min-32-chars ### 沙箱模式选择 -| 沙箱 | 使用场景 | -| -------------------------------------- | -------------------------- | -| `LocalSandboxProvider` | 单用户、受信任的本地工作流 | -| `AioSandboxProvider`(Docker) | 多用户、中等隔离需求 | -| `AioSandboxProvider` + K8s Provisioner | 生产环境、强隔离、多用户 | +| 沙箱 | 使用场景 | +|---|---| +| `LocalSandboxProvider` | 单用户、受信任的本地工作流 | +| `AioSandboxProvider`(Docker) | 多用户、中等隔离需求 | +| `AioSandboxProvider` + K8s Provisioner | 生产环境、强隔离、多用户 | 对于有多个并发用户的任何部署,使用基于容器的沙箱,防止用户之间的执行环境相互干扰。 @@ -158,10 +153,10 @@ SKILLS_PVC_NAME=deer-flow-skills-pvc nginx 路由所有流量,控制路由的关键环境变量: -| 变量 | 默认值 | 描述 | -| -------------------- | ---------------- | ----------------------------- | -| `LANGGRAPH_UPSTREAM` | `langgraph:2024` | LangGraph 服务地址 | -| `LANGGRAPH_REWRITE` | `/` | LangGraph 路由的 URL 重写前缀 | +| 变量 | 默认值 | 描述 | +|---|---|---| +| `LANGGRAPH_UPSTREAM` | `langgraph:2024` | LangGraph 服务地址 | +| `LANGGRAPH_REWRITE` | `/` | LangGraph 路由的 URL 重写前缀 | 这些在 Docker Compose 环境中设置,并在容器启动时由 `envsubst` 处理。 @@ -179,12 +174,12 @@ openssl rand -base64 32 ### 资源建议 -| 服务 | 最低配置 | 推荐配置 | -| ------------------------- | ---------------- | ---------------- | +| 服务 | 最低配置 | 推荐配置 | +|---|---|---| | LangGraph(Agent 运行时) | 2 vCPU、4 GB RAM | 4 vCPU、8 GB RAM | -| Gateway | 0.5 vCPU、512 MB | 1 vCPU、1 GB | -| 前端 | 0.5 vCPU、512 MB | 1 vCPU、1 GB | -| 沙箱容器(每会话) | 1 vCPU、1 GB | 2 vCPU、2 GB | +| Gateway | 0.5 vCPU、512 MB | 1 vCPU、1 GB | +| 前端 | 0.5 vCPU、512 MB | 1 vCPU、1 GB | +| 沙箱容器(每会话) | 1 vCPU、1 GB | 2 vCPU、2 GB | ## 部署后验证 @@ -205,8 +200,5 @@ curl http://localhost:2026/api/models - + diff --git a/frontend/src/content/zh/application/index.mdx b/frontend/src/content/zh/application/index.mdx index 81e7113e2..d8c06d0c4 100644 --- a/frontend/src/content/zh/application/index.mdx +++ b/frontend/src/content/zh/application/index.mdx @@ -8,24 +8,22 @@ import { Callout, Cards } from "nextra/components"; # DeerFlow 应用 - DeerFlow 应用是构建在 DeerFlow Harness 之上的完整 Super Agent - 应用。它将运行时能力打包成一个可部署的产品,包含 Web 界面、API Gateway - 和运维工具。 + DeerFlow 应用是构建在 DeerFlow Harness 之上的完整 Super Agent 应用。它将运行时能力打包成一个可部署的产品,包含 Web 界面、API Gateway 和运维工具。 DeerFlow 应用是 DeerFlow 生产体验的参考实现。它将 Harness 运行时、基于 Web 的对话工作区、API Gateway 和反向代理组合成一个可部署的完整系统。 ## 应用提供什么 -| 能力 | 描述 | -| ---------------- | ----------------------------------------------------- | -| **Web 工作区** | 浏览器对话界面,支持线程、产出物、文件上传和技能选择 | -| **自定义 Agent** | 创建和管理具有不同模型、技能和工具集的命名 Agent | -| **线程管理** | 带检查点和历史记录的持久化对话线程 | -| **流式响应** | 实时 token 流式传输,带思考步骤和工具调用可见性 | -| **产出物查看器** | Agent 生成文件和输出的浏览器内预览和下载 | -| **扩展界面** | 无需编辑配置文件即可启用/禁用 MCP 服务器和技能 | -| **Gateway API** | 桥接前端和 LangGraph 运行时的基于 FastAPI 的 REST API | +| 能力 | 描述 | +|---|---| +| **Web 工作区** | 浏览器对话界面,支持线程、产出物、文件上传和技能选择 | +| **自定义 Agent** | 创建和管理具有不同模型、技能和工具集的命名 Agent | +| **线程管理** | 带检查点和历史记录的持久化对话线程 | +| **流式响应** | 实时 token 流式传输,带思考步骤和工具调用可见性 | +| **产出物查看器** | Agent 生成文件和输出的浏览器内预览和下载 | +| **扩展界面** | 无需编辑配置文件即可启用/禁用 MCP 服务器和技能 | +| **Gateway API** | 桥接前端和 LangGraph 运行时的基于 FastAPI 的 REST API | ## 架构 @@ -58,13 +56,13 @@ DeerFlow 应用以四个服务的形式运行,通过单个 nginx 反向代理 ## 技术栈 -| 层次 | 技术 | -| ------------ | ------------------------------------------------------- | -| 前端 | Next.js 16、React 19、TypeScript、pnpm | -| Gateway | FastAPI、Python 3.12、uvicorn | -| Agent 运行时 | LangGraph、LangChain、DeerFlow Harness | -| 反向代理 | nginx | -| 状态持久化 | LangGraph Server(默认)+ 可选 SQLite/PostgreSQL 检查点 | +| 层次 | 技术 | +|---|---| +| 前端 | Next.js 16、React 19、TypeScript、pnpm | +| Gateway | FastAPI、Python 3.12、uvicorn | +| Agent 运行时 | LangGraph、LangChain、DeerFlow Harness | +| 反向代理 | nginx | +| 状态持久化 | LangGraph Server(默认)+ 可选 SQLite/PostgreSQL 检查点 | diff --git a/frontend/src/content/zh/application/operations-and-troubleshooting.mdx b/frontend/src/content/zh/application/operations-and-troubleshooting.mdx index c047bbd5c..3b4157d4f 100644 --- a/frontend/src/content/zh/application/operations-and-troubleshooting.mdx +++ b/frontend/src/content/zh/application/operations-and-troubleshooting.mdx @@ -13,12 +13,12 @@ import { Callout, Cards } from "nextra/components"; DeerFlow 应用在 `logs/` 目录中写入每个服务的日志: -| 文件 | 内容 | -| -------------------- | -------------------------------------- | +| 文件 | 内容 | +|---|---| | `logs/langgraph.log` | Agent 运行时、工具调用、LangGraph 错误 | -| `logs/gateway.log` | API 请求/响应、Gateway 错误 | -| `logs/frontend.log` | Next.js 服务器日志 | -| `logs/nginx.log` | 代理访问和错误日志 | +| `logs/gateway.log` | API 请求/响应、Gateway 错误 | +| `logs/frontend.log` | Next.js 服务器日志 | +| `logs/nginx.log` | 代理访问和错误日志 | **实时追踪日志**: @@ -31,7 +31,7 @@ tail -f logs/gateway.log # 查看 API 请求 ```yaml # config.yaml -log_level: debug # debug | info | warning | error +log_level: debug # debug | info | warning | error ``` ## 健康检查 @@ -66,14 +66,12 @@ make config-upgrade **症状**:Agent 在响应第一条消息时报错,日志中有 API 认证错误。 **诊断**: - ```bash # 检查 LangGraph 日志中的模型错误 grep -i "error\|apikey\|unauthorized" logs/langgraph.log | tail -20 ``` **解决**: - 1. 验证 `config.yaml` 中 API key 字段名称正确(例如 `$OPENAI_API_KEY`)。 2. 确认对应的环境变量已设置(`echo $OPENAI_API_KEY`)。 3. 检查 `base_url`(如有)是否与提供商的实际端点匹配。 @@ -85,14 +83,12 @@ grep -i "error\|apikey\|unauthorized" logs/langgraph.log | tail -20 **症状**:工具报"文件未找到"或权限错误,即使 Agent 声称已创建文件。 **诊断**: - ```bash # 检查线程用户数据目录是否存在且可写 ls -la backend/.deer-flow/threads/ ``` **解决**: - 1. 确保 `backend/.deer-flow/` 对运行 DeerFlow 的进程可写。 2. 在 Docker 部署中,验证卷挂载路径正确(`DEER_FLOW_ROOT` 设置为绝对路径)。 3. 如果使用基于容器的沙箱,确认 Docker 已运行并且容器镜像已拉取。 @@ -104,7 +100,6 @@ ls -la backend/.deer-flow/threads/ **症状**:`make install` 或前端构建步骤失败,提示 `BETTER_AUTH_SECRET` 错误。 **解决**: - ```bash # 选项 1:设置环境变量(推荐) export BETTER_AUTH_SECRET=your-secret-here-at-least-32-chars @@ -121,14 +116,12 @@ SKIP_ENV_VALIDATION=1 pnpm build **症状**:MCP 工具未出现,`logs/langgraph.log` 中有超时错误。 **诊断**: - ```bash # 检查 MCP 相关错误 grep -i "mcp\|timeout" logs/langgraph.log | tail -20 ``` **解决**: - 1. 验证 `extensions_config.json` 中 MCP 服务器的 `command` 和 `args` 在服务器外部正常工作(手动运行命令)。 2. 确认 MCP 服务器的依赖(如 `npx`、`uvx`)已安装并在 PATH 中。 3. 检查 MCP 服务器是否需要网络访问或特定环境变量。 @@ -140,7 +133,6 @@ grep -i "mcp\|timeout" logs/langgraph.log | tail -20 **症状**:沙箱工具请求挂起,日志中有连接拒绝错误。 **解决**: - 1. 验证 `config.yaml` 中 `provisioner_url` 正确且 Provisioner Pod 运行正常。 2. 检查 `K8S_NAMESPACE` 和 RBAC 配置是否允许 Provisioner 创建 Pod。 3. 验证 `SANDBOX_IMAGE` 可以从 K8s 节点拉取。 @@ -148,7 +140,6 @@ grep -i "mcp\|timeout" logs/langgraph.log | tail -20 ## 数据备份 DeerFlow 将持久化数据存储在: - - **线程数据**:`backend/.deer-flow/threads/` — 每个线程的上传文件、输出和工作区文件 - **检查点**:取决于检查点器配置(SQLite:`backend/.deer-flow/checkpoints.db`,Redis:外部存储) - **记忆**:`backend/.deer-flow/memory.json`(以及 `agents/*/memory.json`) diff --git a/frontend/src/content/zh/application/quick-start.mdx b/frontend/src/content/zh/application/quick-start.mdx index 5ccf117ad..a531a6430 100644 --- a/frontend/src/content/zh/application/quick-start.mdx +++ b/frontend/src/content/zh/application/quick-start.mdx @@ -8,8 +8,7 @@ import { Callout, Cards, Steps } from "nextra/components"; # 快速上手 - 大约 10 分钟即可在本地运行 DeerFlow 应用。你需要一台安装了 Python - 3.12+、Node.js 22+ 的机器,以及至少一个 LLM API Key。 + 大约 10 分钟即可在本地运行 DeerFlow 应用。你需要一台安装了 Python 3.12+、Node.js 22+ 的机器,以及至少一个 LLM API Key。 本指南引导你使用 `make dev` 工作流在本地机器上启动 DeerFlow 应用。所有四个服务(LangGraph、Gateway、前端、nginx)一起启动,通过单个 URL 访问。 @@ -24,13 +23,13 @@ make check 必需工具: -| 工具 | 最低版本 | -| ------- | ------------ | -| Python | 3.12 | -| uv | 最新版 | -| Node.js | 22 | -| pnpm | 10 | -| nginx | 任何近期版本 | +| 工具 | 最低版本 | +|---|---| +| Python | 3.12 | +| uv | 最新版 | +| Node.js | 22 | +| pnpm | 10 | +| nginx | 任何近期版本 | 在 macOS 上,使用 `brew install python uv node pnpm nginx` 安装。在 Linux 上,使用你的发行版包管理器。 @@ -87,7 +86,6 @@ make dev ``` 这会启动: - - LangGraph 服务,端口 `2024` - Gateway API,端口 `8001` - 前端,端口 `3000` @@ -111,17 +109,15 @@ make stop 日志文件: -| 服务 | 日志文件 | -| --------- | -------------------- | +| 服务 | 日志文件 | +|---|---| | LangGraph | `logs/langgraph.log` | -| Gateway | `logs/gateway.log` | -| 前端 | `logs/frontend.log` | -| nginx | `logs/nginx.log` | +| Gateway | `logs/gateway.log` | +| 前端 | `logs/frontend.log` | +| nginx | `logs/nginx.log` | - 如果有问题,先检查日志文件。大多数启动错误(缺失 API - Key、配置解析失败)会出现在 logs/langgraph.log 或{" "} - logs/gateway.log 中。 + 如果有问题,先检查日志文件。大多数启动错误(缺失 API Key、配置解析失败)会出现在 logs/langgraph.loglogs/gateway.log 中。 diff --git a/frontend/src/content/zh/application/workspace-usage.mdx b/frontend/src/content/zh/application/workspace-usage.mdx index e4e3fb541..5ade5cc13 100644 --- a/frontend/src/content/zh/application/workspace-usage.mdx +++ b/frontend/src/content/zh/application/workspace-usage.mdx @@ -8,8 +8,7 @@ import { Callout, Cards } from "nextra/components"; # 工作区使用 - DeerFlow 工作区是你与 Agent - 交互的地方。本页面涵盖主要用户界面工作流——创建对话、上传文件、查看产出物和使用技能。 + DeerFlow 工作区是你与 Agent 交互的地方。本页面涵盖主要用户界面工作流——创建对话、上传文件、查看产出物和使用技能。 DeerFlow 工作区是一个基于浏览器的对话界面,你可以在其中向 Agent 发送消息、上传文件、查看中间步骤,以及下载生成的产出物。 @@ -42,15 +41,13 @@ DeerFlow 工作区是一个基于浏览器的对话界面,你可以在其中 拖放文件到消息输入区域,或点击附件图标上传。上传的文件挂载在沙箱的 `/mnt/user-data/uploads/` 路径下,Agent 可以直接读取和处理。 **支持的操作**: - - 读取和分析文件内容 - 处理数据文件(CSV、JSON、Excel) - 提取 PDF 内容 - 分析图像(需要支持视觉的模型) - 上传大文件时,告诉 Agent - 文件的具体内容,以便获得更好的结果(例如"分析这个包含季度销售数据的 CSV")。 + 上传大文件时,告诉 Agent 文件的具体内容,以便获得更好的结果(例如"分析这个包含季度销售数据的 CSV")。 ## 使用技能 @@ -75,7 +72,6 @@ DeerFlow 工作区是一个基于浏览器的对话界面,你可以在其中 当 Agent 生成文件(报告、图表、代码文件、演示文稿)时,它们会以**产出物**的形式出现在对话中。 点击产出物卡片: - - 在浏览器中预览文件。 - 下载文件到本地机器。 - 复制文件内容。 @@ -91,12 +87,6 @@ DeerFlow 工作区是一个基于浏览器的对话界面,你可以在其中 **删除线程**:从线程侧边栏菜单中选择删除。这会移除线程状态和所有相关的用户数据文件。 - - + + diff --git a/frontend/src/content/zh/harness/configuration.mdx b/frontend/src/content/zh/harness/configuration.mdx index 1b5cf0207..eda2370dc 100644 --- a/frontend/src/content/zh/harness/configuration.mdx +++ b/frontend/src/content/zh/harness/configuration.mdx @@ -8,8 +8,7 @@ import { Callout, Cards } from "nextra/components"; # 配置 - 所有 DeerFlow Harness 行为都由 config.yaml{" "} - 驱动。一个文件控制哪些模型可用、沙箱如何运行、加载哪些工具,以及每个子系统的行为。 + 所有 DeerFlow Harness 行为都由 config.yaml 驱动。一个文件控制哪些模型可用、沙箱如何运行、加载哪些工具,以及每个子系统的行为。 DeerFlow 的配置系统围绕一个目标设计:每一个有意义的行为都应该可以在配置文件中表达,而不是硬编码在应用程序中。这使部署可重现、可审计,并且易于按环境定制。 @@ -80,7 +79,7 @@ models: use: langchain_openai:ChatOpenAI model: gpt-4o api_key: $OPENAI_API_KEY - some_provider_specific_option: value # 传递给 ChatOpenAI 构造函数 + some_provider_specific_option: value # 传递给 ChatOpenAI 构造函数 ``` ## 配置版本 @@ -103,26 +102,26 @@ make config-upgrade 下表将 `config.yaml` 中的每个顶层章节映射到其文档页面: -| 章节 | 描述 | 文档 | -| ----------------- | -------------------------------------------- | ---------------------------------------------------- | -| `log_level` | 日志级别(`debug`/`info`/`warning`/`error`) | — | -| `models` | 可用的 LLM 模型 | [Lead Agent](/docs/harness/lead-agent) | -| `token_usage` | 每次模型调用的 token 追踪 | [中间件](/docs/harness/middlewares) | -| `tools` | 可用的 Agent 工具 | [工具](/docs/harness/tools) | -| `tool_groups` | 工具的命名分组 | [工具](/docs/harness/tools) | -| `tool_search` | 延迟/按需工具加载 | [工具](/docs/harness/tools) | -| `sandbox` | 沙箱提供者和选项 | [沙箱](/docs/harness/sandbox) | -| `skills` | 技能目录和容器路径 | [技能](/docs/harness/skills) | -| `skill_evolution` | Agent 管理的技能创建 | [技能](/docs/harness/skills) | -| `subagents` | 子 Agent 超时和最大轮次 | [子 Agent](/docs/harness/subagents) | -| `acp_agents` | 外部 ACP Agent 集成 | [子 Agent](/docs/harness/subagents) | -| `memory` | 跨会话记忆存储 | [记忆系统](/docs/harness/memory) | -| `summarization` | 对话摘要压缩 | [中间件](/docs/harness/middlewares) | -| `title` | 自动生成线程标题 | [中间件](/docs/harness/middlewares) | -| `checkpointer` | 线程状态持久化 | [Agent 与线程](/docs/application/agents-and-threads) | -| `guardrails` | 工具调用授权 | — | -| `uploads` | 文件上传设置(PDF 转换器) | — | -| `channels` | IM 频道集成(飞书、Slack 等) | — | +| 章节 | 描述 | 文档 | +|---|---|---| +| `log_level` | 日志级别(`debug`/`info`/`warning`/`error`) | — | +| `models` | 可用的 LLM 模型 | [Lead Agent](/docs/harness/lead-agent) | +| `token_usage` | 每次模型调用的 token 追踪 | [中间件](/docs/harness/middlewares) | +| `tools` | 可用的 Agent 工具 | [工具](/docs/harness/tools) | +| `tool_groups` | 工具的命名分组 | [工具](/docs/harness/tools) | +| `tool_search` | 延迟/按需工具加载 | [工具](/docs/harness/tools) | +| `sandbox` | 沙箱提供者和选项 | [沙箱](/docs/harness/sandbox) | +| `skills` | 技能目录和容器路径 | [技能](/docs/harness/skills) | +| `skill_evolution` | Agent 管理的技能创建 | [技能](/docs/harness/skills) | +| `subagents` | 子 Agent 超时和最大轮次 | [子 Agent](/docs/harness/subagents) | +| `acp_agents` | 外部 ACP Agent 集成 | [子 Agent](/docs/harness/subagents) | +| `memory` | 跨会话记忆存储 | [记忆系统](/docs/harness/memory) | +| `summarization` | 对话摘要压缩 | [中间件](/docs/harness/middlewares) | +| `title` | 自动生成线程标题 | [中间件](/docs/harness/middlewares) | +| `checkpointer` | 线程状态持久化 | [Agent 与线程](/docs/application/agents-and-threads) | +| `guardrails` | 工具调用授权 | — | +| `uploads` | 文件上传设置(PDF 转换器) | — | +| `channels` | IM 频道集成(飞书、Slack 等) | — | ## 最小配置示例 diff --git a/frontend/src/content/zh/harness/customization.mdx b/frontend/src/content/zh/harness/customization.mdx index 3b77c0394..25f902acd 100644 --- a/frontend/src/content/zh/harness/customization.mdx +++ b/frontend/src/content/zh/harness/customization.mdx @@ -8,9 +8,7 @@ import { Callout, Cards } from "nextra/components"; # 自定义与扩展 - DeerFlow - 设计为可适配的。你可以通过编写自定义中间件、添加新工具、构建技能包以及通过 - config.yaml 的 use: 字段替换任何内置组件来扩展 Agent 行为。 + DeerFlow 设计为可适配的。你可以通过编写自定义中间件、添加新工具、构建技能包以及通过 config.yaml 的 use: 字段替换任何内置组件来扩展 Agent 行为。 DeerFlow 的可插拔架构意味着系统的大多数部分都可以在不 fork 核心的情况下被替换或扩展。本页面列举了扩展点,并解释如何使用每一个。 diff --git a/frontend/src/content/zh/harness/design-principles.mdx b/frontend/src/content/zh/harness/design-principles.mdx index 97498a91c..c25931d14 100644 --- a/frontend/src/content/zh/harness/design-principles.mdx +++ b/frontend/src/content/zh/harness/design-principles.mdx @@ -8,8 +8,7 @@ import { Callout, Cards } from "nextra/components"; # 设计理念 - DeerFlow 围绕一个核心思想构建:Agent - 行为应该由小型、可观察、可替换的组件组合而成——而不是硬编码到固定的工作流图中。 + DeerFlow 围绕一个核心思想构建:Agent 行为应该由小型、可观察、可替换的组件组合而成——而不是硬编码到固定的工作流图中。 了解 DeerFlow Harness 背后的设计理念,有助于你有效地使用它、自信地扩展它,并推断 Agent 在生产环境中的行为方式。 @@ -102,15 +101,15 @@ DeerFlow 中所有有意义的行为都通过 `config.yaml` 控制。系统的 ## 总结 -| 设计原则 | 实践含义 | -| ---------------------- | ---------------------------------------- | +| 设计原则 | 实践含义 | +|---|---| | Harness 而非 Framework | 开箱即用的运行时,所有基础设施已预先连接 | -| 长时序优先 | 架构假设多步骤、多工具、多轮次任务 | -| 中间件优于继承 | 行为由小型、隔离的插件组合而成 | -| 技能提供专业化 | 领域能力按需注入,保持基础干净 | -| 沙箱用于执行 | 真实文件和命令操作的隔离工作区 | -| 上下文工程 | 主动管理 Agent 所见内容以保持有效性 | -| 配置驱动 | 所有关键行为通过 `config.yaml` 控制 | +| 长时序优先 | 架构假设多步骤、多工具、多轮次任务 | +| 中间件优于继承 | 行为由小型、隔离的插件组合而成 | +| 技能提供专业化 | 领域能力按需注入,保持基础干净 | +| 沙箱用于执行 | 真实文件和命令操作的隔离工作区 | +| 上下文工程 | 主动管理 Agent 所见内容以保持有效性 | +| 配置驱动 | 所有关键行为通过 `config.yaml` 控制 | diff --git a/frontend/src/content/zh/harness/index.mdx b/frontend/src/content/zh/harness/index.mdx index 6c57cd010..ea8712487 100644 --- a/frontend/src/content/zh/harness/index.mdx +++ b/frontend/src/content/zh/harness/index.mdx @@ -8,8 +8,7 @@ import { Callout, Cards } from "nextra/components"; # 安装 DeerFlow Harness - DeerFlow Harness Python 包将以 deerflow{" "} - 名称发布。目前尚未正式发布,安装方式即将推出。 + DeerFlow Harness Python 包将以 deerflow 名称发布。目前尚未正式发布,安装方式即将推出 DeerFlow Harness 是构建自己 Super Agent 系统的 Python SDK 和运行时基础。 diff --git a/frontend/src/content/zh/harness/integration-guide.mdx b/frontend/src/content/zh/harness/integration-guide.mdx index ea0a58c3f..2d2e389a8 100644 --- a/frontend/src/content/zh/harness/integration-guide.mdx +++ b/frontend/src/content/zh/harness/integration-guide.mdx @@ -8,8 +8,7 @@ import { Callout, Cards } from "nextra/components"; # 集成指南 - DeerFlow Harness 可以嵌入任何 Python 应用程序。本指南涵盖在你自己的系统中将 - DeerFlow 作为库使用的集成模式。 + DeerFlow Harness 可以嵌入任何 Python 应用程序。本指南涵盖在你自己的系统中将 DeerFlow 作为库使用的集成模式。 DeerFlow Harness 不仅仅是一个独立应用程序——它是一个可以导入并在你自己的后端、API 服务器、自动化系统或多 Agent 协调器中使用的 Python 库。 diff --git a/frontend/src/content/zh/harness/lead-agent.mdx b/frontend/src/content/zh/harness/lead-agent.mdx index 5e298a809..c6f16a413 100644 --- a/frontend/src/content/zh/harness/lead-agent.mdx +++ b/frontend/src/content/zh/harness/lead-agent.mdx @@ -8,9 +8,7 @@ import { Callout, Cards, Steps } from "nextra/components"; # Lead Agent - Lead Agent 是每个 DeerFlow - 线程中的主要推理和编排单元。它决定要做什么、调用工具、委派子 - Agent,并返回产出物。 + Lead Agent 是每个 DeerFlow 线程中的主要推理和编排单元。它决定要做什么、调用工具、委派子 Agent,并返回产出物。 Lead Agent 是 DeerFlow 线程中的核心执行者。每个对话、任务和工作流都通过它进行。理解它的工作方式有助于你有效地配置它,并在需要时扩展它。 @@ -124,8 +122,7 @@ models: 自定义 Agent 通过 DeerFlow 应用界面或 `/api/agents` 端点创建。其配置存储在后端目录的 `agents/{name}/config.yaml` 中。 - 当在线程中选择自定义 Agent 时,Lead Agent 在运行时加载该 Agent 的配置。为特定 - Agent 切换模型或技能不需要重启服务器。 + 当在线程中选择自定义 Agent 时,Lead Agent 在运行时加载该 Agent 的配置。为特定 Agent 切换模型或技能不需要重启服务器。 diff --git a/frontend/src/content/zh/harness/memory.mdx b/frontend/src/content/zh/harness/memory.mdx index edf2a0e26..71de258ad 100644 --- a/frontend/src/content/zh/harness/memory.mdx +++ b/frontend/src/content/zh/harness/memory.mdx @@ -8,8 +8,7 @@ import { Callout, Cards } from "nextra/components"; # 记忆系统 - 记忆让 DeerFlow 在多个会话中保留有用信息。Agent - 记住用户偏好、项目背景和反复出现的事实,这样它可以在不每次从零开始的情况下给出更好的响应。 + 记忆让 DeerFlow 在多个会话中保留有用信息。Agent 记住用户偏好、项目背景和反复出现的事实,这样它可以在不每次从零开始的情况下给出更好的响应。 记忆是 DeerFlow Harness 的一个运行时功能。它不是简单的对话日志,而是跨多个独立会话持久化、在未来对话中影响 Agent 行为的结构化事实和上下文摘要存储。 diff --git a/frontend/src/content/zh/harness/middlewares.mdx b/frontend/src/content/zh/harness/middlewares.mdx index abdcfb3fe..94b3571c7 100644 --- a/frontend/src/content/zh/harness/middlewares.mdx +++ b/frontend/src/content/zh/harness/middlewares.mdx @@ -8,9 +8,7 @@ import { Callout } from "nextra/components"; # 中间件 - 中间件包裹 Lead Agent 中的每次 LLM - 调用。它们是添加跨领域行为(如记忆、摘要压缩、澄清和 token - 追踪)的主要扩展点。 + 中间件包裹 Lead Agent 中的每次 LLM 调用。它们是添加跨领域行为(如记忆、摘要压缩、澄清和 token 追踪)的主要扩展点。 每次 Lead Agent 调用 LLM 时,都会先后执行一条**中间件链**。中间件可以读取和修改 Agent 的状态、向系统提示注入内容、拦截工具调用,并对模型输出做出反应。 @@ -89,7 +87,7 @@ title: enabled: true max_words: 6 max_chars: 60 - model_name: null # 使用默认模型 + model_name: null # 使用默认模型 ``` --- @@ -151,7 +149,7 @@ summarization: # 触发条件——满足任意一个条件时运行摘要 trigger: - - type: tokens # 当上下文超过 N 个 token 时触发 + - type: tokens # 当上下文超过 N 个 token 时触发 value: 15564 # - type: messages # 当消息数超过 N 时触发 # value: 50 @@ -161,7 +159,7 @@ summarization: # 摘要后保留多少最近历史 keep: type: messages - value: 10 # 保留最近 10 条消息 + value: 10 # 保留最近 10 条消息 # 或者按 token 保留: # type: tokens # value: 3000 @@ -174,13 +172,11 @@ summarization: ``` **触发类型**: - - `tokens`:当对话中总 token 数超过 `value` 时触发。 - `messages`:当消息数超过 `value` 时触发。 - `fraction`:当上下文达到模型最大输入 token 限制的 `value` 比例时触发。 **保留类型**: - - `messages`:摘要后保留最后 `value` 条消息。 - `tokens`:保留最近 `value` 个 token 的历史。 - `fraction`:保留模型最大输入 token 限制的 `value` 比例的最近历史。 diff --git a/frontend/src/content/zh/harness/quick-start.mdx b/frontend/src/content/zh/harness/quick-start.mdx index 3da8c7b3f..bf335e392 100644 --- a/frontend/src/content/zh/harness/quick-start.mdx +++ b/frontend/src/content/zh/harness/quick-start.mdx @@ -85,15 +85,15 @@ agent = create_deerflow_agent( 常用参数: -| 参数 | 说明 | -| ------------------ | -------------------------- | -| `tools` | 提供给 Agent 的额外工具 | -| `system_prompt` | 自定义系统提示词 | -| `features` | 启用或替换内置运行时能力 | +| 参数 | 说明 | +|---|---| +| `tools` | 提供给 Agent 的额外工具 | +| `system_prompt` | 自定义系统提示词 | +| `features` | 启用或替换内置运行时能力 | | `extra_middleware` | 将自定义中间件插入默认链路 | -| `plan_mode` | 启用 Todo 风格的任务跟踪 | -| `checkpointer` | 为多轮运行持久化状态 | -| `name` | Agent 的逻辑名称 | +| `plan_mode` | 启用 Todo 风格的任务跟踪 | +| `checkpointer` | 为多轮运行持久化状态 | +| `name` | Agent 的逻辑名称 | ## 什么时候使用 DeerFlowClient diff --git a/frontend/src/content/zh/harness/sandbox.mdx b/frontend/src/content/zh/harness/sandbox.mdx index 2b01718d6..16a05f9e7 100644 --- a/frontend/src/content/zh/harness/sandbox.mdx +++ b/frontend/src/content/zh/harness/sandbox.mdx @@ -8,8 +8,7 @@ import { Callout, Cards, Tabs } from "nextra/components"; # 沙箱 - 沙箱是 Agent 进行文件和命令操作的隔离工作区。它让 DeerFlow - 能够采取真实行动,而不仅仅是对话。 + 沙箱是 Agent 进行文件和命令操作的隔离工作区。它让 DeerFlow 能够采取真实行动,而不仅仅是对话。 沙箱为 Lead Agent 提供一个受控环境,在其中可以读取文件、写入输出、运行 Shell 命令并生成产出物。没有沙箱,Agent 只能生成文本;有了沙箱,它可以编写和执行代码、处理数据文件、生成图表并构建交付物。 @@ -28,7 +27,7 @@ DeerFlow 支持三种沙箱模式,选择适合你部署的一种: ```yaml sandbox: use: deerflow.sandbox.local:LocalSandboxProvider - allow_host_bash: false # 默认;仅对完全受信任的工作流设置为 true + allow_host_bash: false # 默认;仅对完全受信任的工作流设置为 true ``` ### 基于容器的 AIO 沙箱 @@ -73,10 +72,10 @@ sandbox: 沙箱使用路径映射来桥接主机文件系统和容器的虚拟文件系统。始终配置两个关键映射: -| 主机路径 | 容器路径 | 访问权限 | -| ------------------------------------------- | --------------------------------------------- | -------- | -| `skills/`(来自 `skills.path`) | `/mnt/skills`(来自 `skills.container_path`) | 只读 | -| `.deer-flow/threads/{thread_id}/user-data/` | `/mnt/user-data/` | 读写 | +| 主机路径 | 容器路径 | 访问权限 | +|---|---|---| +| `skills/`(来自 `skills.path`) | `/mnt/skills`(来自 `skills.container_path`) | 只读 | +| `.deer-flow/threads/{thread_id}/user-data/` | `/mnt/user-data/` | 读写 | 技能目录始终以只读方式挂载。线程将其工作数据(上传文件、输出、中间文件)写入 `/mnt/user-data/`。 @@ -95,8 +94,7 @@ sandbox: 自定义挂载的 container_path 不能与保留前缀冲突: - /mnt/skills/mnt/acp-workspace 或{" "} - /mnt/user-data。 + /mnt/skills/mnt/acp-workspace/mnt/user-data ## 输出截断 @@ -127,7 +125,7 @@ sandbox: ```yaml sandbox: - allow_host_bash: true # 危险:授予 Agent 对你机器的 Shell 访问权限 + allow_host_bash: true # 危险:授予 Agent 对你机器的 Shell 访问权限 ``` 即使没有 `bash`,Agent 也可以通过专用文件工具读写文件。 diff --git a/frontend/src/content/zh/harness/skills.mdx b/frontend/src/content/zh/harness/skills.mdx index 9b578da01..f0f0aa659 100644 --- a/frontend/src/content/zh/harness/skills.mdx +++ b/frontend/src/content/zh/harness/skills.mdx @@ -8,8 +8,7 @@ import { Callout, Cards, FileTree, Steps } from "nextra/components"; # 技能 - 技能是面向任务的能力包,教会 Agent 如何完成特定类型的工作。基础 Agent - 保持通用;技能在需要时提供专业化。 + 技能是面向任务的能力包,教会 Agent 如何完成特定类型的工作。基础 Agent 保持通用;技能在需要时提供专业化。 技能不仅仅是提示词。它是一个自包含的能力包,可以包含结构化指令、分步工作流、领域最佳实践、支撑资源和工具配置。技能按需加载——在任务需要时注入内容,否则不影响上下文。 @@ -40,23 +39,23 @@ import { Callout, Cards, FileTree, Steps } from "nextra/components"; DeerFlow 内置以下公共技能: -| 技能 | 描述 | -| ------------------------------ | -------------------------------------------- | -| `deep-research` | 带来源收集、交叉验证和结构化输出的多步骤研究 | -| `data-analysis` | 数据探索、统计分析和洞察生成 | -| `chart-visualization` | 从数据创建图表和可视化 | -| `ppt-generation` | 演示文稿幻灯片生成 | -| `image-generation` | AI 图像生成工作流 | -| `code-documentation` | 自动化代码文档生成 | -| `newsletter-generation` | 新闻简报内容创作 | -| `podcast-generation` | 播客脚本和大纲生成 | -| `academic-paper-review` | 结构化学术论文分析 | -| `consulting-analysis` | 商业咨询框架和分析 | -| `systematic-literature-review` | 文献综述方法论和综合 | -| `github-deep-research` | 仓库和代码深度研究 | -| `frontend-design` | 前端设计和 UI 工作流 | -| `web-design-guidelines` | 网页设计标准和审查 | -| `video-generation` | 视频内容规划和生成 | +| 技能 | 描述 | +|---|---| +| `deep-research` | 带来源收集、交叉验证和结构化输出的多步骤研究 | +| `data-analysis` | 数据探索、统计分析和洞察生成 | +| `chart-visualization` | 从数据创建图表和可视化 | +| `ppt-generation` | 演示文稿幻灯片生成 | +| `image-generation` | AI 图像生成工作流 | +| `code-documentation` | 自动化代码文档生成 | +| `newsletter-generation` | 新闻简报内容创作 | +| `podcast-generation` | 播客脚本和大纲生成 | +| `academic-paper-review` | 结构化学术论文分析 | +| `consulting-analysis` | 商业咨询框架和分析 | +| `systematic-literature-review` | 文献综述方法论和综合 | +| `github-deep-research` | 仓库和代码深度研究 | +| `frontend-design` | 前端设计和 UI 工作流 | +| `web-design-guidelines` | 网页设计标准和审查 | +| `video-generation` | 视频内容规划和生成 | ## 技能生命周期 @@ -133,14 +132,12 @@ DeerFlow 包含一个可选的**技能进化**功能,允许 Agent 在 `skills/ ```yaml skill_evolution: - enabled: false # 设为 true 允许 Agent 管理技能创建 - moderation_model_name: null # 安全扫描模型(null = 使用默认模型) + enabled: false # 设为 true 允许 Agent 管理技能创建 + moderation_model_name: null # 安全扫描模型(null = 使用默认模型) ``` - 只在你信任 Agent - 输出的环境中启用技能进化。新创建的技能在加载前会经过安全扫描,但该功能给予 - Agent 对技能目录的写访问权限。 + 只在你信任 Agent 输出的环境中启用技能进化。新创建的技能在加载前会经过安全扫描,但该功能给予 Agent 对技能目录的写访问权限。 ## 编写自定义技能 diff --git a/frontend/src/content/zh/harness/subagents.mdx b/frontend/src/content/zh/harness/subagents.mdx index 0f99a398a..929dfccd4 100644 --- a/frontend/src/content/zh/harness/subagents.mdx +++ b/frontend/src/content/zh/harness/subagents.mdx @@ -8,8 +8,7 @@ import { Callout, Cards } from "nextra/components"; # 子 Agent - 子 Agent 是 Lead Agent - 委派子任务的专注执行者。它们以隔离的上下文运行,在处理并行或专业工作的同时保持主对话清晰。 + 子 Agent 是 Lead Agent 委派子任务的专注执行者。它们以隔离的上下文运行,在处理并行或专业工作的同时保持主对话清晰。 当一个任务对单个推理线程来说太宽泛,或者部分任务可以并行完成时,Lead Agent 将工作委派给**子 Agent**。子 Agent 是一个独立的 Agent 调用,接收特定任务、执行并返回结果。 @@ -74,10 +73,10 @@ subagents: # 可选:按 Agent 覆盖 agents: general-purpose: - timeout_seconds: 1800 # 复杂任务 30 分钟 + timeout_seconds: 1800 # 复杂任务 30 分钟 max_turns: 160 bash: - timeout_seconds: 300 # 快速命令 5 分钟 + timeout_seconds: 300 # 快速命令 5 分钟 max_turns: 80 ``` @@ -116,9 +115,7 @@ acp_agents: Lead Agent 通过 `invoke_acp_agent` 内置工具调用 ACP Agent。 - ACP Agent 作为 DeerFlow 管理的子进程运行,通过 ACP 协议通信。标准 CLI - 工具(如原始的 `claude` 或 `codex` 命令)默认不兼容 - ACP——请使用上面列出的适配器包或兼容的 ACP 封装器。 + ACP Agent 作为 DeerFlow 管理的子进程运行,通过 ACP 协议通信。标准 CLI 工具(如原始的 `claude` 或 `codex` 命令)默认不兼容 ACP——请使用上面列出的适配器包或兼容的 ACP 封装器。 diff --git a/frontend/src/content/zh/harness/tools.mdx b/frontend/src/content/zh/harness/tools.mdx index 954a1ad01..b23d25f87 100644 --- a/frontend/src/content/zh/harness/tools.mdx +++ b/frontend/src/content/zh/harness/tools.mdx @@ -8,8 +8,7 @@ import { Callout, Cards, Tabs } from "nextra/components"; # 工具 - 工具是 Lead Agent 可以采取的行动。DeerFlow 提供内置工具、社区集成、MCP - 工具和技能工具——全部通过 config.yaml 控制。 + 工具是 Lead Agent 可以采取的行动。DeerFlow 提供内置工具、社区集成、MCP 工具和技能工具——全部通过 config.yaml 控制。 Lead Agent 是一个工具调用 Agent。工具是它与世界交互的方式:搜索网络、读写文件、运行命令、委派任务以及向用户呈现输出。 @@ -75,15 +74,15 @@ task(agent="general-purpose", task="...", context="...") 以下工具与沙箱文件系统交互,需要配置并激活沙箱。 -| 工具 | 描述 | -| ------------- | ---------------------------------------------------------- | -| `ls` | 列出目录中的文件 | -| `read_file` | 读取文件内容 | -| `glob` | 查找匹配模式的文件 | -| `grep` | 搜索文件内容 | -| `write_file` | 向文件写入内容 | -| `str_replace` | 替换文件中的字符串 | -| `bash` | 执行 Shell 命令(需要 `allow_host_bash: true` 或容器沙箱) | +| 工具 | 描述 | +|---|---| +| `ls` | 列出目录中的文件 | +| `read_file` | 读取文件内容 | +| `glob` | 查找匹配模式的文件 | +| `grep` | 搜索文件内容 | +| `write_file` | 向文件写入内容 | +| `str_replace` | 替换文件中的字符串 | +| `bash` | 执行 Shell 命令(需要 `allow_host_bash: true` 或容器沙箱) | 在 `config.yaml` 的 `tools:` 下配置: @@ -121,7 +120,6 @@ tools: 高质量搜索,带结构化结果。需要 [Tavily](https://tavily.com) API Key。 安装:`cd backend && uv add 'deerflow-harness[tavily]'` - ```yaml @@ -132,7 +130,6 @@ tools: 带神经检索的语义搜索。需要 [Exa](https://exa.ai) API Key。 安装:`cd backend && uv add 'deerflow-harness[exa]'` - ```yaml @@ -151,7 +148,7 @@ Firecrawl 驱动的搜索和爬取。需要 [Firecrawl](https://firecrawl.dev) A ```yaml tools: - use: deerflow.community.jina_ai.tools:web_fetch_tool - api_key: $JINA_API_KEY # 可选;匿名使用有速率限制 + api_key: $JINA_API_KEY # 可选;匿名使用有速率限制 ``` 将网页转换为干净的 Markdown。无 API Key 也可使用,但有更严格的速率限制。 diff --git a/frontend/src/content/zh/index.mdx b/frontend/src/content/zh/index.mdx index 5f2a18deb..55ea93007 100644 --- a/frontend/src/content/zh/index.mdx +++ b/frontend/src/content/zh/index.mdx @@ -63,3 +63,4 @@ Harness 章节是技术文档的核心,面向想要构建基于 DeerFlow 系 ### 参考 参考章节提供详细的查阅资料,包括配置、运行时模式、API 和代码映射。 + diff --git a/frontend/src/content/zh/introduction/core-concepts.mdx b/frontend/src/content/zh/introduction/core-concepts.mdx index df3bfe4ad..fd85ab875 100644 --- a/frontend/src/content/zh/introduction/core-concepts.mdx +++ b/frontend/src/content/zh/introduction/core-concepts.mdx @@ -8,8 +8,7 @@ import { Callout, Cards } from "nextra/components"; # 核心概念 - 如果你将 DeerFlow 理解为一个长时序 Agent - 的运行时,而不仅仅是聊天界面或工作流图,它将最易于理解。 + 如果你将 DeerFlow 理解为一个长时序 Agent 的运行时,而不仅仅是聊天界面或工作流图,它将最易于理解。 在深入了解 DeerFlow 之前,先建立一些贯穿整个系统的核心概念。这些概念解释了 DeerFlow 的优化目标以及其架构设计的原因。 diff --git a/frontend/src/content/zh/introduction/harness-vs-app.mdx b/frontend/src/content/zh/introduction/harness-vs-app.mdx index bd31b4bad..7333b6ef7 100644 --- a/frontend/src/content/zh/introduction/harness-vs-app.mdx +++ b/frontend/src/content/zh/introduction/harness-vs-app.mdx @@ -8,8 +8,7 @@ import { Callout, Cards } from "nextra/components"; # Harness 与应用 - DeerFlow 应用是构建在 DeerFlow Harness 之上的最佳实践 Super Agent 应用,而 - DeerFlow Harness 是构建自己 Agent 系统的 Python SDK 和运行时基础。 + DeerFlow 应用是构建在 DeerFlow Harness 之上的最佳实践 Super Agent 应用,而 DeerFlow Harness 是构建自己 Agent 系统的 Python SDK 和运行时基础。 DeerFlow 有两个紧密相关但服务于不同目的的层次: diff --git a/frontend/src/content/zh/introduction/why-deerflow.mdx b/frontend/src/content/zh/introduction/why-deerflow.mdx index 945d844f8..dd59fc2d5 100644 --- a/frontend/src/content/zh/introduction/why-deerflow.mdx +++ b/frontend/src/content/zh/introduction/why-deerflow.mdx @@ -8,8 +8,7 @@ import { Callout, Cards } from "nextra/components"; # 为什么选择 DeerFlow - DeerFlow 起源于深度研究,但逐渐演化为一个通用的长时序 Agent - 运行时——支持技能、记忆、工具和协作调度。 + DeerFlow 起源于深度研究,但逐渐演化为一个通用的长时序 Agent 运行时——支持技能、记忆、工具和协作调度。 DeerFlow 的诞生是因为现代 Agent 系统需要的不仅仅是一个聊天循环。一个真正有用的 Agent 必须能够进行长时序规划、将任务拆解为子任务、使用工具、操作文件、安全地运行代码,并在复杂任务中保持足够的上下文连贯性。DeerFlow 正是为提供这样的运行时基础而构建的。 diff --git a/frontend/src/content/zh/tutorials/first-conversation.mdx b/frontend/src/content/zh/tutorials/first-conversation.mdx index 7c92eaf5e..1e1a9d387 100644 --- a/frontend/src/content/zh/tutorials/first-conversation.mdx +++ b/frontend/src/content/zh/tutorials/first-conversation.mdx @@ -31,7 +31,6 @@ description: 本教程引导你在 DeerFlow 中完成第一次完整的 Agent ### 3. 观察 Agent 的工作过程 你将看到 Agent 进入工作状态: - - 展开**思考步骤**查看它调用了哪些工具 - 观察网络搜索结果的流入 - 等待最终报告生成 @@ -39,7 +38,6 @@ description: 本教程引导你在 DeerFlow 中完成第一次完整的 Agent ### 4. 与结果互动 报告生成后,你可以: - - 要求对某部分进行详细说明 - 要求将报告导出为文件(Agent 会使用 `present_files` 工具) - 要求基于研究结果创建图表 diff --git a/frontend/src/content/zh/tutorials/work-with-memory.mdx b/frontend/src/content/zh/tutorials/work-with-memory.mdx index ac3667af8..64ae09640 100644 --- a/frontend/src/content/zh/tutorials/work-with-memory.mdx +++ b/frontend/src/content/zh/tutorials/work-with-memory.mdx @@ -30,7 +30,6 @@ memory: ## 示例 **第一次对话**: - ``` 我是一名 Python 后端开发者,主要使用 FastAPI 和 PostgreSQL。 我的团队遵循 PEP 8 代码规范,偏好类型注解。 @@ -38,7 +37,6 @@ memory: ``` **后续对话**(无需重复背景): - ``` 帮我写一个用户认证模块 ``` diff --git a/frontend/src/core/agents/api.ts b/frontend/src/core/agents/api.ts index 062a14b24..984ee8832 100644 --- a/frontend/src/core/agents/api.ts +++ b/frontend/src/core/agents/api.ts @@ -1,4 +1,4 @@ -import { fetch } from "@/core/api/fetcher"; +import { fetchWithAuth } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types"; @@ -29,7 +29,7 @@ export async function getAgent(name: string): Promise { } export async function createAgent(request: CreateAgentRequest): Promise { - const res = await fetch(`${getBackendBaseURL()}/api/agents`, { + const res = await fetchWithAuth(`${getBackendBaseURL()}/api/agents`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), @@ -45,7 +45,7 @@ export async function updateAgent( name: string, request: UpdateAgentRequest, ): Promise { - const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, { + const res = await fetchWithAuth(`${getBackendBaseURL()}/api/agents/${name}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), @@ -58,7 +58,7 @@ export async function updateAgent( } export async function deleteAgent(name: string): Promise { - const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, { + const res = await fetchWithAuth(`${getBackendBaseURL()}/api/agents/${name}`, { method: "DELETE", }); if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`); diff --git a/frontend/src/core/api/api-client.ts b/frontend/src/core/api/api-client.ts index 0b4532ca9..4c7576871 100644 --- a/frontend/src/core/api/api-client.ts +++ b/frontend/src/core/api/api-client.ts @@ -13,8 +13,8 @@ import { sanitizeRunStreamOptions } from "./stream-mode"; * * Reading the cookie per-request (rather than baking it into the SDK's * ``defaultHeaders`` at construction) handles login / logout / password - * change cookie rotation transparently. Both the ``/api/langgraph/*`` SDK - * path and the direct REST endpoints in ``fetcher.ts:fetchWithAuth`` + * change cookie rotation transparently. Both the ``/langgraph-compat/*`` + * SDK path and the direct REST endpoints in ``fetcher.ts:fetchWithAuth`` * share :func:`readCsrfCookie` and :const:`STATE_CHANGING_METHODS` so * the contract stays in lockstep. */ @@ -35,7 +35,7 @@ function createCompatibleClient(isMock?: boolean): LangGraphClient { const apiUrl = getLangGraphBaseURL(isMock); console.log(`Creating API client with base URL: ${apiUrl}`); const client = new LangGraphClient({ - apiUrl, + apiUrl: getLangGraphBaseURL(isMock), onRequest: injectCsrfHeader, }); diff --git a/frontend/src/core/api/feedback.ts b/frontend/src/core/api/feedback.ts index bc6021d95..5af3f02c8 100644 --- a/frontend/src/core/api/feedback.ts +++ b/frontend/src/core/api/feedback.ts @@ -1,6 +1,6 @@ import { getBackendBaseURL } from "../config"; -import { fetch } from "./fetcher"; +import { fetchWithAuth } from "./fetcher"; export interface FeedbackData { feedback_id: string; @@ -14,7 +14,7 @@ export async function upsertFeedback( rating: number, comment?: string, ): Promise { - const res = await fetch( + const res = await fetchWithAuth( `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/runs/${encodeURIComponent(runId)}/feedback`, { method: "PUT", @@ -32,7 +32,7 @@ export async function deleteFeedback( threadId: string, runId: string, ): Promise { - const res = await fetch( + const res = await fetchWithAuth( `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/runs/${encodeURIComponent(runId)}/feedback`, { method: "DELETE" }, ); diff --git a/frontend/src/core/api/fetcher.ts b/frontend/src/core/api/fetcher.ts index ca13f425e..d5f30404c 100644 --- a/frontend/src/core/api/fetcher.ts +++ b/frontend/src/core/api/fetcher.ts @@ -53,7 +53,7 @@ export function readCsrfCookie(): string | null { * preserved; the helper only ADDS the CSRF header when it isn't already * present, so explicit overrides win. */ -export async function fetch( +export async function fetchWithAuth( input: RequestInfo | string, init?: RequestInit, ): Promise { @@ -74,7 +74,7 @@ export async function fetch( } } - const res = await globalThis.fetch(url, { + const res = await fetch(url, { ...init, headers, credentials: "include", diff --git a/frontend/src/core/auth/server.ts b/frontend/src/core/auth/server.ts index 6ca3195c4..d170a4ae0 100644 --- a/frontend/src/core/auth/server.ts +++ b/frontend/src/core/auth/server.ts @@ -10,18 +10,6 @@ const SSR_AUTH_TIMEOUT_MS = 5_000; * Returns a tagged AuthResult — callers use exhaustive switch, no try/catch. */ export async function getServerSideUser(): Promise { - if (process.env.DEER_FLOW_AUTH_DISABLED === "1") { - return { - tag: "authenticated", - user: { - id: "e2e-user", - email: "e2e@test.local", - system_role: "admin", - needs_setup: false, - }, - }; - } - const cookieStore = await cookies(); const sessionCookie = cookieStore.get("access_token"); diff --git a/frontend/src/core/mcp/api.ts b/frontend/src/core/mcp/api.ts index 284c9fd25..61e681d34 100644 --- a/frontend/src/core/mcp/api.ts +++ b/frontend/src/core/mcp/api.ts @@ -1,4 +1,4 @@ -import { fetch } from "@/core/api/fetcher"; +import { fetchWithAuth } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; import type { MCPConfig } from "./types"; @@ -9,12 +9,15 @@ export async function loadMCPConfig() { } export async function updateMCPConfig(config: MCPConfig) { - const response = await fetch(`${getBackendBaseURL()}/api/mcp/config`, { - method: "PUT", - headers: { - "Content-Type": "application/json", + const response = await fetchWithAuth( + `${getBackendBaseURL()}/api/mcp/config`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(config), }, - body: JSON.stringify(config), - }); + ); return response.json(); } diff --git a/frontend/src/core/memory/api.ts b/frontend/src/core/memory/api.ts index a68a0bab2..073328808 100644 --- a/frontend/src/core/memory/api.ts +++ b/frontend/src/core/memory/api.ts @@ -1,4 +1,4 @@ -import { fetch } from "../api/fetcher"; +import { fetchWithAuth } from "../api/fetcher"; import { getBackendBaseURL } from "../config"; import type { @@ -86,14 +86,14 @@ export async function loadMemory(): Promise { } export async function clearMemory(): Promise { - const response = await fetch(`${getBackendBaseURL()}/api/memory`, { + const response = await fetchWithAuth(`${getBackendBaseURL()}/api/memory`, { method: "DELETE", }); return readMemoryResponse(response, "Failed to clear memory"); } export async function deleteMemoryFact(factId: string): Promise { - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`, { method: "DELETE", @@ -108,26 +108,32 @@ export async function exportMemory(): Promise { } export async function importMemory(memory: UserMemory): Promise { - const response = await fetch(`${getBackendBaseURL()}/api/memory/import`, { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await fetchWithAuth( + `${getBackendBaseURL()}/api/memory/import`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(memory), }, - body: JSON.stringify(memory), - }); + ); return readMemoryResponse(response, "Failed to import memory"); } export async function createMemoryFact( input: MemoryFactInput, ): Promise { - const response = await fetch(`${getBackendBaseURL()}/api/memory/facts`, { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await fetchWithAuth( + `${getBackendBaseURL()}/api/memory/facts`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(input), }, - body: JSON.stringify(input), - }); + ); return readMemoryResponse(response, "Failed to create memory fact"); } @@ -135,7 +141,7 @@ export async function updateMemoryFact( factId: string, input: MemoryFactPatchInput, ): Promise { - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`, { method: "PATCH", diff --git a/frontend/src/core/skills/api.ts b/frontend/src/core/skills/api.ts index 2fe334a69..03a713d92 100644 --- a/frontend/src/core/skills/api.ts +++ b/frontend/src/core/skills/api.ts @@ -1,4 +1,4 @@ -import { fetch } from "@/core/api/fetcher"; +import { fetchWithAuth } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; import type { Skill } from "./type"; @@ -10,7 +10,7 @@ export async function loadSkills() { } export async function enableSkill(skillName: string, enabled: boolean) { - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/skills/${skillName}`, { method: "PUT", @@ -39,13 +39,16 @@ export interface InstallSkillResponse { export async function installSkill( request: InstallSkillRequest, ): Promise { - const response = await fetch(`${getBackendBaseURL()}/api/skills/install`, { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await fetchWithAuth( + `${getBackendBaseURL()}/api/skills/install`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), }, - body: JSON.stringify(request), - }); + ); if (!response.ok) { // Handle HTTP error responses (4xx, 5xx) diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index ef605d832..b9b58bcc0 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -8,7 +8,8 @@ import { toast } from "sonner"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { getAPIClient } from "../api"; -import { fetch } from "../api/fetcher"; +import type { FeedbackData } from "../api/feedback"; +import { fetchWithAuth } from "../api/fetcher"; import { getBackendBaseURL } from "../config"; import { useI18n } from "../i18n/hooks"; import type { FileInMessage } from "../messages/utils"; @@ -275,6 +276,9 @@ export function useThreadStream({ onFinish(state) { listeners.current.onFinish?.(state.values); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); + void queryClient.invalidateQueries({ + queryKey: ["thread-message-enrichment"], + }); }, }); @@ -300,7 +304,7 @@ export function useThreadStream({ useEffect(() => { if ( optimisticMessages.length > 0 && - thread.messages.length > prevMsgCountRef.current + thread.messages.length > prevMsgCountRef.current + 1 ) { setOptimisticMessages([]); } @@ -696,7 +700,7 @@ export function useDeleteThread() { mutationFn: async ({ threadId }: { threadId: string }) => { await apiClient.threads.delete(threadId); - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`, { method: "DELETE", @@ -769,3 +773,65 @@ export function useRenameThread() { }, }); } + +/** Per-message enrichment data attached by the backend ``/history`` helper. */ +export interface MessageEnrichment { + run_id: string; + /** ``undefined`` = not feedback-eligible; ``null`` = eligible but unrated. */ + feedback?: FeedbackData | null; +} + +/** + * Fetch ``/history`` once and index feedback + run_id by message id. + * + * Replaces the old ``useThreadFeedback`` hook which keyed by AI-message + * ordinal position — an inherently fragile mapping that broke whenever + * ``ai_tool_call`` messages were interleaved with ``ai_message`` messages. + * Keying by ``message.id`` is stable regardless of run count, tool-call + * chains, or summarization. + * + * The ``/history`` response is refreshed on every stream completion via + * ``invalidateQueries(["thread-message-enrichment"])`` in ``onFinish``. + */ +export function useThreadMessageEnrichment( + threadId: string | null | undefined, +) { + return useQuery({ + queryKey: ["thread-message-enrichment", threadId], + queryFn: async (): Promise> => { + const empty = new Map(); + if (!threadId) return empty; + const res = await fetchWithAuth( + `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/history`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ limit: 1 }), + }, + ); + if (!res.ok) return empty; + const entries = (await res.json()) as Array<{ + values?: { + messages?: Array<{ + id?: string; + run_id?: string; + feedback?: FeedbackData | null; + }>; + }; + }>; + const messages = entries[0]?.values?.messages ?? []; + const map = new Map(); + for (const m of messages) { + if (!m.id || !m.run_id) continue; + const entry: MessageEnrichment = { run_id: m.run_id }; + // Preserve presence: "feedback" key absent → ineligible; present with + // null → eligible but unrated; present with object → rated. + if ("feedback" in m) entry.feedback = m.feedback; + map.set(m.id, entry); + } + return map; + }, + enabled: !!threadId, + staleTime: 30_000, + }); +} diff --git a/frontend/src/core/uploads/api.ts b/frontend/src/core/uploads/api.ts index 0ff01fd68..a00a259cb 100644 --- a/frontend/src/core/uploads/api.ts +++ b/frontend/src/core/uploads/api.ts @@ -2,7 +2,7 @@ * API functions for file uploads */ -import { fetch } from "../api/fetcher"; +import { fetchWithAuth } from "../api/fetcher"; import { getBackendBaseURL } from "../config"; export interface UploadedFileInfo { @@ -51,7 +51,7 @@ export async function uploadFiles( formData.append("files", file); }); - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/threads/${threadId}/uploads`, { method: "POST", @@ -92,7 +92,7 @@ export async function deleteUploadedFile( threadId: string, filename: string, ): Promise<{ success: boolean; message: string }> { - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/threads/${threadId}/uploads/${filename}`, { method: "DELETE",