ymq 7dda635c9a feat(agent): 五级作用域工具门禁接线+patch_file+原生视觉多模态
1. _init_components 加第8步 _resolve_tool_scopes: 调 core.tool_sources 五级解析,
   registry 重建为解析后工具集 + capability 工具合成注册(路由 exec_capability_tool)
2. _execute_tool 加作用域执行门禁: LLM 幻觉调用作用域外工具名(generic喊create_task/
   A产线喊B产线工具)直接拒绝并回可行动提示,不只 schema 不给看;解析失败降级不拦截
3. patch_file handler(_t_patch_file): 唯一性校验+replace_all+原子写+越界防护+二进制拒绝
4. 原生视觉: run(image_parts) 构造多模态用户消息; _call_llm 含图失败剥离重试一次
   (_degrade_images_if_needed)+注入如实告知说明(绝不假装看过图); _estimate_tokens/
   _summarize 兼容 list content(_content_as_text); gateway.run_message 加 image_paths
   透传+build_image_parts+超限notes注入
2026-09-10 18:36:54 +08:00

312 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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, session_id: str = "") -> dict:
"""解析用户当前项目 → {pid, pipeline_id, name, default_llm_id, default_llm_name, project_model_name}。
pid 按会话隔离(session_id 非空时读 pipeline_session_settings,无记录回退全局);
同时读出个人选择的模型(default_llm_id → llm.name,仍按用户全局)与项目级模型
(sd_projects.default_model,产线通用、跨会话持久),供 run_message 按优先级覆盖。
"""
ctx = {"pid": "", "pipeline_id": "", "name": "", "default_llm_id": "",
"default_llm_name": "", "project_model_name": ""}
if not user_id:
return ctx
try:
from sqlor.dbpools import DBPools
from .workspace import get_session_project_id
db = DBPools()
async with db.sqlorContext("pipeline") as sor:
# 当前项目:按会话隔离(多 tab 各自项目,互不覆盖)
ctx["pid"] = await get_session_project_id(sor, user_id, session_id) or ""
# 个人模型选择:仍按用户全局读
recs = await sor.sqlExe(
"SELECT default_llm_id FROM pipeline_agent_settings WHERE user_id=${u}$",
{"u": user_id})
if recs:
ctx["default_llm_id"] = getattr(recs[0], "default_llm_id", "") or ""
# 单独查 llm.name(避免 JOIN 触发两表字段 collation 不一致)
if ctx["default_llm_id"]:
# 2026-09-04 切换到模型治理新表(旧 llm 表停用)
llm_recs = await sor.sqlExe(
"SELECT name FROM llm_model 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, default_model 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 ""
ctx["project_model_name"] = getattr(proj[0], "default_model", "") 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 = "",
pipeline_id: str = "", model_id: str = "",
image_paths=None) -> 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 独立会话用;空 = 单会话)
pipeline_id: 产线默认能力(会话入口指定,如 bidding_general/sdlc_general)。
无当前项目时用此产线装载能力,替代硬回退 DEFAULT_ABILITY_ID。
model_id: 前端模型下拉选中的模型(llm.id 或 llm.model_id)。非空时:
① 校验模型存在且可用 → 解析出 llm.name;
② 持久化到当前项目(sd_projects.default_model,产线通用)——
项目模型一经设置即生效,直到用户再次选择,不因新会话/换 tab 丢失;
③ 本条消息直接使用该模型。
空 = 未重新选择,沿用项目已设模型。
"""
from pipeline_core.agent_config import load_agent_config
from .agent_loop_v2 import AgentExecutor
# 0. 空输入守卫(2026-09-07 实测安全缺陷):空/纯空白消息绝不进 LLM。
# 实测根因:curl --data-urlencode 'prompt@不存在的文件' 发出空 prompt,
# 空用户消息下 LLM 自由发挥——一次幻觉出 delete_project 要求确认,
# 一次直接调 run_command 执行 python3 heredoc(非危险清单命令,
# 不触发确认门即执行)。空输入没有任何合法意图,必须入口拦截。
if not (content or "").strip():
yield json.dumps({
"type": "error",
"message": "收到空消息(可能前端输入丢失或调用方 prompt 为空),已拒绝处理。请输入内容后重试。",
}, ensure_ascii=False) + "\n"
return
# 1. 解析项目上下文(纯通用模式跳过,不挂任何产线插件)
if generic:
ctx = {"pid": "", "pipeline_id": "", "name": "", "project_model_name": ""}
else:
ctx = await self.resolve_project(user_id, session_id)
# 产线隔离:入口指定产线时,会话项目必须同产线才生效——
# 防止"元景项目"(sdlc_general)劫持商机/投标产线会话。
if pipeline_id and ctx.get("pipeline_id") and ctx["pipeline_id"] != pipeline_id:
ctx["pid"] = ""
ctx["pipeline_id"] = pipeline_id
# 无当前项目 → 用入口指定的默认产线
elif not ctx.get("pipeline_id") and pipeline_id:
ctx["pipeline_id"] = pipeline_id
# 1.5 模型解析与项目级持久化(优先级:本次显式选择 > 项目已设 > 个人全局 > 产线默认)
chosen_model = ""
if model_id:
chosen_model = await self._resolve_and_persist_model(
user_id, ctx.get("pid", ""), model_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)
# 模型覆盖优先级:本次选择 > 项目模型 > 个人全局选择(个人全局只兜底,不再压过项目设置)
if chosen_model:
config.model_name = chosen_model
elif ctx.get("project_model_name"):
config.model_name = ctx["project_model_name"]
elif 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,
default_pipeline_id=pipeline_id)
# 3.5 原生视觉(2026-09-10):上传图片构造多模态 parts。
# 上限/超限策略在 build_image_parts(8MB/张、4张/条,超限显式告知不静默丢)。
# 构造失败不阻断会话(降级为纯文本 + 说明)。
image_parts, image_notes = [], []
if image_paths:
try:
from pipeline_core.upload_tools import build_image_parts
image_parts, image_notes = build_image_parts(image_paths)
except Exception as e:
logger.warning(f"build_image_parts failed: {e}")
image_notes = [f"图片处理失败({str(e)[:100]}),本条消息按纯文本处理。"]
if image_notes:
content = content + "\n\n[系统说明·本次上传图片] " + ";".join(image_notes)
# 4. 转发标准化事件流
async for chunk in executor.run(content, history=history,
image_parts=image_parts or None):
yield chunk
async def _resolve_and_persist_model(self, user_id: str, project_id: str,
model_id: str) -> str:
"""校验前端选中的模型并持久化到项目。返回 llm.name(空 = 无效/未持久化,调用方走回退链)。
model_id 兼容两种取值:llm_model.id(主键)或 llm_model.vendor_model_id(API 模型名)——
前端 UiCode 的 valueField 历史上两种都用过,这里统一解析,避免语义漂移。
多租户隔离:非系统级用户只能选本机构 + 系统级共享模型。
"""
if not model_id:
return ""
try:
from sqlor.dbpools import DBPools
db = DBPools()
async with db.sqlorContext("pipeline") as sor:
org_id = ""
try:
urecs = await sor.sqlExe(
"SELECT orgid FROM users WHERE id=${u}$", {"u": user_id})
if urecs:
org_id = getattr(urecs[0], "orgid", "") or ""
except Exception:
pass
# 2026-09-04 切换到模型治理新表(旧 llm 表停用)
# 2026-09-06 会话 agent 只接受对话能力(t2t/i2t/m2t):
# 能力白名单唯一事实源 = pipeline_llm.selection.CHAT_CAPS
# (模块未装时兜底同值);存量 capability 空串按 t2t 语义归一;
# 非对话模型(embedding/视频生成等)选中即无效,走回退链,
# 不落项目 default_model
try:
from pipeline_llm.selection import CHAT_CAPS as _chat_caps
except ImportError:
_chat_caps = ("t2t", "i2t", "m2t")
_cap_ph = ",".join("${c%d}$" % i for i in range(len(_chat_caps)))
_cap_params = {"c%d" % i: c for i, c in enumerate(_chat_caps)}
recs = await sor.sqlExe(
"SELECT id, name, org_id FROM llm_model "
"WHERE status='active' AND (id=${m}$ OR vendor_model_id=${m}$) "
"AND COALESCE(NULLIF(capability,''),'t2t') IN (%s) LIMIT 1" % _cap_ph,
dict({"m": model_id}, **_cap_params))
await sor.sqlExe("COMMIT", {})
if not recs:
logger.warning(f"run_message: model_id 无效(不存在或未启用): {model_id}")
return ""
rec = recs[0]
llm_org = getattr(rec, "org_id", "") or ""
if org_id and org_id != "0" and llm_org not in ("", "0", org_id):
logger.warning(f"run_message: 模型不属于本机构: {model_id} (org={org_id})")
return ""
name = getattr(rec, "name", "") or ""
# 持久化到项目:项目模型一经设置即生效,直到用户再次选择
if project_id and name:
cur = await sor.sqlExe(
"SELECT default_model FROM sd_projects WHERE id=${p}$",
{"p": project_id})
await sor.sqlExe("COMMIT", {})
if cur and (getattr(cur[0], "default_model", "") or "") != name:
await sor.sqlExe(
"UPDATE sd_projects SET default_model=${m}$ WHERE id=${p}$",
{"m": name, "p": project_id})
await sor.sqlExe("COMMIT", {})
logger.info(
f"项目模型已更新: project={project_id} model={name} by={user_id}")
return name
except Exception as e:
logger.warning(f"_resolve_and_persist_model failed: {e}")
return ""
# ── 全局单例 ──
_gateway: Optional[Gateway] = None
def get_gateway() -> Gateway:
global _gateway
if _gateway is None:
_gateway = Gateway()
return _gateway