diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py index da4a06824..ea9e8662b 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -10,6 +10,7 @@ from __future__ import annotations import asyncio import json import logging +import re import time from typing import Any @@ -93,20 +94,56 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]: return raw_input +_DEFAULT_ASSISTANT_ID = "lead_agent" + + def resolve_agent_factory(assistant_id: str | None): - """Resolve the agent factory callable from config.""" + """Resolve the agent factory callable from config. + + Custom agents are implemented as ``lead_agent`` + an ``agent_name`` + injected into ``configurable`` — see :func:`build_run_config`. All + ``assistant_id`` values therefore map to the same factory; the routing + happens inside ``make_lead_agent`` when it reads ``cfg["agent_name"]``. + """ from deerflow.agents.lead_agent.agent import make_lead_agent - if assistant_id and assistant_id != "lead_agent": - logger.info("assistant_id=%s requested; falling back to lead_agent", assistant_id) return make_lead_agent -def build_run_config(thread_id: str, request_config: dict[str, Any] | None, metadata: dict[str, Any] | None) -> dict[str, Any]: - """Build a RunnableConfig dict for the agent.""" - configurable = {"thread_id": thread_id} +def build_run_config( + thread_id: str, + request_config: dict[str, Any] | None, + metadata: dict[str, Any] | None, + *, + assistant_id: str | None = None, +) -> dict[str, Any]: + """Build a RunnableConfig dict for the agent. + + When *assistant_id* refers to a custom agent (anything other than + ``"lead_agent"`` / ``None``), the name is forwarded as + ``configurable["agent_name"]``. ``make_lead_agent`` reads this key to + load the matching ``agents//SOUL.md`` and per-agent config — + without it the agent silently runs as the default lead agent. + + This mirrors the channel manager's ``_resolve_run_params`` logic so that + the LangGraph Platform-compatible HTTP API and the IM channel path behave + identically. + """ + configurable: dict[str, Any] = {"thread_id": thread_id} if request_config: configurable.update(request_config.get("configurable", {})) + + # Inject custom agent name when the caller specified a non-default assistant. + # Honour an explicit configurable["agent_name"] in the request if already set. + if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID and "agent_name" not in configurable: + # Normalize the same way ChannelManager does: strip, lowercase, + # replace underscores with hyphens, then validate to prevent path + # traversal and invalid agent directory lookups. + normalized = assistant_id.strip().lower().replace("_", "-") + if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized): + raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization.") + configurable["agent_name"] = normalized + config: dict[str, Any] = {"configurable": configurable, "recursion_limit": 100} if request_config: for k, v in request_config.items(): @@ -233,7 +270,7 @@ async def start_run( agent_factory = resolve_agent_factory(body.assistant_id) graph_input = normalize_input(body.input) - config = build_run_config(thread_id, body.config, body.metadata) + config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id) stream_modes = normalize_stream_modes(body.stream_mode) task = asyncio.create_task( diff --git a/backend/tests/test_gateway_services.py b/backend/tests/test_gateway_services.py index 730616027..3921a2b34 100644 --- a/backend/tests/test_gateway_services.py +++ b/backend/tests/test_gateway_services.py @@ -100,3 +100,63 @@ def test_build_run_config_with_overrides(): assert config["configurable"]["model_name"] == "gpt-4" assert config["tags"] == ["test"] assert config["metadata"]["user"] == "alice" + + +# --------------------------------------------------------------------------- +# Regression tests for issue #1644: +# assistant_id not mapped to agent_name → custom agent SOUL.md never loaded +# --------------------------------------------------------------------------- + + +def test_build_run_config_custom_agent_injects_agent_name(): + """Custom assistant_id must be forwarded as configurable['agent_name']. + + Regression test for #1644: when the LangGraph Platform-compatible + /runs endpoint receives a custom assistant_id (e.g. 'finalis'), the + Gateway must inject configurable['agent_name'] so that make_lead_agent + loads the correct agents/finalis/SOUL.md. + """ + from app.gateway.services import build_run_config + + config = build_run_config("thread-1", None, None, assistant_id="finalis") + assert config["configurable"]["agent_name"] == "finalis", "Custom assistant_id must be forwarded as configurable['agent_name'] so that make_lead_agent loads the correct SOUL.md" + + +def test_build_run_config_lead_agent_no_agent_name(): + """'lead_agent' assistant_id must NOT inject configurable['agent_name'].""" + from app.gateway.services import build_run_config + + config = build_run_config("thread-1", None, None, assistant_id="lead_agent") + assert "agent_name" not in config["configurable"] + + +def test_build_run_config_none_assistant_id_no_agent_name(): + """None assistant_id must NOT inject configurable['agent_name'].""" + from app.gateway.services import build_run_config + + config = build_run_config("thread-1", None, None, assistant_id=None) + assert "agent_name" not in config["configurable"] + + +def test_build_run_config_explicit_agent_name_not_overwritten(): + """An explicit configurable['agent_name'] in the request must take precedence.""" + from app.gateway.services import build_run_config + + config = build_run_config( + "thread-1", + {"configurable": {"agent_name": "explicit-agent"}}, + None, + assistant_id="other-agent", + ) + assert config["configurable"]["agent_name"] == "explicit-agent", "An explicit configurable['agent_name'] in the request body must not be overwritten by the assistant_id mapping" + + +def test_resolve_agent_factory_returns_make_lead_agent(): + """resolve_agent_factory always returns make_lead_agent regardless of assistant_id.""" + from app.gateway.services import resolve_agent_factory + from deerflow.agents.lead_agent.agent import make_lead_agent + + assert resolve_agent_factory(None) is make_lead_agent + assert resolve_agent_factory("lead_agent") is make_lead_agent + assert resolve_agent_factory("finalis") is make_lead_agent + assert resolve_agent_factory("custom-agent-123") is make_lead_agent