208 lines
7.8 KiB
Python
208 lines
7.8 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""智能体互通编排(server-core 权威)。
|
||
|
||
三层权限模型(全部在服务端集中校验,客户端不可绕过):
|
||
|
||
- **L1 企业管理员无条件调用**:企业管理员(company_members.is_admin/member_type∈{admin,owner})
|
||
可调用**本企业任意成员**的智能体,无需授权记录。
|
||
- **L2 企业内部智能体互调**:同一企业内的账号可互相调用彼此的智能体(企业=信任域)。
|
||
- **L3 成员开放调用(临时授权)**:成员 A 通过 agent_access_grants 临时授权成员 B 调用
|
||
A 的智能体(限时 / 限次 / 限 prompt 前缀 scope)。
|
||
|
||
通用约束:目标 agent 的 ``external_callable=False`` 时拒绝一切外部调用(agent 归属用户
|
||
可完全关闭外部调用);``call_mode=confirm`` 时由客户端在执行前向归属用户请求确认。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import datetime, timezone
|
||
|
||
from ..infrastructure.repositories import Database
|
||
|
||
log = logging.getLogger("agent_gate")
|
||
|
||
AGENT_TOPIC_PREFIX = "opc/agent/v1"
|
||
|
||
# 归属用户本人调用自己的智能体,永远允许
|
||
SELF_ALLOWED = True
|
||
|
||
|
||
def parse_agent_addr(addr: str) -> tuple[str, str] | None:
|
||
"""解析 ``{user_id}.{agent_id}`` → (user_id, agent_id);非法返回 None。"""
|
||
if not addr or "." not in addr:
|
||
return None
|
||
user_id, agent_id = addr.split(".", 1)
|
||
if not user_id or not agent_id:
|
||
return None
|
||
return user_id, agent_id
|
||
|
||
|
||
def _is_expired(expires_at: str) -> bool:
|
||
if not expires_at:
|
||
return False
|
||
try:
|
||
exp = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
|
||
if exp.tzinfo is None:
|
||
exp = exp.replace(tzinfo=timezone.utc)
|
||
return exp < datetime.now(timezone.utc)
|
||
except Exception: # noqa: BLE001
|
||
return True # 无法解析视为已过期(保守)
|
||
|
||
def _scope_allows(scope: str, prompt: str) -> bool:
|
||
"""scope 为 prompt 前缀白名单(每行一条前缀);空=全部允许。"""
|
||
if not scope:
|
||
return True
|
||
for line in scope.splitlines():
|
||
prefix = line.strip()
|
||
if prefix and prompt.startswith(prefix):
|
||
return True
|
||
return False
|
||
|
||
|
||
async def authorize_call(db: Database, caller_user_id: str, target_addr: str,
|
||
prompt: str = "") -> tuple[bool, str]:
|
||
"""校验调用权限。返回 (ok, reason)。
|
||
|
||
优先级:自己 → L1 管理员 → L2 同企业 → L3 显式授权 → 拒绝。
|
||
"""
|
||
parsed = parse_agent_addr(target_addr)
|
||
if parsed is None:
|
||
return False, "目标智能体地址无效"
|
||
target_user_id, agent_id = parsed
|
||
|
||
# 自己调自己的智能体
|
||
if SELF_ALLOWED and caller_user_id == target_user_id:
|
||
return True, ""
|
||
|
||
# 目标 agent 是否允许外部调用
|
||
agent = await db.agents.get(agent_id, target_user_id, port="opc")
|
||
if agent is None:
|
||
# 未注册到平台(可能未登录过桌面端),视为不可调用
|
||
return False, "目标智能体不存在或未启用"
|
||
if not agent.get("external_callable", True):
|
||
return False, "该智能体已关闭外部调用"
|
||
|
||
caller_companies = await db.company_members.list_by_user(caller_user_id)
|
||
if not caller_companies:
|
||
return False, "无调用权限(非企业成员)"
|
||
|
||
target_companies = await db.company_members.list_by_user(target_user_id)
|
||
target_company_ids = {c["company_id"] for c in target_companies if c.get("status", "active") == "active"}
|
||
|
||
for cm in caller_companies:
|
||
if cm.get("status", "active") != "active":
|
||
continue
|
||
company_id = cm["company_id"]
|
||
# L1:企业管理员 → 本企业成员 agent
|
||
is_admin = bool(cm.get("is_admin")) or cm.get("member_type") in ("admin", "owner")
|
||
if is_admin and company_id in target_company_ids:
|
||
return True, ""
|
||
# L2:同一企业互调
|
||
if company_id in target_company_ids:
|
||
return True, ""
|
||
|
||
# L3:显式授权
|
||
grant = await db.agent_access_grants.get_active(
|
||
grantor_user_id=target_user_id,
|
||
target_agent_addr=target_addr,
|
||
grantee_user_id=caller_user_id,
|
||
)
|
||
if grant is None:
|
||
return False, "无调用权限(需要企业管理员或临时授权)"
|
||
if _is_expired(grant.expires_at):
|
||
return False, "授权已过期"
|
||
if grant.max_calls > 0 and (grant.used_calls or 0) >= grant.max_calls:
|
||
return False, "授权调用次数已用完"
|
||
if not _scope_allows(grant.scope, prompt):
|
||
return False, "调用内容超出授权范围"
|
||
|
||
# 计数(派发即计数)
|
||
await db.agent_access_grants.consume_call(grant)
|
||
return True, ""
|
||
|
||
|
||
async def handle_agent_task_event(
|
||
db: Database,
|
||
im,
|
||
*,
|
||
conv_id: str,
|
||
sender_id: str,
|
||
sender_name: str,
|
||
content: str,
|
||
metadata: dict | None,
|
||
) -> dict:
|
||
"""处理 im-service 的 agent_task 消息事件(管理员/成员在 IM 里发起调用)。
|
||
|
||
流程:解析地址 → 权限校验 → 目标 agent 存在性 → 在线查询 →
|
||
在线则派发 invoke(im 发布 MQTT),离线则回写 offline 回执。
|
||
"""
|
||
metadata = metadata or {}
|
||
target_addr = str(metadata.get("target_agent_addr") or metadata.get("target_addr") or "").strip()
|
||
if not target_addr:
|
||
return {"ok": False, "reason": "缺少目标智能体地址(target_agent_addr)"}
|
||
|
||
parsed = parse_agent_addr(target_addr)
|
||
if parsed is None:
|
||
return {"ok": False, "reason": "目标智能体地址无效"}
|
||
target_user_id, agent_id = parsed
|
||
|
||
prompt = str(content or metadata.get("prompt") or "").strip()
|
||
if not prompt:
|
||
return {"ok": False, "reason": "缺少调用内容"}
|
||
|
||
ok, reason = await authorize_call(db, sender_id, target_addr, prompt)
|
||
if not ok:
|
||
# 无权限:向会话回写一条 agent_reply 状态消息
|
||
try:
|
||
await im.internal_agent_reply(conv_id, "", "error", f"调用被拒绝:{reason}")
|
||
except Exception: # noqa: BLE001
|
||
log.warning("回写拒绝消息失败 conv=%s", conv_id)
|
||
return {"ok": False, "reason": reason}
|
||
|
||
# 在线查询
|
||
online_info: dict = {}
|
||
try:
|
||
online_info = await im.internal_agent_online(target_addr)
|
||
except Exception: # noqa: BLE001
|
||
log.warning("agent 在线查询失败 addr=%s", target_addr)
|
||
online = bool(online_info.get("online"))
|
||
|
||
if not online:
|
||
try:
|
||
await im.internal_agent_reply(
|
||
conv_id, "", "offline",
|
||
f"成员智能体当前不在线,无法执行(目标:{target_addr})",
|
||
)
|
||
except Exception: # noqa: BLE001
|
||
log.warning("回写离线消息失败 conv=%s", conv_id)
|
||
return {"ok": False, "reason": "offline"}
|
||
|
||
# 组装 invoke payload(复用 inter-agent 协议)
|
||
payload = {
|
||
"v": 1,
|
||
"caller": {
|
||
"user_id": sender_id,
|
||
"agent_id": metadata.get("caller_agent_id", ""),
|
||
"name": sender_name or sender_id,
|
||
},
|
||
"session_id": metadata.get("session_id", "") or "",
|
||
"prompt": prompt,
|
||
"context": {
|
||
"root_session_id": metadata.get("root_session_id", ""),
|
||
"company_id": metadata.get("company_id", ""),
|
||
},
|
||
"mode": metadata.get("mode", "auto"),
|
||
}
|
||
|
||
try:
|
||
result = await im.internal_agent_invoke(target_addr, payload, conv_id)
|
||
except Exception as exc: # noqa: BLE001
|
||
log.warning("agent invoke 派发失败 addr=%s: %s", target_addr, exc)
|
||
try:
|
||
await im.internal_agent_reply(conv_id, "", "error", f"任务派发失败:{exc}")
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return {"ok": False, "reason": str(exc)}
|
||
|
||
return {"ok": True, "task_id": result.get("task_id", "")}
|