Support custom channel assistant IDs via lead_agent (#1500)

* Support custom channel assistant IDs via lead agent

* Normalize custom channel agent names
This commit is contained in:
DanielWalnut
2026-03-28 19:07:38 +08:00
committed by GitHub
parent 520c0352b5
commit 18e3487888
5 changed files with 99 additions and 12 deletions

View File

@@ -304,7 +304,7 @@ channels:
# Optional: global session defaults for all mobile channels # Optional: global session defaults for all mobile channels
session: session:
assistant_id: lead_agent assistant_id: lead_agent # or a custom agent name; custom agents are routed via lead_agent + agent_name
config: config:
recursion_limit: 100 recursion_limit: 100
context: context:
@@ -330,12 +330,12 @@ channels:
# Optional: per-channel / per-user session settings # Optional: per-channel / per-user session settings
session: session:
assistant_id: mobile_agent assistant_id: mobile-agent # custom agent names are also supported here
context: context:
thinking_enabled: false thinking_enabled: false
users: users:
"123456789": "123456789":
assistant_id: vip_agent assistant_id: vip-agent
config: config:
recursion_limit: 150 recursion_limit: 150
context: context:
@@ -343,6 +343,10 @@ channels:
subagent_enabled: true subagent_enabled: true
``` ```
Notes:
- `assistant_id: lead_agent` calls the default LangGraph assistant directly.
- If `assistant_id` is set to a custom agent name, DeerFlow still routes through `lead_agent` and injects that value as `agent_name`, so the custom agent's SOUL/config takes effect for IM channels.
Set the corresponding API keys in your `.env` file: Set the corresponding API keys in your `.env` file:
```bash ```bash

View File

@@ -243,7 +243,7 @@ channels:
# 可选:所有移动端渠道共用的全局 session 默认值 # 可选:所有移动端渠道共用的全局 session 默认值
session: session:
assistant_id: lead_agent assistant_id: lead_agent # 也可以填自定义 agent 名;渠道层会自动转换为 lead_agent + agent_name
config: config:
recursion_limit: 100 recursion_limit: 100
context: context:
@@ -269,12 +269,12 @@ channels:
# 可选:按渠道 / 按用户单独覆盖 session 配置 # 可选:按渠道 / 按用户单独覆盖 session 配置
session: session:
assistant_id: mobile_agent assistant_id: mobile-agent # 这里同样支持自定义 agent
context: context:
thinking_enabled: false thinking_enabled: false
users: users:
"123456789": "123456789":
assistant_id: vip_agent assistant_id: vip-agent
config: config:
recursion_limit: 150 recursion_limit: 150
context: context:
@@ -282,6 +282,10 @@ channels:
subagent_enabled: true subagent_enabled: true
``` ```
说明:
- `assistant_id: lead_agent` 会直接调用默认的 LangGraph assistant。
- 如果 `assistant_id` 填的是自定义 agent 名DeerFlow 仍然会走 `lead_agent`,同时把该值注入为 `agent_name`,这样 IM 渠道也会生效对应 agent 的 SOUL 和配置。
在 `.env` 里设置对应的 API key 在 `.env` 里设置对应的 API key
```bash ```bash

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import mimetypes import mimetypes
import re
import time import time
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any from typing import Any
@@ -17,6 +18,7 @@ logger = logging.getLogger(__name__)
DEFAULT_LANGGRAPH_URL = "http://localhost:2024" DEFAULT_LANGGRAPH_URL = "http://localhost:2024"
DEFAULT_GATEWAY_URL = "http://localhost:8001" DEFAULT_GATEWAY_URL = "http://localhost:8001"
DEFAULT_ASSISTANT_ID = "lead_agent" DEFAULT_ASSISTANT_ID = "lead_agent"
CUSTOM_AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
DEFAULT_RUN_CONFIG: dict[str, Any] = {"recursion_limit": 100} DEFAULT_RUN_CONFIG: dict[str, Any] = {"recursion_limit": 100}
DEFAULT_RUN_CONTEXT: dict[str, Any] = { DEFAULT_RUN_CONTEXT: dict[str, Any] = {
@@ -33,6 +35,10 @@ CHANNEL_CAPABILITIES = {
} }
class InvalidChannelSessionConfigError(ValueError):
"""Raised when IM channel session overrides contain invalid agent config."""
def _as_dict(value: Any) -> dict[str, Any]: def _as_dict(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, Mapping) else {} return dict(value) if isinstance(value, Mapping) else {}
@@ -45,6 +51,21 @@ def _merge_dicts(*layers: Any) -> dict[str, Any]:
return merged return merged
def _normalize_custom_agent_name(raw_value: str) -> str:
"""Normalize legacy channel assistant IDs into valid custom agent names."""
normalized = raw_value.strip().lower().replace("_", "-")
if not normalized:
raise InvalidChannelSessionConfigError(
"Channel session assistant_id is empty. Use 'lead_agent' or a valid custom agent name."
)
if not CUSTOM_AGENT_NAME_PATTERN.fullmatch(normalized):
raise InvalidChannelSessionConfigError(
f"Invalid channel session assistant_id {raw_value!r}. "
"Use 'lead_agent' or a custom agent name containing only letters, digits, and hyphens."
)
return normalized
def _extract_response_text(result: dict | list) -> str: def _extract_response_text(result: dict | list) -> str:
"""Extract the last AI message text from a LangGraph runs.wait result. """Extract the last AI message text from a LangGraph runs.wait result.
@@ -379,6 +400,13 @@ class ChannelManager:
{"thread_id": thread_id}, {"thread_id": thread_id},
) )
# Custom agents are implemented as lead_agent + agent_name context.
# Keep backward compatibility for channel configs that set
# assistant_id: <custom-agent-name> by routing through lead_agent.
if assistant_id != DEFAULT_ASSISTANT_ID:
run_context.setdefault("agent_name", _normalize_custom_agent_name(assistant_id))
assistant_id = DEFAULT_ASSISTANT_ID
return assistant_id, run_config, run_context return assistant_id, run_config, run_context
# -- LangGraph SDK client (lazy) ---------------------------------------- # -- LangGraph SDK client (lazy) ----------------------------------------
@@ -452,6 +480,14 @@ class ChannelManager:
await self._handle_command(msg) await self._handle_command(msg)
else: else:
await self._handle_chat(msg) await self._handle_chat(msg)
except InvalidChannelSessionConfigError as exc:
logger.warning(
"Invalid channel session config for %s (chat=%s): %s",
msg.channel_name,
msg.chat_id,
exc,
)
await self._send_error(msg, str(exc))
except Exception: except Exception:
logger.exception( logger.exception(
"Error handling message from %s (chat=%s)", "Error handling message from %s (chat=%s)",

View File

@@ -498,10 +498,11 @@ class TestChannelManager:
mock_client.runs.wait.assert_called_once() mock_client.runs.wait.assert_called_once()
call_args = mock_client.runs.wait.call_args call_args = mock_client.runs.wait.call_args
assert call_args[0][1] == "mobile_agent" assert call_args[0][1] == "lead_agent"
assert call_args[1]["config"]["recursion_limit"] == 55 assert call_args[1]["config"]["recursion_limit"] == 55
assert call_args[1]["context"]["thinking_enabled"] is False assert call_args[1]["context"]["thinking_enabled"] is False
assert call_args[1]["context"]["subagent_enabled"] is True assert call_args[1]["context"]["subagent_enabled"] is True
assert call_args[1]["context"]["agent_name"] == "mobile-agent"
_run(go()) _run(go())
@@ -525,7 +526,7 @@ class TestChannelManager:
}, },
"users": { "users": {
"vip-user": { "vip-user": {
"assistant_id": "vip_agent", "assistant_id": " VIP_AGENT ",
"config": {"recursion_limit": 77}, "config": {"recursion_limit": 77},
"context": { "context": {
"thinking_enabled": True, "thinking_enabled": True,
@@ -556,14 +557,56 @@ class TestChannelManager:
mock_client.runs.wait.assert_called_once() mock_client.runs.wait.assert_called_once()
call_args = mock_client.runs.wait.call_args call_args = mock_client.runs.wait.call_args
assert call_args[0][1] == "vip_agent" assert call_args[0][1] == "lead_agent"
assert call_args[1]["config"]["recursion_limit"] == 77 assert call_args[1]["config"]["recursion_limit"] == 77
assert call_args[1]["context"]["thinking_enabled"] is True assert call_args[1]["context"]["thinking_enabled"] is True
assert call_args[1]["context"]["subagent_enabled"] is True assert call_args[1]["context"]["subagent_enabled"] is True
assert call_args[1]["context"]["agent_name"] == "vip-agent"
assert call_args[1]["context"]["is_plan_mode"] is True assert call_args[1]["context"]["is_plan_mode"] is True
_run(go()) _run(go())
def test_handle_chat_rejects_invalid_custom_agent_name(self):
from app.channels.manager import ChannelManager
async def go():
bus = MessageBus()
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
manager = ChannelManager(
bus=bus,
store=store,
channel_sessions={
"telegram": {
"assistant_id": "bad agent!",
}
},
)
outbound_received = []
async def capture_outbound(msg):
outbound_received.append(msg)
bus.subscribe_outbound(capture_outbound)
mock_client = _make_mock_langgraph_client()
manager._client = mock_client
await manager.start()
inbound = InboundMessage(channel_name="telegram", chat_id="chat1", user_id="user1", text="hi")
await bus.publish_inbound(inbound)
await _wait_for(lambda: len(outbound_received) >= 1)
await manager.stop()
mock_client.runs.wait.assert_not_called()
assert outbound_received[0].text == (
"Invalid channel session assistant_id 'bad agent!'. "
"Use 'lead_agent' or a custom agent name containing only letters, digits, and hyphens."
)
_run(go())
def test_handle_feishu_chat_streams_multiple_outbound_updates(self, monkeypatch): def test_handle_feishu_chat_streams_multiple_outbound_updates(self, monkeypatch):
from app.channels.manager import ChannelManager from app.channels.manager import ChannelManager

View File

@@ -567,7 +567,7 @@ checkpointer:
# #
# # Optional: default mobile/session settings for all IM channels # # Optional: default mobile/session settings for all IM channels
# session: # session:
# assistant_id: lead_agent # assistant_id: lead_agent # or a custom agent name; custom agents route via lead_agent + agent_name
# config: # config:
# recursion_limit: 100 # recursion_limit: 100
# context: # context:
@@ -593,14 +593,14 @@ checkpointer:
# #
# # Optional: channel-level session overrides # # Optional: channel-level session overrides
# session: # session:
# assistant_id: mobile_agent # assistant_id: mobile-agent # custom agent names are supported here too
# context: # context:
# thinking_enabled: false # thinking_enabled: false
# #
# # Optional: per-user overrides by user_id # # Optional: per-user overrides by user_id
# users: # users:
# "123456789": # "123456789":
# assistant_id: vip_agent # assistant_id: vip-agent
# config: # config:
# recursion_limit: 150 # recursion_limit: 150
# context: # context: