mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-04-25 11:18:22 +00:00
fix: read lead agent options from context (#2515)
* fix: read lead agent options from context * fix: validate runtime context config
This commit is contained in:
parent
ec8a8cae38
commit
b970993425
@ -12,6 +12,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
@ -101,9 +102,10 @@ 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``
|
Custom agents are implemented as ``lead_agent`` + an ``agent_name``
|
||||||
injected into ``configurable`` — see :func:`build_run_config`. All
|
injected into ``configurable`` or ``context`` — see
|
||||||
``assistant_id`` values therefore map to the same factory; the routing
|
:func:`build_run_config`. All ``assistant_id`` values therefore map to the
|
||||||
happens inside ``make_lead_agent`` when it reads ``cfg["agent_name"]``.
|
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
|
from deerflow.agents.lead_agent.agent import make_lead_agent
|
||||||
|
|
||||||
@ -120,10 +122,12 @@ def build_run_config(
|
|||||||
"""Build a RunnableConfig dict for the agent.
|
"""Build a RunnableConfig dict for the agent.
|
||||||
|
|
||||||
When *assistant_id* refers to a custom agent (anything other than
|
When *assistant_id* refers to a custom agent (anything other than
|
||||||
``"lead_agent"`` / ``None``), the name is forwarded as
|
``"lead_agent"`` / ``None``), the name is forwarded as ``agent_name`` in
|
||||||
``configurable["agent_name"]``. ``make_lead_agent`` reads this key to
|
whichever runtime options container is active: ``context`` for
|
||||||
load the matching ``agents/<name>/SOUL.md`` and per-agent config —
|
LangGraph >= 0.6.0 requests, otherwise ``configurable``.
|
||||||
without it the agent silently runs as the default lead agent.
|
``make_lead_agent`` reads this key to load the matching
|
||||||
|
``agents/<name>/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
|
This mirrors the channel manager's ``_resolve_run_params`` logic so that
|
||||||
the LangGraph Platform-compatible HTTP API and the IM channel path behave
|
the LangGraph Platform-compatible HTTP API and the IM channel path behave
|
||||||
@ -142,7 +146,14 @@ def build_run_config(
|
|||||||
thread_id,
|
thread_id,
|
||||||
list(request_config.get("configurable", {}).keys()),
|
list(request_config.get("configurable", {}).keys()),
|
||||||
)
|
)
|
||||||
config["context"] = request_config["context"]
|
context_value = request_config["context"]
|
||||||
|
if context_value is None:
|
||||||
|
context = {}
|
||||||
|
elif isinstance(context_value, Mapping):
|
||||||
|
context = dict(context_value)
|
||||||
|
else:
|
||||||
|
raise ValueError("request config 'context' must be a mapping or null.")
|
||||||
|
config["context"] = context
|
||||||
else:
|
else:
|
||||||
configurable = {"thread_id": thread_id}
|
configurable = {"thread_id": thread_id}
|
||||||
configurable.update(request_config.get("configurable", {}))
|
configurable.update(request_config.get("configurable", {}))
|
||||||
@ -154,13 +165,19 @@ def build_run_config(
|
|||||||
config["configurable"] = {"thread_id": thread_id}
|
config["configurable"] = {"thread_id": thread_id}
|
||||||
|
|
||||||
# Inject custom agent name when the caller specified a non-default assistant.
|
# Inject custom agent name when the caller specified a non-default assistant.
|
||||||
# Honour an explicit configurable["agent_name"] in the request if already set.
|
# Honour an explicit agent_name in the active runtime options container.
|
||||||
if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID and "configurable" in config:
|
if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID:
|
||||||
if "agent_name" not in config["configurable"]:
|
|
||||||
normalized = assistant_id.strip().lower().replace("_", "-")
|
normalized = assistant_id.strip().lower().replace("_", "-")
|
||||||
if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized):
|
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.")
|
raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization.")
|
||||||
config["configurable"]["agent_name"] = normalized
|
if "configurable" in config:
|
||||||
|
target = config["configurable"]
|
||||||
|
elif "context" in config:
|
||||||
|
target = config["context"]
|
||||||
|
else:
|
||||||
|
target = config.setdefault("configurable", {})
|
||||||
|
if target is not None and "agent_name" not in target:
|
||||||
|
target["agent_name"] = normalized
|
||||||
if metadata:
|
if metadata:
|
||||||
config.setdefault("metadata", {}).update(metadata)
|
config.setdefault("metadata", {}).update(metadata)
|
||||||
return config
|
return config
|
||||||
|
|||||||
@ -26,6 +26,15 @@ from deerflow.models import create_chat_model
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_runtime_config(config: RunnableConfig) -> dict:
|
||||||
|
"""Merge legacy configurable options with LangGraph runtime context."""
|
||||||
|
cfg = dict(config.get("configurable", {}) or {})
|
||||||
|
context = config.get("context", {}) or {}
|
||||||
|
if isinstance(context, dict):
|
||||||
|
cfg.update(context)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
def _resolve_model_name(requested_model_name: str | None = None) -> str:
|
def _resolve_model_name(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."""
|
"""Resolve a runtime model name safely, falling back to default if invalid. Returns None if no models are configured."""
|
||||||
app_config = get_app_config()
|
app_config = get_app_config()
|
||||||
@ -248,7 +257,8 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_nam
|
|||||||
middlewares.append(summarization_middleware)
|
middlewares.append(summarization_middleware)
|
||||||
|
|
||||||
# Add TodoList middleware if plan mode is enabled
|
# Add TodoList middleware if plan mode is enabled
|
||||||
is_plan_mode = config.get("configurable", {}).get("is_plan_mode", False)
|
cfg = _get_runtime_config(config)
|
||||||
|
is_plan_mode = cfg.get("is_plan_mode", False)
|
||||||
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
|
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
|
||||||
if todo_list_middleware is not None:
|
if todo_list_middleware is not None:
|
||||||
middlewares.append(todo_list_middleware)
|
middlewares.append(todo_list_middleware)
|
||||||
@ -277,9 +287,9 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_nam
|
|||||||
middlewares.append(DeferredToolFilterMiddleware())
|
middlewares.append(DeferredToolFilterMiddleware())
|
||||||
|
|
||||||
# Add SubagentLimitMiddleware to truncate excess parallel task calls
|
# Add SubagentLimitMiddleware to truncate excess parallel task calls
|
||||||
subagent_enabled = config.get("configurable", {}).get("subagent_enabled", False)
|
subagent_enabled = cfg.get("subagent_enabled", False)
|
||||||
if subagent_enabled:
|
if subagent_enabled:
|
||||||
max_concurrent_subagents = config.get("configurable", {}).get("max_concurrent_subagents", 3)
|
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||||||
middlewares.append(SubagentLimitMiddleware(max_concurrent=max_concurrent_subagents))
|
middlewares.append(SubagentLimitMiddleware(max_concurrent=max_concurrent_subagents))
|
||||||
|
|
||||||
# LoopDetectionMiddleware — detect and break repetitive tool call loops
|
# LoopDetectionMiddleware — detect and break repetitive tool call loops
|
||||||
@ -299,7 +309,7 @@ def make_lead_agent(config: RunnableConfig):
|
|||||||
from deerflow.tools import get_available_tools
|
from deerflow.tools import get_available_tools
|
||||||
from deerflow.tools.builtins import setup_agent
|
from deerflow.tools.builtins import setup_agent
|
||||||
|
|
||||||
cfg = config.get("configurable", {})
|
cfg = _get_runtime_config(config)
|
||||||
|
|
||||||
thinking_enabled = cfg.get("thinking_enabled", True)
|
thinking_enabled = cfg.get("thinking_enabled", True)
|
||||||
reasoning_effort = cfg.get("reasoning_effort", None)
|
reasoning_effort = cfg.get("reasoning_effort", None)
|
||||||
|
|||||||
@ -145,6 +145,21 @@ def test_build_run_config_explicit_agent_name_not_overwritten():
|
|||||||
assert config["configurable"]["agent_name"] == "explicit-agent"
|
assert config["configurable"]["agent_name"] == "explicit-agent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_config_context_custom_agent_injects_agent_name():
|
||||||
|
"""Custom assistant_id must be forwarded as context['agent_name'] in context mode."""
|
||||||
|
from app.gateway.services import build_run_config
|
||||||
|
|
||||||
|
config = build_run_config(
|
||||||
|
"thread-1",
|
||||||
|
{"context": {"model_name": "deepseek-v3"}},
|
||||||
|
None,
|
||||||
|
assistant_id="finalis",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config["context"]["agent_name"] == "finalis"
|
||||||
|
assert "configurable" not in config
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_agent_factory_returns_make_lead_agent():
|
def test_resolve_agent_factory_returns_make_lead_agent():
|
||||||
"""resolve_agent_factory always returns make_lead_agent regardless of assistant_id."""
|
"""resolve_agent_factory always returns make_lead_agent regardless of assistant_id."""
|
||||||
from app.gateway.services import resolve_agent_factory
|
from app.gateway.services import resolve_agent_factory
|
||||||
@ -298,6 +313,36 @@ def test_build_run_config_with_context():
|
|||||||
assert config["recursion_limit"] == 100
|
assert config["recursion_limit"] == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_config_null_context_becomes_empty_context():
|
||||||
|
"""When caller sends context=null, treat it as an empty context object."""
|
||||||
|
from app.gateway.services import build_run_config
|
||||||
|
|
||||||
|
config = build_run_config("thread-1", {"context": None}, None)
|
||||||
|
|
||||||
|
assert config["context"] == {}
|
||||||
|
assert "configurable" not in config
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_config_rejects_non_mapping_context():
|
||||||
|
"""When caller sends a non-object context, raise a clear error instead of a TypeError."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.gateway.services import build_run_config
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="context"):
|
||||||
|
build_run_config("thread-1", {"context": "bad-context"}, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_config_null_context_custom_agent_injects_agent_name():
|
||||||
|
"""Custom assistant_id can still be injected when context=null starts context mode."""
|
||||||
|
from app.gateway.services import build_run_config
|
||||||
|
|
||||||
|
config = build_run_config("thread-1", {"context": None}, None, assistant_id="finalis")
|
||||||
|
|
||||||
|
assert config["context"] == {"agent_name": "finalis"}
|
||||||
|
assert "configurable" not in config
|
||||||
|
|
||||||
|
|
||||||
def test_build_run_config_context_plus_configurable_warns(caplog):
|
def test_build_run_config_context_plus_configurable_warns(caplog):
|
||||||
"""When caller sends both 'context' and 'configurable', prefer 'context' and log a warning."""
|
"""When caller sends both 'context' and 'configurable', prefer 'context' and log a warning."""
|
||||||
import logging
|
import logging
|
||||||
|
|||||||
@ -113,6 +113,54 @@ def test_make_lead_agent_disables_thinking_when_model_does_not_support_it(monkey
|
|||||||
assert result["model"] is not None
|
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):
|
def test_make_lead_agent_rejects_invalid_bootstrap_agent_name(monkeypatch):
|
||||||
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])
|
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user