""" pipeline-service: gateway — agent 网关(统一消息入口 + 会话生命周期 + 通道注册) gateway 的核心价值:通道无关的统一消息入口。 Web(AgentIO) / 微信 等通道都通过 gateway.run_message() 触达 agent, 共享同一套「解析项目上下文 → 加载产线能力 → AgentExecutor 执行」逻辑。 标准化事件流(channel-neutral NDJSON),所有通道可消费: {"type":"progress","message":...} {"type":"tool_call","tool":...,"params":...} {"type":"tool_result","result":...} {"type":"reply","message":...} {"type":"ask_user","message":...} {"type":"error","message":...} 安全模型(微信通道):机构级公众号 + 用户 openid 绑定 + 匿名拒绝, 通道身份(openid)→ 系统 user_id 的映射由各通道适配器负责,gateway 只认 user_id。 """ import json import logging import time from dataclasses import dataclass, field from typing import AsyncGenerator, Dict, Optional logger = logging.getLogger("pipeline.gateway") @dataclass class GatewaySession: """一个会话 = (channel, user_id, session_id) 的 agent 上下文(元数据,executor 每次新建)。""" channel: str user_id: str session_id: str = "" # 会话内唯一标识(web 多 tab 独立会话);空 = 单会话(向后兼容) project_id: str = "" pipeline_id: str = "" project_name: str = "" created_at: float = 0.0 updated_at: float = 0.0 approve_all: bool = False # 会话级"全部确认":用户评估后授权,本会话不再询问危险命令 pending_confirm: Optional[dict] = None # 待确认工具 {"tool":..., "params":...}(危险命令等待用户确认) class Gateway: """agent 网关。""" def __init__(self): self._sessions: Dict[str, GatewaySession] = {} # key = f"{channel}:{user_id}" self._channels: Dict[str, object] = {} # ── 通道注册 ── def register_channel(self, name: str, channel) -> None: """注册一个通道适配器(如 web/wechat)。""" self._channels[name] = channel logger.info(f"Gateway channel registered: {name}") def list_channels(self) -> list: return list(self._channels.keys()) # ── 会话生命周期 ── @staticmethod def _session_key(channel: str, user_id: str, session_id: str = "") -> str: if session_id: return f"{channel}:{user_id}:{session_id}" return f"{channel}:{user_id}" def get_session(self, channel: str, user_id: str, session_id: str = "") -> Optional[GatewaySession]: return self._sessions.get(self._session_key(channel, user_id, session_id)) def clear_session(self, channel: str, user_id: str, session_id: str = "") -> None: """清除会话(/reset 时用)。""" self._sessions.pop(self._session_key(channel, user_id, session_id), None) # ── 上下文解析 ── async def resolve_project(self, user_id: str) -> dict: """解析用户当前项目 → {pid, pipeline_id, name, default_llm_id, default_llm_name}。 同时读出个人选择的模型(default_llm_id → llm.name),供 run_message 覆盖 config.model_name。 """ ctx = {"pid": "", "pipeline_id": "", "name": "", "default_llm_id": "", "default_llm_name": ""} if not user_id: return ctx try: from sqlor.dbpools import DBPools db = DBPools() async with db.sqlorContext("pipeline") as sor: recs = await sor.sqlExe( "SELECT current_project_id, default_llm_id FROM pipeline_agent_settings WHERE user_id=${u}$", {"u": user_id}) if recs: ctx["pid"] = getattr(recs[0], "current_project_id", "") or "" ctx["default_llm_id"] = getattr(recs[0], "default_llm_id", "") or "" # 单独查 llm.name(避免 JOIN 触发两表字段 collation 不一致) if ctx["default_llm_id"]: llm_recs = await sor.sqlExe( "SELECT name FROM llm WHERE id=${id}$ AND status='active'", {"id": ctx["default_llm_id"]}) if llm_recs: ctx["default_llm_name"] = getattr(llm_recs[0], "name", "") or "" if ctx["pid"]: proj = await sor.sqlExe( "SELECT name, pipeline_id FROM sd_projects WHERE id=${p}$", {"p": ctx["pid"]}) if proj: ctx["name"] = getattr(proj[0], "name", "") ctx["pipeline_id"] = getattr(proj[0], "pipeline_id", "") or "" except Exception as e: logger.warning(f"gateway resolve_project failed: {e}") return ctx # ── 统一消息入口 ── async def run_message(self, channel: str, user_id: str, content: str, history=None, role: str = "", generic: bool = False, base_url: str = "", session_id: str = "") -> AsyncGenerator[str, None]: """统一消息入口:解析上下文 → 加载产线能力 → AgentExecutor 执行 → yield 事件流。 Args: channel: 通道名(web / wechat / ...) user_id: 系统用户 ID(通道适配器负责把 openid 映射到 user_id) content: 用户消息内容 history: 可选历史消息 role: 可选产线内角色(驾驶舱 agent 为空) generic: True = 纯通用会话(不解析项目、不加载产线能力,只用 GENERAL_TOOLS + 通用心智) session_id: 会话内唯一标识(web 多 tab 独立会话用;空 = 单会话) """ from pipeline_core.agent_config import load_agent_config from .agent_loop_v2 import AgentExecutor # 1. 解析项目上下文(纯通用模式跳过,不挂任何产线插件) if generic: ctx = {"pid": "", "pipeline_id": "", "name": ""} else: ctx = await self.resolve_project(user_id) # 2. 会话生命周期(跟踪 project 切换;session_id 区分多 tab 会话) key = self._session_key(channel, user_id, session_id) sess = self._sessions.get(key) now = time.time() if sess is None or sess.project_id != ctx["pid"]: sess = GatewaySession( channel=channel, user_id=user_id, session_id=session_id, project_id=ctx["pid"], pipeline_id=ctx["pipeline_id"], project_name=ctx["name"], created_at=now, updated_at=now, ) self._sessions[key] = sess else: sess.project_id = ctx["pid"] sess.pipeline_id = ctx["pipeline_id"] sess.project_name = ctx["name"] sess.updated_at = now # 3. 加载产线能力 + 创建 executor(generic 时不装产线能力) config = await load_agent_config( pipeline_id=ctx["pipeline_id"], project_id=ctx["pid"], generic=generic) # 个人选择的模型优先(default_llm_id → llm.name,覆盖产线 default_model) if ctx.get("default_llm_name"): config.model_name = ctx["default_llm_name"] executor = AgentExecutor( config=config, project_id=ctx["pid"], user_id=user_id, role=role, base_url=base_url, generic=generic, session=sess, session_id=session_id) # 4. 转发标准化事件流 async for chunk in executor.run(content, history=history): yield chunk # ── 全局单例 ── _gateway: Optional[Gateway] = None def get_gateway() -> Gateway: global _gateway if _gateway is None: _gateway = Gateway() return _gateway