请在左侧选择 SubAgent 进行操作
+{{ currentAgent.system_prompt }}
+ diff --git a/backend/package/yuxi/agents/buildin/deep_agent/context.py b/backend/package/yuxi/agents/buildin/deep_agent/context.py index 84dad119..83f5b347 100644 --- a/backend/package/yuxi/agents/buildin/deep_agent/context.py +++ b/backend/package/yuxi/agents/buildin/deep_agent/context.py @@ -104,6 +104,6 @@ class DeepContext(BaseContext): default="siliconflow/Pro/deepseek-ai/DeepSeek-V3.2", metadata={ "name": "Sub-agent Model", - "description": "The model used by sub-agents (e.g., critique-agent, research-agent).", + "description": "子智能体的默认模型,会被子智能体的配置覆盖。", }, ) diff --git a/backend/package/yuxi/agents/buildin/deep_agent/graph.py b/backend/package/yuxi/agents/buildin/deep_agent/graph.py index 622bbaee..67a42aa4 100644 --- a/backend/package/yuxi/agents/buildin/deep_agent/graph.py +++ b/backend/package/yuxi/agents/buildin/deep_agent/graph.py @@ -16,6 +16,7 @@ from yuxi.agents.common.middlewares.knowledge_base_middleware import KnowledgeBa from yuxi.agents.common.middlewares.skills_middleware import SkillsMiddleware from yuxi.agents.common.toolkits.buildin.tools import _create_tavily_search from yuxi.services.mcp_service import get_tools_from_all_servers +from yuxi.services.subagent_service import get_subagent_specs, resolve_subagent_tools from yuxi.utils import logger from .context import DeepContext @@ -26,43 +27,6 @@ def _create_fs_backend(rt): return create_agent_composite_backend(rt) -def _get_research_sub_agent(search_tools: list) -> dict: - """Get research sub-agent config with search tools.""" - return { - "name": "research-agent", - "description": ("利用搜索工具,用于研究更深入的问题。将调研结果写入到主题研究文件中。"), - "system_prompt": ( - "你是一位专注的研究员。你的工作是根据用户的问题进行研究。" - "进行彻底的研究,然后用详细的答案回复用户的问题,只有你的最终答案会被传递给用户。" - "除了你的最终信息,他们不会知道任何其他事情,所以你的最终报告应该就是你的最终信息!" - "将调研结果保存到主题研究文件中 /sub_research/xxx.md 中。" - ), - "tools": search_tools, - } - - -critique_sub_agent = { - "name": "critique-agent", - "description": "用于评论最终报告。给这个代理一些关于你希望它如何评论报告的信息。", - "system_prompt": ( - "你是一位专注的编辑。你的任务是评论一份报告。\n\n" - "你可以在 `final_report.md` 找到这份报告。\n\n" - "你可以在 `question.txt` 找到这份报告的问题/主题。\n\n" - "用户可能会要求评论报告的特定方面。请用详细的评论回复用户,指出报告中可以改进的地方。\n\n" - "如果有助于你评论报告,你可以使用搜索工具来搜索信息\n\n" - "不要自己写入 `final_report.md`。\n\n" - "需要检查的事项:\n" - "- 检查每个部分的标题是否恰当\n" - "- 检查报告的写法是否像论文或教科书——它应该是以文本为主,不要只是一个项目符号列表!\n" - "- 检查报告是否全面。如果任何段落或部分过短,或缺少重要细节,请指出来。\n" - "- 检查文章是否涵盖了行业的关键领域,确保了整体理解,并且没有遗漏重要部分。\n" - "- 检查文章是否深入分析了原因、影响和趋势,提供了有价值的见解\n" - "- 检查文章是否紧扣研究主题并直接回答问题\n" - "- 检查文章是否结构清晰、语言流畅、易于理解。" - ), -} - - class DeepAgent(BaseAgent): name = "深度分析智能体" description = "具备规划、深度分析和子智能体协作能力的智能体,可以处理复杂的多步骤任务" @@ -103,8 +67,10 @@ class DeepAgent(BaseAgent): all_mcp_tools = await get_tools_from_all_servers() # 合并搜索工具和 MCP 工具 - # Build subagents with search tools - research_sub_agent = _get_research_sub_agent(search_tools) + # 从数据库加载 subagent specs(工具名称未解析) + user_subagents = await get_subagent_specs() + # 解析工具名称为实际工具实例 + user_subagents = resolve_subagent_tools(user_subagents, search_tools + all_mcp_tools) # 主 Agent 上下文优化:90k tokens 触发压缩(128k context window 的 70%) summary_middleware = SummaryOffloadMiddleware( @@ -127,7 +93,7 @@ class DeepAgent(BaseAgent): subagents_middleware = SubAgentMiddleware( default_model=sub_model, default_tools=search_tools, - subagents=[critique_sub_agent, research_sub_agent], + subagents=user_subagents, default_middleware=[ RuntimeConfigMiddleware( model_context_name="subagents_model", diff --git a/backend/package/yuxi/repositories/subagent_repository.py b/backend/package/yuxi/repositories/subagent_repository.py new file mode 100644 index 00000000..40858429 --- /dev/null +++ b/backend/package/yuxi/repositories/subagent_repository.py @@ -0,0 +1,112 @@ +"""SubAgent 数据访问层""" + +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from yuxi.storage.postgres.models_business import SubAgent +from yuxi.utils.datetime_utils import utc_now_naive + + +class SubAgentRepository: + def __init__(self, db_session: AsyncSession): + self.db = db_session + + async def list_all(self) -> list[SubAgent]: + """获取所有 SubAgent,按 updated_at 降序""" + result = await self.db.execute(select(SubAgent).order_by(SubAgent.updated_at.desc())) + return list(result.scalars().all()) + + async def get_by_name(self, name: str) -> SubAgent | None: + """根据名称获取 SubAgent""" + result = await self.db.execute(select(SubAgent).where(SubAgent.name == name)) + return result.scalar_one_or_none() + + async def exists_name(self, name: str) -> bool: + """检查名称是否存在""" + return (await self.get_by_name(name)) is not None + + async def create( + self, + *, + name: str, + description: str, + system_prompt: str, + tools: list[str] | None, + model: str | None, + is_builtin: bool, + created_by: str | None, + ) -> SubAgent: + now = utc_now_naive() + item = SubAgent( + name=name, + description=description, + system_prompt=system_prompt, + tools=tools or [], + model=model, + is_builtin=is_builtin, + created_by=created_by, + updated_by=created_by, + created_at=now, + updated_at=now, + ) + self.db.add(item) + await self.db.commit() + await self.db.refresh(item) + return item + + async def update( + self, + item: SubAgent, + *, + description: str | None, + system_prompt: str | None, + tools: list[str] | None, + model: str | None, + model_provided: bool = False, + updated_by: str | None, + ) -> SubAgent: + if description is not None: + item.description = description + if system_prompt is not None: + item.system_prompt = system_prompt + if tools is not None: + item.tools = tools + if model_provided: + item.model = model + item.updated_by = updated_by + item.updated_at = utc_now_naive() + await self.db.commit() + await self.db.refresh(item) + return item + + async def delete(self, item: SubAgent) -> None: + """删除 SubAgent""" + await self.db.delete(item) + await self.db.commit() + + async def upsert(self, data: dict[str, Any], created_by: str | None) -> SubAgent: + """Upsert 操作,如果存在则更新,否则创建""" + name = data["name"] + existing = await self.get_by_name(name) + if existing: + return await self.update( + existing, + description=data.get("description", existing.description), + system_prompt=data.get("system_prompt", existing.system_prompt), + tools=data.get("tools", existing.tools), + model=data.get("model", existing.model), + model_provided="model" in data, + updated_by=created_by, + ) + else: + return await self.create( + name=name, + description=data["description"], + system_prompt=data["system_prompt"], + tools=data.get("tools"), + model=data.get("model"), + is_builtin=data.get("is_builtin", False), + created_by=created_by, + ) diff --git a/backend/package/yuxi/services/subagent_service.py b/backend/package/yuxi/services/subagent_service.py new file mode 100644 index 00000000..872f09f3 --- /dev/null +++ b/backend/package/yuxi/services/subagent_service.py @@ -0,0 +1,208 @@ +"""SubAgent 服务层""" + +import asyncio +from copy import deepcopy +from contextlib import asynccontextmanager +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from yuxi.repositories.subagent_repository import SubAgentRepository +from yuxi.services.mcp_service import get_tools_from_all_servers +from yuxi.storage.postgres.manager import pg_manager + +# SubAgent specs cache for get_subagent_specs +_subagent_specs_cache: list[dict[str, Any]] | None = None +_subagent_specs_lock = asyncio.Lock() + + +@asynccontextmanager +async def _get_session(db: AsyncSession | None = None): + """获取数据库会话的上下文管理器""" + if db is not None: + yield db + else: + async with pg_manager.get_async_session_context() as session: + yield session + +# 内置 SubAgent 配置 +_DEFAULT_SUBAGENTS = [ + { + "name": "research-agent", + "description": "利用搜索工具,用于研究更深入的问题。将调研结果写入到主题研究文件中。", + "system_prompt": ( + "你是一位专注的研究员。你的工作是根据用户的问题进行研究。" + "进行彻底的研究,然后用详细的答案回复用户的问题,只有你的最终答案会被传递给用户。" + "除了你的最终信息,他们不会知道任何其他事情,所以你的最终报告应该就是你的最终信息!" + "将调研结果保存到主题研究文件中 /sub_research/xxx.md 中。" + ), + "tools": ["tavily_search"], + "is_builtin": True, + }, + { + "name": "critique-agent", + "description": "用于评论最终报告。给这个代理一些关于你希望它如何评论报告的信息。", + "system_prompt": ( + "你是一位专注的编辑。你的任务是评论一份报告。\n\n" + "你可以在 `final_report.md` 找到这份报告。\n\n" + "你可以在 `question.txt` 找到这份报告的问题/主题。\n\n" + "用户可能会要求评论报告的特定方面。请用详细的评论回复用户,指出报告中可以改进的地方。\n\n" + "如果有助于你评论报告,你可以使用搜索工具来搜索信息\n\n" + "不要自己写入 `final_report.md`。\n\n" + "需要检查的事项:\n" + "- 检查每个部分的标题是否恰当\n" + "- 检查报告的写法是否像论文或教科书——它应该是以文本为主,不要只是一个项目符号列表!\n" + "- 检查报告是否全面。如果任何段落或部分过短,或缺少重要细节,请指出来。\n" + "- 检查文章是否涵盖了行业的关键领域,确保了整体理解,并且没有遗漏重要部分。\n" + "- 检查文章是否深入分析了原因、影响和趋势,提供了有价值的见解\n" + "- 检查文章是否紧扣研究主题并直接回答问题\n" + "- 检查文章是否结构清晰、语言流畅、易于理解。" + ), + "tools": [], + "is_builtin": True, + }, +] + + +async def init_builtin_subagents() -> None: + """初始化内置 SubAgent(仅创建不存在的)""" + async with pg_manager.get_async_session_context() as session: + repo = SubAgentRepository(session) + for data in _DEFAULT_SUBAGENTS: + if not await repo.exists_name(data["name"]): + await repo.create( + name=data["name"], + description=data["description"], + system_prompt=data["system_prompt"], + tools=data.get("tools", []), + model=None, + is_builtin=data.get("is_builtin", False), + created_by="system", + ) + + +async def get_subagent_specs(db: AsyncSession | None = None) -> list[dict[str, Any]]: + """获取所有 subagent specs,用于 SubAgentMiddleware(工具名称未解析)""" + global _subagent_specs_cache + if _subagent_specs_cache is not None: + return deepcopy(_subagent_specs_cache) + async with _subagent_specs_lock: + if _subagent_specs_cache is not None: + return deepcopy(_subagent_specs_cache) + async with _get_session(db) as session: + repo = SubAgentRepository(session) + subagents = await repo.list_all() + _subagent_specs_cache = [sa.to_subagent_spec() for sa in subagents] + return deepcopy(_subagent_specs_cache) + + +def invalidate_subagent_specs_cache() -> None: + """清除 subagent specs 缓存""" + global _subagent_specs_cache + _subagent_specs_cache = None + + +def resolve_subagent_tools(specs: list[dict[str, Any]], available_tools: list[Any]) -> list[dict[str, Any]]: + """将 subagent specs 中的工具名称解析为实际工具实例""" + available_by_name = {tool.name: tool for tool in available_tools if hasattr(tool, "name")} + resolved_specs = [] + for spec in specs: + resolved_spec = dict(spec) + tool_names = spec.get("tools", []) + resolved_spec["tools"] = [ + available_by_name[name] for name in tool_names if isinstance(name, str) and name in available_by_name + ] + resolved_specs.append(resolved_spec) + return resolved_specs + + +async def _get_available_tools() -> list[Any]: + """获取所有可用的工具实例""" + from yuxi.agents.common.toolkits.buildin.tools import _create_tavily_search + + tools = [] + # 添加 tavily_search 工具 + tavily = _create_tavily_search() + if tavily: + tools.append(tavily) + # 添加 MCP 工具 + mcp_tools = await get_tools_from_all_servers() + tools.extend(mcp_tools) + return tools + +async def get_all_subagents(db: AsyncSession | None = None) -> list[dict[str, Any]]: + """获取所有 SubAgent(含禁用的)""" + async with _get_session(db) as session: + repo = SubAgentRepository(session) + items = await repo.list_all() + return [item.to_dict() for item in items] + + +async def get_subagent(name: str, db: AsyncSession | None = None) -> dict[str, Any] | None: + """获取单个 SubAgent""" + async with _get_session(db) as session: + repo = SubAgentRepository(session) + item = await repo.get_by_name(name) + return item.to_dict() if item else None + + +async def create_subagent( + data: dict[str, Any], + created_by: str | None, + db: AsyncSession | None = None, +) -> dict[str, Any]: + """创建 SubAgent""" + async with _get_session(db) as session: + repo = SubAgentRepository(session) + item = await repo.create( + name=data["name"], + description=data["description"], + system_prompt=data["system_prompt"], + tools=data.get("tools"), + model=data.get("model"), + is_builtin=False, + created_by=created_by, + ) + invalidate_subagent_specs_cache() + return item.to_dict() + + +async def update_subagent( + name: str, + data: dict[str, Any], + updated_by: str | None, + db: AsyncSession | None = None, +) -> dict[str, Any] | None: + """更新 SubAgent""" + async with _get_session(db) as session: + repo = SubAgentRepository(session) + item = await repo.get_by_name(name) + if not item: + return None + if item.is_builtin: + raise ValueError("内置 SubAgent 不可编辑") + item = await repo.update( + item, + description=data.get("description"), + system_prompt=data.get("system_prompt"), + tools=data.get("tools"), + model=data.get("model"), + model_provided="model" in data, + updated_by=updated_by, + ) + invalidate_subagent_specs_cache() + return item.to_dict() + + +async def delete_subagent(name: str, db: AsyncSession | None = None) -> bool: + """删除 SubAgent""" + async with _get_session(db) as session: + repo = SubAgentRepository(session) + item = await repo.get_by_name(name) + if not item: + return False + if item.is_builtin: + raise ValueError("内置 SubAgent 不可删除") + await repo.delete(item) + invalidate_subagent_specs_cache() + return True diff --git a/backend/package/yuxi/storage/postgres/models_business.py b/backend/package/yuxi/storage/postgres/models_business.py index e408cdb2..c63674ee 100644 --- a/backend/package/yuxi/storage/postgres/models_business.py +++ b/backend/package/yuxi/storage/postgres/models_business.py @@ -547,6 +547,51 @@ class TaskRecord(Base): return data +class SubAgent(Base): + """SubAgent 模型 - 用于动态配置子智能体""" + + __tablename__ = "subagents" + + name = Column(String(128), primary_key=True, comment="唯一标识") + description = Column(Text, nullable=False, comment="描述") + system_prompt = Column(Text, nullable=False, comment="系统提示词") + tools = Column(JSON, nullable=False, default=list, comment="工具名称列表") + model = Column(String(128), nullable=True, comment="可选的模型覆盖") + + is_builtin = Column(Boolean, nullable=False, default=False, comment="是否内置") + + created_by = Column(String(100), nullable=True) + updated_by = Column(String(100), nullable=True) + created_at = Column(DateTime, default=utc_now_naive) + updated_at = Column(DateTime, default=utc_now_naive, onupdate=utc_now_naive) + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "description": self.description, + "system_prompt": self.system_prompt, + "tools": self.tools or [], + "model": self.model, + "is_builtin": bool(self.is_builtin), + "created_by": self.created_by, + "updated_by": self.updated_by, + "created_at": format_utc_datetime(self.created_at), + "updated_at": format_utc_datetime(self.updated_at), + } + + def to_subagent_spec(self) -> dict[str, Any]: + """转换为 SubAgentMiddleware 需要的 spec 格式""" + spec = { + "name": self.name, + "description": self.description, + "system_prompt": self.system_prompt, + "tools": self.tools or [], + } + if self.model: + spec["model"] = self.model + return spec + + class AgentRun(Base): """AgentRun table - 运行任务表""" diff --git a/backend/server/routers/__init__.py b/backend/server/routers/__init__.py index 2f4ae1fe..a148c318 100644 --- a/backend/server/routers/__init__.py +++ b/backend/server/routers/__init__.py @@ -10,6 +10,7 @@ from server.routers.evaluation_router import evaluation from server.routers.mcp_router import mcp from server.routers.mindmap_router import mindmap from server.routers.skill_router import skills +from server.routers.subagent_router import subagents_router from server.routers.system_router import system from server.routers.task_router import tasks from server.routers.tool_router import tools @@ -29,4 +30,5 @@ router.include_router(graph) # /api/graph/* router.include_router(tasks) # /api/tasks/* router.include_router(mcp) # /api/system/mcp-servers/* router.include_router(skills) # /api/system/skills/* +router.include_router(subagents_router) # /api/system/subagents/* router.include_router(tools) # /api/system/tools/* diff --git a/backend/server/routers/subagent_router.py b/backend/server/routers/subagent_router.py new file mode 100644 index 00000000..621b7e7f --- /dev/null +++ b/backend/server/routers/subagent_router.py @@ -0,0 +1,147 @@ +"""SubAgent 管理路由""" + +from __future__ import annotations + +from pydantic import BaseModel, Field +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from fastapi import APIRouter, Depends, HTTPException + +from server.utils.auth_middleware import get_admin_user, get_db +from yuxi.services import subagent_service as service +from yuxi.storage.postgres.models_business import User +from yuxi.utils import logger + +subagents_router = APIRouter(prefix="/system/subagents", tags=["subagents"]) + + +class SubAgentCreateRequest(BaseModel): + name: str = Field(..., description="唯一标识") + description: str = Field(..., description="描述") + system_prompt: str = Field(..., description="系统提示词") + tools: list[str] = Field(default_factory=list, description="工具名称列表") + model: str | None = Field(None, description="可选的模型覆盖") + + +class SubAgentUpdateRequest(BaseModel): + description: str | None = Field(None, description="描述") + system_prompt: str | None = Field(None, description="系统提示词") + tools: list[str] | None = Field(None, description="工具名称列表") + model: str | None = Field(None, description="可选的模型覆盖") + + +def _raise_from_value_error(e: ValueError) -> None: + message = str(e) + status_code = 404 if "不存在" in message else 400 + raise HTTPException(status_code=status_code, detail=message) + + +def _raise_internal_error(action: str, error: Exception) -> None: + logger.exception("SubAgent %s failed: %s", action, error) + raise HTTPException(status_code=500, detail=f"{action}失败") + + +def _is_subagent_name_duplicate_error(error: IntegrityError) -> bool: + raw_message = str(getattr(error, "orig", error)).lower() + return ( + "duplicate key" in raw_message + and "subagents" in raw_message + and ("(name)" in raw_message or "subagents_pkey" in raw_message) + ) + + +@subagents_router.get("") +async def list_subagents_route( + _current_user: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + """获取 SubAgent 列表(管理员可读)""" + try: + items = await service.get_all_subagents(db) + return {"success": True, "data": items} + except Exception as e: + _raise_internal_error("获取列表", e) + + +@subagents_router.get("/{name}") +async def get_subagent_route( + name: str, + _current_user: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + """获取单个 SubAgent(管理员可读)""" + try: + item = await service.get_subagent(name, db) + if not item: + raise HTTPException(status_code=404, detail=f"SubAgent '{name}' 不存在") + return {"success": True, "data": item} + except HTTPException: + raise + except Exception as e: + _raise_internal_error("获取", e) + + +@subagents_router.post("") +async def create_subagent_route( + payload: SubAgentCreateRequest, + current_user: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + """创建 SubAgent(管理员)""" + try: + data = payload.model_dump() + item = await service.create_subagent(data, created_by=current_user.username, db=db) + return {"success": True, "data": item} + except IntegrityError as e: + if _is_subagent_name_duplicate_error(e): + raise HTTPException(status_code=409, detail=f"SubAgent '{payload.name}' 已存在") + _raise_internal_error("创建", e) + except HTTPException: + raise + except ValueError as e: + _raise_from_value_error(e) + except Exception as e: + _raise_internal_error("创建", e) + + +@subagents_router.put("/{name}") +async def update_subagent_route( + name: str, + payload: SubAgentUpdateRequest, + current_user: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + """更新 SubAgent(管理员)""" + try: + data = payload.model_dump(exclude_unset=True) + item = await service.update_subagent(name, data, updated_by=current_user.username, db=db) + if not item: + raise HTTPException(status_code=404, detail=f"SubAgent '{name}' 不存在") + return {"success": True, "data": item} + except ValueError as e: + _raise_from_value_error(e) + except HTTPException: + raise + except Exception as e: + _raise_internal_error("更新", e) + + +@subagents_router.delete("/{name}") +async def delete_subagent_route( + name: str, + _current_user: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + """删除 SubAgent(管理员)""" + try: + deleted = await service.delete_subagent(name, db=db) + if not deleted: + raise HTTPException(status_code=404, detail=f"SubAgent '{name}' 不存在") + return {"success": True} + except ValueError as e: + _raise_from_value_error(e) + except HTTPException: + raise + except Exception as e: + _raise_internal_error("删除", e) diff --git a/backend/server/utils/lifespan.py b/backend/server/utils/lifespan.py index 9e998143..afc21140 100644 --- a/backend/server/utils/lifespan.py +++ b/backend/server/utils/lifespan.py @@ -4,6 +4,7 @@ from fastapi import FastAPI from yuxi.services.task_service import tasker from yuxi.services.mcp_service import init_mcp_servers +from yuxi.services.subagent_service import init_builtin_subagents from yuxi.services.run_queue_service import close_queue_clients, get_redis_client from yuxi.storage.postgres.manager import pg_manager from yuxi.knowledge import knowledge_base @@ -28,6 +29,13 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error(f"Failed to initialize MCP servers during startup: {e}") + # 初始化内置 SubAgent + try: + await init_builtin_subagents() + except Exception as e: + logger.error(f"Failed to initialize builtin subagents during startup: {e}") + raise + # 初始化知识库管理器 try: await knowledge_base.initialize() diff --git a/backend/test/test_subagent.py b/backend/test/test_subagent.py new file mode 100644 index 00000000..40c3ef75 --- /dev/null +++ b/backend/test/test_subagent.py @@ -0,0 +1,553 @@ +"""SubAgent 单元测试""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy.exc import IntegrityError + +from yuxi.storage.postgres.models_business import SubAgent +from yuxi.utils.datetime_utils import utc_now_naive + + +# ============================================================================= +# Router Tests +# ============================================================================= + +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from server.routers.subagent_router import subagents_router +from server.utils.auth_middleware import get_admin_user, get_db +from yuxi.storage.postgres.models_business import User + + +def _build_app() -> FastAPI: + app = FastAPI() + app.include_router(subagents_router, prefix="/api") + + async def fake_db(): + return None + + async def fake_admin_user(): + return User( + username="admin", + user_id="admin", + password_hash="x", + role="admin", + ) + + app.dependency_overrides[get_db] = fake_db + app.dependency_overrides[get_admin_user] = fake_admin_user + return app + + +def test_list_subagents_returns_data(monkeypatch): + async def fake_get_all_subagents(_db): + return [ + { + "name": "research-agent", + "description": "Test research agent", + "system_prompt": "You are a researcher", + "tools": ["tavily_search"], + "model": None, + "is_builtin": True, + "created_by": "system", + "updated_by": "system", + "created_at": "2024-01-01T00:00:00", + "updated_at": "2024-01-01T00:00:00", + } + ] + + monkeypatch.setattr("server.routers.subagent_router.service.get_all_subagents", fake_get_all_subagents) + + app = _build_app() + client = TestClient(app) + resp = client.get("/api/system/subagents") + assert resp.status_code == 200, resp.text + payload = resp.json() + assert payload["success"] is True + assert payload["data"][0]["name"] == "research-agent" + assert payload["data"][0]["is_builtin"] is True + + +def test_get_single_subagent(monkeypatch): + async def fake_get_subagent(name, db=None): + if name == "research-agent": + return { + "name": "research-agent", + "description": "Test research agent", + "system_prompt": "You are a researcher", + "tools": ["tavily_search"], + "model": None, + "is_builtin": True, + "created_by": "system", + "updated_by": "system", + "created_at": "2024-01-01T00:00:00", + "updated_at": "2024-01-01T00:00:00", + } + return None + + monkeypatch.setattr("server.routers.subagent_router.service.get_subagent", fake_get_subagent) + + app = _build_app() + client = TestClient(app) + resp = client.get("/api/system/subagents/research-agent") + assert resp.status_code == 200, resp.text + payload = resp.json() + assert payload["success"] is True + assert payload["data"]["name"] == "research-agent" + + +def test_get_single_subagent_not_found(monkeypatch): + async def fake_get_subagent(name, db=None): + return None + + monkeypatch.setattr("server.routers.subagent_router.service.get_subagent", fake_get_subagent) + + app = _build_app() + client = TestClient(app) + resp = client.get("/api/system/subagents/nonexistent") + assert resp.status_code == 404, resp.text + + +def test_create_subagent(monkeypatch): + captured = {} + + async def fake_create_subagent(data, created_by, db=None): + captured["data"] = data + captured["created_by"] = created_by + return { + "name": data["name"], + "description": data["description"], + "system_prompt": data["system_prompt"], + "tools": data.get("tools", []), + "model": data.get("model"), + "is_builtin": False, + "created_by": created_by, + "updated_by": created_by, + "created_at": "2024-01-01T00:00:00", + "updated_at": "2024-01-01T00:00:00", + } + + monkeypatch.setattr("server.routers.subagent_router.service.create_subagent", fake_create_subagent) + + app = _build_app() + client = TestClient(app) + resp = client.post( + "/api/system/subagents", + json={ + "name": "my-agent", + "description": "My custom agent", + "system_prompt": "You are a helpful assistant", + "tools": ["tool_a", "tool_b"], + "model": None, + }, + ) + assert resp.status_code == 200, resp.text + payload = resp.json() + assert payload["success"] is True + assert captured["data"]["name"] == "my-agent" + assert captured["created_by"] == "admin" + + +def test_create_subagent_duplicate_returns_409(monkeypatch): + async def fake_create_subagent(data, created_by, db=None): + raise IntegrityError( + "duplicate", + {}, + Exception('duplicate key value violates unique constraint "subagents_pkey"'), + ) + + monkeypatch.setattr("server.routers.subagent_router.service.create_subagent", fake_create_subagent) + + app = _build_app() + client = TestClient(app) + resp = client.post( + "/api/system/subagents", + json={ + "name": "my-agent", + "description": "My custom agent", + "system_prompt": "You are a helpful assistant", + "tools": [], + "model": None, + }, + ) + assert resp.status_code == 409, resp.text + + +def test_update_subagent(monkeypatch): + captured = {} + + async def fake_update_subagent(name, data, updated_by, db=None): + captured["name"] = name + captured["data"] = data + captured["updated_by"] = updated_by + return { + "name": name, + "description": data.get("description", "Updated description"), + "system_prompt": data.get("system_prompt", "Updated prompt"), + "tools": data.get("tools", []), + "model": data.get("model"), + "is_builtin": False, + "created_by": "admin", + "updated_by": updated_by, + "created_at": "2024-01-01T00:00:00", + "updated_at": "2024-01-01T00:00:00", + } + + monkeypatch.setattr("server.routers.subagent_router.service.update_subagent", fake_update_subagent) + + app = _build_app() + client = TestClient(app) + resp = client.put( + "/api/system/subagents/my-agent", + json={ + "description": "Updated description", + "system_prompt": "Updated prompt", + }, + ) + assert resp.status_code == 200, resp.text + payload = resp.json() + assert payload["success"] is True + assert captured["name"] == "my-agent" + assert captured["updated_by"] == "admin" + + +def test_update_builtin_subagent_fails(monkeypatch): + async def fake_update_subagent(name, data, updated_by, db=None): + raise ValueError("内置 SubAgent 不可编辑") + + monkeypatch.setattr("server.routers.subagent_router.service.update_subagent", fake_update_subagent) + + app = _build_app() + client = TestClient(app) + resp = client.put( + "/api/system/subagents/research-agent", + json={"description": "Try to update builtin"}, + ) + assert resp.status_code == 400, resp.text + + +def test_delete_subagent(monkeypatch): + deleted_name = {"name": None} + + async def fake_delete_subagent(name, db=None): + deleted_name["name"] = name + return True + + monkeypatch.setattr("server.routers.subagent_router.service.delete_subagent", fake_delete_subagent) + + app = _build_app() + client = TestClient(app) + resp = client.delete("/api/system/subagents/my-agent") + assert resp.status_code == 200, resp.text + assert deleted_name["name"] == "my-agent" + + +def test_delete_builtin_subagent_fails(monkeypatch): + async def fake_delete_subagent(name, db=None): + raise ValueError("内置 SubAgent 不可删除") + + monkeypatch.setattr("server.routers.subagent_router.service.delete_subagent", fake_delete_subagent) + + app = _build_app() + client = TestClient(app) + resp = client.delete("/api/system/subagents/research-agent") + assert resp.status_code == 400, resp.text + + +# ============================================================================= +# Repository Tests +# ============================================================================= + +class TestSubAgentRepository: + @pytest.mark.asyncio + async def test_list_all(self): + from yuxi.repositories.subagent_repository import SubAgentRepository + + mock_db = AsyncMock() + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [ + SubAgent( + name="test-agent", + description="Test agent", + system_prompt="You are a test", + tools=["tool_a"], + model=None, + is_builtin=False, + created_by="admin", + updated_by="admin", + created_at=utc_now_naive(), + updated_at=utc_now_naive(), + ) + ] + mock_db.execute.return_value = mock_result + + repo = SubAgentRepository(mock_db) + result = await repo.list_all() + + assert len(result) == 1 + assert result[0].name == "test-agent" + mock_db.execute.assert_called_once() + + @pytest.mark.asyncio + async def test_get_by_name_found(self): + from yuxi.repositories.subagent_repository import SubAgentRepository + + mock_db = AsyncMock() + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = SubAgent( + name="test-agent", + description="Test agent", + system_prompt="You are a test", + tools=[], + model=None, + is_builtin=False, + created_by="admin", + updated_by="admin", + created_at=utc_now_naive(), + updated_at=utc_now_naive(), + ) + mock_db.execute.return_value = mock_result + + repo = SubAgentRepository(mock_db) + result = await repo.get_by_name("test-agent") + + assert result is not None + assert result.name == "test-agent" + + @pytest.mark.asyncio + async def test_get_by_name_not_found(self): + from yuxi.repositories.subagent_repository import SubAgentRepository + + mock_db = AsyncMock() + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_db.execute.return_value = mock_result + + repo = SubAgentRepository(mock_db) + result = await repo.get_by_name("nonexistent") + + assert result is None + + @pytest.mark.asyncio + async def test_update_can_clear_model_when_provided(self): + from yuxi.repositories.subagent_repository import SubAgentRepository + + mock_db = AsyncMock() + repo = SubAgentRepository(mock_db) + item = SubAgent( + name="test-agent", + description="Test agent", + system_prompt="You are a test", + tools=[], + model="gpt-4", + is_builtin=False, + created_by="admin", + updated_by="admin", + created_at=utc_now_naive(), + updated_at=utc_now_naive(), + ) + + await repo.update( + item, + description=None, + system_prompt=None, + tools=None, + model=None, + model_provided=True, + updated_by="admin", + ) + + assert item.model is None + + +# ============================================================================= +# Service Tests +# ============================================================================= + +class TestSubAgentService: + @pytest.mark.asyncio + async def test_init_builtin_subagents_creates_agents(self, monkeypatch): + from yuxi.services import subagent_service as service_module + + created_agents = [] + + class MockRepo: + def __init__(self, session): + pass + + async def exists_name(self, name): + return False + + async def create(self, **kwargs): + created_agents.append(kwargs) + return MagicMock() + + @asynccontextmanager + async def mock_session_context(*args, **kwargs): + yield MagicMock() + + class MockPgManager: + get_async_session_context = mock_session_context + + monkeypatch.setattr(service_module, "SubAgentRepository", MockRepo) + monkeypatch.setattr(service_module, "pg_manager", MockPgManager()) + + await service_module.init_builtin_subagents() + + assert len(created_agents) == 2 + agent_names = [a["name"] for a in created_agents] + assert "research-agent" in agent_names + assert "critique-agent" in agent_names + + @pytest.mark.asyncio + async def test_get_subagent_specs_returns_list(self, monkeypatch): + from yuxi.services import subagent_service as service_module + + mock_spec = { + "name": "test-agent", + "description": "Test", + "system_prompt": "You are a test", + "tools": ["tool_a"], + } + + class MockSubAgent: + def to_subagent_spec(self): + return mock_spec + + class MockRepo: + def __init__(self, session): + pass + + async def list_all(self): + return [MockSubAgent()] + + @asynccontextmanager + async def mock_session_context(*args, **kwargs): + yield MagicMock() + + class MockPgManager: + get_async_session_context = mock_session_context + + monkeypatch.setattr(service_module, "SubAgentRepository", MockRepo) + monkeypatch.setattr(service_module, "pg_manager", MockPgManager()) + monkeypatch.setattr(service_module, "_get_available_tools", AsyncMock(return_value=[])) + + result = await service_module.get_subagent_specs() + + assert len(result) == 1 + assert result[0]["name"] == "test-agent" + + @pytest.mark.asyncio + async def test_get_subagent_specs_returns_defensive_copy(self, monkeypatch): + from yuxi.services import subagent_service as service_module + + service_module._subagent_specs_cache = [ + { + "name": "test-agent", + "description": "Test", + "system_prompt": "You are a test", + "tools": ["tool_a"], + } + ] + + first = await service_module.get_subagent_specs() + first[0]["tools"].append("tool_b") + second = await service_module.get_subagent_specs() + + assert second[0]["tools"] == ["tool_a"] + service_module.invalidate_subagent_specs_cache() + + def test_resolve_subagent_tools_does_not_mutate_input(self): + from yuxi.services import subagent_service as service_module + + mock_tool = MagicMock() + mock_tool.name = "tool_a" + specs = [ + { + "name": "test-agent", + "description": "Test", + "system_prompt": "You are a test", + "tools": ["tool_a"], + } + ] + + resolved = service_module.resolve_subagent_tools(specs, [mock_tool]) + + assert specs[0]["tools"] == ["tool_a"] + assert resolved[0]["tools"] == [mock_tool] + + +# ============================================================================= +# Model Tests +# ============================================================================= + +class TestSubAgentModel: + def test_to_dict(self): + now = utc_now_naive() + agent = SubAgent( + name="test-agent", + description="Test agent", + system_prompt="You are a test", + tools=["tool_a", "tool_b"], + model="gpt-4", + is_builtin=False, + created_by="admin", + updated_by="admin", + created_at=now, + updated_at=now, + ) + + result = agent.to_dict() + + assert result["name"] == "test-agent" + assert result["description"] == "Test agent" + assert result["system_prompt"] == "You are a test" + assert result["tools"] == ["tool_a", "tool_b"] + assert result["model"] == "gpt-4" + assert result["is_builtin"] is False + assert result["created_by"] == "admin" + + def test_to_subagent_spec(self): + agent = SubAgent( + name="test-agent", + description="Test agent", + system_prompt="You are a test", + tools=["tool_a"], + model="gpt-4", + is_builtin=False, + created_by="admin", + updated_by="admin", + created_at=utc_now_naive(), + updated_at=utc_now_naive(), + ) + + spec = agent.to_subagent_spec() + + assert spec["name"] == "test-agent" + assert spec["description"] == "Test agent" + assert spec["system_prompt"] == "You are a test" + assert spec["tools"] == ["tool_a"] + assert spec["model"] == "gpt-4" + + def test_to_subagent_spec_no_model(self): + agent = SubAgent( + name="test-agent", + description="Test agent", + system_prompt="You are a test", + tools=[], + model=None, + is_builtin=False, + created_by="admin", + updated_by="admin", + created_at=utc_now_naive(), + updated_at=utc_now_naive(), + ) + + spec = agent.to_subagent_spec() + + assert "model" not in spec diff --git a/web/src/apis/index.js b/web/src/apis/index.js index b10c7297..14644fc9 100644 --- a/web/src/apis/index.js +++ b/web/src/apis/index.js @@ -13,6 +13,7 @@ export * from './mindmap_api' // 思维导图API export * from './department_api' // 部门管理API export * from './mcp_api' // MCP API export * from './skill_api' // Skills API +export * from './subagent_api' // SubAgent API export * from './tool_api' // 工具 API // 导出基础工具函数 diff --git a/web/src/apis/subagent_api.js b/web/src/apis/subagent_api.js new file mode 100644 index 00000000..0a82bac1 --- /dev/null +++ b/web/src/apis/subagent_api.js @@ -0,0 +1,71 @@ +import { apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base' + +/** + * SubAgent 管理 API 模块 + * 包含 SubAgent 的增删改查功能 + */ + +const BASE_URL = '/api/system/subagents' + +// ============================================================================= +// === SubAgent CRUD === +// ============================================================================= + +/** + * 获取所有 SubAgent 配置 + * @returns {Promise} - SubAgent 列表 + */ +export const getSubAgents = async () => { + return apiAdminGet(BASE_URL) +} + +/** + * 获取单个 SubAgent 配置 + * @param {string} name - SubAgent 名称 + * @returns {Promise} - SubAgent 配置 + */ +export const getSubAgent = async (name) => { + return apiAdminGet(`${BASE_URL}/${encodeURIComponent(name)}`) +} + +/** + * 创建新的 SubAgent + * @param {Object} data - SubAgent 配置数据 + * @returns {Promise} - 创建结果 + */ +export const createSubAgent = async (data) => { + return apiAdminPost(BASE_URL, data) +} + +/** + * 更新 SubAgent 配置 + * @param {string} name - SubAgent 名称 + * @param {Object} data - 更新数据 + * @returns {Promise} - 更新结果 + */ +export const updateSubAgent = async (name, data) => { + return apiAdminPut(`${BASE_URL}/${encodeURIComponent(name)}`, data) +} + +/** + * 删除 SubAgent + * @param {string} name - SubAgent 名称 + * @returns {Promise} - 删除结果 + */ +export const deleteSubAgent = async (name) => { + return apiAdminDelete(`${BASE_URL}/${encodeURIComponent(name)}`) +} + +// ============================================================================= +// === 导出为对象形式(兼容现有代码风格)=== +// ============================================================================= + +export const subagentApi = { + getSubAgents, + getSubAgent, + createSubAgent, + updateSubAgent, + deleteSubAgent, +} + +export default subagentApi diff --git a/web/src/assets/icons/subagents.svg b/web/src/assets/icons/subagents.svg new file mode 100644 index 00000000..3ffd446d --- /dev/null +++ b/web/src/assets/icons/subagents.svg @@ -0,0 +1,26 @@ + \ No newline at end of file diff --git a/web/src/components/McpServersComponent.vue b/web/src/components/McpServersComponent.vue index eec2d74f..1a4a0312 100644 --- a/web/src/components/McpServersComponent.vue +++ b/web/src/components/McpServersComponent.vue @@ -48,7 +48,6 @@ />
请在左侧选择 SubAgent 进行操作
+{{ currentAgent.system_prompt }}
+