Files
server-core/app/agent_gate/__init__.py
T

208 lines
7.8 KiB
Python
Raw Normal View History

# -*- 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", "")}