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

208 lines
7.8 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.
# -*- 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", "")}