ForcePilot/backend/package/yuxi/repositories/agent_repository.py

539 lines
22 KiB
Python
Raw Normal View History

from __future__ import annotations
import re
import uuid
from typing import Any
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.storage.postgres.models_business import Agent, User
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.share_config import SHARE_ACCESS_LEVELS, normalize_share_config
DEFAULT_AGENT_SLUG = "default-chatbot"
DEFAULT_AGENT_NAME = "智能助手"
DEFAULT_AGENT_BACKEND_ID = "ChatbotAgent"
2026-06-01 22:28:45 +08:00
SUB_AGENT_BACKEND_ID = "SubAgentBackend"
DEFAULT_AGENT_DESCRIPTION = "基础的对话机器人,可以回答问题,可在配置中启用需要的工具。"
DEFAULT_SHARE_CONFIG = {"access_level": "global", "department_ids": [], "user_uids": []}
GENERAL_PURPOSE_AGENT_SLUG = "general-purpose"
GENERAL_PURPOSE_AGENT_NAME = "通用任务"
GENERAL_PURPOSE_AGENT_DESCRIPTION = (
"面向没有专用角色约束的一般任务,使用默认运行配置独立完成分析、整理、写作或文件处理。"
)
WEB_SEARCH_AGENT_SLUG = "web-search"
WEB_SEARCH_AGENT_NAME = "网页检索"
WEB_SEARCH_AGENT_DESCRIPTION = "围绕检索目标持续搜索网页,返回带引用来源的摘要资料。"
2026-06-03 18:55:34 +08:00
WEB_SEARCH_SYSTEM_PROMPT = """你是「网页检索」子智能体,专注于面向目标的网页信息检索。
你的职责围绕调用方给定的检索目标使用网页搜索工具持续检索直到收集到足以回答目标的信息
工作方式
1. 拆解目标确定需要检索的关键问题与检索词
2026-06-03 18:55:34 +08:00
2. 多轮调用搜索工具依据上一轮结果调整检索词补充遗漏角度交叉验证关键事实直到信息充分或确认无法获取更多有效信息
3. 优先采信权威时效性强且彼此印证的来源对存在冲突的信息要说明分歧
输出要求
- 返回一份结构化的摘要资料按主题或要点组织
2026-06-03 18:55:34 +08:00
- 每条关键结论后使用 <cite source="$URL" type="url">$INDEX</cite> 标注引用来源$INDEX 1 开始递增
- 引用不单独成行直接跟在结论后面
- 在结尾汇总参考来源列表逐条列出标题与 URL
- 不要编造来源或链接无法验证的信息要明确标注"""
DEEP_RESEARCH_AGENT_SLUG = "deep-research"
DEEP_RESEARCH_AGENT_NAME = "深度研究"
DEEP_RESEARCH_AGENT_DESCRIPTION = (
"面向多来源、需事实核查的深度研究任务:规划拆解、并行调度调研子智能体、核验并综合成带引用的结构化报告。"
)
DEEP_RESEARCH_SYSTEM_PROMPT = """你是「深度研究」智能体,负责一项深度研究任务的整体把控与子智能体调度。
你的核心定位是编排者而不是亲自完成所有检索把繁重可独立可并行的调研与核验工作派发给子智能体自己专注于规划调度与最终综合
工作方式
1. 接到研究任务后先读取 `deep-research` 技能read_file SKILL.md获取完整方法论并严格据此执行
2. 问题不明确时先澄清范围再用待办拆解出可独立调研的子问题
3. 优先用 `task` 工具把子问题并行派发给调研子智能体仅在澄清范围或补少量零散事实时自己直接检索
4. 对关键结论与相互冲突的发现派发核查子智能体核验未通过的结论不写入正文或明确降级标注
5. 证据充分后由你统一综合为结构化带引用的报告不要简单拼接子智能体返回的原文
始终全程跟踪进度最终交付一份可直接使用围绕论证组织来源可追溯的报告"""
RESEARCH_EXPLORER_AGENT_SLUG = "research-explorer"
RESEARCH_EXPLORER_AGENT_NAME = "调研探索员"
RESEARCH_EXPLORER_AGENT_DESCRIPTION = "围绕单个子问题多轮检索网页与知识库,交叉验证后返回带引用的结构化发现。"
RESEARCH_EXPLORER_SYSTEM_PROMPT = """你是「调研探索员」子智能体。
专注于围绕调用方给定的**单个子问题**收集充分可追溯的证据
你的职责围绕该子问题持续检索网页与知识库直到收集到足以回答它的信息
工作方式
1. 拆解子问题确定需要检索的关键点与检索词
2. 多轮调用检索工具依据上一轮结果调整检索词补充遗漏角度交叉验证关键事实直到信息充分或确认无法获取更多有效信息
3. 优先采信权威时效性强且彼此印证的来源对存在冲突的信息要说明分歧
输出要求
- 返回一份围绕该子问题按要点组织的结构化发现不要展开成完整报告
- 每条关键结论后使用 <cite source="$URL" type="url">$INDEX</cite> 标注引用来源$INDEX 1 开始递增
- 引用紧跟结论后不单独成行
- 结尾汇总参考来源列表逐条列出标题与 URL
- 不要编造来源或链接无法验证的信息要明确标注证据缺口"""
FACT_VERIFIER_AGENT_SLUG = "fact-verifier"
FACT_VERIFIER_AGENT_NAME = "事实核查员"
FACT_VERIFIER_AGENT_DESCRIPTION = "对给定论断做对抗式核验,逐条给出支持/存疑/反驳判定、依据来源与置信度,并标注冲突。"
FACT_VERIFIER_SYSTEM_PROMPT = """你是「事实核查员」子智能体,专注于对调用方给定的论断做对抗式核验。
你的职责对每一条论断独立查证默认持怀疑态度证据不足时倾向判定存疑而不是默认相信
工作方式
1. 逐条拆出待核验的论断事实数字因果时间等
2. 主动检索权威独立的来源交叉比对优先寻找能反驳该论断的证据
3. 对来源之间的冲突如实呈现不强行调和
输出要求
- 对每条论断给出判定支持 / 存疑 / 反驳+ 简要依据 + 依据来源 + 置信度//
- 关键依据后使用 <cite source="$URL" type="url">$INDEX</cite> 标注来源$INDEX 1 开始递增
- 明确标注无法查证或来源相互冲突的论断
- 不要编造来源或链接"""
ACCESS_LEVELS = SHARE_ACCESS_LEVELS
ADMIN_ROLES = {"admin", "superadmin"}
def is_builtin_agent(agent: Agent) -> bool:
return agent.slug == DEFAULT_AGENT_SLUG
2026-06-01 22:28:45 +08:00
def resolve_agent_is_subagent(backend_id: str, is_subagent: bool | None = None) -> bool:
expected = backend_id == SUB_AGENT_BACKEND_ID
if is_subagent is not None and bool(is_subagent) != expected:
raise ValueError("SubAgentBackend 与 is_subagent 必须保持一致")
return expected
def normalize_agent_share_config(
share_config: dict | None,
*,
user_uid: str | None = None,
department_id: int | str | None = None,
force_private: bool = False,
) -> dict:
if force_private:
if not user_uid:
raise ValueError("私有智能体必须绑定创建用户")
return {"access_level": "user", "department_ids": [], "user_uids": [str(user_uid)]}
return normalize_share_config(
share_config,
default_config=DEFAULT_SHARE_CONFIG,
default_access_level="global",
invalid_access_level_message="无效的智能体权限等级",
user_uid=user_uid,
department_id=department_id,
)
def user_can_access_agent(user: User, agent: Agent) -> bool:
if user.role == "superadmin":
return True
user_uid = str(user.uid)
if agent.created_by == user_uid:
return True
share_config = agent.share_config or DEFAULT_SHARE_CONFIG.copy()
access_level = share_config.get("access_level")
if access_level == "global":
return True
if access_level == "department":
if user.department_id is None:
return False
try:
return int(user.department_id) in [int(value) for value in share_config.get("department_ids") or []]
except (TypeError, ValueError):
return False
if access_level == "user":
return user_uid in (share_config.get("user_uids") or [])
return False
def user_can_manage_agent(user: User, agent: Agent) -> bool:
return user.role in ADMIN_ROLES or agent.created_by == str(user.uid)
def _slugify(value: str | None) -> str:
base = re.sub(r"[^a-zA-Z0-9_-]+", "-", (value or "").strip().lower()).strip("-")
return base[:56] or f"agent-{uuid.uuid4().hex[:12]}"
class AgentRepository:
def __init__(self, db_session: AsyncSession):
self.db = db_session
async def ensure_default_agent(self, *, created_by: str | None = None) -> Agent:
agent = await self.get_by_slug(DEFAULT_AGENT_SLUG)
if agent:
needs_update = False
if agent.share_config != DEFAULT_SHARE_CONFIG:
agent.share_config = DEFAULT_SHARE_CONFIG.copy()
needs_update = True
if not agent.description:
agent.description = DEFAULT_AGENT_DESCRIPTION
needs_update = True
2026-06-01 22:28:45 +08:00
if getattr(agent, "is_subagent", False):
agent.is_subagent = False
needs_update = True
if not agent.is_default:
return await self.set_default(agent=agent, updated_by=created_by)
if needs_update:
agent.updated_by = created_by
agent.updated_at = utc_now_naive()
await self.db.commit()
await self.db.refresh(agent)
return agent
agent = Agent(
slug=DEFAULT_AGENT_SLUG,
backend_id=DEFAULT_AGENT_BACKEND_ID,
name=DEFAULT_AGENT_NAME,
description=DEFAULT_AGENT_DESCRIPTION,
icon=None,
pics=[],
config_json={"context": {}},
share_config=DEFAULT_SHARE_CONFIG.copy(),
is_default=True,
2026-06-01 22:28:45 +08:00
is_subagent=False,
created_by=created_by,
updated_by=created_by,
created_at=utc_now_naive(),
updated_at=utc_now_naive(),
)
self.db.add(agent)
await self.db.commit()
await self.db.refresh(agent)
return agent
async def ensure_web_search_subagent(self, *, created_by: str | None = None) -> Agent:
agent = await self.get_by_slug(WEB_SEARCH_AGENT_SLUG)
if agent:
return agent
agent = Agent(
slug=WEB_SEARCH_AGENT_SLUG,
backend_id=SUB_AGENT_BACKEND_ID,
name=WEB_SEARCH_AGENT_NAME,
description=WEB_SEARCH_AGENT_DESCRIPTION,
icon=None,
pics=[],
config_json={"context": {"system_prompt": WEB_SEARCH_SYSTEM_PROMPT}},
share_config=DEFAULT_SHARE_CONFIG.copy(),
is_default=False,
is_subagent=True,
created_by=created_by,
updated_by=created_by,
created_at=utc_now_naive(),
updated_at=utc_now_naive(),
)
self.db.add(agent)
await self.db.commit()
await self.db.refresh(agent)
return agent
async def ensure_general_purpose_subagent(self, *, created_by: str | None = None) -> Agent:
return await self._ensure_builtin_agent(
slug=GENERAL_PURPOSE_AGENT_SLUG,
backend_id=SUB_AGENT_BACKEND_ID,
name=GENERAL_PURPOSE_AGENT_NAME,
description=GENERAL_PURPOSE_AGENT_DESCRIPTION,
config_context={},
is_subagent=True,
created_by=created_by,
)
async def _ensure_builtin_agent(
self,
*,
slug: str,
backend_id: str,
name: str,
description: str,
config_context: dict,
is_subagent: bool,
created_by: str | None = None,
) -> Agent:
"""落库一个内置 Agent已存在则原样返回避免覆盖管理员后续修改。"""
agent = await self.get_by_slug(slug)
if agent:
return agent
agent = Agent(
slug=slug,
backend_id=backend_id,
name=name,
description=description,
icon=None,
pics=[],
config_json={"context": config_context},
share_config=DEFAULT_SHARE_CONFIG.copy(),
is_default=False,
is_subagent=is_subagent,
created_by=created_by,
updated_by=created_by,
created_at=utc_now_naive(),
updated_at=utc_now_naive(),
)
self.db.add(agent)
await self.db.commit()
await self.db.refresh(agent)
return agent
async def ensure_deep_research_agents(self, *, created_by: str | None = None) -> None:
"""落库内置「深度研究」编排器及其配套调研、核查子智能体。"""
await self._ensure_builtin_agent(
slug=RESEARCH_EXPLORER_AGENT_SLUG,
backend_id=SUB_AGENT_BACKEND_ID,
name=RESEARCH_EXPLORER_AGENT_NAME,
description=RESEARCH_EXPLORER_AGENT_DESCRIPTION,
config_context={"system_prompt": RESEARCH_EXPLORER_SYSTEM_PROMPT},
is_subagent=True,
created_by=created_by,
)
await self._ensure_builtin_agent(
slug=FACT_VERIFIER_AGENT_SLUG,
backend_id=SUB_AGENT_BACKEND_ID,
name=FACT_VERIFIER_AGENT_NAME,
description=FACT_VERIFIER_AGENT_DESCRIPTION,
config_context={"system_prompt": FACT_VERIFIER_SYSTEM_PROMPT},
is_subagent=True,
created_by=created_by,
)
await self._ensure_builtin_agent(
slug=DEEP_RESEARCH_AGENT_SLUG,
backend_id=DEFAULT_AGENT_BACKEND_ID,
name=DEEP_RESEARCH_AGENT_NAME,
description=DEEP_RESEARCH_AGENT_DESCRIPTION,
config_context={
"system_prompt": DEEP_RESEARCH_SYSTEM_PROMPT,
"subagents": [RESEARCH_EXPLORER_AGENT_SLUG, FACT_VERIFIER_AGENT_SLUG],
"skills": [DEEP_RESEARCH_AGENT_SLUG],
},
is_subagent=False,
created_by=created_by,
)
2026-06-01 22:28:45 +08:00
async def list_visible(self, *, user: User, include_subagents: bool = False) -> list[Agent]:
stmt = select(Agent)
if not include_subagents:
stmt = stmt.where(Agent.is_subagent.is_(False))
result = await self.db.execute(stmt.order_by(Agent.is_default.desc(), Agent.id.asc()))
agents = list(result.scalars().all())
if user.role == "superadmin":
return agents
return [agent for agent in agents if user_can_access_agent(user, agent)]
async def list_visible_subagents(self, *, user: User) -> list[Agent]:
result = await self.db.execute(
select(Agent).where(Agent.is_subagent.is_(True)).order_by(Agent.name.asc(), Agent.id.asc())
)
agents = list(result.scalars().all())
if user.role == "superadmin":
return agents
return [agent for agent in agents if user_can_access_agent(user, agent)]
async def get_by_slug(self, slug: str) -> Agent | None:
result = await self.db.execute(select(Agent).where(Agent.slug == slug))
return result.scalar_one_or_none()
2026-06-01 22:28:45 +08:00
async def get_visible_by_slug(self, *, slug: str, user: User, include_subagents: bool = False) -> Agent | None:
agent = await self.get_by_slug(slug)
2026-06-01 22:28:45 +08:00
if not agent or (agent.is_subagent and not include_subagents):
return None
if user_can_access_agent(user, agent):
return agent
return None
async def get_visible_subagent_by_slug(self, *, slug: str, user: User) -> Agent | None:
agent = await self.get_visible_by_slug(slug=slug, user=user, include_subagents=True)
if agent and agent.is_subagent:
return agent
return None
async def get_default(self) -> Agent | None:
result = await self.db.execute(select(Agent).where(Agent.is_default.is_(True)))
return result.scalar_one_or_none()
async def set_default(self, *, agent: Agent, updated_by: str | None = None) -> Agent:
2026-06-01 22:28:45 +08:00
if agent.is_subagent:
raise ValueError("子智能体不能设为默认智能体")
if not is_builtin_agent(agent):
raise ValueError("默认智能体已固定为内置智能助手")
share_config = agent.share_config or DEFAULT_SHARE_CONFIG.copy()
if share_config.get("access_level") != "global":
raise ValueError("内置智能体必须全局共享")
now = utc_now_naive()
await self.db.execute(update(Agent).where(Agent.is_default.is_(True)).values(is_default=False, updated_at=now))
agent.is_default = True
agent.updated_by = updated_by
agent.updated_at = now
await self.db.commit()
await self.db.refresh(agent)
return agent
async def _slug_exists(self, slug: str) -> bool:
result = await self.db.execute(select(Agent.id).where(Agent.slug == slug))
return result.scalar_one_or_none() is not None
async def _unique_slug(self, desired: str | None, name: str) -> str:
base = _slugify(desired or name)
candidate = base
idx = 2
while await self._slug_exists(candidate):
suffix = f"-{idx}"
candidate = f"{base[: 80 - len(suffix)]}{suffix}"
idx += 1
return candidate
async def create(
self,
*,
name: str,
backend_id: str,
slug: str | None = None,
description: str | None = None,
icon: str | None = None,
pics: list[str] | None = None,
config_json: dict | None = None,
share_config: dict | None = None,
is_default: bool = False,
2026-06-01 22:28:45 +08:00
is_subagent: bool | None = None,
created_by: str | None = None,
creator: User | None = None,
) -> Agent:
2026-06-01 22:28:45 +08:00
resolved_is_subagent = resolve_agent_is_subagent(backend_id, is_subagent)
if resolved_is_subagent and is_default:
raise ValueError("子智能体不能设为默认智能体")
normalized_share_config = normalize_agent_share_config(
share_config,
user_uid=str(creator.uid) if creator else created_by,
department_id=creator.department_id if creator else None,
force_private=bool(creator and creator.role not in ADMIN_ROLES),
)
if is_default and normalized_share_config.get("access_level") != "global":
raise ValueError("默认智能体必须全局共享")
agent = Agent(
slug=await self._unique_slug(slug, name),
backend_id=backend_id,
name=name.strip() or "未命名智能体",
description=description,
icon=icon,
pics=pics or [],
config_json=config_json or {"context": {}},
share_config=normalized_share_config,
is_default=False,
2026-06-01 22:28:45 +08:00
is_subagent=resolved_is_subagent,
created_by=created_by,
updated_by=created_by,
created_at=utc_now_naive(),
updated_at=utc_now_naive(),
)
self.db.add(agent)
await self.db.commit()
await self.db.refresh(agent)
if is_default:
return await self.set_default(agent=agent, updated_by=created_by)
return agent
async def update(
self,
agent: Agent,
*,
name: str | None = None,
description: str | None = None,
icon: str | None = None,
pics: list[str] | None = None,
config_json: dict | None = None,
share_config: dict | None = None,
2026-06-01 22:28:45 +08:00
is_subagent: bool | None = None,
updated_by: str | None = None,
updater: User | None = None,
) -> Agent:
2026-06-01 22:28:45 +08:00
if is_subagent is not None:
agent.is_subagent = resolve_agent_is_subagent(agent.backend_id, is_subagent)
if name is not None:
agent.name = name.strip() or "未命名智能体"
if description is not None:
agent.description = description
if icon is not None:
agent.icon = icon
if pics is not None:
agent.pics = pics
if config_json is not None:
agent.config_json = config_json
if share_config is not None:
if is_builtin_agent(agent):
agent.share_config = DEFAULT_SHARE_CONFIG.copy()
else:
normalized_share_config = normalize_agent_share_config(
share_config,
user_uid=str(updater.uid) if updater else updated_by,
department_id=updater.department_id if updater else None,
force_private=bool(updater and updater.role not in ADMIN_ROLES),
)
agent.share_config = normalized_share_config
agent.updated_by = updated_by
agent.updated_at = utc_now_naive()
await self.db.commit()
await self.db.refresh(agent)
return agent
async def delete(self, *, agent: Agent) -> None:
await self.db.delete(agent)
await self.db.commit()
async def serialize(
self,
agent: Agent,
*,
user: User,
include_configurable_items: bool = False,
backend_info_cache: dict[tuple[str, bool, str], dict] | None = None,
) -> dict[str, Any]:
data = agent.to_dict()
data["can_manage"] = user_can_manage_agent(user, agent)
data["is_builtin"] = is_builtin_agent(agent)
data["permission_locked"] = is_builtin_agent(agent)
2026-06-01 22:28:45 +08:00
from yuxi.agents.buildin import agent_manager
backend = agent_manager.get_agent(agent.backend_id)
if backend:
cache_key = (agent.backend_id, include_configurable_items, user.role)
backend_info = backend_info_cache.get(cache_key) if backend_info_cache is not None else None
if backend_info is None:
backend_info = await backend.get_info(
include_configurable_items=include_configurable_items,
user_role=user.role,
db=self.db if include_configurable_items else None,
user=user if include_configurable_items else None,
)
if backend_info_cache is not None:
backend_info_cache[cache_key] = backend_info
data["capabilities"] = backend_info.get("capabilities", [])
data["metadata"] = backend_info.get("metadata", {})
if include_configurable_items:
data["configurable_items"] = backend_info.get("configurable_items", {})
else:
data["capabilities"] = []
data["metadata"] = {}
if include_configurable_items:
data["configurable_items"] = {}
return data