diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index 12fedd5b2..a908e9f96 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -19,8 +19,6 @@ from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddlewar 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 AppConfig, get_app_config -from deerflow.config.memory_config import get_memory_config -from deerflow.config.summarization_config import get_summarization_config from deerflow.models import create_chat_model logger = logging.getLogger(__name__) @@ -52,7 +50,8 @@ def _resolve_model_name(requested_model_name: str | None = None, *, app_config: def _create_summarization_middleware(*, app_config: AppConfig | None = None) -> DeerFlowSummarizationMiddleware | None: """Create and configure the summarization middleware from config.""" - config = get_summarization_config() + resolved_app_config = app_config or get_app_config() + config = resolved_app_config.summarization if not config.enabled: return None @@ -73,9 +72,9 @@ def _create_summarization_middleware(*, app_config: AppConfig | None = 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, app_config=app_config) + model = create_chat_model(name=config.model_name, thinking_enabled=False, app_config=resolved_app_config) else: - model = create_chat_model(thinking_enabled=False, app_config=app_config) + model = create_chat_model(thinking_enabled=False, app_config=resolved_app_config) model = model.with_config(tags=["middleware:summarize"]) # Prepare kwargs @@ -92,18 +91,13 @@ def _create_summarization_middleware(*, app_config: AppConfig | None = None) -> kwargs["summary_prompt"] = config.summary_prompt hooks: list[BeforeSummarizationHook] = [] - if get_memory_config().enabled: + if resolved_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: - resolved_app_config = app_config or get_app_config() - skills_container_path = resolved_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" + skills_container_path = resolved_app_config.skills.container_path or "/mnt/skills" return DeerFlowSummarizationMiddleware( **kwargs, @@ -279,10 +273,10 @@ def _build_middlewares( middlewares.append(TokenUsageMiddleware()) # Add TitleMiddleware - middlewares.append(TitleMiddleware()) + middlewares.append(TitleMiddleware(app_config=resolved_app_config)) # Add MemoryMiddleware (after TitleMiddleware) - middlewares.append(MemoryMiddleware(agent_name=agent_name)) + middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory)) # Add ViewImageMiddleware only if the current model supports vision. # Use the resolved runtime model_name from make_lead_agent to avoid stale config values. @@ -316,7 +310,9 @@ def _build_middlewares( def make_lead_agent(config: RunnableConfig): """LangGraph graph factory; keep the signature compatible with LangGraph Server.""" - return _make_lead_agent(config, app_config=get_app_config()) + runtime_config = _get_runtime_config(config) + runtime_app_config = runtime_config.get("app_config") + return _make_lead_agent(config, app_config=runtime_app_config or get_app_config()) def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 9b6fd9cd4..b02c86344 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -158,7 +158,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 | None = None) -> str: """Dynamically build subagent type descriptions from registry. Mirrors Codex's pattern where agent_type_description is dynamically generated @@ -180,7 +180,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=app_config) if config is not None: desc = config.description.split("\n")[0].strip() # First line only for brevity lines.append(f"- **{name}**: {desc}") @@ -188,7 +188,7 @@ 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 | None = None) -> str: """Build the subagent system prompt section with dynamic concurrency limit. Args: @@ -198,12 +198,12 @@ def _build_subagent_section(max_concurrent: int) -> str: Formatted subagent section string. """ n = max_concurrent - available_names = get_available_subagent_names() + available_names = get_available_subagent_names(app_config=app_config) if app_config is not None else get_available_subagent_names() 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=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()' @@ -530,21 +530,28 @@ 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(agent_name: str | None = None, *, app_config: AppConfig | 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. + app_config: Explicit application config. When provided, memory options + are read from this value instead of the global config singleton. Returns: Formatted memory context string wrapped in XML tags, or empty string if disabled. """ 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 - config = get_memory_config() + if app_config is None: + from deerflow.config.memory_config import get_memory_config + + config = get_memory_config() + else: + config = app_config.memory + if not config.enabled or not config.injection_enabled: return "" @@ -558,8 +565,8 @@ def _get_memory_context(agent_name: str | None = None) -> str: {memory_content} """ - except Exception as e: - logger.error("Failed to load memory context: %s", e) + except Exception: + logger.exception("Failed to load memory context") return "" @@ -599,15 +606,20 @@ def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_c """Generate the skills prompt section with available skills list.""" skills = _get_enabled_skills_for_config(app_config) - try: - from deerflow.config import get_app_config + if app_config is None: + try: + from deerflow.config import get_app_config - config = app_config or 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 + else: + config = 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 if not skills and not skill_evolution_enabled: return "" @@ -640,13 +652,17 @@ def get_deferred_tools_prompt_section(*, app_config: AppConfig | None = None) -> """ from deerflow.tools.builtins.tool_search import get_deferred_registry - try: - from deerflow.config import get_app_config + if app_config is None: + try: + from deerflow.config import get_app_config - config = app_config or get_app_config() - if not config.tool_search.enabled: + config = get_app_config() + except Exception: return "" - except Exception: + else: + config = app_config + + if not config.tool_search.enabled: return "" registry = get_deferred_registry() @@ -657,15 +673,19 @@ def get_deferred_tools_prompt_section(*, app_config: AppConfig | None = None) -> return f"\n{names}\n" -def _build_acp_section() -> str: +def _build_acp_section(*, app_config: AppConfig | None = None) -> str: """Build the ACP agent prompt section, only if ACP agents are configured.""" - try: - from deerflow.config.acp_config import get_acp_agents + if app_config is None: + try: + from deerflow.config.acp_config import get_acp_agents - agents = get_acp_agents() - if not agents: + agents = get_acp_agents() + except Exception: return "" - except Exception: + else: + agents = getattr(app_config, "acp_agents", {}) or {} + + if not agents: return "" return ( @@ -679,14 +699,18 @@ def _build_acp_section() -> str: def _build_custom_mounts_section(*, app_config: AppConfig | None = None) -> str: """Build a prompt section for explicitly configured sandbox mounts.""" - try: - from deerflow.config import get_app_config + if app_config is None: + try: + from deerflow.config import get_app_config - config = app_config or get_app_config() - mounts = config.sandbox.mounts or [] - except Exception: - logger.exception("Failed to load configured sandbox mounts for the lead-agent prompt") - return "" + config = get_app_config() + except Exception: + logger.exception("Failed to load configured sandbox mounts for the lead-agent prompt") + return "" + else: + config = app_config + + mounts = config.sandbox.mounts or [] if not mounts: return "" @@ -709,11 +733,11 @@ def apply_prompt_template( app_config: AppConfig | None = None, ) -> str: # Get memory context - memory_context = _get_memory_context(agent_name) + memory_context = _get_memory_context(agent_name, app_config=app_config) # 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=app_config) if subagent_enabled else "" # Add subagent reminder to critical_reminders if enabled subagent_reminder = ( @@ -740,7 +764,7 @@ def apply_prompt_template( deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config) # Build ACP agent section only if ACP agents are configured - acp_section = _build_acp_section() + acp_section = _build_acp_section(app_config=app_config) custom_mounts_section = _build_custom_mounts_section(app_config=app_config) acp_and_mounts_section = "\n".join(section for section in (acp_section, custom_mounts_section) if section) diff --git a/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py index 059f8ffc2..ae5f9cfbb 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py @@ -1,7 +1,7 @@ """Middleware for memory mechanism.""" import logging -from typing import override +from typing import TYPE_CHECKING, override from langchain.agents import AgentState from langchain.agents.middleware import AgentMiddleware @@ -13,6 +13,9 @@ from deerflow.agents.memory.queue import get_memory_queue from deerflow.config.memory_config import get_memory_config from deerflow.runtime.user_context import get_effective_user_id +if TYPE_CHECKING: + from deerflow.config.memory_config import MemoryConfig + logger = logging.getLogger(__name__) @@ -34,14 +37,17 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]): state_schema = MemoryMiddlewareState - def __init__(self, agent_name: str | None = None): + def __init__(self, agent_name: str | None = None, *, memory_config: "MemoryConfig | None" = None): """Initialize the MemoryMiddleware. Args: agent_name: If provided, memory is stored per-agent. If None, uses global memory. + memory_config: Explicit memory config. When omitted, legacy global + config fallback is used. """ super().__init__() self._agent_name = agent_name + self._memory_config = memory_config @override def after_agent(self, state: MemoryMiddlewareState, runtime: Runtime) -> dict | None: @@ -54,7 +60,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]): Returns: None (no state changes needed from this middleware). """ - config = get_memory_config() + config = self._memory_config or get_memory_config() if not config.enabled: return None diff --git a/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py index 5cd5bb46c..01080be14 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py @@ -2,7 +2,7 @@ import logging import re -from typing import Any, NotRequired, override +from typing import TYPE_CHECKING, Any, NotRequired, override from langchain.agents import AgentState from langchain.agents.middleware import AgentMiddleware @@ -12,6 +12,10 @@ from langgraph.runtime import Runtime from deerflow.config.title_config import get_title_config from deerflow.models import create_chat_model +if TYPE_CHECKING: + from deerflow.config.app_config import AppConfig + from deerflow.config.title_config import TitleConfig + logger = logging.getLogger(__name__) @@ -26,6 +30,18 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): state_schema = TitleMiddlewareState + def __init__(self, *, app_config: "AppConfig | None" = None, title_config: "TitleConfig | None" = None): + super().__init__() + self._app_config = app_config + self._title_config = title_config + + def _get_title_config(self): + if self._title_config is not None: + return self._title_config + if self._app_config is not None: + return self._app_config.title + return get_title_config() + def _normalize_content(self, content: object) -> str: if isinstance(content, str): return content @@ -47,7 +63,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): def _should_generate_title(self, state: TitleMiddlewareState) -> bool: """Check if we should generate a title for this thread.""" - config = get_title_config() + config = self._get_title_config() if not config.enabled: return False @@ -72,7 +88,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): Returns (prompt_string, user_msg) so callers can use user_msg as fallback. """ - config = get_title_config() + config = self._get_title_config() messages = state.get("messages", []) user_msg_content = next((m.content for m in messages if m.type == "human"), "") @@ -94,14 +110,14 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): def _parse_title(self, content: object) -> str: """Normalize model output into a clean title string.""" - config = get_title_config() + config = self._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 def _fallback_title(self, user_msg: str) -> str: - config = get_title_config() + config = self._get_title_config() fallback_chars = min(config.max_chars, 50) if len(user_msg) > fallback_chars: return user_msg[:fallback_chars].rstrip() + "..." @@ -135,14 +151,17 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): if not self._should_generate_title(state): return None - config = get_title_config() + config = self._get_title_config() prompt, user_msg = self._build_title_prompt(state) try: + model_kwargs = {"thinking_enabled": False} + if self._app_config is not None: + model_kwargs["app_config"] = self._app_config if config.model_name: - model = create_chat_model(name=config.model_name, thinking_enabled=False) + model = create_chat_model(name=config.model_name, **model_kwargs) else: - model = create_chat_model(thinking_enabled=False) + model = create_chat_model(**model_kwargs) response = await model.ainvoke(prompt, config=self._get_runnable_config()) title = self._parse_title(response.content) if title: diff --git a/backend/packages/harness/deerflow/config/app_config.py b/backend/packages/harness/deerflow/config/app_config.py index a41108372..dae41b14d 100644 --- a/backend/packages/harness/deerflow/config/app_config.py +++ b/backend/packages/harness/deerflow/config/app_config.py @@ -8,7 +8,7 @@ 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.acp_config import ACPAgentConfig, 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.database_config import DatabaseConfig @@ -95,6 +95,7 @@ class AppConfig(BaseModel): summarization: SummarizationConfig = Field(default_factory=SummarizationConfig, description="Conversation summarization configuration") memory: MemoryConfig = Field(default_factory=MemoryConfig, description="Memory subsystem configuration") agents_api: AgentsApiConfig = Field(default_factory=AgentsApiConfig, description="Custom-agent management API configuration") + acp_agents: dict[str, ACPAgentConfig] = Field(default_factory=dict, description="ACP-compatible agent configuration") 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") diff --git a/backend/packages/harness/deerflow/runtime/runs/worker.py b/backend/packages/harness/deerflow/runtime/runs/worker.py index 1223c2127..d8f9c139b 100644 --- a/backend/packages/harness/deerflow/runtime/runs/worker.py +++ b/backend/packages/harness/deerflow/runtime/runs/worker.py @@ -21,7 +21,7 @@ import inspect import logging from dataclasses import dataclass, field from functools import lru_cache -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, cast if TYPE_CHECKING: from langchain_core.messages import HumanMessage @@ -39,12 +39,19 @@ logger = logging.getLogger(__name__) _VALID_LG_MODES = {"values", "updates", "checkpoints", "tasks", "debug", "messages", "custom"} -def _build_runtime_context(thread_id: str, run_id: str, caller_context: Any | None) -> dict[str, Any]: +def _build_runtime_context( + thread_id: str, + run_id: str, + caller_context: Any | None, + app_config: AppConfig | None = None, +) -> dict[str, Any]: """Build the dict that becomes ``ToolRuntime.context`` for the run. Always includes ``thread_id`` and ``run_id``. Additional keys from the caller's ``config['context']`` (e.g. ``agent_name`` for the bootstrap flow — issue #2677) - are merged in but never override ``thread_id``/``run_id``. + are merged in but never override ``thread_id``/``run_id``. The resolved + ``AppConfig`` is added by the worker so tools can consume it without ambient + global lookups. langgraph 1.1+ surfaces this as ``runtime.context`` via the parent runtime stored under ``config['configurable']['__pregel_runtime']`` — see @@ -54,6 +61,8 @@ def _build_runtime_context(thread_id: str, run_id: str, caller_context: Any | No if isinstance(caller_context, dict): for key, value in caller_context.items(): runtime_ctx.setdefault(key, value) + if app_config is not None: + runtime_ctx["app_config"] = app_config return runtime_ctx @@ -74,6 +83,18 @@ class RunContext: app_config: AppConfig | None = field(default=None) +def _install_runtime_context(config: dict, runtime_context: dict[str, Any]) -> None: + existing_context = config.get("context") + if isinstance(existing_context, dict): + existing_context.setdefault("thread_id", runtime_context["thread_id"]) + existing_context.setdefault("run_id", runtime_context["run_id"]) + if "app_config" in runtime_context: + existing_context["app_config"] = runtime_context["app_config"] + return + + config["context"] = dict(runtime_context) + + def _compute_agent_factory_supports_app_config(agent_factory: Any) -> bool: try: return "app_config" in inspect.signature(agent_factory).parameters @@ -191,11 +212,9 @@ async def run_agent( # access thread-level data. langgraph-cli does this automatically; we must do it # manually here because we drive the graph through ``agent.astream(config=...)`` # without passing the official ``context=`` parameter. - runtime_ctx = _build_runtime_context(thread_id, run_id, config.get("context")) - if "context" in config and isinstance(config["context"], dict): - config["context"].setdefault("thread_id", thread_id) - config["context"].setdefault("run_id", run_id) - runtime = Runtime(context=runtime_ctx, store=store) + runtime_ctx = _build_runtime_context(thread_id, run_id, config.get("context"), ctx.app_config) + _install_runtime_context(config, runtime_ctx) + runtime = Runtime(context=cast(Any, runtime_ctx), store=store) config.setdefault("configurable", {})["__pregel_runtime"] = runtime # Inject RunJournal as a LangChain callback handler. diff --git a/backend/packages/harness/deerflow/subagents/executor.py b/backend/packages/harness/deerflow/subagents/executor.py index ab850ede7..2fe5c05dc 100644 --- a/backend/packages/harness/deerflow/subagents/executor.py +++ b/backend/packages/harness/deerflow/subagents/executor.py @@ -168,6 +168,8 @@ def _get_isolated_subagent_loop() -> asyncio.AbstractEventLoop: _isolated_subagent_loop_thread = thread _isolated_subagent_loop_started = started_event + if _isolated_subagent_loop is None: + raise RuntimeError("Isolated subagent event loop is not initialized") return _isolated_subagent_loop @@ -308,8 +310,10 @@ class SubagentExecutor: try: from deerflow.skills.storage import get_or_new_skill_storage + storage_kwargs = {"app_config": self.app_config} if self.app_config is not None else {} + storage = await asyncio.to_thread(get_or_new_skill_storage, **storage_kwargs) # Use asyncio.to_thread to avoid blocking the event loop (LangGraph ASGI requirement) - all_skills = await asyncio.to_thread(get_or_new_skill_storage().load_skills, enabled_only=True) + all_skills = await asyncio.to_thread(storage.load_skills, enabled_only=True) logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} loaded {len(all_skills)} enabled skills from disk") except Exception: logger.warning(f"[trace={self.trace_id}] Failed to load skills for subagent {self.config.name}", exc_info=True) @@ -395,6 +399,10 @@ class SubagentExecutor: status=SubagentStatus.RUNNING, started_at=datetime.now(), ) + ai_messages = result.ai_messages + if ai_messages is None: + ai_messages = [] + result.ai_messages = ai_messages try: agent = self._create_agent() @@ -404,10 +412,12 @@ class SubagentExecutor: run_config: RunnableConfig = { "recursion_limit": self.config.max_turns, } - context = {} + context: dict[str, Any] = {} if self.thread_id: run_config["configurable"] = {"thread_id": self.thread_id} context["thread_id"] = self.thread_id + if self.app_config is not None: + context["app_config"] = self.app_config logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} starting async execution with max_turns={self.config.max_turns}") @@ -454,13 +464,13 @@ class SubagentExecutor: message_id = message_dict.get("id") is_duplicate = False if message_id: - is_duplicate = any(msg.get("id") == message_id for msg in result.ai_messages) + is_duplicate = any(msg.get("id") == message_id for msg in ai_messages) else: - is_duplicate = message_dict in result.ai_messages + is_duplicate = message_dict in ai_messages if not is_duplicate: - result.ai_messages.append(message_dict) - logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} captured AI message #{len(result.ai_messages)}") + ai_messages.append(message_dict) + logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} captured AI message #{len(ai_messages)}") logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} completed async execution") diff --git a/backend/packages/harness/deerflow/subagents/registry.py b/backend/packages/harness/deerflow/subagents/registry.py index b34d7e9bd..4c4f3f183 100644 --- a/backend/packages/harness/deerflow/subagents/registry.py +++ b/backend/packages/harness/deerflow/subagents/registry.py @@ -2,6 +2,7 @@ import logging from dataclasses import replace +from typing import Any from deerflow.sandbox.security import is_host_bash_allowed from deerflow.subagents.builtins import BUILTIN_SUBAGENTS @@ -10,19 +11,26 @@ from deerflow.subagents.config import SubagentConfig logger = logging.getLogger(__name__) -def _build_custom_subagent_config(name: str) -> SubagentConfig | None: +def _resolve_subagents_app_config(app_config: Any | None = None): + if app_config is None: + from deerflow.config.subagents_config import get_subagents_app_config + + return get_subagents_app_config() + return getattr(app_config, "subagents", app_config) + + +def _build_custom_subagent_config(name: str, *, app_config: Any | None = None) -> SubagentConfig | None: """Build a SubagentConfig from config.yaml custom_agents section. Args: name: The name of the custom subagent. + app_config: Optional AppConfig or SubagentsAppConfig to resolve from. 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) + subagents_config = _resolve_subagents_app_config(app_config) + custom = subagents_config.custom_agents.get(name) if custom is None: return None @@ -39,7 +47,7 @@ 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: Any | None = None) -> SubagentConfig | None: """Get a subagent configuration by name, with config.yaml overrides applied. Resolution order (mirrors Codex's config layering): @@ -49,6 +57,7 @@ def get_subagent_config(name: str) -> SubagentConfig | None: Args: name: The name of the subagent. + app_config: Optional AppConfig or SubagentsAppConfig to resolve overrides from. Returns: SubagentConfig if found (with any config.yaml overrides applied), None otherwise. @@ -56,7 +65,7 @@ def get_subagent_config(name: str) -> SubagentConfig | None: # 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=app_config) if config is None: return None @@ -65,12 +74,9 @@ def get_subagent_config(name: str) -> SubagentConfig | None: # (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 - - app_config = get_subagents_app_config() + subagents_config = _resolve_subagents_app_config(app_config) is_builtin = name in BUILTIN_SUBAGENTS - agent_override = app_config.agents.get(name) + agent_override = subagents_config.agents.get(name) overrides = {} @@ -79,27 +85,27 @@ def get_subagent_config(name: str) -> SubagentConfig | 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 + elif is_builtin and subagents_config.timeout_seconds != config.timeout_seconds: + logger.debug("Subagent '%s': timeout from global default (%ss -> %ss)", name, config.timeout_seconds, subagents_config.timeout_seconds) + overrides["timeout_seconds"] = subagents_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 + elif is_builtin and subagents_config.max_turns is not None and subagents_config.max_turns != config.max_turns: + logger.debug("Subagent '%s': max_turns from global default (%s -> %s)", name, config.max_turns, subagents_config.max_turns) + overrides["max_turns"] = subagents_config.max_turns # Model: per-agent override only (no global default for model) - effective_model = app_config.get_model_for(name) + effective_model = subagents_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 = subagents_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 +116,21 @@ def get_subagent_config(name: str) -> SubagentConfig | None: return config -def list_subagents() -> list[SubagentConfig]: +def list_subagents(*, app_config: Any | None = None) -> 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) + for name in get_subagent_names(app_config=app_config): + config = get_subagent_config(name, app_config=app_config) if config is not None: configs.append(config) return configs -def get_subagent_names() -> list[str]: +def get_subagent_names(*, app_config: Any | None = None) -> list[str]: """Get all available subagent names (built-in + custom). Returns: @@ -133,25 +139,23 @@ 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: + subagents_config = _resolve_subagents_app_config(app_config) + for custom_name in subagents_config.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: Any | None = None) -> 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=app_config) try: - host_bash_allowed = is_host_bash_allowed() + host_bash_allowed = is_host_bash_allowed(app_config) if hasattr(app_config, "sandbox") else is_host_bash_allowed() except Exception: logger.debug("Could not determine host bash availability; exposing all subagents") return names diff --git a/backend/packages/harness/deerflow/tools/builtins/task_tool.py b/backend/packages/harness/deerflow/tools/builtins/task_tool.py index 42062f0aa..1328507b2 100644 --- a/backend/packages/harness/deerflow/tools/builtins/task_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/task_tool.py @@ -4,7 +4,7 @@ import asyncio import logging import uuid from dataclasses import replace -from typing import Annotated +from typing import TYPE_CHECKING, Annotated, Any, cast from langchain.tools import InjectedToolCallId, ToolRuntime, tool from langgraph.config import get_stream_writer @@ -22,9 +22,21 @@ from deerflow.subagents.executor import ( request_cancel_background_task, ) +if TYPE_CHECKING: + from deerflow.config.app_config import AppConfig + logger = logging.getLogger(__name__) +def _get_runtime_app_config(runtime: Any) -> "AppConfig | None": + context = getattr(runtime, "context", None) + if isinstance(context, dict): + app_config = context.get("app_config") + if app_config is not None: + return cast("AppConfig", app_config) + return None + + def _merge_skill_allowlists(parent: list[str] | None, child: list[str] | None) -> list[str] | None: """Return the effective subagent skill allowlist under the parent policy.""" if parent is None: @@ -81,15 +93,18 @@ 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() + runtime_app_config = _get_runtime_app_config(runtime) + available_subagent_names = get_available_subagent_names(app_config=runtime_app_config) if runtime_app_config is not None else get_available_subagent_names() # Get subagent configuration - config = get_subagent_config(subagent_type) + config = get_subagent_config(subagent_type, app_config=runtime_app_config) if runtime_app_config is not None else get_subagent_config(subagent_type) 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(): - return f"Error: {LOCAL_BASH_SUBAGENT_DISABLED_MESSAGE}" + if subagent_type == "bash": + host_bash_allowed = is_host_bash_allowed(runtime_app_config) if runtime_app_config is not None else is_host_bash_allowed() + if not host_bash_allowed: + return f"Error: {LOCAL_BASH_SUBAGENT_DISABLED_MESSAGE}" # Build config overrides overrides: dict = {} @@ -136,25 +151,34 @@ async def task_tool( # Inherit parent agent's tool_groups so subagents respect the same restrictions parent_tool_groups = metadata.get("tool_groups") - app_config = None - if config.model == "inherit" and parent_model is None: - app_config = get_app_config() - effective_model = resolve_subagent_model_name(config, parent_model, app_config=app_config) + resolved_app_config = runtime_app_config + if config.model == "inherit" and parent_model is None and resolved_app_config is None: + resolved_app_config = get_app_config() + effective_model = resolve_subagent_model_name(config, parent_model, app_config=resolved_app_config) # Subagents should not have subagent tools enabled (prevent recursive nesting) - tools = get_available_tools(model_name=effective_model, groups=parent_tool_groups, subagent_enabled=False) + available_tools_kwargs = { + "model_name": effective_model, + "groups": parent_tool_groups, + "subagent_enabled": False, + } + if resolved_app_config is not None: + available_tools_kwargs["app_config"] = resolved_app_config + tools = get_available_tools(**available_tools_kwargs) # Create executor - executor = SubagentExecutor( - config=config, - tools=tools, - app_config=app_config, - parent_model=parent_model, - sandbox_state=sandbox_state, - thread_data=thread_data, - thread_id=thread_id, - trace_id=trace_id, - ) + executor_kwargs = { + "config": config, + "tools": tools, + "parent_model": parent_model, + "sandbox_state": sandbox_state, + "thread_data": thread_data, + "thread_id": thread_id, + "trace_id": trace_id, + } + if resolved_app_config is not None: + executor_kwargs["app_config"] = resolved_app_config + executor = SubagentExecutor(**executor_kwargs) # Start background execution (always async to prevent blocking) # Use tool_call_id as task_id for better traceability @@ -189,11 +213,12 @@ async def task_tool( last_status = result.status # Check for new AI messages and send task_running events - current_message_count = len(result.ai_messages) + ai_messages = result.ai_messages or [] + current_message_count = len(ai_messages) if current_message_count > last_message_count: # Send task_running event for each new message for i in range(last_message_count, current_message_count): - message = result.ai_messages[i] + message = ai_messages[i] writer( { "type": "task_running", diff --git a/backend/packages/harness/deerflow/tools/tools.py b/backend/packages/harness/deerflow/tools/tools.py index 2ba6eb6b4..14d93e65f 100644 --- a/backend/packages/harness/deerflow/tools/tools.py +++ b/backend/packages/harness/deerflow/tools/tools.py @@ -141,10 +141,14 @@ 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() + if app_config is None: + from deerflow.config.acp_config import get_acp_agents + + acp_agents = get_acp_agents() + else: + acp_agents = getattr(config, "acp_agents", {}) or {} 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/tests/test_invoke_acp_agent_tool.py b/backend/tests/test_invoke_acp_agent_tool.py index 3c5f6f0ff..8c44403b8 100644 --- a/backend/tests/test_invoke_acp_agent_tool.py +++ b/backend/tests/test_invoke_acp_agent_tool.py @@ -697,3 +697,33 @@ def test_get_available_tools_includes_invoke_acp_agent_when_agents_configured(mo assert "invoke_acp_agent" in [tool.name for tool in tools] load_acp_config_from_dict({}) + + +def test_get_available_tools_uses_explicit_app_config_for_acp_agents(monkeypatch): + explicit_agents = {"codex": ACPAgentConfig(command="codex-acp", description="Codex CLI")} + explicit_config = SimpleNamespace( + tools=[], + models=[], + tool_search=SimpleNamespace(enabled=False), + skill_evolution=SimpleNamespace(enabled=False), + get_model_config=lambda name: None, + acp_agents=explicit_agents, + ) + sentinel_tool = SimpleNamespace(name="invoke_acp_agent") + captured: dict[str, object] = {} + + def fail_get_acp_agents(): + raise AssertionError("ambient get_acp_agents() must not be used when app_config is explicit") + + def fake_build_invoke_acp_agent_tool(agents): + captured["agents"] = agents + return sentinel_tool + + monkeypatch.setattr("deerflow.tools.tools.is_host_bash_allowed", lambda config=None: True) + monkeypatch.setattr("deerflow.config.acp_config.get_acp_agents", fail_get_acp_agents) + monkeypatch.setattr("deerflow.tools.builtins.invoke_acp_agent_tool.build_invoke_acp_agent_tool", fake_build_invoke_acp_agent_tool) + + tools = get_available_tools(include_mcp=False, subagent_enabled=False, app_config=explicit_config) + + assert captured["agents"] is explicit_agents + assert "invoke_acp_agent" in [tool.name for tool in tools] diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py index c22377b88..b240116cd 100644 --- a/backend/tests/test_lead_agent_model_resolution.py +++ b/backend/tests/test_lead_agent_model_resolution.py @@ -72,6 +72,44 @@ def test_internal_make_lead_agent_uses_explicit_app_config(monkeypatch): assert result["model"] is not None +def test_make_lead_agent_uses_runtime_app_config_from_context_without_global_read(monkeypatch): + app_config = _make_app_config([_make_model("context-model", supports_thinking=False)]) + + import deerflow.tools as tools_module + + def _raise_get_app_config(): + raise AssertionError("ambient get_app_config() must not be used when runtime context already carries app_config") + + monkeypatch.setattr(lead_agent_module, "get_app_config", _raise_get_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, **kwargs: []) + + captured: dict[str, object] = {} + + def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None): + captured["name"] = name + captured["app_config"] = app_config + 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", + "app_config": app_config, + } + } + ) + + assert captured == { + "name": "context-model", + "app_config": app_config, + } + assert result["model"] is not None + + def test_resolve_model_name_falls_back_to_default(monkeypatch, caplog): app_config = _make_app_config( [ @@ -276,6 +314,16 @@ def test_build_middlewares_passes_explicit_app_config_to_shared_factory(monkeypa ) monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **kwargs: None) monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None) + monkeypatch.setattr( + lead_agent_module, + "TitleMiddleware", + lambda *, app_config: captured.setdefault("title_app_config", app_config) or "title-middleware", + ) + monkeypatch.setattr( + lead_agent_module, + "MemoryMiddleware", + lambda agent_name=None, *, memory_config: captured.setdefault("memory_config", memory_config) or "memory-middleware", + ) middlewares = lead_agent_module._build_middlewares( {"configurable": {"is_plan_mode": False, "subagent_enabled": False}}, @@ -286,17 +334,16 @@ def test_build_middlewares_passes_explicit_app_config_to_shared_factory(monkeypa assert captured == { "app_config": app_config, "lazy_init": True, + "title_app_config": app_config, + "memory_config": app_config.memory, } assert middlewares[0] == "base-middleware" 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("model-masswork", supports_thinking=False)]) + app_config.summarization = SummarizationConfig(enabled=True, model_name="model-masswork") + app_config.memory = MemoryConfig(enabled=False) from unittest.mock import MagicMock @@ -311,13 +358,55 @@ def test_create_summarization_middleware_uses_configured_model_alias(monkeypatch captured["app_config"] = app_config return fake_model + def _raise_get_app_config(): + raise AssertionError("ambient get_app_config() must not be used when app_config is explicit") + + monkeypatch.setattr(lead_agent_module, "get_app_config", _raise_get_app_config) monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model) monkeypatch.setattr(lead_agent_module, "DeerFlowSummarizationMiddleware", lambda **kwargs: kwargs) - middleware = lead_agent_module._create_summarization_middleware(app_config=_make_app_config([_make_model("model-masswork", supports_thinking=False)])) + middleware = lead_agent_module._create_summarization_middleware(app_config=app_config) assert captured["name"] == "model-masswork" assert captured["thinking_enabled"] is False - assert captured["app_config"] is not None + assert captured["app_config"] is app_config assert middleware["model"] is fake_model fake_model.with_config.assert_called_once_with(tags=["middleware:summarize"]) + + +def test_create_summarization_middleware_threads_resolved_app_config_to_model(monkeypatch): + fallback_app_config = _make_app_config([_make_model("fallback-model", supports_thinking=False)]) + fallback_app_config.summarization = SummarizationConfig(enabled=True, model_name="fallback-model") + fallback_app_config.memory = MemoryConfig(enabled=False) + + from unittest.mock import MagicMock + + captured: dict[str, object] = {} + fake_model = MagicMock() + fake_model.with_config.return_value = fake_model + + def _fake_create_chat_model(*, name=None, thinking_enabled, reasoning_effort=None, app_config=None): + captured["app_config"] = app_config + return fake_model + + monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: fallback_app_config) + monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model) + monkeypatch.setattr(lead_agent_module, "DeerFlowSummarizationMiddleware", lambda **kwargs: kwargs) + + lead_agent_module._create_summarization_middleware() + + assert captured["app_config"] is fallback_app_config + + +def test_memory_middleware_uses_explicit_memory_config_without_global_read(monkeypatch): + from deerflow.agents.middlewares import memory_middleware as memory_middleware_module + from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware + + def _raise_get_memory_config(): + raise AssertionError("ambient get_memory_config() must not be used when memory_config is explicit") + + monkeypatch.setattr(memory_middleware_module, "get_memory_config", _raise_get_memory_config) + + middleware = MemoryMiddleware(memory_config=MemoryConfig(enabled=False)) + + assert middleware.after_agent({"messages": []}, runtime=MagicMock(context={"thread_id": "thread-1"})) is None diff --git a/backend/tests/test_lead_agent_prompt.py b/backend/tests/test_lead_agent_prompt.py index edbcd5193..ecaca314a 100644 --- a/backend/tests/test_lead_agent_prompt.py +++ b/backend/tests/test_lead_agent_prompt.py @@ -4,6 +4,7 @@ from types import SimpleNamespace import anyio from deerflow.agents.lead_agent import prompt as prompt_module +from deerflow.config.subagents_config import CustomSubagentConfig, SubagentsAppConfig from deerflow.skills.types import Skill @@ -40,6 +41,21 @@ def test_build_custom_mounts_section_lists_configured_mounts(monkeypatch): assert "read-only" in section +def test_build_custom_mounts_section_uses_explicit_app_config_without_global_read(monkeypatch): + mounts = [SimpleNamespace(container_path="/home/user/shared", read_only=False)] + config = SimpleNamespace(sandbox=SimpleNamespace(mounts=mounts)) + + def fail_get_app_config(): + raise AssertionError("ambient get_app_config() must not be used when app_config is explicit") + + monkeypatch.setattr("deerflow.config.get_app_config", fail_get_app_config) + + section = prompt_module._build_custom_mounts_section(app_config=config) + + assert "`/home/user/shared`" in section + assert "read-write" in section + + def test_apply_prompt_template_includes_custom_mounts(monkeypatch): mounts = [SimpleNamespace(container_path="/home/user/shared", read_only=False)] config = SimpleNamespace( @@ -49,8 +65,8 @@ def test_apply_prompt_template_includes_custom_mounts(monkeypatch): 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 **kwargs: "") - monkeypatch.setattr(prompt_module, "_build_acp_section", lambda: "") - monkeypatch.setattr(prompt_module, "_get_memory_context", lambda agent_name=None: "") + monkeypatch.setattr(prompt_module, "_build_acp_section", lambda **kwargs: "") + monkeypatch.setattr(prompt_module, "_get_memory_context", lambda agent_name=None, **kwargs: "") monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None: "") prompt = prompt_module.apply_prompt_template() @@ -67,8 +83,8 @@ def test_apply_prompt_template_includes_relative_path_guidance(monkeypatch): 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 **kwargs: "") - monkeypatch.setattr(prompt_module, "_build_acp_section", lambda: "") - monkeypatch.setattr(prompt_module, "_get_memory_context", lambda agent_name=None: "") + monkeypatch.setattr(prompt_module, "_build_acp_section", lambda **kwargs: "") + monkeypatch.setattr(prompt_module, "_get_memory_context", lambda agent_name=None, **kwargs: "") monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None: "") prompt = prompt_module.apply_prompt_template() @@ -77,6 +93,123 @@ def test_apply_prompt_template_includes_relative_path_guidance(monkeypatch): assert "`hello.txt`, `../uploads/data.csv`, and `../outputs/report.md`" in prompt +def test_apply_prompt_template_threads_explicit_app_config_without_global_config(monkeypatch): + mounts = [SimpleNamespace(container_path="/home/user/shared", read_only=False)] + explicit_config = SimpleNamespace( + sandbox=SimpleNamespace(mounts=mounts), + skills=SimpleNamespace(container_path="/mnt/explicit-skills"), + skill_evolution=SimpleNamespace(enabled=False), + tool_search=SimpleNamespace(enabled=False), + memory=SimpleNamespace(enabled=False, injection_enabled=True, max_injection_tokens=2000), + acp_agents={}, + ) + + def fail_get_app_config(): + raise AssertionError("ambient get_app_config() must not be used when app_config is explicit") + + def fail_get_memory_config(): + raise AssertionError("ambient get_memory_config() must not be used when app_config is explicit") + + monkeypatch.setattr("deerflow.config.get_app_config", fail_get_app_config) + monkeypatch.setattr("deerflow.config.memory_config.get_memory_config", fail_get_memory_config) + monkeypatch.setattr(prompt_module, "get_or_new_skill_storage", lambda app_config=None: SimpleNamespace(load_skills=lambda enabled_only=True: [])) + monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None: "") + + prompt = prompt_module.apply_prompt_template(app_config=explicit_config) + + assert "`/home/user/shared`" in prompt + assert "Custom Mounted Directories" in prompt + + +def test_apply_prompt_template_threads_explicit_app_config_to_subagents_without_global_config(monkeypatch): + explicit_config = SimpleNamespace( + sandbox=SimpleNamespace( + use="deerflow.sandbox.local:LocalSandboxProvider", + allow_host_bash=False, + mounts=[], + ), + subagents=SubagentsAppConfig( + custom_agents={ + "researcher": CustomSubagentConfig( + description="Research agent\nwith details", + system_prompt="You research.", + ) + } + ), + skills=SimpleNamespace(container_path="/mnt/skills"), + skill_evolution=SimpleNamespace(enabled=False), + tool_search=SimpleNamespace(enabled=False), + memory=SimpleNamespace(enabled=False, injection_enabled=True, max_injection_tokens=2000), + acp_agents={}, + ) + + def fail_get_app_config(): + raise AssertionError("ambient get_app_config() must not be used when app_config is explicit") + + def fail_get_subagents_app_config(): + raise AssertionError("ambient get_subagents_app_config() must not be used when app_config is explicit") + + monkeypatch.setattr("deerflow.config.get_app_config", fail_get_app_config) + monkeypatch.setattr("deerflow.config.subagents_config.get_subagents_app_config", fail_get_subagents_app_config) + monkeypatch.setattr(prompt_module, "get_or_new_skill_storage", lambda app_config=None: SimpleNamespace(load_skills=lambda enabled_only=True: [])) + monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None: "") + + prompt = prompt_module.apply_prompt_template(subagent_enabled=True, app_config=explicit_config) + + assert "**researcher**: Research agent" in prompt + assert "**bash**" not in prompt + + +def test_build_acp_section_uses_explicit_app_config_without_global_config(monkeypatch): + explicit_config = SimpleNamespace(acp_agents={"codex": object()}) + + def fail_get_acp_agents(): + raise AssertionError("ambient get_acp_agents() must not be used when app_config is explicit") + + monkeypatch.setattr("deerflow.config.acp_config.get_acp_agents", fail_get_acp_agents) + + section = prompt_module._build_acp_section(app_config=explicit_config) + + assert "ACP Agent Tasks" in section + assert "/mnt/acp-workspace/" in section + + +def test_get_memory_context_uses_explicit_app_config_without_global_config(monkeypatch): + explicit_config = SimpleNamespace( + memory=SimpleNamespace(enabled=True, injection_enabled=True, max_injection_tokens=1234), + ) + captured: dict[str, object] = {} + + def fail_get_memory_config(): + raise AssertionError("ambient get_memory_config() must not be used when app_config is explicit") + + def fake_get_memory_data(agent_name=None, *, user_id=None): + captured["agent_name"] = agent_name + captured["user_id"] = user_id + return {"facts": []} + + def fake_format_memory_for_injection(memory_data, *, max_tokens): + captured["memory_data"] = memory_data + captured["max_tokens"] = max_tokens + return "remember this" + + monkeypatch.setattr("deerflow.config.memory_config.get_memory_config", fail_get_memory_config) + monkeypatch.setattr("deerflow.runtime.user_context.get_effective_user_id", lambda: "user-1") + monkeypatch.setattr("deerflow.agents.memory.get_memory_data", fake_get_memory_data) + monkeypatch.setattr("deerflow.agents.memory.format_memory_for_injection", fake_format_memory_for_injection) + + context = prompt_module._get_memory_context("agent-a", app_config=explicit_config) + + assert "" in context + assert "remember this" in context + assert captured == { + "agent_name": "agent-a", + "user_id": "user-1", + "memory_data": {"facts": []}, + "max_tokens": 1234, + } + + def test_refresh_skills_system_prompt_cache_async_reloads_immediately(monkeypatch, tmp_path): def make_skill(name: str) -> Skill: skill_dir = tmp_path / name diff --git a/backend/tests/test_lead_agent_skills.py b/backend/tests/test_lead_agent_skills.py index fe983d916..576f6bd19 100644 --- a/backend/tests/test_lead_agent_skills.py +++ b/backend/tests/test_lead_agent_skills.py @@ -106,7 +106,11 @@ def test_get_skills_prompt_section_uses_explicit_config_for_enabled_skills(monke skill_evolution=SimpleNamespace(enabled=False), ) + def fail_get_app_config(): + raise AssertionError("ambient get_app_config() must not be used when app_config is explicit") + monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: [_make_skill("global-skill")]) + monkeypatch.setattr("deerflow.config.get_app_config", fail_get_app_config) monkeypatch.setattr( "deerflow.agents.lead_agent.prompt.get_or_new_skill_storage", lambda app_config=None, **kwargs: __import__("types").SimpleNamespace(load_skills=lambda *, enabled_only: [_make_skill("explicit-skill")] if app_config is explicit_config else []), diff --git a/backend/tests/test_run_worker_rollback.py b/backend/tests/test_run_worker_rollback.py index b2b8da77f..0c99663ad 100644 --- a/backend/tests/test_run_worker_rollback.py +++ b/backend/tests/test_run_worker_rollback.py @@ -1,8 +1,12 @@ +import asyncio +from types import SimpleNamespace from unittest.mock import AsyncMock, call import pytest -from deerflow.runtime.runs.worker import _agent_factory_supports_app_config, _build_runtime_context, _rollback_to_pre_run_checkpoint +from deerflow.runtime.runs.manager import RunManager +from deerflow.runtime.runs.schemas import RunStatus +from deerflow.runtime.runs.worker import RunContext, _agent_factory_supports_app_config, _build_runtime_context, _install_runtime_context, _rollback_to_pre_run_checkpoint, run_agent class FakeCheckpointer: @@ -12,6 +16,73 @@ class FakeCheckpointer: self.aput_writes = AsyncMock() +def test_build_runtime_context_includes_app_config_when_present(): + app_config = object() + + context = _build_runtime_context("thread-1", "run-1", None, app_config) + + assert context["thread_id"] == "thread-1" + assert context["run_id"] == "run-1" + assert context["app_config"] is app_config + + +def test_install_runtime_context_preserves_existing_thread_id_and_threads_app_config(): + app_config = object() + config = {"context": {"thread_id": "caller-thread"}} + + _install_runtime_context( + config, + { + "thread_id": "record-thread", + "run_id": "run-1", + "app_config": app_config, + }, + ) + + assert config["context"]["thread_id"] == "caller-thread" + assert config["context"]["run_id"] == "run-1" + assert config["context"]["app_config"] is app_config + + +@pytest.mark.anyio +async def test_run_agent_threads_explicit_app_config_into_config_only_factory(): + run_manager = RunManager() + record = await run_manager.create("thread-1") + bridge = SimpleNamespace( + publish=AsyncMock(), + publish_end=AsyncMock(), + cleanup=AsyncMock(), + ) + app_config = object() + captured: dict[str, object] = {} + + class DummyAgent: + async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False): + captured["astream_context"] = config["context"] + yield {"messages": []} + + def factory(*, config): + captured["factory_context"] = config["context"] + return DummyAgent() + + await run_agent( + bridge, + run_manager, + record, + ctx=RunContext(checkpointer=None, app_config=app_config), + agent_factory=factory, + graph_input={}, + config={}, + ) + await asyncio.sleep(0) + + assert captured["factory_context"]["app_config"] is app_config + assert captured["astream_context"]["app_config"] is app_config + assert run_manager.get(record.run_id).status == RunStatus.success + bridge.publish_end.assert_awaited_once_with(record.run_id) + bridge.cleanup.assert_awaited_once_with(record.run_id, delay=60) + + @pytest.mark.anyio async def test_rollback_restores_snapshot_without_deleting_thread(): checkpointer = FakeCheckpointer(put_result={"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": "restored-1"}}) diff --git a/backend/tests/test_subagent_executor.py b/backend/tests/test_subagent_executor.py index 1b2251444..102ac091a 100644 --- a/backend/tests/test_subagent_executor.py +++ b/backend/tests/test_subagent_executor.py @@ -204,7 +204,7 @@ class TestAgentConstruction: SubagentExecutor = classes["SubagentExecutor"] - app_config = object() + app_config = SimpleNamespace(models=[SimpleNamespace(name="default-model")]) model = object() middlewares = [object()] agent = object() @@ -266,6 +266,43 @@ class TestAgentConstruction: assert captured["agent"]["tools"] == [] assert captured["agent"]["system_prompt"] == base_config.system_prompt + @pytest.mark.anyio + async def test_load_skill_messages_uses_explicit_app_config_for_skill_storage( + self, + classes, + base_config, + monkeypatch: pytest.MonkeyPatch, + tmp_path, + ): + """Explicit app_config must be threaded into subagent skill storage lookup.""" + SubagentExecutor = classes["SubagentExecutor"] + + app_config = SimpleNamespace(models=[SimpleNamespace(name="default-model")]) + skill_dir = tmp_path / "demo-skill" + skill_dir.mkdir() + skill_file = skill_dir / "SKILL.md" + skill_file.write_text("Use demo skill", encoding="utf-8") + captured: dict[str, object] = {} + + def fake_get_or_new_skill_storage(*, app_config=None): + captured["app_config"] = app_config + return SimpleNamespace(load_skills=lambda *, enabled_only: [SimpleNamespace(name="demo-skill", skill_file=skill_file)]) + + monkeypatch.setattr("deerflow.skills.storage.get_or_new_skill_storage", fake_get_or_new_skill_storage) + + executor = SubagentExecutor( + config=base_config, + tools=[], + app_config=app_config, + thread_id="test-thread", + ) + + messages = await executor._load_skill_messages() + + assert captured["app_config"] is app_config + assert len(messages) == 1 + assert "Use demo skill" in messages[0].content + # ----------------------------------------------------------------------------- # Async Execution Path Tests diff --git a/backend/tests/test_subagent_skills_config.py b/backend/tests/test_subagent_skills_config.py index f121ccf25..b1ca0c24d 100644 --- a/backend/tests/test_subagent_skills_config.py +++ b/backend/tests/test_subagent_skills_config.py @@ -9,6 +9,8 @@ Covers: - Skills filter passthrough in task_tool config assembly """ +from types import SimpleNamespace + import pytest from deerflow.config.subagents_config import ( @@ -343,12 +345,54 @@ class TestRegistryCustomAgentLookup: assert config.timeout_seconds == 600 assert config.model == "inherit" + def test_custom_agent_found_from_explicit_app_config_without_global_config(self, monkeypatch): + from deerflow.subagents.registry import get_subagent_config + + def fail_get_subagents_app_config(): + raise AssertionError("ambient get_subagents_app_config() must not be used when app_config is explicit") + + monkeypatch.setattr("deerflow.config.subagents_config.get_subagents_app_config", fail_get_subagents_app_config) + + app_config = SimpleNamespace( + subagents=SubagentsAppConfig( + custom_agents={ + "analysis": CustomSubagentConfig( + description="Data analysis specialist", + system_prompt="You are a data analysis subagent.", + skills=["data-analysis"], + ) + } + ) + ) + + config = get_subagent_config("analysis", app_config=app_config) + + assert config is not None + assert config.name == "analysis" + assert config.skills == ["data-analysis"] + 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_get_available_subagent_names_falls_back_when_subagents_app_config_lacks_sandbox(self, monkeypatch): + from deerflow.subagents import registry as registry_module + from deerflow.subagents.registry import get_available_subagent_names + + captured: dict[str, tuple] = {} + + def fake_is_host_bash_allowed(*args, **kwargs): + captured["args"] = args + return True + + monkeypatch.setattr(registry_module, "is_host_bash_allowed", fake_is_host_bash_allowed) + + get_available_subagent_names(app_config=SubagentsAppConfig()) + + assert captured["args"] == () + 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 diff --git a/backend/tests/test_task_tool_core_logic.py b/backend/tests/test_task_tool_core_logic.py index d436f1725..428b7a066 100644 --- a/backend/tests/test_task_tool_core_logic.py +++ b/backend/tests/test_task_tool_core_logic.py @@ -24,8 +24,11 @@ class FakeSubagentStatus(Enum): TIMED_OUT = "timed_out" -def _make_runtime() -> SimpleNamespace: +def _make_runtime(*, app_config=None) -> SimpleNamespace: # Minimal ToolRuntime-like object; task_tool only reads these three attributes. + context = {"thread_id": "thread-1"} + if app_config is not None: + context["app_config"] = app_config return SimpleNamespace( state={ "sandbox": {"sandbox_id": "local"}, @@ -35,14 +38,14 @@ def _make_runtime() -> SimpleNamespace: "outputs_path": "/tmp/outputs", }, }, - context={"thread_id": "thread-1"}, + context=context, config={"metadata": {"model_name": "ark-model", "trace_id": "trace-1"}}, ) -def _make_subagent_config() -> SubagentConfig: +def _make_subagent_config(name: str = "general-purpose") -> SubagentConfig: return SubagentConfig( - name="general-purpose", + name=name, description="General helper", system_prompt="Base system prompt", max_turns=50, @@ -112,6 +115,68 @@ def test_task_tool_rejects_bash_subagent_when_host_bash_disabled(monkeypatch): assert result.startswith("Error: Bash subagent is disabled") +def test_task_tool_threads_runtime_app_config_to_subagent_dependencies(monkeypatch): + app_config = object() + config = _make_subagent_config(name="bash") + runtime = _make_runtime(app_config=app_config) + events = [] + captured = {} + + class DummyExecutor: + def __init__(self, **kwargs): + captured["executor_kwargs"] = kwargs + + def execute_async(self, prompt, task_id=None): + captured["prompt"] = prompt + return task_id or "generated-task-id" + + def fake_get_available_subagent_names(*, app_config): + captured["names_app_config"] = app_config + return ["bash"] + + def fake_get_subagent_config(name, *, app_config): + captured["config_lookup"] = (name, app_config) + return config + + def fake_is_host_bash_allowed(config): + captured["bash_gate_app_config"] = config + return True + + def fake_get_available_tools(**kwargs): + captured["tools_kwargs"] = kwargs + return ["tool-a"] + + monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus) + monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor) + monkeypatch.setattr(task_tool_module, "get_available_subagent_names", fake_get_available_subagent_names) + monkeypatch.setattr(task_tool_module, "get_subagent_config", fake_get_subagent_config) + monkeypatch.setattr(task_tool_module, "is_host_bash_allowed", fake_is_host_bash_allowed) + 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", fake_get_available_tools) + + output = _run_task_tool( + runtime=runtime, + description="运行命令", + prompt="inspect files", + subagent_type="bash", + tool_call_id="tc-explicit-config", + ) + + assert output == "Task Succeeded. Result: done" + assert captured["names_app_config"] is app_config + assert captured["config_lookup"] == ("bash", app_config) + assert captured["bash_gate_app_config"] is app_config + assert captured["tools_kwargs"]["app_config"] is app_config + assert captured["executor_kwargs"]["app_config"] is app_config + assert captured["executor_kwargs"]["tools"] == ["tool-a"] + + def test_task_tool_emits_running_and_completed_events(monkeypatch): config = _make_subagent_config() runtime = _make_runtime() @@ -421,7 +486,8 @@ def test_task_tool_runtime_none_passes_groups_none(monkeypatch): 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) - monkeypatch.setattr(task_tool_module, "get_app_config", lambda: SimpleNamespace(models=[SimpleNamespace(name="default-model")])) + fallback_app_config = SimpleNamespace(models=[SimpleNamespace(name="default-model")]) + monkeypatch.setattr(task_tool_module, "get_app_config", lambda: fallback_app_config) output = _run_task_tool( runtime=None, @@ -433,7 +499,12 @@ def test_task_tool_runtime_none_passes_groups_none(monkeypatch): assert output == "Task Succeeded. Result: ok" # runtime is None -> metadata is empty dict -> groups=None, model falls back to app default. - get_available_tools.assert_called_once_with(model_name="default-model", groups=None, subagent_enabled=False) + get_available_tools.assert_called_once_with( + model_name="default-model", + groups=None, + subagent_enabled=False, + app_config=fallback_app_config, + ) config = _make_subagent_config() events = [] diff --git a/backend/tests/test_title_middleware_core_logic.py b/backend/tests/test_title_middleware_core_logic.py index afd10f2b3..ede4dc0a4 100644 --- a/backend/tests/test_title_middleware_core_logic.py +++ b/backend/tests/test_title_middleware_core_logic.py @@ -1,6 +1,7 @@ """Core behavior tests for TitleMiddleware.""" import asyncio +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock from langchain_core.messages import AIMessage, HumanMessage @@ -98,6 +99,34 @@ class TestTitleMiddlewareCoreLogic: "tags": ["middleware:title"], } + def test_generate_title_uses_explicit_app_config_without_global_config(self, monkeypatch): + title_config = TitleConfig(enabled=True, model_name="title-model", max_chars=20) + app_config = SimpleNamespace(title=title_config) + middleware = TitleMiddleware(app_config=app_config) + model = MagicMock() + model.ainvoke = AsyncMock(return_value=AIMessage(content="显式标题")) + + def fail_get_title_config(): + raise AssertionError("ambient get_title_config() must not be used when app_config is explicit") + + monkeypatch.setattr(title_middleware_module, "get_title_config", fail_get_title_config) + monkeypatch.setattr(title_middleware_module, "create_chat_model", MagicMock(return_value=model)) + + state = { + "messages": [ + HumanMessage(content="请帮我写一个标题"), + AIMessage(content="好的"), + ] + } + result = asyncio.run(middleware._agenerate_title_result(state)) + + assert result == {"title": "显式标题"} + title_middleware_module.create_chat_model.assert_called_once_with( + name="title-model", + thinking_enabled=False, + app_config=app_config, + ) + def test_generate_title_normalizes_structured_message_content(self, monkeypatch): _set_test_title_config(max_chars=20) middleware = TitleMiddleware()