diff --git a/backend/package/yuxi/agents/buildin/chatbot/graph.py b/backend/package/yuxi/agents/buildin/chatbot/graph.py index d7dcdc2d..b54b2006 100644 --- a/backend/package/yuxi/agents/buildin/chatbot/graph.py +++ b/backend/package/yuxi/agents/buildin/chatbot/graph.py @@ -11,6 +11,7 @@ from yuxi.agents.middlewares import ( SummaryOffloadMiddleware, save_attachments_to_fs, ) +from yuxi.agents.middlewares.channel_tool_middleware import ChannelToolMiddleware from yuxi.agents.middlewares.knowledge_base_middleware import KnowledgeBaseMiddleware from yuxi.agents.middlewares.skills_middleware import SkillsMiddleware from yuxi.services.mcp_service import get_tools_from_all_servers @@ -51,6 +52,7 @@ async def _build_middlewares(context): save_attachments_to_fs, # 附件注入提示词 KnowledgeBaseMiddleware(), # 知识库工具 RuntimeConfigMiddleware(extra_tools=all_mcp_tools), # 运行时配置应用(模型/工具/MCP/提示词) + ChannelToolMiddleware(), # 渠道工具注入 SkillsMiddleware(), # Skills 中间件(提示词注入、依赖展开、动态激活) subagents_middleware, summary_middleware, diff --git a/backend/package/yuxi/agents/buildin/deep_agent/graph.py b/backend/package/yuxi/agents/buildin/deep_agent/graph.py index 3050b9ec..b78f93d8 100644 --- a/backend/package/yuxi/agents/buildin/deep_agent/graph.py +++ b/backend/package/yuxi/agents/buildin/deep_agent/graph.py @@ -14,6 +14,7 @@ from yuxi.agents.middlewares import ( SummaryOffloadMiddleware, save_attachments_to_fs, ) +from yuxi.agents.middlewares.channel_tool_middleware import ChannelToolMiddleware from yuxi.agents.middlewares.knowledge_base_middleware import KnowledgeBaseMiddleware from yuxi.agents.middlewares.skills_middleware import SkillsMiddleware from yuxi.agents.toolkits.buildin.tools import _create_tavily_search @@ -97,6 +98,7 @@ class DeepAgent(BaseAgent): middleware=[ FilesystemMiddleware(backend=create_agent_composite_backend), # 文件系统后端 RuntimeConfigMiddleware(extra_tools=all_mcp_tools), + ChannelToolMiddleware(), # 渠道工具注入 SkillsMiddleware(), # Skills 中间件(提示词注入、依赖展开、动态激活) save_attachments_to_fs, # 附件注入提示词 TodoListMiddleware(system_prompt="任务结束前,应该检查维护的待办事项列表是否结束。"), diff --git a/backend/package/yuxi/agents/context.py b/backend/package/yuxi/agents/context.py index 4009fa03..88cb689e 100644 --- a/backend/package/yuxi/agents/context.py +++ b/backend/package/yuxi/agents/context.py @@ -114,6 +114,24 @@ class BaseContext: }, ) + channel_type: str = field( + default="", + metadata={ + "name": "渠道类型", + "configurable": False, + "description": "当前对话的渠道类型(feishu/dingtalk 等),用于渠道工具的运行时执行。", + }, + ) + + channel_tools: list[dict] = field( + default_factory=list, + metadata={ + "name": "渠道工具", + "configurable": False, + "description": "渠道注册的 Agent 工具列表(OpenAI function schema 格式),由 ChannelToolMiddleware 转换为可执行工具。", + }, + ) + @classmethod def get_configurable_items(cls): """实现一个可配置的参数列表,在 UI 上配置时使用""" diff --git a/backend/package/yuxi/agents/middlewares/__init__.py b/backend/package/yuxi/agents/middlewares/__init__.py index a59c7d69..61a68644 100644 --- a/backend/package/yuxi/agents/middlewares/__init__.py +++ b/backend/package/yuxi/agents/middlewares/__init__.py @@ -1,10 +1,12 @@ from .attachment_middleware import inject_attachment_context, save_attachments_to_fs +from .channel_tool_middleware import ChannelToolMiddleware from .context_middlewares import context_aware_prompt, context_based_model from .dynamic_tool_middleware import DynamicToolMiddleware from .runtime_config_middleware import RuntimeConfigMiddleware from .summary_middleware import SummaryOffloadMiddleware, create_summary_offload_middleware __all__ = [ + "ChannelToolMiddleware", "DynamicToolMiddleware", "RuntimeConfigMiddleware", "SummaryOffloadMiddleware", diff --git a/backend/package/yuxi/agents/middlewares/channel_tool_middleware.py b/backend/package/yuxi/agents/middlewares/channel_tool_middleware.py new file mode 100644 index 00000000..af50b87b --- /dev/null +++ b/backend/package/yuxi/agents/middlewares/channel_tool_middleware.py @@ -0,0 +1,118 @@ +import json +from collections.abc import Callable +from typing import Any, Literal + +from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse +from langchain_core.tools import StructuredTool +from pydantic import BaseModel, Field, create_model + +from yuxi.channel.protocols import AgentToolProtocol +from yuxi.channel.plugins.registry import ChannelPluginRegistry +from yuxi.utils.logging_config import logger + +_TYPE_MAP: dict[str, type] = { + "string": str, + "integer": int, + "number": float, + "boolean": bool, + "array": list, + "object": dict, +} + + +def _schema_to_pydantic_model(tool_name: str, parameters: dict) -> type[BaseModel]: + properties = parameters.get("properties", {}) + required = set(parameters.get("required", [])) + + fields_def: dict[str, Any] = {} + for param_name, param_info in properties.items(): + json_type = param_info.get("type", "string") + enum_values = param_info.get("enum") + + if enum_values: + param_type = Literal[tuple(enum_values)] # type: ignore[valid-type] + else: + param_type = _TYPE_MAP.get(json_type, str) + + field_kwargs: dict[str, Any] = {"description": param_info.get("description", "")} + if param_name not in required: + field_kwargs["default"] = None + + fields_def[param_name] = (param_type, Field(**field_kwargs)) + + model_name = f"{tool_name}_args" + return create_model(model_name, **fields_def) + + +def _make_channel_tool_executor(channel_type: str, tool_name: str): + async def _execute(**kwargs): + plugin = ChannelPluginRegistry.get(channel_type) + if plugin is None: + return f"Error: 渠道 '{channel_type}' 未注册" + if not isinstance(plugin, AgentToolProtocol): + return f"Error: 渠道 '{channel_type}' 不支持 Agent 工具协议" + try: + result = await plugin.execute_agent_tool(tool_name, kwargs, {}) + except Exception: + logger.exception("Channel tool execution failed: %s.%s", channel_type, tool_name) + return f"Error: 工具 '{tool_name}' 执行异常" + if result.get("success"): + return json.dumps(result.get("result", ""), ensure_ascii=False) + return f"Error: {result.get('error', 'Unknown error')}" + + return _execute + + +class ChannelToolMiddleware(AgentMiddleware): + def __init__(self): + super().__init__() + self._cache: dict[tuple[str, int], list[StructuredTool]] = {} + + def _build_tools(self, channel_type: str, tool_schemas: list[dict]) -> list[StructuredTool]: + schema_hash = hash(json.dumps(tool_schemas, sort_keys=True, ensure_ascii=False)) + cache_key = (channel_type, schema_hash) + if cache_key in self._cache: + return self._cache[cache_key] + + tools: list[StructuredTool] = [] + for schema in tool_schemas: + func_def = schema.get("function", {}) + tool_name = func_def.get("name", "") + if not tool_name: + continue + description = func_def.get("description", "") + parameters = func_def.get("parameters", {}) + + try: + args_model = _schema_to_pydantic_model(tool_name, parameters) + executor = _make_channel_tool_executor(channel_type, tool_name) + tool = StructuredTool.from_function( + name=tool_name, + description=description, + args_schema=args_model, + coroutine=executor, + ) + tools.append(tool) + except Exception: + logger.exception("Failed to create channel tool: %s.%s", channel_type, tool_name) + + self._cache[cache_key] = tools + logger.debug("ChannelToolMiddleware built %d tools for channel %s", len(tools), channel_type) + return tools + + async def awrap_model_call( + self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse] + ) -> ModelResponse: + runtime_context = request.runtime.context + channel_type = getattr(runtime_context, "channel_type", None) or "" + channel_tools = getattr(runtime_context, "channel_tools", None) or [] + + if channel_type and channel_tools: + langchain_tools = self._build_tools(channel_type, channel_tools) + if langchain_tools: + tools_by_name = {t.name: t for t in request.tools or []} + for t in langchain_tools: + tools_by_name[t.name] = t + request = request.override(tools=list(tools_by_name.values())) + + return await handler(request)