diff --git a/REFACTOR.md b/REFACTOR.md index 498640d1..045d383b 100644 --- a/REFACTOR.md +++ b/REFACTOR.md @@ -29,6 +29,10 @@ - [ ] 附件上传能够支持转换为 PDF,待办:查看 OCR 模型的状态,样式优化,保存的文件名不对 - [ ] 参考 PR,实现内置 Dashscope 的 Embedding 和 rerank 的方法 - [ ] 优化知识库的 API 接口设计,使用 /{db_id}/xxx 的形式,整合 mindmap / eval 接口 +- [ ] 前端新增基于 ID 的随机像素头像生成机制(类似 GitHub 方块头像),用于替代默认图片 +- [ ] 子智能体右上角功能增强:任务按钮常驻,展开后展示已激活子智能体、附件、state 等信息 +- [ ] 流式输出混排修复:ongoing 流式返回时按 thread ID 区分主/子智能体内容,子智能体内容单独可视化渲染 +- [ ] 子智能体中间过程可查看:流式结束后,通过子智能体自身 thread ID 获取并渲染中间调用过程 - [x] allow multi-hop qa generate - [x] 在工作区的文件编辑的时候,保存和取消的按钮应该是悬浮在编辑框的右上角,而不是在 header 上面 - [x] default enable all build in tools / kbs / skills / mcps / subagents diff --git a/backend/package/uv.lock b/backend/package/uv.lock index 54b60761..d84622fe 100644 --- a/backend/package/uv.lock +++ b/backend/package/uv.lock @@ -720,7 +720,7 @@ wheels = [ [[package]] name = "deepagents" -version = "0.6.3" +version = "0.6.7" source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } dependencies = [ { name = "langchain" }, @@ -730,9 +730,9 @@ dependencies = [ { name = "langsmith" }, { name = "wcmatch" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/e4/f0db12de9a63ace505a81a3bd1bff2012fa94d57698bcc4edb6cc44daf3a/deepagents-0.6.3.tar.gz", hash = "sha256:ce2bc1d91a29b8355e43bdf89da553705e103cd1f2b3ff7b999b69be8eac1237", size = 180551, upload-time = "2026-05-20T21:16:38.473Z" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/bb/bb837a2c51631fe9d7eedf6aca7629ddca6336831801e75efcd2f5fa9c27/deepagents-0.6.7.tar.gz", hash = "sha256:af7b5857b28e29a847a4ced4cc7aaa809d33d42107696b1ca5d978d17e96b831", size = 194236, upload-time = "2026-05-30T04:42:14.591Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/e9/35a5668b4226afe8ec83a71c38e53a677b207ed9a10bd806b1be6cbf6277/deepagents-0.6.3-py3-none-any.whl", hash = "sha256:5c9a3ab30cd24eae0a5afe654fe5030ead4f8a86154c77757dd6350342ad50ac", size = 205652, upload-time = "2026-05-20T21:16:36.999Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/25/3a7f23d04778ccb95542f1b1ed39826290916221cc8203d0fb8b26c3edc1/deepagents-0.6.7-py3-none-any.whl", hash = "sha256:3518c1e5f4b9f6588ba39912b668b42b5c864b99a638e94c166d1c7176c7388e", size = 218740, upload-time = "2026-05-30T04:42:13.225Z" }, ] [[package]] @@ -1623,16 +1623,16 @@ wheels = [ [[package]] name = "langchain" -version = "1.3.1" +version = "1.3.2" source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph" }, { name = "pydantic" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/11/e5/6350e77a9e2764eaafcb2d581cbf0b800f53c6bc98fdf5ebc85f3a931ded/langchain-1.3.1.tar.gz", hash = "sha256:bc283c220233230f48b8e50ab1fbf1b688bcb206d933fa448d40a9b143177f62", size = 581329, upload-time = "2026-05-15T18:14:55.368Z" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/d0/c7f9d3d26c0e3f8bb146c6d707ee0fc1d30d8da65a59626e8a580085e929/langchain-1.3.2.tar.gz", hash = "sha256:ffd5f204a46b5fa1a38bf89ba3b45ca0902c02d18fa7d2a2eaeaeb1f5bf19d0a", size = 600598, upload-time = "2026-05-26T18:17:57.715Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/11/3d7ed10b535413a07ed5e15682abcb77f3c4204ac49586977a495f9b24e6/langchain-1.3.1-py3-none-any.whl", hash = "sha256:154e9c30c90b391eba4315296f6bf6b6fac6b058ddea4cc771a10470968fe36f", size = 114345, upload-time = "2026-05-15T18:14:53.984Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/82/a54edcd1c48163de5642eb10fa2cb58b13a8889c659964f63f0306b58b1e/langchain-1.3.2-py3-none-any.whl", hash = "sha256:900f6b3f4ee08b9ba3cdbe667dbf42525bd6f66a4a07a7f1db26262673e41ed6", size = 121225, upload-time = "2026-05-26T18:17:56.075Z" }, ] [[package]] @@ -1835,7 +1835,7 @@ wheels = [ [[package]] name = "langgraph" -version = "1.2.0" +version = "1.2.2" source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } dependencies = [ { name = "langchain-core" }, @@ -1845,9 +1845,9 @@ dependencies = [ { name = "pydantic" }, { name = "xxhash" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/61/d5d25e783035aa307d289b37e082258a6061c0fb4caa4a284f3bf1e87169/langgraph-1.2.0.tar.gz", hash = "sha256:4a9baaf62afc5d5f63144a50095140a34b9aa9b7cea695d25326d564775348e7", size = 690248, upload-time = "2026-05-12T03:46:39.164Z" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/5a/ffc12434ee8aecab830d58b4d204ddea45073eae7639c963310f671a5bf5/langgraph-1.2.2.tar.gz", hash = "sha256:f54a98458976b3ff0774683867df125fb52d8dbedeb2441d0b0656a51331cee5", size = 695730, upload-time = "2026-05-26T18:07:28.49Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/e8/e3304ac0015c2bdb04ad9785e4ed65c788855ce7857ce6104dd2f5d322db/langgraph-1.2.0-py3-none-any.whl", hash = "sha256:03fd5895a8d4b70db1ff63ebc3bacead29dd20cd794a8b1a483e7ec9018f7a65", size = 234262, upload-time = "2026-05-12T03:46:37.971Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/9b/b08d578bba73e25351152dfd3d6d21e81210a5fff1b6f26e56f33197c8f5/langgraph-1.2.2-py3-none-any.whl", hash = "sha256:0a851bf4ba5939c5474a2fd57e6b439b5315283e254e42943bd392c2d71a5e03", size = 236376, upload-time = "2026-05-26T18:07:26.577Z" }, ] [[package]] @@ -5299,7 +5299,7 @@ wheels = [ [[package]] name = "yuxi" -version = "0.6.2b0" +version = "0.6.2" source = { editable = "." } dependencies = [ { name = "agent-sandbox" }, @@ -5392,7 +5392,7 @@ requires-dist = [ { name = "chardet", specifier = ">=5.0.0" }, { name = "colorlog", specifier = ">=6.9.0" }, { name = "dashscope", specifier = ">=1.23.2" }, - { name = "deepagents", specifier = ">=0.2.5" }, + { name = "deepagents", specifier = ">=0.6.7" }, { name = "docling", specifier = ">=2.68.0" }, { name = "docx2txt", specifier = ">=0.9" }, { name = "httpx", specifier = ">=0.27.0" }, diff --git a/backend/package/yuxi/agents/base.py b/backend/package/yuxi/agents/base.py index d71ca2d9..82c19448 100644 --- a/backend/package/yuxi/agents/base.py +++ b/backend/package/yuxi/agents/base.py @@ -1,8 +1,11 @@ from __future__ import annotations +import asyncio import os from abc import abstractmethod +from contextlib import suppress from pathlib import Path +from typing import Any from langgraph.checkpoint.memory import InMemorySaver from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver, aiosqlite @@ -10,10 +13,61 @@ from langgraph.graph.state import CompiledStateGraph from yuxi import config as sys_config from yuxi.agents.context import BaseContext, resolve_agent_resource_options +from yuxi.agents.subagent_thread import make_child_thread_id from yuxi.storage.postgres.manager import pg_manager from yuxi.utils import logger +def _json_safe(value: Any) -> Any: + if value is None or isinstance(value, str | int | float | bool): + return value + if isinstance(value, dict): + return {str(key): _json_safe(child) for key, child in value.items()} + if isinstance(value, list | tuple): + return [_json_safe(child) for child in value] + if hasattr(value, "model_dump"): + return _json_safe(value.model_dump()) + return str(value) + + +def _subagent_route_for_namespace( + routes: dict[tuple[str, ...], dict[str, str]], namespace: list[str] +) -> dict[str, str] | None: + ns = tuple(namespace) + for path, route in sorted(routes.items(), key=lambda item: len(item[0]), reverse=True): + if ns[: len(path)] == path: + return route + return None + + +async def _collect_subagent_routes( + run, parent_thread_id: str, routes: dict[tuple[str, ...], dict[str, str]] +) -> None: + subagents = getattr(run, "subagents", None) + if subagents is None: + return + + try: + async for subagent in subagents: + path = tuple(getattr(subagent, "path", ()) or ()) + subagent_type = getattr(subagent, "name", None) or getattr(subagent, "graph_name", None) + cause = getattr(subagent, "cause", None) + tool_call_id = ( + cause.get("tool_call_id") if isinstance(cause, dict) else getattr(subagent, "trigger_call_id", None) + ) + if path and isinstance(subagent_type, str) and isinstance(tool_call_id, str) and tool_call_id: + routes[path] = { + "thread_id": make_child_thread_id(parent_thread_id, subagent_type, tool_call_id), + "parent_thread_id": parent_thread_id, + "subagent_type": subagent_type, + "tool_call_id": tool_call_id, + } + except asyncio.CancelledError: + raise + except Exception as exc: + logger.debug(f"collect subagent stream routes failed: {exc}") + + class BaseAgent: """ 定义一个基础 Agent 供 各类 graph 继承 @@ -112,11 +166,11 @@ class BaseAgent: ): yield msg, metadata - async def stream_messages_with_state(self, messages: list[str], input_context=None, **kwargs): + async def _stream_input_with_state(self, graph_input, input_context=None, **kwargs): context = self.context_schema() context.update_from_dict(input_context or {}) graph = await self.get_graph(context=context) - logger.debug(f"stream_messages_with_state: {context=}") + logger.debug(f"stream_with_state: {context=}") input_config = { "configurable": {"thread_id": context.thread_id, "uid": context.uid}, @@ -130,13 +184,56 @@ class BaseAgent: if tags := kwargs.get("tags"): input_config["tags"] = list(tags) - async for mode, payload in graph.astream( - {"messages": messages}, - stream_mode=["messages", "values"], + run = await graph.astream_events( + graph_input, context=context, config=input_config, - ): - yield mode, payload + version="v3", + ) + subagent_routes: dict[tuple[str, ...], dict[str, str]] = {} + route_task = asyncio.create_task(_collect_subagent_routes(run, context.thread_id, subagent_routes)) + try: + async for event in run: + params = event.get("params") or {} + namespace = list(params.get("namespace") or []) + method = event.get("method") + data = params.get("data") + subagent_route = _subagent_route_for_namespace(subagent_routes, namespace) + if namespace and subagent_route is None: + await asyncio.sleep(0) + subagent_route = _subagent_route_for_namespace(subagent_routes, namespace) + + if method == "messages": + msg, metadata = data + metadata = dict(metadata or {}) + metadata["namespace"] = namespace + metadata["stream_event"] = {"method": method, "namespace": namespace} + if subagent_route: + metadata.update(subagent_route) + yield "messages", (msg, metadata) + elif method == "values" and not namespace: + yield "values", data + elif method in {"tasks", "tools", "lifecycle"}: + event_payload = { + "method": method, + "namespace": namespace, + "data": _json_safe(data), + } + if subagent_route: + event_payload.update(subagent_route) + yield "stream_event", event_payload + finally: + route_task.cancel() + with suppress(asyncio.CancelledError): + await route_task + + async def stream_messages_with_state(self, messages: list[str], input_context=None, **kwargs): + async for event in self._stream_input_with_state({"messages": messages}, input_context, **kwargs): + yield event + + async def stream_resume_with_state(self, resume_input, input_context=None, **kwargs): + async for event in self._stream_input_with_state(resume_input, input_context, **kwargs): + yield event async def invoke_messages(self, messages: list[str], input_context=None, **kwargs): context = self.context_schema() diff --git a/backend/package/yuxi/agents/buildin/chatbot/__init__.py b/backend/package/yuxi/agents/buildin/chatbot/__init__.py index 180012be..3ed36adf 100644 --- a/backend/package/yuxi/agents/buildin/chatbot/__init__.py +++ b/backend/package/yuxi/agents/buildin/chatbot/__init__.py @@ -1,3 +1,5 @@ +from .context import ChatBotContext from .graph import ChatbotAgent +from .state import ChatBotState, SubAgentRunState, merge_subagent_runs -__all__ = ["ChatbotAgent"] +__all__ = ["ChatBotContext", "ChatBotState", "ChatbotAgent", "SubAgentRunState", "merge_subagent_runs"] diff --git a/backend/package/yuxi/agents/buildin/chatbot/context.py b/backend/package/yuxi/agents/buildin/chatbot/context.py new file mode 100644 index 00000000..0f1728b6 --- /dev/null +++ b/backend/package/yuxi/agents/buildin/chatbot/context.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass, field + +from yuxi.agents.context import BaseContext + + +@dataclass(kw_only=True) +class ChatBotContext(BaseContext): + subagents: list[str] | None = field( + default=None, + metadata={ + "name": "子智能体", + "options": [], + "description": "可选子智能体列表,只有被选中的子智能体会作为 task 工具候选。为空表示启用当前用户可见的全部子智能体。", + "type": "list", + "kind": "subagents", + }, + ) diff --git a/backend/package/yuxi/agents/buildin/chatbot/graph.py b/backend/package/yuxi/agents/buildin/chatbot/graph.py index 220c0c88..8a8a5a1c 100644 --- a/backend/package/yuxi/agents/buildin/chatbot/graph.py +++ b/backend/package/yuxi/agents/buildin/chatbot/graph.py @@ -1,10 +1,9 @@ from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware -from deepagents.middleware.subagents import SubAgentMiddleware from langchain.agents import create_agent from langchain.agents.middleware import ModelRetryMiddleware, TodoListMiddleware -from yuxi.agents import BaseAgent, BaseState, load_chat_model -from yuxi.agents.backends import create_agent_composite_backend, create_agent_filesystem_middleware +from yuxi.agents import BaseAgent, load_chat_model +from yuxi.agents.backends import create_agent_filesystem_middleware from yuxi.agents.context import prepare_agent_runtime_context from yuxi.agents.middlewares import ( create_summary_middleware, @@ -12,10 +11,12 @@ from yuxi.agents.middlewares import ( ) from yuxi.agents.middlewares.knowledge_base_middleware import KnowledgeBaseMiddleware from yuxi.agents.middlewares.skills_middleware import SkillsMiddleware -from yuxi.agents.subagents.service import build_subagent_middleware_specs, get_subagents_from_slugs +from yuxi.agents.middlewares.subagent_task_middleware import create_subagent_task_middleware from yuxi.agents.toolkits.service import resolve_configured_runtime_tools +from .context import ChatBotContext from .prompt import TODO_MID_PROMPT, build_prompt_with_context +from .state import ChatBotState async def _build_middlewares(context): @@ -30,36 +31,26 @@ async def _build_middlewares(context): trim_tokens_to_summarize=4000, ) - # subagents - subagents = await get_subagents_from_slugs(context.subagents) - default_subagent_middleware = [ - create_agent_filesystem_middleware(tool_token_limit_before_evict=500), # 文件系统后端 - PatchToolCallsMiddleware(), - summary_middleware, - ] - subagents_middleware = SubAgentMiddleware( - backend=create_agent_composite_backend, - subagents=build_subagent_middleware_specs( - subagents, - default_model=load_chat_model(fully_specified_name=context.subagents_model), - default_middleware=default_subagent_middleware, - model_loader=load_chat_model, - ), - state_schema=BaseState, - ) - # all middlewares middlewares = [ - create_agent_filesystem_middleware(tool_token_limit_before_evict=500), # 文件系统后端 - save_attachments_to_fs, # 附件注入提示词 - KnowledgeBaseMiddleware(), # 知识库工具 - SkillsMiddleware(), # Skills 中间件(提示词注入、依赖展开、动态激活) - subagents_middleware, - summary_middleware, - TodoListMiddleware(system_prompt=TODO_MID_PROMPT), # 待办事项中间件 - PatchToolCallsMiddleware(), - ModelRetryMiddleware(), # 模型重试中间件 + create_agent_filesystem_middleware( + getattr(context, "tool_token_limit", 20) * 1024, + context=context, + ), + save_attachments_to_fs, + KnowledgeBaseMiddleware(), + SkillsMiddleware(), ] - + subagent_middleware = await create_subagent_task_middleware(context) + if subagent_middleware: + middlewares.append(subagent_middleware) + middlewares.extend( + [ + summary_middleware, + TodoListMiddleware(system_prompt=TODO_MID_PROMPT), + PatchToolCallsMiddleware(), + ModelRetryMiddleware(), + ] + ) return middlewares @@ -67,6 +58,7 @@ class ChatbotAgent(BaseAgent): name = "智能助手" description = "基础的对话机器人,可以回答问题,可在配置中启用需要的工具。" capabilities = ["file_upload", "files"] # 支持文件上传功能 + context_schema = ChatBotContext def __init__(self, **kwargs): super().__init__(**kwargs) @@ -84,7 +76,7 @@ class ChatbotAgent(BaseAgent): tools=await resolve_configured_runtime_tools(context), system_prompt=build_prompt_with_context(context), middleware=await _build_middlewares(context), - state_schema=BaseState, + state_schema=ChatBotState, checkpointer=await self._get_checkpointer(), ) diff --git a/backend/package/yuxi/agents/buildin/chatbot/state.py b/backend/package/yuxi/agents/buildin/chatbot/state.py new file mode 100644 index 00000000..38624c09 --- /dev/null +++ b/backend/package/yuxi/agents/buildin/chatbot/state.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Annotated, Literal, TypedDict + +from yuxi.agents.state import BaseState + + +class SubAgentRunState(TypedDict, total=False): + id: str + subagent_type: str + subagent_name: str + child_thread_id: str + description: str + status: Literal["completed", "failed"] + created_at: str + completed_at: str + result_preview: str + error: str | None + artifacts: list[str] + + +def merge_subagent_runs( + existing: list[SubAgentRunState] | None, + new: list[SubAgentRunState] | None, +) -> list[SubAgentRunState]: + if existing is None: + return list(new or []) + if new is None: + return existing + + merged = [dict(item) for item in existing] + index = {item.get("id"): position for position, item in enumerate(merged) if item.get("id")} + for item in new: + run = dict(item) + run_id = run.get("id") + if run_id and run_id in index: + merged[index[run_id]] = {**merged[index[run_id]], **run} + else: + if run_id: + index[run_id] = len(merged) + merged.append(run) + return merged + + +class ChatBotState(BaseState): + subagent_runs: Annotated[list[SubAgentRunState], merge_subagent_runs] diff --git a/backend/package/yuxi/agents/buildin/deep_agent/__init__.py b/backend/package/yuxi/agents/buildin/deep_agent/__init__.py deleted file mode 100644 index 6983c466..00000000 --- a/backend/package/yuxi/agents/buildin/deep_agent/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Deep Agent - 深度分析智能体模块 - -基于 deepagents 库构建的深度分析智能体,具备以下特性: -- 任务规划和分解能力 -- 深度知识搜索和分析 -- 子智能体协作 -- 文件系统和长期记忆 -- 综合分析和报告生成 -""" - -from .context import DeepContext -from .graph import DeepAgent - -__all__ = [ - "DeepAgent", - "DeepContext", -] - -# 模块元数据 -__version__ = "1.0.0" -__author__ = "Yuxi Team" -__description__ = "基于 create_deep_agent 的深度分析智能体" diff --git a/backend/package/yuxi/agents/buildin/deep_agent/context.py b/backend/package/yuxi/agents/buildin/deep_agent/context.py deleted file mode 100644 index bced6e7e..00000000 --- a/backend/package/yuxi/agents/buildin/deep_agent/context.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Deep Agent Context - 基于BaseContext的深度分析上下文配置""" - -from dataclasses import dataclass, field - -from yuxi.agents import BaseContext - -from .prompt import DEEP_PROMPT - - -@dataclass -class DeepContext(BaseContext): - """ - Deep Agent 的上下文配置,继承自 BaseContext - 专门用于深度分析任务的配置管理 - """ - - system_prompt: str = field( - default=DEEP_PROMPT, - metadata={"name": "系统提示词", "description": "Deep智能体的角色和行为指导", "kind": "prompt"}, - ) - - subagents_model: str = field( - default="siliconflow-cn:deepseek-ai/DeepSeek-V4-Flash", - metadata={ - "name": "Sub-agent Model", - "description": "子智能体的默认模型,会被子智能体的配置覆盖。", - "kind": "llm", - }, - ) diff --git a/backend/package/yuxi/agents/buildin/deep_agent/graph.py b/backend/package/yuxi/agents/buildin/deep_agent/graph.py deleted file mode 100644 index 535c4bbd..00000000 --- a/backend/package/yuxi/agents/buildin/deep_agent/graph.py +++ /dev/null @@ -1,128 +0,0 @@ -import os - -from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware -from deepagents.middleware.subagents import SubAgentMiddleware -from langchain.agents import create_agent -from langchain.agents.middleware import ( - TodoListMiddleware, - ToolCallLimitMiddleware, -) - -from yuxi.agents import BaseAgent, BaseState, load_chat_model -from yuxi.agents.backends import create_agent_composite_backend, create_agent_filesystem_middleware -from yuxi.agents.context import prepare_agent_runtime_context -from yuxi.agents.middlewares import ( - create_summary_middleware, - save_attachments_to_fs, -) -from yuxi.agents.middlewares.knowledge_base_middleware import KnowledgeBaseMiddleware -from yuxi.agents.middlewares.skills_middleware import SkillsMiddleware -from yuxi.agents.subagents.service import build_subagent_middleware_specs, get_subagents_from_slugs -from yuxi.agents.toolkits.buildin.tools import _create_tavily_search -from yuxi.agents.toolkits.service import resolve_configured_runtime_tools -from yuxi.utils import logger -from yuxi.utils.datetime_utils import shanghai_now - -from .prompt import DEEP_PROMPT - - -class DeepAgent(BaseAgent): - name = "深度分析" - description = "具备规划、深度分析和子智能体协作能力的智能体,可以处理复杂的多步骤任务" - capabilities = ["file_upload", "files"] # 支持文件上传功能 - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.graph = None - self.checkpointer = None - - async def get_tools(self): - """返回 Deep Agent 的专用工具""" - tools = [] - if os.getenv("TAVILY_API_KEY"): - tavily = _create_tavily_search() - if tavily: - tools.append(tavily) - - if not tools: - logger.warning("No search tools configured, DeepAgent will work without web search") - return tools - - async def get_graph(self, context=None, **kwargs): - - context = await prepare_agent_runtime_context( - context or self.context_schema(), - context_schema=self.context_schema, - ) - current_date = f"当前日期:{shanghai_now().strftime('%Y-%m-%d')}" - system_prompt = f"{current_date}\n\n{DEEP_PROMPT.strip()}\n\n{context.system_prompt or ''}" - - model = load_chat_model(context.model) - sub_model = load_chat_model(context.subagents_model) - search_tools = await self.get_tools() - - # 从数据库加载 subagent specs(工具名称已解析) - user_subagents = await get_subagents_from_slugs(context.subagents) - - # 主 Agent 上下文优化:90k tokens 触发压缩,保留最近 50% - summary_middleware = create_summary_middleware( - model=model, - trigger=("tokens", 90000), - keep=("tokens", 45000), - trim_tokens_to_summarize=4000, - ) - - default_subagent_middleware = [ - create_agent_filesystem_middleware(tool_token_limit_before_evict=500), # 文件系统后端 - PatchToolCallsMiddleware(), - summary_middleware, - # 子 Agent 搜索工具限制:tavily_search 最多 8 次 - ToolCallLimitMiddleware( - tool_name="tavily_search", - run_limit=8, - exit_behavior="continue", - ), - ] - subagents_middleware = SubAgentMiddleware( - backend=create_agent_composite_backend, - subagents=build_subagent_middleware_specs( - user_subagents, - default_model=sub_model, - default_tools=search_tools, - default_middleware=default_subagent_middleware, - model_loader=load_chat_model, - ), - state_schema=BaseState, - ) - - # 使用 create_deep_agent 创建深度智能体 - graph = create_agent( - model=model, - tools=await resolve_configured_runtime_tools(context), - system_prompt=system_prompt, - middleware=[ - create_agent_filesystem_middleware(tool_token_limit_before_evict=500), # 文件系统后端 - SkillsMiddleware(), # Skills 中间件(提示词注入、依赖展开、动态激活) - save_attachments_to_fs, # 附件注入提示词 - TodoListMiddleware(system_prompt="任务结束前,应该检查维护的待办事项列表是否结束。"), - PatchToolCallsMiddleware(), - KnowledgeBaseMiddleware(), # 知识库工具 - subagents_middleware, - summary_middleware, - # 工具调用限制:tavily_search 总调用最多 20 次 - ToolCallLimitMiddleware( - tool_name="tavily_search", - thread_limit=20, - exit_behavior="continue", - ), - # 总工具调用轮次限制:防止单次运行无限循环 - ToolCallLimitMiddleware( - run_limit=50, - exit_behavior="end", - ), - ], - state_schema=BaseState, - checkpointer=await self._get_checkpointer(), - ) - - return graph diff --git a/backend/package/yuxi/agents/buildin/deep_agent/prompt.py b/backend/package/yuxi/agents/buildin/deep_agent/prompt.py deleted file mode 100644 index 9e777de1..00000000 --- a/backend/package/yuxi/agents/buildin/deep_agent/prompt.py +++ /dev/null @@ -1,93 +0,0 @@ -from yuxi.utils.paths import ( - VIRTUAL_PATH_OUTPUTS, - VIRTUAL_PATH_PREFIX, - VIRTUAL_PATH_UPLOADS, - VIRTUAL_PATH_WORKSPACE, -) - -DEEP_PROMPT = f"""你是一位专家级研究员。你的工作是进行彻底的研究,然后撰写一份精美的报告。 - -你应该做的第一件事是把原始的用户问题写入 `question.txt`,以便你有一个记录。 - -首先,你应该并行使用 research-agent 进行深入研究。,当你认为有足够的信息来撰写最终报告时,就把它写入 `final_report.md` -其次(如果有必要),你可以调用 critique-agent 来获取对最终报告(文件)的评价。 -之后(如果需要)你可以做更多的研究并编辑 `final_report.md` -最终通知用户,已生成完毕,可以在状态工作台中下载最终报告。 - -你可以根据需要重复这个过程,直到你对结果满意为止。 - -务必注意: -1. 一次只编辑一个文件(如果你并行调用这个工具,可能会有冲突)。 -2. 一次只给 research-agent 一个主题。不要传递多个子问题。 - - -以下是撰写最终报告的说明: - - - -关键:确保答案的语言与人类信息的语言相同!如果你制定了一个待办事项计划,你应该在计划中注明报告应该使用什么语言。 -注意:报告应该使用的语言是问题所在的语言,而不是问题所涉及的国家/地区的语言。 - -请根据整体研究简报创建一个详细的答案,该答案应: -1. 组织良好,有恰当的标题(# 用于标题,## 用于章节,### 用于子章节) -2. 包含研究中的具体事实和见解 -3. 使用 [标题](URL) 格式引用相关来源 -4. 图片应该使用 ![描述](图片URL) 格式引用 -5. 提供平衡、透彻的分析。尽可能全面,并包含与整体研究问题相关的所有信息。使用你进行深入研究,并期望得到详细、全面的答案 -6. 在末尾包含一个“来源”部分,列出所有引用的链接 - -你可以用多种不同的方式来组织你的报告。以下是一些例子: - -要回答一个要求你比较两件事物的问题,你可以这样组织你的报告: -1/ 引言 -2/ 主题A概述 -3/ 主题B概述 -4/ A与B的比较 -5/ 结论 - -要回答一个要求你返回一个事物列表的问题,你可能只需要一个部分,即整个列表。 -1/ 事物列表或表格 -或者,你可以选择将列表中的每一项都作为报告中的一个独立部分。当被要求提供列表时,你不需要引言或结论。 -1/ 项目1 -2/ 项目2 -3/ 项目3 - -要回答一个要求你总结一个主题、给出一份报告或概述的问题,你可以这样组织你的报告: -1/ 主题概述 -2/ 概念1 -3/ 概念2 -4/ 概念3 -5/ 结论 - -请记住:章节是一个非常灵活和松散的概念。你可以按照你认为最好的方式来组织你的报告,包括上面没有列出的方式! -确保你的各个部分是连贯的,并且对读者来说是有意义的。 - -对于报告的每个部分,请执行以下操作: -- 使用简单、清晰的语言,报告内容要详实。 -- 格式符合学术论文、技术报告、公文文件等格式,不要过于随意,段落不可过于简短。 -- 对报告的每个部分使用 ## 作为章节标题(Markdown 格式) -- 绝不要将自己称为报告的作者。这应该是一份专业的报告,不含任何自我指涉的语言。 -- 不要在报告中说你正在做什么。只需撰写报告,不要添加任何你自己的评论。 -- 每个部分的长度应足以用你收集到的信息。预计各部分会长且详尽。你正在撰写一份深入的研究报告,用户会期望得到透彻的答案。 - - -<引用规则> -- 在你的文本中为每个唯一的 URL/文件路径 分配一个引文编号 -- 以 ### 来源 结尾,列出每个来源及其对应的编号 -- 重要提示:无论你选择哪些来源,最终列表中的来源编号都应连续无间断(1,2,3,4...) -- 每个来源都应该是列表中的一个独立行项目,这样在 markdown 中它会被渲染成一个列表。 -- 示例格式: - [1] 来源标题: URL/文件路径 - [2] 来源标题: URL/文件路径 -- 引用非常重要。请确保包含这些内容,并特别注意确保其正确性。用户通常会使用这些引文来查找更多信息。 - - - -你可以使用一些工具。 - -系统主要工作路径为 {VIRTUAL_PATH_PREFIX},但必须遵守规范: -- {VIRTUAL_PATH_WORKSPACE}:用于存放工作文件(用户目录,不要轻易写入) -- {VIRTUAL_PATH_OUTPUTS}:用于写入的文件夹 - - {VIRTUAL_PATH_OUTPUTS}/tmp/:用于存放中间结果或备份内容 -- {VIRTUAL_PATH_UPLOADS}:用于存放用户上传的文件 -""" diff --git a/backend/package/yuxi/agents/buildin/subagent/__init__.py b/backend/package/yuxi/agents/buildin/subagent/__init__.py new file mode 100644 index 00000000..fe86f382 --- /dev/null +++ b/backend/package/yuxi/agents/buildin/subagent/__init__.py @@ -0,0 +1,4 @@ +from .context import SubAgentContext +from .graph import SubAgentBackend + +__all__ = ["SubAgentBackend", "SubAgentContext"] diff --git a/backend/package/yuxi/agents/buildin/subagent/context.py b/backend/package/yuxi/agents/buildin/subagent/context.py new file mode 100644 index 00000000..d62ab98e --- /dev/null +++ b/backend/package/yuxi/agents/buildin/subagent/context.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass, field + +from yuxi.agents.context import BaseContext + + +@dataclass(kw_only=True) +class SubAgentContext(BaseContext): + parent_thread_id: str | None = field( + default=None, + metadata={"name": "父线程ID", "configurable": False, "hide": True}, + ) + file_thread_id: str | None = field( + default=None, + metadata={"name": "文件线程ID", "configurable": False, "hide": True}, + ) + skills_thread_id: str | None = field( + default=None, + metadata={"name": "Skills线程ID", "configurable": False, "hide": True}, + ) + is_subagent_runtime: bool = field( + default=False, + metadata={"name": "子智能体运行态", "configurable": False, "hide": True}, + ) diff --git a/backend/package/yuxi/agents/buildin/subagent/graph.py b/backend/package/yuxi/agents/buildin/subagent/graph.py new file mode 100644 index 00000000..94af9aec --- /dev/null +++ b/backend/package/yuxi/agents/buildin/subagent/graph.py @@ -0,0 +1,59 @@ +from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware +from langchain.agents import create_agent +from langchain.agents.middleware import ModelRetryMiddleware, TodoListMiddleware + +from yuxi.agents import BaseAgent, BaseState, load_chat_model +from yuxi.agents.backends import create_agent_filesystem_middleware +from yuxi.agents.buildin.chatbot.prompt import TODO_MID_PROMPT, build_prompt_with_context +from yuxi.agents.buildin.subagent.context import SubAgentContext +from yuxi.agents.context import prepare_agent_runtime_context +from yuxi.agents.middlewares import create_summary_middleware, save_attachments_to_fs +from yuxi.agents.middlewares.knowledge_base_middleware import KnowledgeBaseMiddleware +from yuxi.agents.middlewares.skills_middleware import SkillsMiddleware +from yuxi.agents.toolkits.service import resolve_configured_runtime_tools + + +async def _build_middlewares(context): + summary_trigger_tokens = getattr(context, "summary_threshold", 100) * 1024 + summary_middleware = create_summary_middleware( + model=load_chat_model(fully_specified_name=context.model), + trigger=("tokens", summary_trigger_tokens), + keep=("tokens", summary_trigger_tokens // 2), + trim_tokens_to_summarize=4000, + ) + + return [ + create_agent_filesystem_middleware( + getattr(context, "tool_token_limit", 20) * 1024, + context=context, + ), + save_attachments_to_fs, + KnowledgeBaseMiddleware(), + SkillsMiddleware(), + summary_middleware, + TodoListMiddleware(system_prompt=TODO_MID_PROMPT), + PatchToolCallsMiddleware(), + ModelRetryMiddleware(), + ] + + +class SubAgentBackend(BaseAgent): + name = "子智能体" + description = "用于被主智能体通过 task 工具调用的专用智能体后端。" + capabilities = ["file_upload", "files"] + context_schema = SubAgentContext + + async def get_graph(self, context=None, **kwargs): + context = await prepare_agent_runtime_context( + context or self.context_schema(), + context_schema=self.context_schema, + ) + + return create_agent( + model=load_chat_model(fully_specified_name=context.model), + tools=await resolve_configured_runtime_tools(context), + system_prompt=build_prompt_with_context(context), + middleware=await _build_middlewares(context), + state_schema=BaseState, + checkpointer=await self._get_checkpointer(), + ) diff --git a/backend/package/yuxi/agents/context.py b/backend/package/yuxi/agents/context.py index c8106fbd..0d29a39e 100644 --- a/backend/package/yuxi/agents/context.py +++ b/backend/package/yuxi/agents/context.py @@ -129,29 +129,6 @@ class BaseContext: }, ) - subagents_model: str = field( - default=sys_config.default_model, - metadata={ - "name": "子智能体的默认模型", - "description": "为所有子智能体设置默认模型,可在各子智能体配置中单独覆盖。", - "kind": "llm", - }, - ) - - subagents: list[str] | None = field( - default=None, - metadata={ - "name": "子智能体", - "options": [], - "description": ( - "可选子智能体列表,默认选择当前用户可用的全部 SubAgent。" - "为空表示不启用任何 SubAgent,但依然会启用一个 general-purpose 的子智能体。" - ), - "type": "list", - "kind": "subagents", - }, - ) - summary_threshold: int = field( default=100, metadata={ @@ -212,7 +189,9 @@ class BaseContext: setattr(self, key, value) -_DEFAULT_ALL_CONTEXT_FIELDS = frozenset({"tools", "knowledges", "mcps", "skills", "subagents"}) +_DEFAULT_ALL_CONTEXT_FIELDS = frozenset({"tools", "knowledges", "mcps", "skills"}) +_EMPTY_ALL_CONTEXT_FIELDS = frozenset({"subagents"}) +_AGENT_RESOURCE_FIELDS = _DEFAULT_ALL_CONTEXT_FIELDS | _EMPTY_ALL_CONTEXT_FIELDS def _normalize_selected_resource_keys(value: Any, available: list[str]) -> list[str]: @@ -238,6 +217,12 @@ def _resource_fields_requiring_available_keys(normalized: dict, resource_fields: for field_name in resource_fields: current = normalized.get(field_name) if current is None: + if field_name in _DEFAULT_ALL_CONTEXT_FIELDS | _EMPTY_ALL_CONTEXT_FIELDS: + fields_to_load.add(field_name) + else: + normalized[field_name] = [] + elif field_name in _EMPTY_ALL_CONTEXT_FIELDS and current == []: + normalized[field_name] = None fields_to_load.add(field_name) elif isinstance(current, list) and current: fields_to_load.add(field_name) @@ -261,7 +246,7 @@ async def resolve_agent_resource_options( db, user, ) -> dict[str, list[dict[str, str]]]: - fields_to_load = _DEFAULT_ALL_CONTEXT_FIELDS if resource_fields is None else resource_fields + fields_to_load = _AGENT_RESOURCE_FIELDS if resource_fields is None else resource_fields if not fields_to_load: return {} @@ -301,13 +286,11 @@ async def resolve_agent_resource_options( _resource_option(skill.slug, skill.name, skill.description) for skill in skills if skill.slug ] if "subagents" in fields_to_load: - from yuxi.agents.subagents.service import get_all_subagents + from yuxi.repositories.agent_repository import AgentRepository - subagents = await get_all_subagents(db) + subagents = await AgentRepository(db).list_visible_subagents(user=user) options["subagents"] = [ - _resource_option(item.get("slug"), item.get("name"), item.get("description")) - for item in subagents - if item.get("enabled") and item.get("slug") + _resource_option(agent.slug, agent.name, agent.description) for agent in subagents if agent.slug ] return options @@ -325,7 +308,7 @@ async def normalize_agent_context_config( filtered = filter_config_by_role({"context": raw_context}, getattr(user, "role", None), schema) normalized = dict(filtered.get("context") or {}) field_names = {item.name for item in fields(schema)} - resource_fields = _DEFAULT_ALL_CONTEXT_FIELDS & field_names + resource_fields = _AGENT_RESOURCE_FIELDS & field_names if not resource_fields: return normalized @@ -355,6 +338,7 @@ async def prepare_agent_runtime_context( context_schema: type[BaseContext] | None = None, ) -> BaseContext: """准备 Agent 运行时上下文,主要是根据 context 中的 uid 加载用户可访问的资源列表,并进行规范化处理。""" + schema = context_schema or type(context) uid = str(getattr(context, "uid", "") or "").strip() if not uid: return context @@ -364,7 +348,7 @@ async def prepare_agent_runtime_context( from yuxi.repositories.user_repository import UserRepository from yuxi.storage.postgres.manager import pg_manager - resource_fields = _DEFAULT_ALL_CONTEXT_FIELDS + resource_fields = _AGENT_RESOURCE_FIELDS async with pg_manager.get_async_session_context() as db: user = await UserRepository().get_by_uid_with_db(db, uid) if user is None: @@ -387,7 +371,7 @@ async def prepare_agent_runtime_context( raw_resources, db=db, user=user, - context_schema=context_schema, + context_schema=schema, ) for field_name in resource_fields: if hasattr(context, field_name): diff --git a/backend/package/yuxi/agents/middlewares/subagent_task_middleware.py b/backend/package/yuxi/agents/middlewares/subagent_task_middleware.py new file mode 100644 index 00000000..c22659f4 --- /dev/null +++ b/backend/package/yuxi/agents/middlewares/subagent_task_middleware.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Annotated, Any + +from deepagents import SubagentTransformer +from deepagents.middleware._utils import append_to_system_message +from langchain.agents.middleware.types import AgentMiddleware, ContextT, ModelRequest, ModelResponse, ResponseT +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage +from langchain_core.tools import StructuredTool +from langgraph.prebuilt.tool_node import ToolRuntime +from langgraph.types import Command + +from yuxi.agents.subagent_thread import make_child_thread_id +from yuxi.repositories.agent_repository import SUB_AGENT_BACKEND_ID, AgentRepository +from yuxi.repositories.user_repository import UserRepository +from yuxi.storage.postgres.manager import pg_manager +from yuxi.storage.postgres.models_business import Agent +from yuxi.utils.datetime_utils import utc_isoformat + +_EXCLUDED_STATE_KEYS = { + "messages", + "todos", + "structured_response", + "skills_metadata", + "skills_load_errors", + "activated_skills", + "memory_contents", +} + +TASK_SYSTEM_PROMPT = """## `task`(子智能体任务工具) + +你可以使用 `task` 工具把复杂、独立的子任务交给已配置的子智能体处理。子智能体只返回最终结果,你看不到它的中间步骤。 + +使用原则: +- 任务足够复杂、可以独立完成、或需要隔离上下文时使用。 +- 多个互不依赖的子任务可以并行调用多个 `task`。 +- 简单问题或少量直接工具调用不要委派。 +- 调用时必须选择下方可用的 `subagent_type`,并在 `description` 中写清目标、上下文和期望输出。 +- 不要通过 shell、curl、HTTP API 或命令行间接调用子智能体;需要子智能体时必须使用 `task` 工具。 + +Available subagent types: + +{available_agents}""" + +TASK_TOOL_DESCRIPTION = """Launch a configured Yuxi subagent to handle an isolated task. + +Available subagent types: +{available_agents} + +Use `subagent_type` to select one available subagent and put the full task brief in `description`. +Do not call subagents through shell, curl, HTTP APIs, or command-line indirection.""" + + +TASK_DESCRIPTION_ARG = "需要子智能体独立完成的任务描述,包含必要上下文和期望输出。" +SUBAGENT_TYPE_ARG = "要调用的子智能体标识,必须是工具描述中列出的可用类型之一。" + + +def _get_agent_backend(backend_id: str): + from yuxi.agents.buildin import agent_manager + + return agent_manager.get_agent(backend_id) + + +def _final_assistant_text(messages: list[Any]) -> str: + for message in reversed(messages): + if isinstance(message, AIMessage): + text = message.text.rstrip() if message.text else "" + if text: + return text + return "子智能体已完成任务,但没有返回文本结果。" + + +def _result_artifacts(result: dict[str, Any]) -> list[str]: + artifacts = result.get("artifacts") + return list(artifacts) if isinstance(artifacts, list) else [] + + +def _preview_text(text: str, limit: int = 500) -> str: + return text if len(text) <= limit else f"{text[:limit]}..." + + +def _completed_tool_response(result: dict[str, Any], tool_call_id: str, subagent_run: dict[str, Any]) -> Command: + final_text = _final_assistant_text(result.get("messages") or []) + artifacts = _result_artifacts(result) + subagent_run = { + **subagent_run, + "status": "completed", + "completed_at": utc_isoformat(), + "result_preview": _preview_text(final_text), + "error": None, + "artifacts": artifacts, + } + update: dict[str, Any] = {"messages": [ToolMessage(final_text, tool_call_id=tool_call_id)]} + if artifacts: + update["artifacts"] = artifacts + update["subagent_runs"] = [subagent_run] + return Command(update=update) + + +def _failed_tool_response(error: Exception, tool_call_id: str, subagent_run: dict[str, Any]) -> Command: + error_text = str(error) + message = f"子智能体 {subagent_run['subagent_type']} 调用失败:{error_text}" + update = { + "messages": [ToolMessage(message, tool_call_id=tool_call_id)], + "subagent_runs": [ + { + **subagent_run, + "status": "failed", + "completed_at": utc_isoformat(), + "result_preview": _preview_text(message), + "error": error_text, + "artifacts": [], + } + ], + } + return Command(update=update) + + +def _state_for_child( + description: str, + runtime: ToolRuntime, + *, + parent_thread_id: str, + file_thread_id: str, + skills_thread_id: str, +) -> dict[str, Any]: + state = {key: value for key, value in runtime.state.items() if key not in _EXCLUDED_STATE_KEYS} + state.update( + { + "parent_thread_id": parent_thread_id, + "file_thread_id": file_thread_id, + "skills_thread_id": skills_thread_id, + } + ) + state["messages"] = [HumanMessage(content=description)] + return state + + +def _child_config( + runtime: ToolRuntime, + *, + child_thread_id: str, + uid: str, + parent_thread_id: str, + file_thread_id: str, + skills_thread_id: str, +) -> dict: + parent_config = runtime.config or {} + config: dict[str, Any] = {} + if "callbacks" in parent_config: + config["callbacks"] = parent_config["callbacks"] + if "tags" in parent_config: + config["tags"] = parent_config["tags"] + parent_configurable = ( + parent_config.get("configurable") if isinstance(parent_config.get("configurable"), dict) else {} + ) + parent_configurable = { + key: value + for key, value in parent_configurable.items() + if not str(key).startswith(("checkpoint_", "__pregel_")) + } + config["configurable"] = { + **parent_configurable, + "thread_id": child_thread_id, + "uid": uid, + "parent_thread_id": parent_thread_id, + "file_thread_id": file_thread_id, + "skills_thread_id": skills_thread_id, + "ls_agent_type": "subagent", + } + config["recursion_limit"] = parent_config.get("recursion_limit", 300) + return config + + +class YuxiSubAgentMiddleware(AgentMiddleware[Any, ContextT, ResponseT]): + def __init__(self, *, parent_context, subagents: list[Agent]) -> None: + super().__init__() + self.parent_context = parent_context + self.subagents = {agent.slug: agent for agent in subagents} + available_agents = "\n".join(f"- {agent.slug}: {agent.description or agent.name}" for agent in subagents) + self.system_prompt = TASK_SYSTEM_PROMPT.format(available_agents=available_agents) + self.tools = [self._build_task_tool(available_agents)] + self.subagent_names = frozenset(self.subagents) + self.transformers = [ + lambda scope: SubagentTransformer(scope, subagent_names=self.subagent_names) + ] + + def _build_task_tool(self, available_agents: str) -> StructuredTool: + def task( + description: Annotated[str, TASK_DESCRIPTION_ARG], + subagent_type: Annotated[str, SUBAGENT_TYPE_ARG], + runtime: ToolRuntime, + ) -> str: + return "task 工具仅支持异步调用" + + async def atask( + description: Annotated[str, TASK_DESCRIPTION_ARG], + subagent_type: Annotated[str, SUBAGENT_TYPE_ARG], + runtime: ToolRuntime, + ) -> str | Command: + if subagent_type not in self.subagents: + allowed = ", ".join(f"`{slug}`" for slug in self.subagents) + return f"无法调用子智能体 {subagent_type},可用子智能体只有:{allowed}" + if not runtime.tool_call_id: + raise ValueError("Tool call ID is required for subagent invocation") + + parent_thread_id = str( + getattr(self.parent_context, "parent_thread_id", None) or self.parent_context.thread_id + ) + file_thread_id = str(getattr(self.parent_context, "file_thread_id", None) or parent_thread_id) + uid = str(getattr(self.parent_context, "uid", "") or "").strip() + if not uid: + return "无法调用子智能体:当前运行时缺少 uid" + + agent = self.subagents[subagent_type] + backend = _get_agent_backend(agent.backend_id) + if not backend or agent.backend_id != SUB_AGENT_BACKEND_ID: + return f"无法调用子智能体 {subagent_type}:后端配置无效" + + child_thread_id = make_child_thread_id(parent_thread_id, agent.slug, runtime.tool_call_id) + subagent_run = { + "id": runtime.tool_call_id, + "subagent_type": subagent_type, + "subagent_name": agent.name, + "child_thread_id": child_thread_id, + "description": description, + "created_at": utc_isoformat(), + } + child_context = backend.context_schema() + config_context = (agent.config_json or {}).get("context") if isinstance(agent.config_json, dict) else None + if isinstance(config_context, dict): + child_context.update_from_dict(config_context) + child_context.uid = uid + child_context.thread_id = child_thread_id + child_context.parent_thread_id = parent_thread_id + child_context.file_thread_id = file_thread_id + child_context.skills_thread_id = child_thread_id + child_context.is_subagent_runtime = True + + try: + graph = await backend.get_graph(context=child_context) + result = await graph.ainvoke( + _state_for_child( + description, + runtime, + parent_thread_id=parent_thread_id, + file_thread_id=file_thread_id, + skills_thread_id=child_thread_id, + ), + config=_child_config( + runtime, + child_thread_id=child_thread_id, + uid=uid, + parent_thread_id=parent_thread_id, + file_thread_id=file_thread_id, + skills_thread_id=child_thread_id, + ), + context=child_context, + ) + except Exception as exc: + return _failed_tool_response(exc, runtime.tool_call_id, subagent_run) + return _completed_tool_response(result, runtime.tool_call_id, subagent_run) + + return StructuredTool.from_function( + name="task", + func=task, + coroutine=atask, + description=TASK_TOOL_DESCRIPTION.format(available_agents=available_agents), + infer_schema=True, + ) + + def wrap_model_call( + self, + request: ModelRequest[ContextT], + handler: Callable[[ModelRequest[ContextT]], ModelResponse[ResponseT]], + ) -> ModelResponse[ResponseT]: + return handler( + request.override(system_message=append_to_system_message(request.system_message, self.system_prompt)) + ) + + async def awrap_model_call( + self, + request: ModelRequest[ContextT], + handler: Callable[[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]], + ) -> ModelResponse[ResponseT]: + return await handler( + request.override(system_message=append_to_system_message(request.system_message, self.system_prompt)) + ) + + +async def create_subagent_task_middleware(parent_context) -> YuxiSubAgentMiddleware | None: + selected_slugs = [ + str(slug).strip() for slug in (getattr(parent_context, "subagents", None) or []) if str(slug).strip() + ] + uid = str(getattr(parent_context, "uid", "") or "").strip() + if not uid: + return None + + async with pg_manager.get_async_session_context() as db: + user = await UserRepository().get_by_uid_with_db(db, uid) + if user is None: + return None + repo = AgentRepository(db) + if selected_slugs: + subagents: list[Agent] = [] + seen: set[str] = set() + for slug in selected_slugs: + if slug in seen: + continue + seen.add(slug) + agent = await repo.get_visible_subagent_by_slug(slug=slug, user=user) + if agent and agent.backend_id == SUB_AGENT_BACKEND_ID: + subagents.append(agent) + else: + subagents = [ + agent + for agent in await repo.list_visible_subagents(user=user) + if agent.backend_id == SUB_AGENT_BACKEND_ID + ] + + if not subagents: + return None + return YuxiSubAgentMiddleware(parent_context=parent_context, subagents=subagents) diff --git a/backend/package/yuxi/agents/state.py b/backend/package/yuxi/agents/state.py index c2616523..6c7a9acc 100644 --- a/backend/package/yuxi/agents/state.py +++ b/backend/package/yuxi/agents/state.py @@ -28,3 +28,4 @@ class AgentStatePayload(TypedDict): todos: list files: dict artifacts: list[str] + subagent_runs: list[dict] diff --git a/backend/package/yuxi/agents/subagent_thread.py b/backend/package/yuxi/agents/subagent_thread.py new file mode 100644 index 00000000..8f4e4da6 --- /dev/null +++ b/backend/package/yuxi/agents/subagent_thread.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ChildThreadId: + parent_thread_id: str + agent_slug: str + suffix: str + + +def safe_subagent_slug(value: str) -> str: + return re.sub(r"[^A-Za-z0-9_-]+", "-", value).strip("-") or "subagent" + + +def make_child_thread_id(parent_thread_id: str, agent_slug: str, tool_call_id: str) -> str: + suffix = hashlib.sha256( + f"{parent_thread_id}:{agent_slug}:{tool_call_id}".encode("utf-8") + ).hexdigest()[:8] + return f"{parent_thread_id}_sub_{safe_subagent_slug(agent_slug)}_{suffix}" + + +def parse_child_thread_id(thread_id: str) -> ChildThreadId | None: + parent_thread_id, separator, child_suffix = str(thread_id or "").rpartition("_sub_") + if not separator or not parent_thread_id: + return None + + agent_slug, separator, suffix = child_suffix.rpartition("_") + if not separator or not agent_slug or len(suffix) != 8: + return None + if any(char not in "0123456789abcdef" for char in suffix.lower()): + return None + return ChildThreadId(parent_thread_id=parent_thread_id, agent_slug=agent_slug, suffix=suffix) diff --git a/backend/package/yuxi/agents/subagents/__init__.py b/backend/package/yuxi/agents/subagents/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/package/yuxi/agents/subagents/repository.py b/backend/package/yuxi/agents/subagents/repository.py deleted file mode 100644 index dcebdae9..00000000 --- a/backend/package/yuxi/agents/subagents/repository.py +++ /dev/null @@ -1,111 +0,0 @@ -"""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 list_enabled(self) -> list[SubAgent]: - """获取已启用的 SubAgent。""" - result = await self.db.execute( - select(SubAgent).where(SubAgent.enabled.is_(True)).order_by(SubAgent.updated_at.desc()) - ) - return list(result.scalars().all()) - - async def list_all_specs(self) -> list[dict[str, Any]]: - """获取已启用的 SubAgent 运行规格,按 updated_at 降序。""" - items = await self.list_enabled() - return [item.to_subagent_spec() for item in items] - - async def get_by_slug(self, slug: str) -> SubAgent | None: - """根据 slug 获取 SubAgent""" - result = await self.db.execute(select(SubAgent).where(SubAgent.slug == slug)) - return result.scalar_one_or_none() - - async def exists_slug(self, slug: str) -> bool: - """检查 slug 是否存在(仅查询计数,不获取完整数据)""" - from sqlalchemy import func, select - - result = await self.db.execute(select(func.count()).select_from(SubAgent).where(SubAgent.slug == slug)) - return result.scalar() > 0 - - async def create( - self, - *, - slug: str, - 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( - slug=slug, - name=name, - description=description, - system_prompt=system_prompt, - tools=tools or [], - model=model, - enabled=True, - 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, - *, - name: str | None, - description: str | None, - system_prompt: str | None, - tools: list[str] | None, - model: str | None, - model_provided: bool = False, - updated_by: str | None, - ) -> SubAgent: - # 批量更新非空字段 - updates = { - "name": name, - "description": description, - "system_prompt": system_prompt, - "tools": tools, - } - for field, value in updates.items(): - if value is not None: - setattr(item, field, value) - # model_provided=True 时显式设置 model 值(包括 None),用于清空字段 - 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() diff --git a/backend/package/yuxi/agents/subagents/service.py b/backend/package/yuxi/agents/subagents/service.py deleted file mode 100644 index 9782ff89..00000000 --- a/backend/package/yuxi/agents/subagents/service.py +++ /dev/null @@ -1,301 +0,0 @@ -"""SubAgent 服务层""" - -import asyncio -from contextlib import asynccontextmanager -from copy import deepcopy -from typing import Any - -from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from yuxi.agents.subagents.repository import SubAgentRepository -from yuxi.storage.postgres.manager import pg_manager -from yuxi.storage.postgres.models_business import SubAgent -from yuxi.utils import logger -from yuxi.utils.paths import VIRTUAL_PATH_OUTPUTS - -# 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 = [ - { - "slug": "research-agent", - "name": "研究员", - "description": "利用搜索工具,用于研究更深入的问题。将调研结果写入到主题研究文件中。", - "system_prompt": ( - "你是一位专注的研究员。你的工作是根据用户的问题进行研究。" - "进行彻底的研究,然后用详细的答案回复用户的问题,只有你的最终答案会被传递给用户。" - "除了你的最终信息,他们不会知道任何其他事情,所以你的最终报告应该就是你的最终信息!" - f"将调研结果保存到主题研究文件中 {VIRTUAL_PATH_OUTPUTS}/sub_research/xxx.md 中。" - ), - "tools": ["tavily_search"], - "is_builtin": True, - }, - { - "slug": "critique-agent", - "name": "评论员", - "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, - }, -] - -_SYNCED_SUBAGENT_FIELDS = ("name", "description", "system_prompt", "tools", "model", "is_builtin") - - -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: - item = await repo.get_by_slug(data["slug"]) - if item is None: - await repo.create( - slug=data["slug"], - 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", - ) - continue - - changed = False - for field in _SYNCED_SUBAGENT_FIELDS: - next_value = data.get(field) - current_value = getattr(item, field) - if current_value != next_value: - setattr(item, field, deepcopy(next_value)) - changed = True - if changed: - item.updated_by = "system" - await session.commit() - clear_specs_cache() - - -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) - _subagent_specs_cache = await repo.list_all_specs() - return deepcopy(_subagent_specs_cache) - - -async def get_enabled_subagent_slugs(db: AsyncSession | None = None) -> list[str]: - if _subagent_specs_cache is not None: - return [spec["slug"] for spec in _subagent_specs_cache if isinstance(spec.get("slug"), str)] - - async with _get_session(db) as session: - result = await session.execute( - select(SubAgent.slug).where(SubAgent.enabled.is_(True)).order_by(SubAgent.updated_at.desc()) - ) - return [slug for slug in result.scalars().all() if isinstance(slug, str)] - - -def clear_specs_cache() -> None: - """清除 subagent specs 缓存""" - global _subagent_specs_cache - _subagent_specs_cache = None - - -def build_subagent_middleware_specs( - subagents: list[dict[str, Any]], - *, - default_model: Any, - default_tools: list[Any] | None = None, - default_middleware: list[Any] | None = None, - include_general_purpose: bool = True, - model_loader: Any | None = None, -) -> list[dict[str, Any]]: - tools = list(default_tools or []) - middleware = list(default_middleware or []) - specs: list[dict[str, Any]] = [] - - if include_general_purpose: - specs.append( - { - **GENERAL_PURPOSE_SUBAGENT, - "model": default_model, - "tools": list(tools), - "middleware": list(middleware), - } - ) - - for spec in subagents: - item = {key: value for key, value in spec.items() if key != "slug"} - model_name = item.get("model") - item["model"] = model_loader(model_name) if model_name and model_loader else (model_name or default_model) - item["tools"] = list(item.get("tools") or tools) - item["middleware"] = [*middleware, *list(item.get("middleware", []))] - specs.append(item) - - return specs - - -async def get_subagents_from_slugs(selected_slugs: Any, *, db: AsyncSession | None = None) -> list[dict[str, Any]]: - """根据 slug 获取 subagent specs(含工具解析)。""" - specs = await get_subagent_specs(db) - - if not selected_slugs: - return [] - - selected_set = set(selected_slugs) - available = {spec["slug"] for spec in specs if isinstance(spec.get("slug"), str)} - matched = [spec for spec in specs if spec.get("slug") in selected_set] - missing = [slug for slug in selected_slugs if slug not in available] - if missing: - logger.warning(f"Configured subagents not found, skip: {missing}") - - # 处理工具 - # 仅从子智能体配置中的工具名称进行解析;不做 Tavily/MCP 特殊注入。 - from yuxi.agents.toolkits import get_all_tool_instances - - all_tools = get_all_tool_instances() - all_tool_names = {tool.name: tool for tool in all_tools} - resolved_specs = [] - for spec in matched: - resolved_spec = dict(spec) - tool_names = spec.get("tools", []) - resolved_spec["tools"] = [all_tool_names[name] for name in tool_names if name in all_tool_names] - resolved_specs.append(resolved_spec) - - return resolved_specs - - -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(slug: 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_slug(slug) - 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( - slug=data["slug"], - 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, - ) - clear_specs_cache() - return item.to_dict() - - -async def update_subagent( - slug: 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_slug(slug) - if not item: - return None - if item.is_builtin: - raise ValueError("内置 SubAgent 不可编辑") - item = await repo.update( - item, - name=data.get("name"), - 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, - ) - clear_specs_cache() - return item.to_dict() - - -async def delete_subagent(slug: str, db: AsyncSession | None = None) -> bool: - """删除 SubAgent""" - async with _get_session(db) as session: - repo = SubAgentRepository(session) - item = await repo.get_by_slug(slug) - if not item: - return False - if item.is_builtin: - raise ValueError("内置 SubAgent 不可删除") - await repo.delete(item) - clear_specs_cache() - return True - - -async def set_subagent_enabled( - slug: str, - enabled: bool, - *, - 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_slug(slug) - if not item: - return None - item.enabled = enabled - item.updated_by = updated_by - await session.commit() - await session.refresh(item) - clear_specs_cache() - return item.to_dict() diff --git a/backend/package/yuxi/agents/toolkits/buildin/tools.py b/backend/package/yuxi/agents/toolkits/buildin/tools.py index af5f169a..087c1bb2 100644 --- a/backend/package/yuxi/agents/toolkits/buildin/tools.py +++ b/backend/package/yuxi/agents/toolkits/buildin/tools.py @@ -78,7 +78,7 @@ def _normalize_presented_artifact_path(filepath: str, runtime: ToolRuntime) -> s outputs_virtual_prefix = f"{VIRTUAL_PATH_PREFIX}/outputs" runtime_context = runtime.context - thread_id = getattr(runtime_context, "thread_id", None) + thread_id = getattr(runtime_context, "file_thread_id", None) or getattr(runtime_context, "thread_id", None) if not thread_id: raise ValueError("当前运行时缺少 thread_id") uid = getattr(runtime_context, "uid", None) diff --git a/backend/package/yuxi/repositories/agent_repository.py b/backend/package/yuxi/repositories/agent_repository.py index 50dcc831..1bed386c 100644 --- a/backend/package/yuxi/repositories/agent_repository.py +++ b/backend/package/yuxi/repositories/agent_repository.py @@ -7,13 +7,13 @@ from typing import Any from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession -from yuxi.agents.buildin import agent_manager from yuxi.storage.postgres.models_business import Agent, User from yuxi.utils.datetime_utils import utc_now_naive DEFAULT_AGENT_SLUG = "default-chatbot" DEFAULT_AGENT_NAME = "智能助手" DEFAULT_AGENT_BACKEND_ID = "ChatbotAgent" +SUB_AGENT_BACKEND_ID = "SubAgentBackend" DEFAULT_AGENT_DESCRIPTION = "基础的对话机器人,可以回答问题,可在配置中启用需要的工具。" DEFAULT_SHARE_CONFIG = {"access_level": "global", "department_ids": [], "user_uids": []} ACCESS_LEVELS = {"global", "department", "user"} @@ -24,6 +24,13 @@ def is_builtin_agent(agent: Agent) -> bool: return agent.slug == DEFAULT_AGENT_SLUG +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_department_ids(department_ids: list | None) -> list[int]: return [int(department_id) for department_id in department_ids or []] @@ -119,6 +126,9 @@ class AgentRepository: if not agent.description: agent.description = DEFAULT_AGENT_DESCRIPTION needs_update = True + 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: @@ -138,6 +148,7 @@ class AgentRepository: config_json={"context": {}}, share_config=DEFAULT_SHARE_CONFIG.copy(), is_default=True, + is_subagent=False, created_by=created_by, updated_by=created_by, created_at=utc_now_naive(), @@ -148,8 +159,20 @@ class AgentRepository: await self.db.refresh(agent) return agent - async def list_visible(self, *, user: User) -> list[Agent]: - result = await self.db.execute(select(Agent).order_by(Agent.is_default.desc(), Agent.id.asc())) + 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 @@ -159,9 +182,17 @@ class AgentRepository: result = await self.db.execute(select(Agent).where(Agent.slug == slug)) return result.scalar_one_or_none() - async def get_visible_by_slug(self, *, slug: str, user: User) -> Agent | None: + async def get_visible_by_slug(self, *, slug: str, user: User, include_subagents: bool = False) -> Agent | None: agent = await self.get_by_slug(slug) - if agent and user_can_access_agent(user, agent): + 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 @@ -170,6 +201,8 @@ class AgentRepository: return result.scalar_one_or_none() async def set_default(self, *, agent: Agent, updated_by: str | None = None) -> Agent: + if agent.is_subagent: + raise ValueError("子智能体不能设为默认智能体") if not is_builtin_agent(agent): raise ValueError("默认智能体已固定为内置智能助手") share_config = agent.share_config or DEFAULT_SHARE_CONFIG.copy() @@ -211,9 +244,13 @@ class AgentRepository: config_json: dict | None = None, share_config: dict | None = None, is_default: bool = False, + is_subagent: bool | None = None, created_by: str | None = None, creator: User | None = None, ) -> Agent: + 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, @@ -233,6 +270,7 @@ class AgentRepository: config_json=config_json or {"context": {}}, share_config=normalized_share_config, is_default=False, + is_subagent=resolved_is_subagent, created_by=created_by, updated_by=created_by, created_at=utc_now_naive(), @@ -255,9 +293,12 @@ class AgentRepository: pics: list[str] | None = None, config_json: dict | None = None, share_config: dict | None = None, + is_subagent: bool | None = None, updated_by: str | None = None, updater: User | None = None, ) -> Agent: + 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: @@ -303,6 +344,8 @@ class AgentRepository: data["is_builtin"] = is_builtin_agent(agent) data["permission_locked"] = is_builtin_agent(agent) + 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) diff --git a/backend/package/yuxi/services/chat_service.py b/backend/package/yuxi/services/chat_service.py index 460920ad..0f68d5bd 100644 --- a/backend/package/yuxi/services/chat_service.py +++ b/backend/package/yuxi/services/chat_service.py @@ -12,6 +12,7 @@ from yuxi.agents.backends.sandbox.paths import sandbox_workspace_agents_prompt_f from yuxi.agents.buildin import agent_manager from yuxi.agents.context import normalize_agent_context_config from yuxi.agents.state import AgentStatePayload +from yuxi.agents.subagent_thread import parse_child_thread_id from yuxi.repositories.agent_repository import AgentRepository from yuxi.repositories.conversation_repository import ConversationRepository from yuxi.services.conversation_service import serialize_attachment @@ -140,15 +141,17 @@ def _build_langfuse_run_context( def extract_agent_state(values: dict) -> AgentStatePayload: """从 LangGraph state 中提取 agent 状态""" if not isinstance(values, dict): - return {"todos": [], "files": {}, "artifacts": []} + return {"todos": [], "files": {}, "artifacts": [], "subagent_runs": []} # 直接获取,信任 state 的数据结构 todos = values.get("todos") artifacts = values.get("artifacts") + subagent_runs = values.get("subagent_runs") result: AgentStatePayload = { "todos": list(todos)[:20] if todos else [], "files": values.get("files") or {}, "artifacts": list(artifacts) if artifacts else [], + "subagent_runs": list(subagent_runs) if subagent_runs else [], } return result @@ -163,6 +166,189 @@ def _agent_state_signature(agent_state: AgentStatePayload | dict | None) -> str: return str(agent_state) +def _metadata_thread_id(metadata: dict | None, fallback: str | None = None) -> str | None: + if not isinstance(metadata, dict): + return fallback + + for source in ( + metadata, + metadata.get("configurable"), + metadata.get("metadata"), + metadata.get("stream_event"), + ): + if isinstance(source, dict): + value = source.get("thread_id") + if isinstance(value, str) and value.strip(): + return value.strip() + return fallback + + +def _metadata_namespace(metadata: dict | None) -> list[str]: + if not isinstance(metadata, dict): + return [] + namespace = metadata.get("namespace") + if isinstance(namespace, list): + return [str(item) for item in namespace] + return [] + + +def _json_safe(value: Any) -> Any: + if value is None or isinstance(value, str | int | float | bool): + return value + if isinstance(value, dict): + return {str(key): _json_safe(child) for key, child in value.items()} + if isinstance(value, list | tuple): + return [_json_safe(child) for child in value] + if hasattr(value, "model_dump"): + return _json_safe(value.model_dump()) + return str(value) + + +def _stream_message_key(metadata: dict | None, namespace: list[str], thread_id: str | None) -> tuple[str, str]: + if not isinstance(metadata, dict): + return thread_id or "", "/".join(namespace) + return thread_id or "", str(metadata.get("run_id") or metadata.get("langgraph_node") or "/".join(namespace)) + + +def _stream_message_id( + message_ids: dict[tuple[str, str], str], + key: tuple[str, str], + preferred: str | None = None, +) -> str: + if preferred: + message_ids[key] = preferred + return preferred + return message_ids.setdefault(key, str(uuid.uuid4())) + + +def _message_chunk_yuxi_events( + msg_dict: dict[str, Any], + *, + message_id: str, + thread_id: str | None, + namespace: list[str], +) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + route = {"thread_id": thread_id, "namespace": namespace} + content = msg_dict.get("content") + additional_kwargs = msg_dict.get("additional_kwargs") if isinstance(msg_dict.get("additional_kwargs"), dict) else {} + reasoning_content = msg_dict.get("reasoning_content") + additional_reasoning_content = additional_kwargs.get("reasoning_content") + + message_event: dict[str, Any] = {"type": "message_delta", "message_id": message_id, **route} + if isinstance(content, str) and content: + message_event["content"] = content + if isinstance(reasoning_content, str) and reasoning_content: + message_event["reasoning_content"] = reasoning_content + if isinstance(additional_reasoning_content, str) and additional_reasoning_content: + message_event["additional_reasoning_content"] = additional_reasoning_content + if len(message_event) > 4: + events.append(message_event) + + tool_call_chunks = msg_dict.get("tool_call_chunks") + if isinstance(tool_call_chunks, list): + for tool_call_chunk in tool_call_chunks: + if not isinstance(tool_call_chunk, dict): + continue + args_delta = tool_call_chunk.get("args") + if args_delta is None: + args_delta = "" + elif not isinstance(args_delta, str): + args_delta = json.dumps(args_delta, ensure_ascii=False) + if not tool_call_chunk.get("id") and not tool_call_chunk.get("name") and not args_delta: + continue + events.append( + { + "type": "tool_call_delta", + "message_id": message_id, + "tool_call_id": tool_call_chunk.get("id"), + "name": tool_call_chunk.get("name"), + "args_delta": args_delta, + "index": tool_call_chunk.get("index") if tool_call_chunk.get("index") is not None else 0, + **route, + } + ) + return events + + +def _protocol_event_yuxi_event( + event: dict[str, Any], + *, + message_id: str | None, + thread_id: str | None, + namespace: list[str], +) -> dict[str, Any] | None: + event_name = event.get("event") + if event_name in {"message-start", "content-block-start", "message-finish"} or not message_id: + return None + + route = {"thread_id": thread_id, "namespace": namespace} + if event_name == "content-block-delta": + delta = event.get("delta") if isinstance(event.get("delta"), dict) else {} + text = delta.get("text") + if delta.get("type") == "text-delta" and isinstance(text, str) and text: + return {"type": "message_delta", "message_id": message_id, "content": text, **route} + return None + + if event_name == "content-block-finish": + content = event.get("content") if isinstance(event.get("content"), dict) else {} + if content.get("type") != "tool_call" or not content.get("id") and not content.get("name"): + return None + return { + "type": "tool_call", + "message_id": message_id, + "tool_call_id": content.get("id"), + "name": content.get("name"), + "args": content.get("args") if content.get("args") is not None else {}, + "index": event.get("index") if event.get("index") is not None else 0, + **route, + } + + return None + + +def _stream_event_response(event: dict[str, Any]) -> str: + if event.get("type") != "message_delta": + return "" + return str(event.get("content") or "") + + +def _message_payload_yuxi_events( + msg: Any, + *, + metadata: dict[str, Any], + namespace: list[str], + thread_id: str | None, + protocol_message_ids: dict[tuple[str, str], str], +) -> list[dict[str, Any]]: + message_key = _stream_message_key(metadata, namespace, thread_id) + if isinstance(msg, dict) and isinstance(msg.get("event"), str): + preferred_message_id = str(msg["id"]) if msg.get("event") == "message-start" and msg.get("id") else None + message_id = _stream_message_id(protocol_message_ids, message_key, preferred_message_id) + stream_event = _protocol_event_yuxi_event( + msg, + message_id=message_id, + thread_id=thread_id, + namespace=namespace, + ) + return [stream_event] if stream_event else [] + + if isinstance(msg, AIMessageChunk) or hasattr(msg, "model_dump"): + msg_dict = msg.model_dump() + elif isinstance(msg, dict): + msg_dict = dict(msg) + else: + msg_dict = {"content": str(msg)} + + message_id = str(msg_dict.get("id") or _stream_message_id(protocol_message_ids, message_key)) + return _message_chunk_yuxi_events( + msg_dict, + message_id=message_id, + thread_id=thread_id, + namespace=namespace, + ) + + async def _stream_agent_events(agent, messages, *, input_context=None, **kwargs): if hasattr(agent, "stream_messages_with_state"): async for mode, payload in agent.stream_messages_with_state( @@ -193,7 +379,19 @@ async def _save_ai_message( trace_info: dict[str, Any] | None = None, ) -> None: content = msg_dict.get("content", "") - tool_calls_data = msg_dict.get("tool_calls", []) + tool_calls_data = msg_dict.get("tool_calls") or [] + if isinstance(content, list): + if not tool_calls_data: + tool_calls_data = [ + {"id": item.get("id"), "name": item.get("name"), "args": item.get("args") or {}} + for item in content + if isinstance(item, dict) and item.get("type") == "tool_call" + ] + content = "\n".join( + item.get("text", "") for item in content if isinstance(item, dict) and isinstance(item.get("text"), str) + ) + elif not isinstance(content, str): + content = str(content) extra_metadata = dict(msg_dict) if trace_info: extra_metadata.update(trace_info) @@ -287,10 +485,25 @@ async def save_messages_from_langgraph_state( existing_ids = await _get_existing_message_ids(conv_repo, thread_id) for msg in messages: - msg_dict = msg.model_dump() if hasattr(msg, "model_dump") else {} - msg_type = msg_dict.get("type", "unknown") + if hasattr(msg, "model_dump"): + msg_dict = msg.model_dump() + elif isinstance(msg, dict): + msg_dict = dict(msg) + else: + continue - if msg_type == "human" or getattr(msg, "id", None) in existing_ids: + msg_type = msg_dict.get("type", "unknown") + if msg_type == "unknown": + role = msg_dict.get("role") + if role in {"assistant", "ai"}: + msg_type = "ai" + elif role in {"user", "human"}: + msg_type = "human" + elif role == "tool": + msg_type = "tool" + + msg_id = getattr(msg, "id", None) or msg_dict.get("id") + if msg_type == "human" or msg_id in existing_ids: continue if msg_type == "ai": @@ -712,9 +925,11 @@ async def stream_agent_chat( start_time = asyncio.get_event_loop().time() def make_chunk(content=None, **kwargs): + chunk_thread_id = kwargs.pop("thread_id", None) or meta.get("thread_id") or thread_id return ( json.dumps( - {"request_id": meta.get("request_id"), "response": content, **kwargs}, ensure_ascii=False + {"request_id": meta.get("request_id"), "response": content, "thread_id": chunk_thread_id, **kwargs}, + ensure_ascii=False, ).encode("utf-8") + b"\n" ) @@ -841,6 +1056,7 @@ async def stream_agent_chat( full_msg = None accumulated_content = [] + protocol_message_ids: dict[tuple[str, str], str] = {} async for mode, payload in _stream_agent_events( agent, messages, @@ -857,30 +1073,57 @@ async def stream_agent_chat( yield make_chunk(status="agent_state", agent_state=agent_state, meta=meta) continue + if mode == "stream_event": + yield make_chunk( + status="stream_event", + event=payload, + namespace=payload.get("namespace") if isinstance(payload, dict) else [], + meta=meta, + thread_id=payload.get("thread_id") if isinstance(payload, dict) else None, + ) + continue + msg, metadata = payload - if isinstance(msg, AIMessageChunk): - accumulated_content.append(msg.content) - trace_info = get_trace_info(langfuse_run) + namespace = _metadata_namespace(metadata) + chunk_thread_id = _metadata_thread_id(metadata, thread_id if not namespace else None) + if namespace and not chunk_thread_id: + continue - content_for_check = "".join(accumulated_content[-10:]) - if conf.enable_content_guard and await content_guard.check_with_keywords(content_for_check): - full_msg = AIMessage(content="".join(accumulated_content)) - await save_partial_message( - conv_repo, - thread_id, - full_msg, - "content_guard_blocked", - trace_info=trace_info, - ) - meta["time_cost"] = asyncio.get_event_loop().time() - start_time - yield make_chunk(status="interrupted", message="检测到敏感内容,已中断输出", meta=meta) - return + is_subagent_chunk = bool(chunk_thread_id and chunk_thread_id != thread_id) + stream_events = _message_payload_yuxi_events( + msg, + metadata=metadata, + namespace=namespace, + thread_id=chunk_thread_id, + protocol_message_ids=protocol_message_ids, + ) - yield make_chunk(content=msg.content, msg=msg.model_dump(), metadata=metadata, status="loading") - else: - msg_dict = msg.model_dump() - trace_info = get_trace_info(langfuse_run) - yield make_chunk(msg=msg_dict, metadata=metadata, status="loading") + for stream_event in stream_events: + content = _stream_event_response(stream_event) + if not is_subagent_chunk and content: + trace_info = get_trace_info(langfuse_run) + accumulated_content.append(content) + content_for_check = "".join(accumulated_content[-10:]) + if conf.enable_content_guard and await content_guard.check_with_keywords(content_for_check): + full_msg = AIMessage(content="".join(accumulated_content)) + await save_partial_message( + conv_repo, + thread_id, + full_msg, + "content_guard_blocked", + trace_info=trace_info, + ) + meta["time_cost"] = asyncio.get_event_loop().time() - start_time + yield make_chunk(status="interrupted", message="检测到敏感内容,已中断输出", meta=meta) + return + + yield make_chunk( + content=content, + stream_event=stream_event, + metadata=metadata, + status="loading", + thread_id=chunk_thread_id, + ) full_msg = _ensure_full_msg(full_msg, accumulated_content) trace_info = get_trace_info(langfuse_run) @@ -991,9 +1234,11 @@ async def stream_agent_resume( start_time = asyncio.get_event_loop().time() def make_resume_chunk(content=None, **kwargs): + chunk_thread_id = kwargs.pop("thread_id", None) or meta.get("thread_id") or thread_id return ( json.dumps( - {"request_id": meta.get("request_id"), "response": content, **kwargs}, ensure_ascii=False + {"request_id": meta.get("request_id"), "response": content, "thread_id": chunk_thread_id, **kwargs}, + ensure_ascii=False, ).encode("utf-8") + b"\n" ) @@ -1018,7 +1263,6 @@ async def stream_agent_resume( meta["backend_id"] = agent_item.backend_id context = agent.context_schema() context.update(await _build_agent_input_context(agent_config or {}, thread_id=thread_id, uid=uid)) - graph = await agent.get_graph(context=context) langfuse_run = _build_langfuse_run_context( current_user=current_user, thread_id=thread_id, @@ -1031,18 +1275,16 @@ async def stream_agent_resume( trace_info: dict[str, Any] = {} last_agent_state_signature = "" - stream_source = graph.astream( + stream_source = agent.stream_resume_with_state( resume_command, - context=context, - config={ - "configurable": {"thread_id": thread_id, "uid": uid}, - "callbacks": langfuse_run.callbacks, - "metadata": langfuse_run.metadata, - "tags": langfuse_run.tags, - }, - stream_mode=["messages", "values"], + input_context=agent_config or {}, + callbacks=langfuse_run.callbacks, + metadata=langfuse_run.metadata, + tags=langfuse_run.tags, ) + protocol_message_ids: dict[tuple[str, str], str] = {} + try: async for mode, payload in stream_source: if mode == "values": @@ -1053,16 +1295,48 @@ async def stream_agent_resume( yield make_resume_chunk(status="agent_state", agent_state=agent_state, meta=meta) continue - msg, metadata = payload - trace_info = get_trace_info(langfuse_run) - msg_dict = msg.model_dump() - if "id" not in msg_dict: - msg_dict["id"] = str(uuid.uuid4()) + if mode == "stream_event": + event_payload = payload if isinstance(payload, dict) else {} + yield make_resume_chunk( + status="stream_event", + event=event_payload, + namespace=event_payload.get("namespace") or [], + meta=meta, + thread_id=event_payload.get("thread_id"), + ) + continue - yield make_resume_chunk( - content=getattr(msg, "content", ""), msg=msg_dict, metadata=metadata, status="loading" + if mode != "messages": + continue + + msg, metadata = payload + metadata = dict(metadata or {}) + namespace = _metadata_namespace(metadata) + chunk_thread_id = _metadata_thread_id(metadata, thread_id if not namespace else None) + if namespace and not chunk_thread_id: + continue + + if chunk_thread_id == thread_id: + trace_info = get_trace_info(langfuse_run) + + stream_events = _message_payload_yuxi_events( + msg, + metadata=metadata, + namespace=namespace, + thread_id=chunk_thread_id, + protocol_message_ids=protocol_message_ids, ) + for stream_event in stream_events: + content = _stream_event_response(stream_event) + yield make_resume_chunk( + content=content, + stream_event=stream_event, + metadata=metadata, + status="loading", + thread_id=chunk_thread_id, + ) + langgraph_config = {"configurable": {"thread_id": thread_id, "uid": uid}} async for chunk in check_and_handle_interrupts(agent, langgraph_config, make_resume_chunk, meta, thread_id): yield chunk @@ -1070,6 +1344,7 @@ async def stream_agent_resume( meta["time_cost"] = asyncio.get_event_loop().time() - start_time try: + graph = await agent.get_graph(context=context) state = await graph.aget_state(langgraph_config) agent_state = extract_agent_state(getattr(state, "values", {})) if state else {} except Exception: @@ -1128,28 +1403,104 @@ async def stream_agent_resume( flush_langfuse() +def _serialize_state_messages(values: dict[str, Any]) -> list[dict[str, Any]]: + messages = values.get("messages") if isinstance(values, dict) else None + if not isinstance(messages, list): + return [] + serialized = [] + for message in messages: + if hasattr(message, "model_dump"): + serialized.append(message.model_dump()) + elif isinstance(message, dict): + serialized.append(dict(message)) + else: + serialized.append({"type": "unknown", "content": str(message)}) + return serialized + + +async def _read_checkpoint_state(agent, *, uid: str, thread_id: str): + graph = await agent.get_graph() + langgraph_config = {"configurable": {"uid": uid, "thread_id": thread_id}} + return await graph.aget_state(langgraph_config) + + async def get_agent_state_view( *, thread_id: str, current_uid: str, db, + include_messages: bool = False, ) -> dict: from fastapi import HTTPException conv_repo = ConversationRepository(db) + agent_repo = AgentRepository(db) conversation = await conv_repo.get_conversation_by_thread_id(thread_id) - if not conversation or conversation.uid != str(current_uid) or conversation.status == "deleted": + if conversation: + if conversation.uid != str(current_uid) or conversation.status == "deleted": + raise HTTPException(status_code=404, detail="对话线程不存在") + + agent_item = await agent_repo.get_by_slug(conversation.agent_id) + if not agent_item: + raise HTTPException(status_code=404, detail="智能体不存在") + agent = agent_manager.get_agent(agent_item.backend_id) + if not agent: + raise HTTPException(status_code=404, detail="智能体后端不存在") + state = await _read_checkpoint_state(agent, uid=str(current_uid), thread_id=thread_id) + values = getattr(state, "values", {}) if state else {} + response = {"agent_state": extract_agent_state(values)} + if include_messages: + response["messages"] = _serialize_state_messages(values) + return response + + child_thread = parse_child_thread_id(thread_id) + if not child_thread: raise HTTPException(status_code=404, detail="对话线程不存在") - agent_item = await AgentRepository(db).get_by_slug(conversation.agent_id) - if not agent_item: - raise HTTPException(status_code=404, detail="智能体不存在") - agent = agent_manager.get_agent(agent_item.backend_id) - if not agent: - raise HTTPException(status_code=404, detail="智能体后端不存在") - graph = await agent.get_graph() - langgraph_config = {"configurable": {"uid": str(current_uid), "thread_id": thread_id}} - state = await graph.aget_state(langgraph_config) - agent_state = extract_agent_state(getattr(state, "values", {})) if state else {} + parent_thread_id = child_thread.parent_thread_id + parent_conversation = await conv_repo.get_conversation_by_thread_id(parent_thread_id) + if ( + not parent_conversation + or parent_conversation.uid != str(current_uid) + or parent_conversation.status == "deleted" + ): + raise HTTPException(status_code=404, detail="对话线程不存在") - return {"agent_state": agent_state} + parent_agent_item = await agent_repo.get_by_slug(parent_conversation.agent_id) + if not parent_agent_item: + raise HTTPException(status_code=404, detail="智能体不存在") + parent_agent = agent_manager.get_agent(parent_agent_item.backend_id) + if not parent_agent: + raise HTTPException(status_code=404, detail="智能体后端不存在") + + parent_state = await _read_checkpoint_state(parent_agent, uid=str(current_uid), thread_id=parent_thread_id) + parent_values = getattr(parent_state, "values", {}) if parent_state else {} + parent_agent_state = extract_agent_state(parent_values) + subagent_run = next( + ( + run + for run in parent_agent_state.get("subagent_runs", []) + if isinstance(run, dict) and run.get("child_thread_id") == thread_id + ), + None, + ) + if not subagent_run: + raise HTTPException(status_code=404, detail="对话线程不存在") + + child_agent_item = await agent_repo.get_by_slug(str(subagent_run.get("subagent_type") or "")) + if not child_agent_item: + raise HTTPException(status_code=404, detail="智能体不存在") + child_agent = agent_manager.get_agent(child_agent_item.backend_id) + if not child_agent: + raise HTTPException(status_code=404, detail="智能体后端不存在") + + child_state = await _read_checkpoint_state(child_agent, uid=str(current_uid), thread_id=thread_id) + child_values = getattr(child_state, "values", {}) if child_state else {} + response = { + "agent_state": extract_agent_state(child_values), + "parent_thread_id": parent_thread_id, + "subagent_run": subagent_run, + } + if include_messages: + response["messages"] = _serialize_state_messages(child_values) + return response diff --git a/backend/package/yuxi/services/conversation_service.py b/backend/package/yuxi/services/conversation_service.py index 10e50d4a..a0b7fafd 100644 --- a/backend/package/yuxi/services/conversation_service.py +++ b/backend/package/yuxi/services/conversation_service.py @@ -909,7 +909,7 @@ async def get_thread_history_view( if msg.tool_calls: msg_dict["tool_calls"] = [ { - "id": str(tc.id), + "id": tc.langgraph_tool_call_id or str(tc.id), "name": tc.tool_name, "function": {"name": tc.tool_name}, "args": tc.tool_input or {}, diff --git a/backend/package/yuxi/services/run_worker.py b/backend/package/yuxi/services/run_worker.py index 8d33bcf6..1814e90b 100644 --- a/backend/package/yuxi/services/run_worker.py +++ b/backend/package/yuxi/services/run_worker.py @@ -76,32 +76,53 @@ class RunContext: return +_ALL_THREADS = object() + + +@dataclass +class _ThreadBuffer: + items: list[dict] = field(default_factory=list) + chars: int = 0 + last_flush: float = field(default_factory=time.monotonic) + + class ChunkedEventWriter: def __init__(self, run_id: str, thread_id: str | None, interval_ms: int = 100, max_chars: int = 512): self.run_id = run_id self.thread_id = thread_id self.interval_seconds = interval_ms / 1000 self.max_chars = max_chars - self.buffer: list[dict] = [] - self.buffer_chars = 0 - self.last_flush = time.monotonic() + self.thread_buffers: dict[str | None, _ThreadBuffer] = {} - async def append(self, chunk: dict): - self.buffer.append(chunk) - content = chunk.get("response") or "" - self.buffer_chars += len(content) if isinstance(content, str) else 0 + def _target_thread_id(self, thread_id: str | None = None) -> str | None: + return thread_id or self.thread_id - now = time.monotonic() - if (now - self.last_flush) >= self.interval_seconds or self.buffer_chars >= self.max_chars: - await self.flush() + async def append(self, chunk: dict, *, thread_id: str | None = None): + target_thread_id = self._target_thread_id(thread_id or _thread_id_from_mapping(chunk)) + buffer = self.thread_buffers.setdefault(target_thread_id, _ThreadBuffer()) + buffer.items.append(chunk) + buffer.chars += _loading_chunk_size(chunk) - async def flush(self): - if not self.buffer: + if _flush_loading_chunk_immediately(chunk): + await self.flush(target_thread_id) return - await append_run_event(self.run_id, "messages", {"items": self.buffer}, thread_id=self.thread_id) - self.buffer = [] - self.buffer_chars = 0 - self.last_flush = time.monotonic() + + if (time.monotonic() - buffer.last_flush) >= self.interval_seconds or buffer.chars >= self.max_chars: + await self.flush(target_thread_id) + + async def flush(self, thread_id: str | None | object = _ALL_THREADS): + if thread_id is _ALL_THREADS: + for target_thread_id in list(self.thread_buffers): + await self.flush(target_thread_id) + return + + buffer = self.thread_buffers.get(thread_id) + if not buffer or not buffer.items: + return + await append_run_event(self.run_id, "messages", {"items": buffer.items}, thread_id=thread_id) + buffer.items = [] + buffer.chars = 0 + buffer.last_flush = time.monotonic() async def _get_run(run_id: str): @@ -170,6 +191,44 @@ def _iter_json_chunks(chunk_bytes: bytes) -> list[dict]: return chunks +def _thread_id_from_mapping(value: object) -> str | None: + if not isinstance(value, dict): + return None + thread_id = value.get("thread_id") + if isinstance(thread_id, str) and thread_id.strip(): + return thread_id.strip() + for key in ("meta", "metadata", "configurable", "stream_event"): + nested = value.get(key) + if isinstance(nested, dict): + nested_thread_id = _thread_id_from_mapping(nested) + if nested_thread_id: + return nested_thread_id + return None + + +def _loading_chunk_size(chunk: dict) -> int: + response = chunk.get("response") + total = len(response) if isinstance(response, str) else 0 + stream_event = chunk.get("stream_event") + if not isinstance(stream_event, dict): + return total + + for key in ("content", "reasoning_content", "additional_reasoning_content", "args_delta"): + value = stream_event.get(key) + if isinstance(value, str): + total += len(value) + return total + + +def _flush_loading_chunk_immediately(chunk: dict) -> bool: + stream_event = chunk.get("stream_event") + return isinstance(stream_event, dict) and stream_event.get("type") == "tool_call" + + +def _chunk_thread_id(chunk: dict, fallback: str | None) -> str | None: + return _thread_id_from_mapping(chunk) or fallback + + def _map_chunk_to_run_event(chunk: dict) -> tuple[str, dict]: status = chunk.get("status") or "event" if status == "loading": @@ -300,15 +359,21 @@ async def process_agent_run(ctx, run_id: str): async for chunk_bytes in _consume_stream_with_cancel(stream, run_ctx): for chunk in _iter_json_chunks(chunk_bytes): + target_thread_id = _chunk_thread_id(chunk, thread_id) if chunk.get("status") == "loading": - await writer.append(chunk) + await writer.append(chunk, thread_id=target_thread_id) continue - await writer.flush() + await writer.flush(target_thread_id) status = chunk.get("status") or "event" event_type, event_payload = _map_chunk_to_run_event(chunk) if event_type != "end": - await append_run_event(run_id, event_type, event_payload, thread_id=thread_id) + await append_run_event(run_id, event_type, event_payload, thread_id=target_thread_id) + + if target_thread_id != thread_id: + if await run_ctx.is_cancelled(): + raise asyncio.CancelledError(f"run {run_id} cancelled") + continue if status == "finished": await mark_run_terminal(run_id, "completed") diff --git a/backend/package/yuxi/storage/postgres/models_business.py b/backend/package/yuxi/storage/postgres/models_business.py index 69e9cef8..a315098b 100644 --- a/backend/package/yuxi/storage/postgres/models_business.py +++ b/backend/package/yuxi/storage/postgres/models_business.py @@ -171,6 +171,7 @@ class Agent(Base): share_config = Column(JSON, nullable=False, default=dict) is_default = Column(Boolean, nullable=False, default=False, index=True) + is_subagent = Column(Boolean, nullable=False, default=False, index=True) created_by = Column(String(64), nullable=True, index=True) updated_by = Column(String(64), nullable=True) @@ -192,6 +193,7 @@ class Agent(Base): "config_json": self.config_json or {}, "share_config": self.share_config or {}, "is_default": bool(self.is_default), + "is_subagent": bool(self.is_subagent), "created_by": self.created_by, "updated_by": self.updated_by, "created_at": format_utc_datetime(self.created_at), @@ -659,58 +661,6 @@ class TaskRecord(Base): return data -class SubAgent(Base): - """SubAgent 模型 - 用于动态配置子智能体""" - - __tablename__ = "subagents" - - id = Column(Integer, primary_key=True, autoincrement=True) - slug = Column(String(128), nullable=False, unique=True, index=True, comment="稳定标识") - name = Column(String(128), nullable=False, 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="可选的模型覆盖") - enabled = Column(Boolean, nullable=False, default=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 { - "id": self.id, - "slug": self.slug, - "name": self.name, - "description": self.description, - "system_prompt": self.system_prompt, - "tools": self.tools or [], - "model": self.model, - "enabled": bool(self.enabled), - "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 = { - "slug": self.slug, - "name": self.slug, - "description": self.description, - "system_prompt": self.system_prompt, - "tools": self.tools or [], - } - if self.model: - spec["model"] = self.model - return spec - - class APIKey(Base): """API Key 模型""" diff --git a/backend/server/routers/__init__.py b/backend/server/routers/__init__.py index f4ecbde0..d456087f 100644 --- a/backend/server/routers/__init__.py +++ b/backend/server/routers/__init__.py @@ -10,7 +10,6 @@ from server.routers.auth_dept_router import department from server.routers.mcp_router import mcp from server.routers.model_provider_router import model_providers from server.routers.skill_router import skills, user_skills -from server.routers.subagent_router import subagents_router from server.routers.system_router import system from server.routers.system_task_router import tasks from server.routers.tool_router import tools @@ -37,7 +36,6 @@ router.include_router(mcp) # /api/system/mcp-servers/* MCP 服务管理 router.include_router(model_providers) # /api/system/model-providers/* 独立模型配置 router.include_router(skills) # /api/system/skills/* Skills 管理 router.include_router(user_skills) # /api/skills/* 用户可用 Skills -router.include_router(subagents_router) # /api/system/subagents/* 子智能体管理 router.include_router(tools) # /api/system/tools/* 工具列表与配置 router.include_router(user_router) # /api/user/* 用户级配置与凭据 router.include_router(filesystem_router) # /api/viewer/filesystem/* 工作台文件系统视图 diff --git a/backend/server/routers/agent_router.py b/backend/server/routers/agent_router.py index c9ab2e17..af24a6b4 100644 --- a/backend/server/routers/agent_router.py +++ b/backend/server/routers/agent_router.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from fastapi import APIRouter, Depends, Header, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException, Query from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -37,6 +37,7 @@ class AgentCreate(BaseModel): pics: list[str] | None = None config_json: dict | None = None share_config: dict | None = None + is_subagent: bool | None = None set_default: bool = False @@ -47,6 +48,7 @@ class AgentUpdate(BaseModel): pics: list[str] | None = None config_json: dict | None = None share_config: dict | None = None + is_subagent: bool | None = None class AgentRunCreate(BaseModel): @@ -110,10 +112,14 @@ async def get_agent_backend( @agent_router.get("") -async def list_agents(current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db)): +async def list_agents( + include_subagents: bool = Query(False), + current_user: User = Depends(get_required_user), + db: AsyncSession = Depends(get_db), +): repo = AgentRepository(db) await repo.ensure_default_agent() - items = await repo.list_visible(user=current_user) + items = await repo.list_visible(user=current_user, include_subagents=include_subagents) backend_info_cache: dict[tuple[str, bool, str], dict] = {} agents = [await _serialize_agent(repo, item, current_user, backend_info_cache=backend_info_cache) for item in items] return {"agents": agents} @@ -149,6 +155,7 @@ async def create_agent( config_json=_filter_agent_config_json(payload.backend_id, payload.config_json, current_user.role), share_config=payload.share_config, is_default=payload.set_default, + is_subagent=payload.is_subagent, created_by=str(current_user.uid), creator=current_user, ) @@ -160,7 +167,7 @@ async def create_agent( @agent_router.get("/{agent_id}") async def get_agent(agent_id: str, current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db)): repo = AgentRepository(db) - item = await repo.get_visible_by_slug(slug=agent_id, user=current_user) + item = await repo.get_visible_by_slug(slug=agent_id, user=current_user, include_subagents=True) if not item: raise HTTPException(status_code=404, detail="智能体不存在") return {"agent": await _serialize_agent(repo, item, current_user, include_configurable_items=True)} @@ -174,7 +181,7 @@ async def update_agent( db: AsyncSession = Depends(get_db), ): repo = AgentRepository(db) - item = await repo.get_visible_by_slug(slug=agent_id, user=current_user) + item = await repo.get_visible_by_slug(slug=agent_id, user=current_user, include_subagents=True) if not item: raise HTTPException(status_code=404, detail="智能体不存在") if not user_can_manage_agent(current_user, item): @@ -197,6 +204,7 @@ async def update_agent( if payload.config_json is not None else None, share_config=payload.share_config, + is_subagent=payload.is_subagent, updated_by=str(current_user.uid), updater=current_user, ) @@ -210,7 +218,7 @@ async def delete_agent( agent_id: str, current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db) ): repo = AgentRepository(db) - item = await repo.get_visible_by_slug(slug=agent_id, user=current_user) + item = await repo.get_visible_by_slug(slug=agent_id, user=current_user, include_subagents=True) if not item: raise HTTPException(status_code=404, detail="智能体不存在") if not user_can_manage_agent(current_user, item): diff --git a/backend/server/routers/chat_router.py b/backend/server/routers/chat_router.py index 9dd00fe9..557a61d9 100644 --- a/backend/server/routers/chat_router.py +++ b/backend/server/routers/chat_router.py @@ -192,6 +192,7 @@ async def get_thread_history( @chat.get("/thread/{thread_id}/state") async def get_thread_state( thread_id: str, + include_messages: bool = Query(False), current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): @@ -201,6 +202,7 @@ async def get_thread_state( thread_id=thread_id, current_uid=str(current_user.uid), db=db, + include_messages=include_messages, ) except HTTPException: raise diff --git a/backend/server/routers/subagent_router.py b/backend/server/routers/subagent_router.py deleted file mode 100644 index 6b4dfb06..00000000 --- a/backend/server/routers/subagent_router.py +++ /dev/null @@ -1,172 +0,0 @@ -"""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.agents.subagents import 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): - slug: str = Field(..., description="稳定标识") - 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): - name: str | None = Field(None, description="展示名称") - 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="可选的模型覆盖") - - -class SubAgentStatusRequest(BaseModel): - enabled: bool = Field(..., 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_slug_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 "(slug)" 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("/{slug}") -async def get_subagent_route( - slug: str, - _current_user: User = Depends(get_admin_user), - db: AsyncSession = Depends(get_db), -): - """获取单个 SubAgent(管理员可读)""" - try: - item = await service.get_subagent(slug, db) - if not item: - raise HTTPException(status_code=404, detail=f"SubAgent '{slug}' 不存在") - 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_slug_duplicate_error(e): - raise HTTPException(status_code=409, detail=f"SubAgent '{payload.slug}' 已存在") - _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("/{slug}") -async def update_subagent_route( - slug: 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(slug, data, updated_by=current_user.username, db=db) - if not item: - raise HTTPException(status_code=404, detail=f"SubAgent '{slug}' 不存在") - 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("/{slug}") -async def delete_subagent_route( - slug: str, - _current_user: User = Depends(get_admin_user), - db: AsyncSession = Depends(get_db), -): - """删除 SubAgent(管理员)""" - try: - deleted = await service.delete_subagent(slug, db=db) - if not deleted: - raise HTTPException(status_code=404, detail=f"SubAgent '{slug}' 不存在") - return {"success": True} - except ValueError as e: - _raise_from_value_error(e) - except HTTPException: - raise - except Exception as e: - _raise_internal_error("删除", e) - - -@subagents_router.put("/{slug}/status") -async def update_subagent_status_route( - slug: str, - payload: SubAgentStatusRequest, - current_user: User = Depends(get_admin_user), - db: AsyncSession = Depends(get_db), -): - """更新 SubAgent 启用状态(管理员)。""" - try: - item = await service.set_subagent_enabled(slug, payload.enabled, updated_by=current_user.username, db=db) - if not item: - raise HTTPException(status_code=404, detail=f"SubAgent '{slug}' 不存在") - return { - "success": True, - "data": item, - "message": f"SubAgent '{slug}' 已{'添加' if payload.enabled else '移除'}", - } - 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 2f5852b1..1e7f04cb 100644 --- a/backend/server/utils/lifespan.py +++ b/backend/server/utils/lifespan.py @@ -7,7 +7,6 @@ from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver from yuxi.services.task_service import tasker from yuxi.agents.mcp.service import ensure_builtin_mcp_servers_in_db from yuxi.models.providers.service import ensure_builtin_model_providers_in_db -from yuxi.agents.subagents.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 @@ -68,13 +67,6 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error(f"Failed to initialize model cache 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 - # 初始化知识库管理器 if os.environ.get("LITE_MODE", "").lower() in ("true", "1"): logger.info("LITE_MODE enabled, skipping knowledge base initialization") diff --git a/backend/test/e2e/test_subagent_stream_e2e.py b/backend/test/e2e/test_subagent_stream_e2e.py new file mode 100644 index 00000000..33fab09f --- /dev/null +++ b/backend/test/e2e/test_subagent_stream_e2e.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +import asyncio +import json +import os +import uuid +from typing import Any + +import httpx +import pytest + +pytestmark = [pytest.mark.asyncio, pytest.mark.e2e, pytest.mark.slow] + +RUN_TIMEOUT_SECONDS = int(os.getenv("E2E_RUN_TIMEOUT_SECONDS", "300")) + + +def _assert_ok(response: httpx.Response) -> None: + assert response.status_code < 400, response.text + + +async def _create_agent( + client: httpx.AsyncClient, + headers: dict[str, str], + payload: dict[str, Any], +) -> dict[str, Any]: + response = await client.post("/api/agent", json=payload, headers=headers) + _assert_ok(response) + agent = response.json().get("agent") + assert isinstance(agent, dict), response.text + return agent + + +async def _delete_agent(client: httpx.AsyncClient, headers: dict[str, str], slug: str) -> None: + response = await client.delete(f"/api/agent/{slug}", headers=headers) + assert response.status_code in {200, 404}, response.text + + +async def _create_thread(client: httpx.AsyncClient, headers: dict[str, str], agent_id: str, marker: str) -> str: + response = await client.post( + "/api/chat/thread", + json={"agent_id": agent_id, "title": f"subagent-stream-e2e-{marker}", "metadata": {"marker": marker}}, + headers=headers, + ) + _assert_ok(response) + payload = response.json() + thread_id = payload.get("thread_id") or payload.get("id") + assert thread_id, payload + return str(thread_id) + + +async def _create_run( + client: httpx.AsyncClient, + headers: dict[str, str], + *, + agent_id: str, + thread_id: str, + query: str, +) -> str: + response = await client.post( + "/api/agent/runs", + json={ + "query": query, + "agent_id": agent_id, + "thread_id": thread_id, + "meta": {"request_id": f"subagent-stream-e2e-{uuid.uuid4()}"}, + }, + headers=headers, + ) + _assert_ok(response) + run_id = response.json().get("run_id") + assert run_id, response.text + return str(run_id) + + +async def _iter_sse(client: httpx.AsyncClient, headers: dict[str, str], run_id: str): + async with client.stream("GET", f"/api/agent/runs/{run_id}/events", headers=headers) as response: + _assert_ok(response) + event = "message" + event_id = None + data_lines: list[str] = [] + async for line in response.aiter_lines(): + if not line: + if data_lines: + data_text = "\n".join(data_lines) + yield event, json.loads(data_text), event_id + event = "message" + event_id = None + data_lines = [] + continue + if line.startswith(":"): + continue + if line.startswith("event:"): + event = line[len("event:") :].strip() or "message" + elif line.startswith("id:"): + event_id = line[len("id:") :].strip() + elif line.startswith("data:"): + data_lines.append(line[len("data:") :].strip()) + + +def _collect_message_chunks(payload: dict[str, Any]) -> list[dict[str, Any]]: + chunks = [] + chunk = payload.get("chunk") + if isinstance(chunk, dict): + chunks.append(chunk) + items = payload.get("items") + if isinstance(items, list): + chunks.extend(item for item in items if isinstance(item, dict)) + return chunks + + +async def _consume_run_stream( + client: httpx.AsyncClient, + headers: dict[str, str], + run_id: str, +) -> tuple[dict[str, int], dict[str, Any], list[dict[str, Any]]]: + event_counts: dict[str, int] = {} + latest_agent_state: dict[str, Any] = {} + message_chunks: list[dict[str, Any]] = [] + terminal_status = "" + + async def consume() -> None: + nonlocal latest_agent_state, terminal_status + async for event, payload, _event_id in _iter_sse(client, headers, run_id): + event_counts[event] = event_counts.get(event, 0) + 1 + if event == "messages": + message_chunks.extend(_collect_message_chunks(payload)) + if event == "custom" and payload.get("name") == "yuxi.agent_state": + agent_state = payload.get("agent_state") + if isinstance(agent_state, dict): + latest_agent_state = agent_state + assert event != "error", payload + if event == "end": + event_payload = payload.get("payload") if isinstance(payload.get("payload"), dict) else payload + terminal_status = str(event_payload.get("status") or "") + return + + await asyncio.wait_for(consume(), timeout=RUN_TIMEOUT_SECONDS) + assert terminal_status == "completed", {"status": terminal_status, "event_counts": event_counts} + return event_counts, latest_agent_state, message_chunks + + +def _find_tool_call_ids(value: Any) -> set[str]: + ids: set[str] = set() + if isinstance(value, dict): + tool_calls = value.get("tool_calls") + if isinstance(tool_calls, list): + for tool_call in tool_calls: + if isinstance(tool_call, dict) and tool_call.get("id"): + ids.add(str(tool_call["id"])) + for child in value.values(): + ids.update(_find_tool_call_ids(child)) + elif isinstance(value, list): + for item in value: + ids.update(_find_tool_call_ids(item)) + return ids + + +async def _read_thread_file( + client: httpx.AsyncClient, + headers: dict[str, str], + thread_id: str, + path: str, +) -> str: + response = await client.get( + f"/api/chat/thread/{thread_id}/files/content", + params={"path": path}, + headers=headers, + ) + _assert_ok(response) + content = response.json().get("content") + assert isinstance(content, list), response.text + return "\n".join(str(line) for line in content) + + +async def test_subagent_stream_records_run_and_shares_output_files( + e2e_client: httpx.AsyncClient, + e2e_headers: dict[str, str], +): + me_response = await e2e_client.get("/api/auth/me", headers=e2e_headers) + _assert_ok(me_response) + me = me_response.json() + if me.get("role") not in {"admin", "superadmin"}: + pytest.skip("Subagent E2E needs an admin user to create temporary agents.") + uid = str(me.get("uid") or "") + assert uid, me + + suffix = uuid.uuid4().hex[:8] + marker = f"YUXI_SUBAGENT_STREAM_E2E_{suffix}" + sub_slug = f"e2e-subagent-{suffix}" + main_slug = f"e2e-main-{suffix}" + output_path = "/home/gem/user-data/outputs/subagents.txt" + expected_content = "由这个子智能体创建" + created_agents: list[str] = [] + + default_response = await e2e_client.get("/api/agent/default", headers=e2e_headers) + _assert_ok(default_response) + default_context = ((default_response.json().get("agent") or {}).get("config_json") or {}).get("context") or {} + base_context: dict[str, Any] = {"tools": [], "knowledges": [], "mcps": [], "skills": []} + if default_context.get("model"): + base_context["model"] = default_context["model"] + + share_config = {"access_level": "user", "department_ids": [], "user_uids": [uid]} + + try: + sub_agent = await _create_agent( + e2e_client, + e2e_headers, + { + "name": f"E2E 子智能体 {suffix}", + "slug": sub_slug, + "backend_id": "SubAgentBackend", + "description": "真实流式 E2E 子智能体", + "config_json": { + "context": { + **base_context, + "system_prompt": ( + "你是专门负责文件写入和文件校验的子智能体。收到任务后必须使用文件系统工具完成任务," + "不要向用户提问。必须严格写入用户指定路径,文件内容必须完全符合用户要求," + "不要自动追加句号、引号、说明或其他字符。完成后只回复写入的路径和文件内容。" + ), + } + }, + "share_config": share_config, + "is_subagent": True, + }, + ) + created_agents.append(sub_slug) + + await _create_agent( + e2e_client, + e2e_headers, + { + "name": f"E2E 主智能体 {suffix}", + "slug": main_slug, + "backend_id": "ChatbotAgent", + "description": "真实流式 E2E 主智能体", + "config_json": { + "context": { + **base_context, + "subagents": [sub_slug], + "system_prompt": ( + "你是主智能体。遇到用户要求创建、修改或验证文件的任务时," + "必须调用 task 工具交给可用子智能体完成,不要自己写文件," + "也不要通过 shell、curl 或 HTTP API 调用子智能体。子智能体完成后,简短汇总结果。" + ), + } + }, + "share_config": share_config, + "is_subagent": False, + }, + ) + created_agents.append(main_slug) + + default_agents_response = await e2e_client.get("/api/agent", headers=e2e_headers) + _assert_ok(default_agents_response) + default_agent_slugs = {str(item.get("slug")) for item in default_agents_response.json().get("agents") or []} + assert sub_slug not in default_agent_slugs + + management_agents_response = await e2e_client.get("/api/agent?include_subagents=true", headers=e2e_headers) + _assert_ok(management_agents_response) + management_agent_slugs = { + str(item.get("slug")) for item in management_agents_response.json().get("agents") or [] + } + assert sub_slug in management_agent_slugs + + thread_id = await _create_thread(e2e_client, e2e_headers, main_slug, marker) + query = ( + f"请调用子智能体 {sub_slug} 在 outputs 目录创建文件 {output_path}。" + "必须通过 task 工具调用子智能体完成。" + f"文件内容必须完全等于下面一行:\n{expected_content}\n" + "不要添加句号、引号、说明或其他任何字符。完成后只需要回复文件路径。" + ) + run_id = await _create_run( + e2e_client, + e2e_headers, + agent_id=main_slug, + thread_id=thread_id, + query=query, + ) + + event_counts, stream_agent_state, message_chunks = await _consume_run_stream( + e2e_client, + e2e_headers, + run_id, + ) + assert event_counts.get("messages", 0) > 0 + + run_response = await e2e_client.get(f"/api/agent/runs/{run_id}", headers=e2e_headers) + _assert_ok(run_response) + assert (run_response.json().get("run") or {}).get("status") == "completed" + + state_response = await e2e_client.get(f"/api/chat/thread/{thread_id}/state", headers=e2e_headers) + _assert_ok(state_response) + final_agent_state = state_response.json().get("agent_state") or stream_agent_state + subagent_runs = final_agent_state.get("subagent_runs") or [] + assert subagent_runs, final_agent_state + completed_run = next((item for item in subagent_runs if item.get("status") == "completed"), subagent_runs[0]) + assert completed_run.get("subagent_type") == sub_slug + assert completed_run.get("subagent_name") == sub_agent["name"] + assert completed_run.get("child_thread_id") + assert completed_run.get("id") + + child_thread_id = str(completed_run["child_thread_id"]) + child_state_response = await e2e_client.get( + f"/api/chat/thread/{child_thread_id}/state", + params={"include_messages": "true"}, + headers=e2e_headers, + ) + _assert_ok(child_state_response) + child_state_payload = child_state_response.json() + assert child_state_payload.get("parent_thread_id") == thread_id + assert (child_state_payload.get("subagent_run") or {}).get("child_thread_id") == child_thread_id + assert child_state_payload.get("messages"), child_state_payload + + leaked_child_chunks = [ + chunk for chunk in message_chunks if child_thread_id in json.dumps(chunk, ensure_ascii=False, default=str) + ] + assert leaked_child_chunks == [] + + history_response = await e2e_client.get(f"/api/chat/thread/{thread_id}/history", headers=e2e_headers) + _assert_ok(history_response) + tool_call_ids = _find_tool_call_ids(history_response.json()) + assert str(completed_run["id"]) in tool_call_ids + + files_response = await e2e_client.get( + f"/api/chat/thread/{thread_id}/files", + params={"path": "/home/gem/user-data/outputs", "recursive": "true"}, + headers=e2e_headers, + ) + _assert_ok(files_response) + files_payload = files_response.json() + file_paths = {str(item.get("path") or "") for item in files_payload.get("files") or []} + assert output_path in file_paths, { + "output_path": output_path, + "files": files_payload, + "subagent_run": completed_run, + } + assert (await _read_thread_file(e2e_client, e2e_headers, thread_id, output_path)).strip() == expected_content + + tree_response = await e2e_client.get( + "/api/viewer/filesystem/tree", + params={"thread_id": thread_id, "path": "/home/gem/user-data/outputs"}, + headers=e2e_headers, + ) + _assert_ok(tree_response) + assert output_path in json.dumps(tree_response.json(), ensure_ascii=False) + + viewer_file_response = await e2e_client.get( + "/api/viewer/filesystem/file", + params={"thread_id": thread_id, "path": output_path}, + headers=e2e_headers, + ) + _assert_ok(viewer_file_response) + assert expected_content in json.dumps(viewer_file_response.json(), ensure_ascii=False) + + finally: + for slug in reversed(created_agents): + await _delete_agent(e2e_client, e2e_headers, slug) diff --git a/backend/test/unit/agents/test_context_auth.py b/backend/test/unit/agents/test_context_auth.py index d32e4276..c02064bf 100644 --- a/backend/test/unit/agents/test_context_auth.py +++ b/backend/test/unit/agents/test_context_auth.py @@ -33,6 +33,11 @@ filter_config_by_role = context_module.filter_config_by_role normalize_agent_context_config = context_module.normalize_agent_context_config +@dataclass(kw_only=True) +class ChatBotContext(BaseContext): + subagents: list[str] | None = field(default=None, metadata={"kind": "subagents"}) + + @dataclass class SuperAdminOnlyContext(BaseContext): secret_setting: str = field(default="hidden", metadata={"name": "Secret", "auth": "superadmin"}) @@ -110,11 +115,16 @@ async def test_normalize_agent_context_config_expands_null_and_filters_explicit_ types.SimpleNamespace(slug="skill-b", name="Skill B", description=""), ] - async def fake_get_all_subagents(_db=None): - return [ - {"slug": "research-agent", "name": "Research", "description": "", "enabled": True}, - {"slug": "critique-agent", "name": "Critique", "description": "", "enabled": True}, - ] + class FakeAgentRepository: + def __init__(self, _db): + pass + + async def list_visible_subagents(self, *, user): + assert user.uid == "u1" + return [ + types.SimpleNamespace(slug="research-agent", name="Research", description=""), + types.SimpleNamespace(slug="critique-agent", name="Critique", description=""), + ] monkeypatch.setitem( sys.modules, @@ -143,8 +153,8 @@ async def test_normalize_agent_context_config_expands_null_and_filters_explicit_ ) monkeypatch.setitem( sys.modules, - "yuxi.agents.subagents.service", - types.SimpleNamespace(get_all_subagents=fake_get_all_subagents), + "yuxi.repositories.agent_repository", + types.SimpleNamespace(AgentRepository=FakeAgentRepository), ) normalized = await normalize_agent_context_config( @@ -158,6 +168,7 @@ async def test_normalize_agent_context_config_expands_null_and_filters_explicit_ }, db=object(), user=types.SimpleNamespace(role="user", uid="u1", department_id=None), + context_schema=ChatBotContext, ) assert normalized["tools"] == ["ask_user_question", "tavily_search"] @@ -167,6 +178,15 @@ async def test_normalize_agent_context_config_expands_null_and_filters_explicit_ assert normalized["subagents"] == ["research-agent"] assert "summary_threshold" not in normalized + empty_subagents_normalized = await normalize_agent_context_config( + {"tools": [], "knowledges": [], "mcps": [], "skills": [], "subagents": []}, + db=object(), + user=types.SimpleNamespace(role="user", uid="u1", department_id=None), + context_schema=ChatBotContext, + ) + + assert empty_subagents_normalized["subagents"] == ["research-agent", "critique-agent"] + @pytest.mark.asyncio async def test_prepare_agent_runtime_context_filters_resources_and_derives_runtime_scope(monkeypatch): @@ -182,9 +202,6 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti types.SimpleNamespace(slug="skill-b", name="Skill B", description=""), ] - async def fake_get_all_subagents(_db=None): - return [{"slug": "sub-a", "name": "Sub A", "description": "", "enabled": True}] - async def fake_resolve_visible_knowledge_bases(context): assert context.knowledges == ["kb-a"] context._visible_knowledge_bases = [{"slug": "kb-a", "name": "Docs A"}] @@ -214,6 +231,14 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti assert uid == "u1" return types.SimpleNamespace(role="user", uid="u1", department_id=None) + class FakeAgentRepository: + def __init__(self, _db): + pass + + async def list_visible_subagents(self, *, user): + assert user.uid == "u1" + return [types.SimpleNamespace(slug="research-agent", name="Research", description="")] + monkeypatch.setitem( sys.modules, "yuxi.agents.backends.knowledge_base_backend", @@ -260,11 +285,10 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti ) monkeypatch.setitem( sys.modules, - "yuxi.agents.subagents.service", - types.SimpleNamespace(get_all_subagents=fake_get_all_subagents), + "yuxi.repositories.agent_repository", + types.SimpleNamespace(AgentRepository=FakeAgentRepository), ) - - context = BaseContext( + context = ChatBotContext( uid="u1", tools=["ask_user_question", "missing"], knowledges=["kb-a", "missing"], @@ -279,7 +303,7 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti assert prepared.knowledges == ["kb-a"] assert prepared.mcps == ["mcp-a"] assert prepared.skills == ["skill-a"] - assert prepared.subagents == [] + assert prepared.subagents == ["research-agent"] assert prepared._visible_knowledge_bases == [{"slug": "kb-a", "name": "Docs A"}] assert prepared._prompt_skills == ["skill-a", "skill-b"] assert prepared._readable_skills == ["skill-a", "skill-b"] @@ -321,7 +345,7 @@ async def test_prepare_agent_runtime_context_clears_resources_for_missing_user(m types.SimpleNamespace(pg_manager=types.SimpleNamespace(get_async_session_context=lambda: FakeSessionContext())), ) - context = BaseContext( + context = ChatBotContext( uid="missing", tools=["tool"], knowledges=["kb"], diff --git a/backend/test/unit/middlewares/test_subagent_task_middleware.py b/backend/test/unit/middlewares/test_subagent_task_middleware.py new file mode 100644 index 00000000..201f5dd9 --- /dev/null +++ b/backend/test/unit/middlewares/test_subagent_task_middleware.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +import yuxi.agents.middlewares.subagent_task_middleware as subagent_task_middleware +from langchain_core.messages import AIMessage, HumanMessage +from langgraph.prebuilt.tool_node import ToolRuntime +from langgraph.types import Command +from yuxi.agents.middlewares.subagent_task_middleware import YuxiSubAgentMiddleware +from yuxi.agents.subagent_thread import make_child_thread_id +from yuxi.repositories.agent_repository import SUB_AGENT_BACKEND_ID + + +class _ChildContext: + def __init__(self): + self.model = None + + def update_from_dict(self, values: dict): + for key, value in values.items(): + if hasattr(self, key): + setattr(self, key, value) + + +@pytest.mark.asyncio +async def test_create_task_middleware_loads_all_visible_subagents_when_empty(monkeypatch) -> None: + class _SessionContext: + async def __aenter__(self): + return object() + + async def __aexit__(self, exc_type, exc, tb): + return None + + class _UserRepository: + async def get_by_uid_with_db(self, _db, uid): + assert uid == "user-1" + return SimpleNamespace(uid="user-1", role="user") + + class _AgentRepository: + def __init__(self, _db): + pass + + async def list_visible_subagents(self, *, user): + assert user.uid == "user-1" + return [ + SimpleNamespace( + slug="worker", + name="Worker", + description="work on scoped tasks", + backend_id=SUB_AGENT_BACKEND_ID, + config_json={}, + ), + SimpleNamespace( + slug="invalid", + name="Invalid", + description="invalid backend", + backend_id="ChatbotAgent", + config_json={}, + ), + ] + + async def get_visible_subagent_by_slug(self, *, slug, user): + raise AssertionError("empty subagents should load all visible subagents") + + monkeypatch.setattr( + subagent_task_middleware, + "pg_manager", + SimpleNamespace(get_async_session_context=lambda: _SessionContext()), + ) + monkeypatch.setattr(subagent_task_middleware, "UserRepository", _UserRepository) + monkeypatch.setattr(subagent_task_middleware, "AgentRepository", _AgentRepository) + + middleware = await subagent_task_middleware.create_subagent_task_middleware( + SimpleNamespace(thread_id="parent-thread", uid="user-1", subagents=[]) + ) + + assert isinstance(middleware, YuxiSubAgentMiddleware) + assert middleware.subagent_names == frozenset({"worker"}) + assert middleware.transformers + + +@pytest.mark.asyncio +async def test_task_tool_rejects_unconfigured_subagent() -> None: + middleware = YuxiSubAgentMiddleware( + parent_context=SimpleNamespace(thread_id="parent-thread", uid="user-1"), + subagents=[ + SimpleNamespace( + slug="worker", + name="Worker", + description="work on scoped tasks", + backend_id=SUB_AGENT_BACKEND_ID, + config_json={}, + ) + ], + ) + runtime = ToolRuntime( + state={}, + context=None, + tool_call_id="tool-1", + store=None, + stream_writer=lambda _: None, + config={}, + ) + + result = await middleware.tools[0].ainvoke( + {"description": "do work", "subagent_type": "missing", "runtime": runtime} + ) + + assert result == "无法调用子智能体 missing,可用子智能体只有:`worker`" + + +@pytest.mark.asyncio +async def test_task_tool_invokes_subagent_with_child_scope(monkeypatch) -> None: + captured = {} + + class _Graph: + async def ainvoke(self, state, *, config, context): + captured["state"] = state + captured["config"] = config + captured["context"] = context + return { + "messages": [AIMessage(content="child done")], + "artifacts": ["/home/gem/user-data/outputs/report.md"], + "todos": ["should not merge"], + } + + class _Backend: + context_schema = _ChildContext + + async def get_graph(self, *, context): + captured["graph_context"] = context + return _Graph() + + monkeypatch.setattr( + subagent_task_middleware, + "_get_agent_backend", + lambda backend_id: _Backend() if backend_id == SUB_AGENT_BACKEND_ID else None, + ) + times = iter(["2026-05-31T01:00:00Z", "2026-05-31T01:00:03Z"]) + monkeypatch.setattr(subagent_task_middleware, "utc_isoformat", lambda: next(times)) + + middleware = YuxiSubAgentMiddleware( + parent_context=SimpleNamespace( + thread_id="child-runtime-thread", + parent_thread_id="parent-thread", + file_thread_id="parent-file-thread", + uid="user-1", + ), + subagents=[ + SimpleNamespace( + slug="worker.agent", + name="Worker", + description="work on scoped tasks", + backend_id=SUB_AGENT_BACKEND_ID, + config_json={"context": {"model": "provider:model", "subagents": ["nested"]}}, + ) + ], + ) + runtime = SimpleNamespace( + tool_call_id="tool-1", + state={ + "messages": [HumanMessage(content="parent")], + "todos": ["parent todo"], + "activated_skills": ["parent-skill"], + "kept": "value", + }, + config={ + "callbacks": ["stream-callback"], + "tags": ["parent"], + "recursion_limit": 42, + "configurable": {"checkpoint_ns": "parent-ns", "__pregel_task_id": "parent-task"}, + }, + ) + + result = await middleware.tools[0].coroutine( + description="write a report", + subagent_type="worker.agent", + runtime=runtime, + ) + + child_thread_id = make_child_thread_id("parent-thread", "worker.agent", "tool-1") + assert isinstance(result, Command) + assert result.update["messages"][0].content == "child done" + assert result.update["messages"][0].tool_call_id == "tool-1" + assert result.update["artifacts"] == ["/home/gem/user-data/outputs/report.md"] + assert result.update["subagent_runs"] == [ + { + "id": "tool-1", + "subagent_type": "worker.agent", + "subagent_name": "Worker", + "child_thread_id": child_thread_id, + "description": "write a report", + "created_at": "2026-05-31T01:00:00Z", + "status": "completed", + "completed_at": "2026-05-31T01:00:03Z", + "result_preview": "child done", + "error": None, + "artifacts": ["/home/gem/user-data/outputs/report.md"], + } + ] + assert captured["state"]["kept"] == "value" + assert captured["state"]["parent_thread_id"] == "parent-thread" + assert captured["state"]["file_thread_id"] == "parent-file-thread" + assert captured["state"]["skills_thread_id"] == child_thread_id + assert captured["state"]["messages"] == [HumanMessage(content="write a report")] + assert "todos" not in captured["state"] + assert "activated_skills" not in captured["state"] + assert captured["config"]["callbacks"] == ["stream-callback"] + assert captured["config"]["tags"] == ["parent"] + assert captured["config"]["recursion_limit"] == 42 + assert captured["config"]["configurable"] == { + "thread_id": child_thread_id, + "uid": "user-1", + "parent_thread_id": "parent-thread", + "file_thread_id": "parent-file-thread", + "skills_thread_id": child_thread_id, + "ls_agent_type": "subagent", + } + assert captured["context"] is captured["graph_context"] + assert captured["context"].model == "provider:model" + assert captured["context"].thread_id == child_thread_id + assert captured["context"].parent_thread_id == "parent-thread" + assert captured["context"].file_thread_id == "parent-file-thread" + assert captured["context"].skills_thread_id == child_thread_id + assert not hasattr(captured["context"], "subagents") + assert captured["context"].is_subagent_runtime is True + + +@pytest.mark.asyncio +async def test_task_tool_records_failed_subagent_run(monkeypatch) -> None: + class _Graph: + async def ainvoke(self, state, *, config, context): + del state, config, context + raise RuntimeError("child boom") + + class _Backend: + context_schema = _ChildContext + + async def get_graph(self, *, context): + del context + return _Graph() + + monkeypatch.setattr( + subagent_task_middleware, + "_get_agent_backend", + lambda backend_id: _Backend() if backend_id == SUB_AGENT_BACKEND_ID else None, + ) + times = iter(["2026-05-31T02:00:00Z", "2026-05-31T02:00:04Z"]) + monkeypatch.setattr(subagent_task_middleware, "utc_isoformat", lambda: next(times)) + + middleware = YuxiSubAgentMiddleware( + parent_context=SimpleNamespace(thread_id="parent-thread", uid="user-1"), + subagents=[ + SimpleNamespace( + slug="worker", + name="Worker", + description="work on scoped tasks", + backend_id=SUB_AGENT_BACKEND_ID, + config_json={}, + ) + ], + ) + runtime = SimpleNamespace(tool_call_id="tool-1", state={}, config={}) + + result = await middleware.tools[0].coroutine( + description="write a report", + subagent_type="worker", + runtime=runtime, + ) + + assert isinstance(result, Command) + assert result.update["messages"][0].content == "子智能体 worker 调用失败:child boom" + assert result.update["subagent_runs"] == [ + { + "id": "tool-1", + "subagent_type": "worker", + "subagent_name": "Worker", + "child_thread_id": make_child_thread_id( + "parent-thread", "worker", "tool-1" + ), + "description": "write a report", + "created_at": "2026-05-31T02:00:00Z", + "status": "failed", + "completed_at": "2026-05-31T02:00:04Z", + "result_preview": "子智能体 worker 调用失败:child boom", + "error": "child boom", + "artifacts": [], + } + ] diff --git a/backend/test/unit/routers/test_subagent.py b/backend/test/unit/routers/test_subagent.py index f67c5005..f611b8fc 100644 --- a/backend/test/unit/routers/test_subagent.py +++ b/backend/test/unit/routers/test_subagent.py @@ -1,691 +1,157 @@ -"""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 -# ============================================================================= +import importlib +from types import SimpleNamespace from fastapi import FastAPI 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 +from server.utils.auth_middleware import get_admin_user, get_db, get_required_user + +agent_router_module = importlib.import_module("server.routers.agent_router") -def _build_app() -> FastAPI: +def _user(role: str = "admin"): + return SimpleNamespace(uid="admin", role=role, department_id=None) + + +def _agent(slug: str, *, backend_id: str = "ChatbotAgent", is_subagent: bool = False): + return SimpleNamespace( + id=slug, + slug=slug, + name=slug, + backend_id=backend_id, + description="", + icon=None, + pics=[], + config_json={}, + share_config={"access_level": "user", "user_uids": ["admin"]}, + is_default=False, + is_subagent=is_subagent, + can_manage=True, + ) + + +class _FakeAgentManager: + def get_agent(self, backend_id: str): + if backend_id in {"ChatbotAgent", "SubAgentBackend"}: + return SimpleNamespace(context_schema=None) + return None + + +class _ListRepo: + items = [ + _agent("chatbot", backend_id="ChatbotAgent"), + _agent("worker", backend_id="SubAgentBackend", is_subagent=True), + ] + include_subagents_calls: list[bool] = [] + + def __init__(self, _db): + pass + + async def ensure_default_agent(self): + return self.items[0] + + async def list_visible(self, *, user, include_subagents: bool = False): + del user + self.include_subagents_calls.append(include_subagents) + if include_subagents: + return self.items + return [item for item in self.items if not item.is_subagent] + + async def serialize(self, item, **_kwargs): + return dict(item.__dict__) + + +class _CreateRepo(_ListRepo): + created_payload = None + + async def create(self, **kwargs): + type(self).created_payload = kwargs + return _agent(kwargs["slug"], backend_id=kwargs["backend_id"], is_subagent=kwargs["is_subagent"]) + + +class _RejectingCreateRepo(_ListRepo): + async def create(self, **_kwargs): + raise ValueError("SubAgentBackend 与 is_subagent 必须保持一致") + + +def _build_app(monkeypatch, repo_cls) -> TestClient: + monkeypatch.setattr(agent_router_module, "agent_manager", _FakeAgentManager()) + monkeypatch.setattr(agent_router_module, "AgentRepository", repo_cls) + app = FastAPI() - app.include_router(subagents_router, prefix="/api") + app.include_router(agent_router_module.agent_router, prefix="/api") async def fake_db(): return None - async def fake_admin_user(): - return User( - username="admin", - uid="admin", - password_hash="x", - role="admin", - ) + async def fake_user(): + return _user() app.dependency_overrides[get_db] = fake_db - app.dependency_overrides[get_admin_user] = fake_admin_user - return app + app.dependency_overrides[get_required_user] = fake_user + app.dependency_overrides[get_admin_user] = fake_user + return TestClient(app) -def test_list_subagents_returns_data(monkeypatch): - async def fake_get_all_subagents(_db): - return [ - { - "slug": "research-agent", - "name": "research-agent", - "description": "Test research agent", - "system_prompt": "You are a researcher", - "tools": ["tavily_search"], - "model": None, - "enabled": True, - "is_builtin": True, - "created_by": "system", - "updated_by": "system", - "created_at": "2024-01-01T00:00:00", - "updated_at": "2024-01-01T00:00:00", - } - ] +def test_agent_list_excludes_subagents_by_default(monkeypatch): + _ListRepo.include_subagents_calls = [] + client = _build_app(monkeypatch, _ListRepo) - monkeypatch.setattr("server.routers.subagent_router.service.get_all_subagents", fake_get_all_subagents) + response = client.get("/api/agent") - 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 + assert response.status_code == 200, response.text + payload = response.json() + assert [agent["slug"] for agent in payload["agents"]] == ["chatbot"] + assert _ListRepo.include_subagents_calls == [False] -def test_get_single_subagent(monkeypatch): - async def fake_get_subagent(name, db=None): - if name == "research-agent": - return { - "slug": "research-agent", - "name": "research-agent", - "description": "Test research agent", - "system_prompt": "You are a researcher", - "tools": ["tavily_search"], - "model": None, - "enabled": True, - "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 +def test_agent_management_list_can_include_subagents(monkeypatch): + _ListRepo.include_subagents_calls = [] + client = _build_app(monkeypatch, _ListRepo) - monkeypatch.setattr("server.routers.subagent_router.service.get_subagent", fake_get_subagent) + response = client.get("/api/agent?include_subagents=true") - 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" + assert response.status_code == 200, response.text + payload = response.json() + assert [agent["slug"] for agent in payload["agents"]] == ["chatbot", "worker"] + assert payload["agents"][1]["is_subagent"] is True + assert _ListRepo.include_subagents_calls == [True] -def test_get_single_subagent_not_found(monkeypatch): - async def fake_get_subagent(name, db=None): - return None +def test_create_subagent_backend_agent_sets_subagent_flag(monkeypatch): + _CreateRepo.created_payload = None + client = _build_app(monkeypatch, _CreateRepo) - 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 { - "slug": data["slug"], - "name": data["name"], - "description": data["description"], - "system_prompt": data["system_prompt"], - "tools": data.get("tools", []), - "model": data.get("model"), - "enabled": True, - "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", + response = client.post( + "/api/agent", json={ - "slug": "my-agent", - "name": "My Agent", - "description": "My custom agent", - "system_prompt": "You are a helpful assistant", - "tools": ["tool_a", "tool_b"], - "model": None, + "name": "Worker", + "slug": "worker", + "backend_id": "SubAgentBackend", + "is_subagent": True, }, ) - assert resp.status_code == 200, resp.text - payload = resp.json() - assert payload["success"] is True - assert captured["data"]["slug"] == "my-agent" - assert captured["data"]["name"] == "My Agent" - assert captured["created_by"] == "admin" + + assert response.status_code == 200, response.text + assert _CreateRepo.created_payload["backend_id"] == "SubAgentBackend" + assert _CreateRepo.created_payload["is_subagent"] is True + assert response.json()["agent"]["is_subagent"] is True -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_slug_key" ' - "Detail: Key (slug)=(my-agent) already exists." - ), - ) +def test_create_subagent_backend_rejects_mismatched_flag(monkeypatch): + client = _build_app(monkeypatch, _RejectingCreateRepo) - monkeypatch.setattr("server.routers.subagent_router.service.create_subagent", fake_create_subagent) - - app = _build_app() - client = TestClient(app) - resp = client.post( - "/api/system/subagents", + response = client.post( + "/api/agent", json={ - "slug": "my-agent", - "name": "My Agent", - "description": "My custom agent", - "system_prompt": "You are a helpful assistant", - "tools": [], - "model": None, + "name": "Worker", + "slug": "worker", + "backend_id": "SubAgentBackend", + "is_subagent": False, }, ) - 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 { - "slug": name, - "name": name, - "description": data.get("description", "Updated description"), - "system_prompt": data.get("system_prompt", "Updated prompt"), - "tools": data.get("tools", []), - "model": data.get("model"), - "enabled": True, - "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 - - -def test_update_subagent_status(monkeypatch): - captured = {} - - async def fake_set_subagent_enabled(name, enabled, updated_by, db=None): - captured["name"] = name - captured["enabled"] = enabled - captured["updated_by"] = updated_by - return { - "slug": name, - "name": name, - "description": "Test", - "system_prompt": "Prompt", - "tools": [], - "model": None, - "enabled": enabled, - "is_builtin": True, - "created_by": "system", - "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.set_subagent_enabled", fake_set_subagent_enabled) - - app = _build_app() - client = TestClient(app) - resp = client.put("/api/system/subagents/research-agent/status", json={"enabled": False}) - assert resp.status_code == 200, resp.text - payload = resp.json() - assert payload["success"] is True - assert payload["data"]["enabled"] is False - assert captured == {"name": "research-agent", "enabled": False, "updated_by": "admin"} - - -def test_update_subagent_status_not_found(monkeypatch): - async def fake_set_subagent_enabled(name, enabled, updated_by, db=None): - return None - - monkeypatch.setattr("server.routers.subagent_router.service.set_subagent_enabled", fake_set_subagent_enabled) - - app = _build_app() - client = TestClient(app) - resp = client.put("/api/system/subagents/missing/status", json={"enabled": True}) - assert resp.status_code == 404, resp.text - - -# ============================================================================= -# Repository Tests -# ============================================================================= - - -class TestSubAgentRepository: - @pytest.mark.asyncio - async def test_list_all(self): - from yuxi.agents.subagents.repository import SubAgentRepository - - mock_db = AsyncMock() - mock_result = MagicMock() - mock_result.scalars.return_value.all.return_value = [ - SubAgent( - slug="test-agent", - 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_slug_found(self): - from yuxi.agents.subagents.repository import SubAgentRepository - - mock_db = AsyncMock() - mock_result = MagicMock() - mock_result.scalar_one_or_none.return_value = SubAgent( - slug="test-agent", - 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_slug("test-agent") - - assert result is not None - assert result.name == "test-agent" - - @pytest.mark.asyncio - async def test_get_by_slug_not_found(self): - from yuxi.agents.subagents.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_slug("nonexistent") - - assert result is None - - @pytest.mark.asyncio - async def test_update_can_clear_model_when_provided(self): - from yuxi.agents.subagents.repository import SubAgentRepository - - mock_db = AsyncMock() - repo = SubAgentRepository(mock_db) - item = SubAgent( - slug="test-agent", - 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, - name=None, - 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.agents.subagents import service as service_module - - created_agents = [] - - class MockRepo: - def __init__(self, session): - pass - - async def get_by_slug(self, name): - return None - - async def create(self, **kwargs): - created_agents.append(kwargs) - return MagicMock() - - @asynccontextmanager - async def mock_session_context(*args, **kwargs): - session = MagicMock() - session.commit = MagicMock() - - async def _commit(): - return None - - session.commit = _commit - yield session - - 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_slugs = [a["slug"] for a in created_agents] - assert "research-agent" in agent_slugs - assert "critique-agent" in agent_slugs - - @pytest.mark.asyncio - async def test_get_subagent_specs_returns_list(self, monkeypatch): - from yuxi.agents.subagents import service as service_module - - mock_spec = { - "slug": "test-agent", - "name": "test-agent", - "description": "Test", - "system_prompt": "You are a test", - "tools": ["tool_a"], - } - - class MockRepo: - def __init__(self, session): - pass - - async def list_all_specs(self): - return [mock_spec] - - @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()) - - 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.agents.subagents import service as service_module - - service_module._subagent_specs_cache = [ - { - "slug": "test-agent", - "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.clear_specs_cache() - - -# ============================================================================= -# Model Tests -# ============================================================================= - - -class TestSubAgentModel: - def test_to_dict(self): - now = utc_now_naive() - agent = SubAgent( - slug="test-agent", - 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( - slug="test-agent", - 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( - slug="test-agent", - 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 - - -class TestDeepAgentSubagentSelection: - @pytest.mark.asyncio - async def test_get_subagents_from_slugs_filters_and_resolves_tools(self, monkeypatch): - from yuxi.agents.subagents import service as service_module - - async def fake_get_specs(_db=None): - return [ - { - "slug": "research-agent", - "name": "research-agent", - "description": "r", - "system_prompt": "s", - "tools": ["tool_a"], - }, - { - "slug": "critique-agent", - "name": "critique-agent", - "description": "c", - "system_prompt": "s", - "tools": [], - }, - ] - - mock_tool = MagicMock() - mock_tool.name = "tool_a" - - monkeypatch.setattr(service_module, "get_subagent_specs", fake_get_specs) - monkeypatch.setattr("yuxi.agents.toolkits.get_all_tool_instances", lambda: [mock_tool]) - - resolved_specs = await service_module.get_subagents_from_slugs(["research-agent", "missing-agent"]) - - assert [item["name"] for item in resolved_specs] == ["research-agent"] - assert resolved_specs[0]["tools"] == [mock_tool] - - def test_builtin_research_subagent_uses_virtual_outputs_path(self): - from yuxi.agents.subagents import service as service_module - - research_agent = next(item for item in service_module._DEFAULT_SUBAGENTS if item["slug"] == "research-agent") - - assert "/home/gem/user-data/outputs/sub_research/xxx.md" in research_agent["system_prompt"] - - def test_build_subagent_middleware_specs_uses_default_tools_for_empty_tools(self): - from yuxi.agents.subagents import service as service_module - - default_tool = MagicMock() - - specs = service_module.build_subagent_middleware_specs( - [ - { - "slug": "empty-tools-agent", - "name": "empty-tools-agent", - "description": "", - "system_prompt": "s", - "tools": [], - } - ], - default_model="test-model", - default_tools=[default_tool], - include_general_purpose=False, - ) - - assert specs[0]["tools"] == [default_tool] - - @pytest.mark.asyncio - async def test_get_subagents_from_slugs_none_selects_none(self, monkeypatch): - from yuxi.agents.subagents import service as service_module - - async def fake_get_specs(_db=None): - return [ - { - "slug": "research-agent", - "name": "research-agent", - "description": "r", - "system_prompt": "s", - "tools": ["tool_a"], - } - ] - - monkeypatch.setattr(service_module, "get_subagent_specs", fake_get_specs) - - assert await service_module.get_subagents_from_slugs(None) == [] + assert response.status_code == 422 + assert "is_subagent" in response.json()["detail"] diff --git a/backend/test/unit/services/test_agent_artifacts_state.py b/backend/test/unit/services/test_agent_artifacts_state.py index 532c734f..12209894 100644 --- a/backend/test/unit/services/test_agent_artifacts_state.py +++ b/backend/test/unit/services/test_agent_artifacts_state.py @@ -4,6 +4,7 @@ from yuxi.agents.backends.sandbox import ( sandbox_outputs_dir, sandbox_uploads_dir, ) +from yuxi.agents.buildin.chatbot.state import merge_subagent_runs from yuxi.agents.state import merge_artifacts from yuxi.agents.toolkits.buildin.tools import _normalize_presented_artifact_path from yuxi.services.chat_service import extract_agent_state @@ -24,6 +25,20 @@ def test_merge_artifacts_deduplicates_and_preserves_order(): ] +def test_merge_subagent_runs_updates_existing_run_by_id(): + assert merge_subagent_runs( + [{"id": "run-1", "status": "completed", "result_preview": "old"}], + [ + {"id": "run-1", "status": "failed", "error": "boom"}, + {"id": "run-2", "status": "completed"}, + ], + ) == [ + {"id": "run-1", "status": "failed", "result_preview": "old", "error": "boom"}, + {"id": "run-2", "status": "completed"}, + ] + + + def test_normalize_presented_artifact_path_accepts_host_path(): thread_id = "artifacts-host-path" ensure_thread_dirs(thread_id, "user-1") @@ -69,9 +84,11 @@ def test_extract_agent_state_includes_artifacts(): "todos": [{"content": "done", "status": "completed"}], "files": {"/tmp/demo.txt": {"content": ["x"]}}, "artifacts": ["/home/gem/user-data/outputs/demo.txt"], + "subagent_runs": [{"id": "tool-1", "status": "completed"}], } ) assert state["todos"] == [{"content": "done", "status": "completed"}] assert state["files"] == {"/tmp/demo.txt": {"content": ["x"]}} assert state["artifacts"] == ["/home/gem/user-data/outputs/demo.txt"] + assert state["subagent_runs"] == [{"id": "tool-1", "status": "completed"}] diff --git a/backend/test/unit/services/test_chat_service_langfuse_stream.py b/backend/test/unit/services/test_chat_service_langfuse_stream.py index 4dd84ef1..e03808ef 100644 --- a/backend/test/unit/services/test_chat_service_langfuse_stream.py +++ b/backend/test/unit/services/test_chat_service_langfuse_stream.py @@ -170,6 +170,139 @@ async def test_stream_agent_chat_passes_langfuse_callbacks_and_persists_trace_in assert isinstance(calls["stream_messages"][0], HumanMessage) +@pytest.mark.asyncio +async def test_stream_agent_chat_maps_raw_protocol_events_to_yuxi_stream_events(monkeypatch: pytest.MonkeyPatch): + class FakeGraph: + async def aget_state(self, _config): + return SimpleNamespace(values={"messages": [], "files": {}, "artifacts": []}) + + class FakeAgent: + context_schema = None + + async def stream_messages_with_state(self, messages, input_context=None, **kwargs): + del messages, input_context, kwargs + metadata = {"run_id": "run-1"} + yield "messages", ({"event": "message-start", "id": "msg-1", "role": "ai"}, metadata) + yield "messages", ({"event": "content-block-start", "index": 0, "content": {"type": "text"}}, metadata) + yield ( + "messages", + ( + {"event": "content-block-delta", "index": 0, "delta": {"type": "text-delta", "text": "hello"}}, + metadata, + ), + ) + yield ( + "messages", + ( + { + "event": "content-block-delta", + "index": 1, + "delta": { + "type": "block-delta", + "fields": { + "type": "tool_call_chunk", + "id": "call-1", + "name": "task", + "args": '{"description":"do', + "index": 0, + }, + }, + }, + metadata, + ), + ) + yield ( + "messages", + ( + { + "event": "content-block-finish", + "index": 1, + "content": { + "type": "tool_call", + "id": "call-1", + "name": "task", + "args": {"description": "do work", "subagent_type": "worker"}, + }, + }, + metadata, + ), + ) + yield "messages", ({"event": "message-finish", "usage": {}}, metadata) + + async def stream_messages(self, messages, input_context=None, **kwargs): + raise AssertionError("stream_messages fallback should not be used") + + async def get_graph(self): + return FakeGraph() + + async def fake_resolve_agent_runtime(**_kwargs): + return SimpleNamespace(slug="test-agent", backend_id="ChatbotAgent"), FakeAgent(), {} + + async def fake_save_messages_from_langgraph_state(*, agent_instance, thread_id, conv_repo, config_dict, trace_info): + return None + + async def fake_guard_check(_content): + return False + + async def fake_guard_check_with_keywords(_content): + return False + + async def fake_interrupts(agent, langgraph_config, make_chunk, meta, thread_id): + if False: + yield None + return + + monkeypatch.setattr(svc, "_resolve_agent_runtime", fake_resolve_agent_runtime) + monkeypatch.setattr(svc, "normalize_agent_context_config", _fake_normalize_agent_context_config) + monkeypatch.setattr(svc, "ConversationRepository", _FakeConvRepo) + monkeypatch.setattr(svc, "save_messages_from_langgraph_state", fake_save_messages_from_langgraph_state) + monkeypatch.setattr(svc.content_guard, "check", fake_guard_check) + monkeypatch.setattr(svc.content_guard, "check_with_keywords", fake_guard_check_with_keywords) + monkeypatch.setattr(svc, "check_and_handle_interrupts", fake_interrupts) + monkeypatch.setattr( + svc, + "_build_langfuse_run_context", + lambda **kwargs: SimpleNamespace(callbacks=[], metadata={}, tags=[], trace_id=None), + ) + monkeypatch.setattr(svc, "get_trace_info", lambda _run_context: {}) + monkeypatch.setattr(svc, "flush_langfuse", lambda: None) + + chunks = [] + async for chunk in svc.stream_agent_chat( + query="hello", + agent_id="test-agent", + thread_id="thread-1", + meta={"request_id": "req-1"}, + image_content=None, + current_user=SimpleNamespace(id=1, uid="user-1", role="user", department_id="dept-1"), + db=object(), + ): + chunks.append(json.loads(chunk.decode("utf-8"))) + + loading_chunks = [chunk for chunk in chunks if chunk.get("status") == "loading"] + assert [chunk["stream_event"]["type"] for chunk in loading_chunks] == ["message_delta", "tool_call"] + assert loading_chunks[0]["response"] == "hello" + assert loading_chunks[0]["stream_event"] == { + "type": "message_delta", + "message_id": "msg-1", + "thread_id": "thread-1", + "namespace": [], + "content": "hello", + } + assert loading_chunks[1]["response"] == "" + assert loading_chunks[1]["stream_event"] == { + "type": "tool_call", + "message_id": "msg-1", + "tool_call_id": "call-1", + "name": "task", + "args": {"description": "do work", "subagent_type": "worker"}, + "index": 1, + "thread_id": "thread-1", + "namespace": [], + } + assert all("msg" not in chunk for chunk in loading_chunks) + + @pytest.mark.asyncio async def test_stream_agent_chat_emits_realtime_agent_state_from_values(monkeypatch: pytest.MonkeyPatch): class FakeGraph: diff --git a/backend/test/unit/services/test_chat_service_sync.py b/backend/test/unit/services/test_chat_service_sync.py index ccaede29..f1d1870b 100644 --- a/backend/test/unit/services/test_chat_service_sync.py +++ b/backend/test/unit/services/test_chat_service_sync.py @@ -5,6 +5,7 @@ from types import SimpleNamespace import pytest from langchain.messages import AIMessage, HumanMessage +from yuxi.agents.subagent_thread import make_child_thread_id from yuxi.services import chat_service as svc @@ -19,6 +20,7 @@ async def _fake_normalize_agent_context_config(context, **_kwargs): class _FakeConvRepo: def __init__(self, _db): self.saved_messages: list[dict] = [] + self.tool_calls: list[dict] = [] self.conversations: dict[str, SimpleNamespace] = {} def _conversation(self, thread_id: str) -> SimpleNamespace: @@ -59,6 +61,29 @@ class _FakeConvRepo: async def get_conversation_by_thread_id(self, thread_id: str): return self._conversation(thread_id) + async def get_messages_by_thread_id(self, _thread_id: str): + return [] + + async def add_tool_call( + self, + *, + message_id: int, + tool_name: str, + tool_input: dict | None = None, + status: str = "pending", + langgraph_tool_call_id: str | None = None, + ): + self.tool_calls.append( + { + "message_id": message_id, + "tool_name": tool_name, + "tool_input": tool_input, + "status": status, + "langgraph_tool_call_id": langgraph_tool_call_id, + } + ) + return SimpleNamespace(id=len(self.tool_calls)) + async def create_conversation(self, *, uid: str, agent_id: str, thread_id: str, metadata: dict | None = None): conversation = SimpleNamespace( id=1, @@ -78,6 +103,56 @@ class _FakeConvRepo: return [] +@pytest.mark.asyncio +async def test_save_messages_from_langgraph_state_handles_dict_tool_call_blocks() -> None: + class FakeGraph: + async def aget_state(self, _config): + return SimpleNamespace( + values={ + "messages": [ + { + "id": "ai-tool-call", + "role": "assistant", + "content": [ + { + "type": "tool_call", + "id": "call-task-1", + "name": "task", + "args": {"description": "write file", "subagent_type": "worker"}, + } + ], + } + ] + } + ) + + class FakeAgent: + async def get_graph(self): + return FakeGraph() + + conv_repo = _FakeConvRepo(None) + + await svc.save_messages_from_langgraph_state( + agent_instance=FakeAgent(), + thread_id="thread-1", + conv_repo=conv_repo, + config_dict={"configurable": {"thread_id": "thread-1", "uid": "user-1"}}, + trace_info=None, + ) + + assert conv_repo.saved_messages[0]["content"] == "" + assert conv_repo.saved_messages[0]["extra_metadata"]["content"][0]["id"] == "call-task-1" + assert conv_repo.tool_calls == [ + { + "message_id": 1, + "tool_name": "task", + "tool_input": {"description": "write file", "subagent_type": "worker"}, + "status": "pending", + "langgraph_tool_call_id": "call-task-1", + } + ] + + @pytest.mark.asyncio async def test_agent_chat_uses_invoke_messages_and_persists_langgraph_state(monkeypatch: pytest.MonkeyPatch): calls: dict[str, object] = {} @@ -154,7 +229,7 @@ async def test_agent_chat_uses_invoke_messages_and_persists_langgraph_state(monk assert result["response"] == "Hi from invoke" assert result["thread_id"] == "thread-1" assert result["request_id"] == "req-1" - assert result["agent_state"] == {"todos": ["todo-1"], "files": {}, "artifacts": []} + assert result["agent_state"] == {"todos": ["todo-1"], "files": {}, "artifacts": [], "subagent_runs": []} invoke_messages = calls["invoke_messages"] assert isinstance(invoke_messages, list) @@ -258,6 +333,81 @@ async def test_build_agent_input_context_merges_workspace_agents_prompt(monkeypa assert context["uid"] == "user-1" +@pytest.mark.asyncio +async def test_get_agent_state_view_allows_recorded_child_thread(monkeypatch: pytest.MonkeyPatch): + child_thread_id = make_child_thread_id("parent-thread", "worker", "tool-1") + + class ConvRepo: + def __init__(self, _db): + pass + + async def get_conversation_by_thread_id(self, thread_id: str): + if thread_id == "parent-thread": + return SimpleNamespace(uid="user-1", agent_id="main-agent", status="active") + return None + + class AgentRepo: + def __init__(self, _db): + pass + + async def get_by_slug(self, slug: str): + if slug == "main-agent": + return SimpleNamespace(backend_id="ChatbotAgent") + if slug == "worker": + return SimpleNamespace(backend_id="SubAgentBackend") + return None + + class Graph: + def __init__(self, values): + self.values = values + + async def aget_state(self, config): + return SimpleNamespace(values=self.values(config["configurable"]["thread_id"])) + + class Agent: + def __init__(self, values): + self.values = values + + async def get_graph(self): + return Graph(self.values) + + def parent_values(_thread_id: str): + return { + "subagent_runs": [ + { + "id": "tool-1", + "subagent_type": "worker", + "subagent_name": "Worker", + "child_thread_id": child_thread_id, + "status": "completed", + } + ] + } + + def child_values(_thread_id: str): + return {"messages": [HumanMessage(content="do work"), AIMessage(content="done")], "artifacts": ["out.txt"]} + + monkeypatch.setattr(svc, "ConversationRepository", ConvRepo) + monkeypatch.setattr(svc, "AgentRepository", AgentRepo) + monkeypatch.setattr( + svc.agent_manager, + "get_agent", + lambda backend_id: Agent(parent_values) if backend_id == "ChatbotAgent" else Agent(child_values), + ) + + result = await svc.get_agent_state_view( + thread_id=child_thread_id, + current_uid="user-1", + db=object(), + include_messages=True, + ) + + assert result["parent_thread_id"] == "parent-thread" + assert result["subagent_run"]["id"] == "tool-1" + assert result["agent_state"]["artifacts"] == ["out.txt"] + assert [message["type"] for message in result["messages"]] == ["human", "ai"] + + @pytest.mark.asyncio async def test_build_agent_input_context_keeps_prompt_when_workspace_agents_prompt_empty( monkeypatch: pytest.MonkeyPatch, diff --git a/backend/test/unit/services/test_chat_stream_interrupt.py b/backend/test/unit/services/test_chat_stream_interrupt.py index 77ed9c3f..e979da37 100644 --- a/backend/test/unit/services/test_chat_stream_interrupt.py +++ b/backend/test/unit/services/test_chat_stream_interrupt.py @@ -15,6 +15,7 @@ from yuxi.services.chat_service import ( _coerce_interrupt_payload, stream_agent_resume, ) +from yuxi.services import chat_service as svc from yuxi.utils.question_utils import normalize_options @@ -196,6 +197,79 @@ async def test_stream_agent_resume_init_does_not_render_resume_input(): assert "Resume with input" not in json.dumps(first_chunk, ensure_ascii=False) +@pytest.mark.asyncio +async def test_stream_agent_resume_routes_subagent_chunks(monkeypatch): + class FakeContext: + def __init__(self): + self.thread_id = None + self.uid = None + + def update(self, values): + for key, value in values.items(): + setattr(self, key, value) + + def model_dump(self): + return {"thread_id": self.thread_id, "uid": self.uid} + + class FakeAgent: + context_schema = FakeContext + + async def stream_resume_with_state(self, resume_command, input_context=None, **kwargs): + yield "messages", ( + {"content": "child token", "id": "msg-child"}, + {"namespace": ["task:1"], "thread_id": "child-thread"}, + ) + + async def get_graph(self, context=None): + class FakeGraph: + async def aget_state(self, _config): + return SimpleNamespace(values={}) + + return FakeGraph() + + async def fake_resolve_agent_runtime(**_kwargs): + return SimpleNamespace(slug="main-agent", backend_id="ChatbotAgent"), FakeAgent(), {} + + async def fake_save_messages_from_langgraph_state(**_kwargs): + return None + + async def fake_check_and_handle_interrupts(*_args, **_kwargs): + if False: + yield None + + async def fake_build_agent_input_context(*_args, **_kwargs): + return {"thread_id": "parent-thread", "uid": "user-1"} + + monkeypatch.setattr(svc, "_resolve_agent_runtime", fake_resolve_agent_runtime) + monkeypatch.setattr(svc, "_build_agent_input_context", fake_build_agent_input_context) + monkeypatch.setattr(svc, "_build_langfuse_run_context", lambda **_kwargs: SimpleNamespace(callbacks=[], metadata={}, tags=[])) + monkeypatch.setattr(svc, "check_and_handle_interrupts", fake_check_and_handle_interrupts) + monkeypatch.setattr(svc, "save_messages_from_langgraph_state", fake_save_messages_from_langgraph_state) + monkeypatch.setattr(svc, "ConversationRepository", lambda _db: object()) + monkeypatch.setattr(svc, "flush_langfuse", lambda: None) + + stream = stream_agent_resume( + thread_id="parent-thread", + resume_input={"ok": True}, + meta={"request_id": "req-1"}, + current_user=SimpleNamespace(uid="user-1"), + db=object(), + ) + + chunks = [] + async for raw in stream: + chunk = json.loads(raw.decode("utf-8")) + chunks.append(chunk) + if chunk.get("status") == "loading": + break + await stream.aclose() + + loading = chunks[-1] + assert loading["thread_id"] == "child-thread" + assert loading["response"] == "child token" + assert loading["stream_event"]["thread_id"] == "child-thread" + + class TestCoerceInterruptPayload: """测试 _coerce_interrupt_payload 函数""" diff --git a/backend/test/unit/services/test_run_worker.py b/backend/test/unit/services/test_run_worker.py index c3e9000b..9e709ed4 100644 --- a/backend/test/unit/services/test_run_worker.py +++ b/backend/test/unit/services/test_run_worker.py @@ -153,6 +153,83 @@ async def test_process_agent_run_retryable_error_retries_then_completes(monkeypa assert terminal_statuses == ["completed"] +@pytest.mark.asyncio +async def test_chunked_event_writer_flushes_loading_chunks_by_thread(monkeypatch: pytest.MonkeyPatch): + events: list[dict] = [] + + async def fake_append_run_event(run_id: str, event_type: str, payload: dict, *, thread_id: str | None = None): + events.append({"run_id": run_id, "event_type": event_type, "payload": payload, "thread_id": thread_id}) + + monkeypatch.setattr(run_worker, "append_run_event", fake_append_run_event) + + writer = run_worker.ChunkedEventWriter("run-1", "parent-thread") + await writer.append({"status": "loading", "response": "parent", "thread_id": "parent-thread"}) + await writer.append({"status": "loading", "response": "child", "thread_id": "child-thread"}) + await writer.flush() + + assert events == [ + { + "run_id": "run-1", + "event_type": "messages", + "payload": {"items": [{"status": "loading", "response": "parent", "thread_id": "parent-thread"}]}, + "thread_id": "parent-thread", + }, + { + "run_id": "run-1", + "event_type": "messages", + "payload": {"items": [{"status": "loading", "response": "child", "thread_id": "child-thread"}]}, + "thread_id": "child-thread", + }, + ] + + +@pytest.mark.asyncio +async def test_chunked_event_writer_flushes_semantic_tool_call_immediately(monkeypatch: pytest.MonkeyPatch): + events: list[dict] = [] + + async def fake_append_run_event(run_id: str, event_type: str, payload: dict, *, thread_id: str | None = None): + events.append({"run_id": run_id, "event_type": event_type, "payload": payload, "thread_id": thread_id}) + + monkeypatch.setattr(run_worker, "append_run_event", fake_append_run_event) + + writer = run_worker.ChunkedEventWriter("run-1", "parent-thread") + chunk = { + "status": "loading", + "response": "", + "thread_id": "parent-thread", + "stream_event": { + "type": "tool_call", + "message_id": "msg-1", + "tool_call_id": "call-1", + "name": "task", + "args": {"description": "do work"}, + "index": 0, + "thread_id": "parent-thread", + "namespace": [], + }, + } + await writer.append(chunk) + + assert events == [ + { + "run_id": "run-1", + "event_type": "messages", + "payload": {"items": [chunk]}, + "thread_id": "parent-thread", + } + ] + + +def test_chunk_thread_id_reads_nested_metadata(): + assert ( + run_worker._chunk_thread_id( + {"metadata": {"configurable": {"thread_id": "child-thread"}}}, + "parent-thread", + ) + == "child-thread" + ) + + @pytest.mark.asyncio async def test_worker_startup_ensures_builtin_mcp_servers(monkeypatch: pytest.MonkeyPatch): calls: list[str] = [] diff --git a/docs/agents/agents-config.md b/docs/agents/agents-config.md index 481cd44b..79f0d6f0 100644 --- a/docs/agents/agents-config.md +++ b/docs/agents/agents-config.md @@ -19,8 +19,8 @@ Yuxi 的智能体系统基于 LangGraph 构建。对开发者来说,最重要 仓库中已经内置了可直接参考的智能体: -- `chatbot`:通用对话智能体 -- `deep_agent`:深度分析智能体 +- `chatbot`:通用对话智能体,使用 `ChatBotContext` 扩展可调用子智能体配置 +- `subagent`:专用子智能体后端,使用 `SubAgentContext`,用于被主 Agent 通过 task 工具调用 ## 2. Agent 的代码组织 @@ -82,10 +82,14 @@ class MyAgent(BaseAgent): | `knowledges` | 关联知识库 | | `mcps` | 启用的 MCP 服务器 | | `skills` | 关联 Skills | -| `subagents_model` | 子智能体默认模型 | -| `subagents` | 启用的子智能体 | | `summary_threshold` | 摘要触发阈值 | -| `thread_id` / `user_id` | 运行期标识,不作为页面配置项暴露 | +| `thread_id` / `uid` | 运行期标识,不作为页面配置项暴露 | + +`tools`、`knowledges`、`mcps`、`skills` 在未显式配置时会默认启用当前用户可访问的全部资源。 + +`ChatBotContext` 在 `BaseContext` 之上增加 `subagents` 字段,表示当前主 Agent 允许调用的子智能体。`subagents` 不使用默认全量语义,未显式选择时表示不启用子智能体。 + +`SubAgentContext` 在 `BaseContext` 之上增加 `parent_thread_id`、`file_thread_id`、`skills_thread_id` 与 `is_subagent_runtime` 等隐藏运行态字段,不包含 `subagents`,因此子智能体不能继续配置下一层子智能体。 ### 3.2 前端配置项如何从 Context 生成 @@ -183,7 +187,7 @@ Context 的价值不只在“配置页面”。它贯穿了从配置加载到实 也就是说,运行期 Context 的基础来源并不是前端临时状态,而是数据库中保存的 Agent。 -此外,用户工作区会默认创建 `agents/AGENTS.md`。当 Agent 开始执行时,后端会读取当前用户工作区下的这个文件,并将其内容追加到 `system_prompt`,用于补充该用户对 Agent 的长期指令或工作区约定。该文件属于用户级共享工作区,内容会随 `user_id` 和当前 `thread_id` 映射到运行时工作区路径;文件不存在、为空或不可读时不会影响 Agent 启动,单次注入内容最多读取 64 KiB,超出部分会截断并追加提示。 +此外,用户工作区会默认创建 `agents/AGENTS.md`。当 Agent 开始执行时,后端会读取当前用户工作区下的这个文件,并将其内容追加到 `system_prompt`,用于补充该用户对 Agent 的长期指令或工作区约定。该文件属于用户级共享工作区,内容会随 `uid` 和当前运行的线程作用域映射到运行时工作区路径;文件不存在、为空或不可读时不会影响 Agent 启动,单次注入内容最多读取 64 KiB,超出部分会截断并追加提示。 合并后的提示词结构可以理解为: @@ -215,11 +219,10 @@ config_json.context + runtime ids -> context_schema instance - 主模型选择:`context.model` - 系统提示词拼接:`context.system_prompt` -- 子智能体默认模型:`context.subagents_model` -- 子智能体列表:`context.subagents` +- 可调用子智能体列表:`context.subagents` - 摘要阈值:`context.summary_threshold` -因此 Graph 不是和 Context 解耦的。相反,Graph 的构造本身就依赖 Context。 +因此 Graph 不是和 Context 解耦的。相反,Graph 的构造本身就依赖 Context。普通 Agent 在 `context.subagents` 非空时会挂载 Yuxi 的 task middleware;`SubAgentBackend` 自身隐藏并清空 `subagents` 字段,因此子智能体不会继续调用子智能体。 ### 4.4 Graph 构建与中间件运行阶段 @@ -241,7 +244,8 @@ config_json.context + runtime ids -> context_schema instance - 读取 `_readable_skills` 校验可激活 Skill - 根据 `activated_skills` 按需挂载工具和 MCP 依赖 - 文件系统与沙盒接入 - - 通过 `thread_id` 获取对应沙盒 + - 普通 Agent 默认使用当前 `thread_id` 作为文件与 Skills 作用域 + - 子智能体使用 child `thread_id` 做 checkpoint,`file_thread_id` 指向父会话 uploads/outputs,`skills_thread_id` 指向子智能体自身 Skills 作用域 - 通过 `_readable_skills` 决定 `/home/gem/skills` 的可读范围 所以 Context 既是输入配置,也是 Graph 创建前整理出的运行时资源上下文。 @@ -291,7 +295,7 @@ class MyAgent(BaseAgent): | `file_upload` | 启用上传入口 | | `files` | 启用文件面板 | -像 todo 这类运行态信息,不建议再放进 `capabilities`。Yuxi 当前会直接从 LangGraph state 中提取 `agent_state.todos`,前端按运行时是否真的存在任务来决定是否展示待办入口与状态卡片。 +像 todo 这类运行态信息,不建议再放进 `capabilities`。Yuxi 当前会直接从 LangGraph state 中提取 `agent_state`,前端在创建对话后常态化展示状态入口,并在状态面板中渲染 `todos`、`files`、`artifacts`、`subagent_runs` 等运行时内容。 它解决的是“Agent 先天支持什么固定入口”,而不是“运行时当前产生了什么状态”。 @@ -324,4 +328,4 @@ class MyAgent(BaseAgent): - [沙盒架构与设计](./sandbox-architecture.md) - [MCP 集成](./mcp-integration.md) - [Skills 管理](./skills-management.md) -- [SubAgents 管理](./subagents-management.md) +- [子智能体](./subagents-management.md) diff --git a/docs/agents/subagents-management.md b/docs/agents/subagents-management.md index 8b6eff87..a4da0898 100644 --- a/docs/agents/subagents-management.md +++ b/docs/agents/subagents-management.md @@ -1,175 +1,112 @@ -# SubAgents 管理 +# 子智能体 -SubAgent 是 Deep Agent 的可调用子智能体配置。你可以在管理界面维护多个子智能体,Deep Agent 在运行时会根据任务需要调用它们协作完成研究、评审等步骤。 - -这篇文档同时面向两类读者: - -- 使用者:了解如何在 UI 中创建和维护 SubAgent。 -- 开发者:了解后端数据结构、加载流程、调用链路和注意事项。 +Yuxi 的子智能体是 Agent-backed 形态:它仍然是 `agents` 表中的一级 Agent,只是额外带有 `is_subagent=true` 标记,并使用专用后端 `SubAgentBackend`。子智能体不再有独立的创建入口、独立表或独立管理接口。 ## 用户视角 -### SubAgent 能解决什么问题 +### 子智能体能解决什么问题 -当任务复杂、需要并行信息收集或分工处理时,主智能体可以调用一个或多个 SubAgent 执行子任务。例如: +当任务复杂、需要分工处理时,主 Agent 可以通过 `task` 工具把一个子任务交给子智能体。例如: - 研究型子任务:聚焦检索和资料整理。 - 评审型子任务:对草稿进行结构和质量审查。 -- 领域型子任务:使用指定工具和指定模型处理特定领域问题。 +- 领域型子任务:使用指定模型、工具、知识库或 Skills 处理特定领域问题。 -### 在哪里管理 +### 在哪里创建和编辑 -在扩展管理页面中,切换到 Subagents 管理标签页即可进行增删改查。 +子智能体与普通 Agent 使用同一个管理入口:进入模型配置中的“智能体”管理页,点击新增智能体,并在后端类型中选择 `SubAgentBackend`。 -主要字段说明: +创建和编辑流程与普通 Agent 保持一致: -| 字段 | 说明 | -|------|------| -| name | 唯一标识,创建后不可修改 | -| description | 子智能体用途说明 | -| system_prompt | 子智能体行为约束 | -| tools | 可用工具名称列表(从工具系统选择/输入) | -| model | 可选,子智能体模型覆盖 | -| enabled | 是否已添加到运行时 | -| is_builtin | 是否内置(内置项不可编辑、不可删除) | +- 展示信息、共享权限、系统提示词和运行配置都保存在同一份 Agent 配置中。 +- 模型、工具、知识库、MCP 和 Skills 仍通过 Agent runtime config 表单配置。 +- 子智能体不会出现在聊天页的 Agent 快速切换列表中。 +- 子智能体不能再配置或调用其他子智能体。 -### 配置建议 +### 如何让主 Agent 调用子智能体 -- name 建议语义化,例如 market-research-agent、fact-check-agent。 -- system_prompt 尽量聚焦单一职责,避免一个 SubAgent 同时承担多个目标。 -- tools 只保留该角色需要的最小集合。 -- model 仅在该角色对模型能力有明确要求时设置;否则保持空值,使用默认策略。 +仅创建子智能体不会让它自动参与运行。需要编辑主 Agent,在 runtime config 的“子智能体”字段中选择允许调用的子智能体。 -### 使用方式与启用条件 +`subagents` 字段表示当前主 Agent 的允许列表: -SubAgent 配置保存在数据库中,但仅有配置并不会自动参与运行。要让子智能体真正可被调用,必须在 Agent 的 graph 中接入 `SubAgentMiddleware`。 +- 未选择时表示不启用子智能体。 +- 只会调用当前用户可访问且 `is_subagent=true` 的 Agent。 +- 每个子智能体使用自己的 `config_json.context`,包括模型、工具、知识库、MCP、Skills 和系统提示词。 -可参考 Deep Agent 的实现: - -```python -subagents_middleware = SubAgentMiddleware( - default_model=sub_model, - default_tools=search_tools, - subagents=user_subagents, - default_middleware=[...], - general_purpose_agent=True, -) - -graph = create_agent( - model=model, - middleware=[ - ..., - subagents_middleware, - ..., - ], -) -``` - -启用规则可以总结为: - -- 未接入 `SubAgentMiddleware`:SubAgent 功能不生效(即使数据库里已配置)。 -- 已接入 `SubAgentMiddleware`:只有 `enabled=true` 且被 Agent 配置选中的 SubAgent 才可调用。 -- 可调用范围由 Agent 配置中的 `subagents` 列表与数据库启用状态共同决定。 +这和工具、知识库、MCP、Skills 的默认全量语义不同:这些资源未显式配置时会默认启用当前用户可访问的全部资源,但子智能体必须显式选择。 ## 开发者视角 ### 数据模型 -SubAgent 存储在业务库表 subagents 中,核心字段定义在: +子智能体复用 `agents` 表,核心字段包括: -- backend/package/yuxi/storage/postgres/models_business.py +| 字段 | 说明 | +|------|------| +| `backend_id` | 子智能体固定使用 `SubAgentBackend` | +| `is_subagent` | 子智能体标记,`SubAgentBackend` 必须对应 `true` | +| `config_json.context` | 子智能体自己的运行配置 | +| `share_config` | 可见性与管理权限,沿用 Agent 共享模型 | -模型转换方法: +后端会校验 `backend_id` 与 `is_subagent` 一致:普通 Agent 不能伪装成子智能体,`SubAgentBackend` 也不能以普通 Agent 形态保存。子智能体不能被设置为默认 Agent。 -- to_dict: 提供管理接口输出。 -- to_subagent_spec: 提供 SubAgentMiddleware 所需结构。 +### API 与列表语义 -### 后端分层 +子智能体沿用 `/api/agent` CRUD: -SubAgent 采用 Router -> Service -> Repository 的标准分层: +- `GET /api/agent` 默认只返回聊天可用的普通 Agent。 +- `GET /api/agent?include_subagents=true` 返回管理页需要的完整 Agent 列表。 +- 创建或更新 `SubAgentBackend` 时,payload 会携带或推导 `is_subagent=true`。 +- 详情、更新和删除仍走同一套 Agent 管理接口,并复用现有权限过滤。 -- 路由:backend/server/routers/subagent_router.py -- 服务:backend/package/yuxi/services/subagent_service.py -- 仓储:backend/package/yuxi/repositories/subagent_repository.py +旧的独立 SubAgent 管理链路已经移除,不再维护单独的启停状态、内置初始化或 spec 缓存。 -职责划分: +### 运行时调用链 -- Router: 鉴权、请求参数校验、HTTP 错误语义。 -- Service: 业务规则、缓存管理、工具解析、内置项初始化。 -- Repository: 数据库读写与更新语义。 +主 Agent 构图时,如果 `context.subagents` 非空,会挂载 Yuxi 的 task middleware。middleware 会把允许的子智能体列表注入模型提示,并暴露一个 `task` 工具。 -### 运行时加载与调用链 +工具参数为: -Deep Agent 构图时会动态加载 SubAgent: +```python +class TaskToolSchema(BaseModel): + description: str + subagent_type: str +``` -1. 从数据库读取 SubAgent spec。 -2. 将 tools 字符串列表解析为真实工具实例。 -3. 传入 SubAgentMiddleware,参与执行。 +执行时的关键流程: -关键代码: +1. 从父 Agent 的 `context.subagents` 读取允许的子智能体 slug。 +2. 使用 `AgentRepository` 加载当前用户可见且 `is_subagent=true` 的 Agent。 +3. 为本次调用生成 child checkpoint thread id,例如 `_sub__`。 +4. 使用子智能体自己的 `SubAgentContext` 和 `config_json.context` 构建真实 Agent graph。 +5. 调用结束后,只把子智能体最终 assistant 文本作为 `task` 工具结果返回给主 Agent。 -- backend/package/yuxi/agents/buildin/deep_agent/graph.py -- backend/package/yuxi/services/subagent_service.py +`SubAgentBackend` 复用普通 Agent 的运行时资源归一化流程,但不会挂载 task middleware;它的 `subagents` 字段隐藏且默认为空,因此不会形成嵌套子智能体调用。 -在当前实现中,子智能体默认模型由 DeepContext.subagents_model 提供;单个 SubAgent 若配置了 model,会在子智能体级别覆盖默认模型。 +### 文件系统与沙盒作用域 -### 内置 SubAgent 初始化 +子智能体与主 Agent 共享文件系统时使用拆分作用域: -系统启动时会自动确保内置 SubAgent 存在,并用代码中的最新定义覆盖数据库中的展示字段: - -- research-agent -- critique-agent - -初始化逻辑: - -- backend/package/yuxi/services/subagent_service.py 中的 _DEFAULT_SUBAGENTS 和 init_builtin_subagents -- backend/server/utils/lifespan.py 启动阶段调用 - -内置项保护规则: - -- 内置 SubAgent 不可编辑。 -- 内置 SubAgent 不可删除。 -- 内置 SubAgent 可以移除/重新添加(通过 `enabled` 控制)。 - -### API 概览 - -接口前缀:/api/system/subagents - -| 方法 | 路径 | 说明 | +| 路径/作用域 | 普通 Agent | 子智能体 | |------|------|------| -| GET | /api/system/subagents | 列表 | -| GET | /api/system/subagents/{name} | 详情 | -| POST | /api/system/subagents | 创建 | -| PUT | /api/system/subagents/{name} | 更新 | -| PUT | /api/system/subagents/{name}/status | 更新添加状态 | -| DELETE | /api/system/subagents/{name} | 删除 | +| LangGraph checkpoint | 当前 `thread_id` | child `thread_id` | +| `/home/gem/user-data/workspace` | 当前 `uid` 的共享工作区 | 同一 `uid` 的共享工作区 | +| `/home/gem/user-data/uploads` | 当前会话文件作用域 | 父会话 `file_thread_id` | +| `/home/gem/user-data/outputs` | 当前会话文件作用域 | 父会话 `file_thread_id` | +| `/home/gem/skills` | 当前 Agent 的 Skills 作用域 | 子智能体自己的 `skills_thread_id` | -所有接口均要求管理员权限。 - -### 一致性与缓存 - -为了降低频繁查询数据库的开销,服务层维护了 subagent spec 缓存,并在创建/更新/删除后主动失效。 - -建议: - -- 不要绕过 Service 直接写库,否则缓存不会自动失效。 -- 如果新增导入/批量同步逻辑,务必在完成后调用缓存失效函数。 +这保证子智能体可以读取父会话上传、产物也会回到父会话 artifacts 中,同时子智能体的 Skills 不会污染主 Agent。 ## 常见问题 -### Q1: 为什么配置了 tools,但运行时没生效? +### 为什么创建了子智能体,主 Agent 仍不会调用? -A: tools 字段保存的是工具名称。只有在运行时可用工具集合中存在同名工具,才会被解析并注入。请检查: +需要在主 Agent 的 runtime config 中显式选择该子智能体。`subagents` 为空时不会启用 task 工具。 -- 工具名称是否拼写正确。 -- 该工具是否已在当前环境启用。 -- MCP 工具服务是否已成功加载。 -- Agent graph 是否已接入 `SubAgentMiddleware`。 +### 为什么聊天 Agent 列表里看不到子智能体? -### Q2: 删除后为什么主智能体偶尔还调用旧 SubAgent? +这是预期行为。子智能体是被主 Agent 调用的后端配置,不是直接进入聊天的 Agent;管理页会使用包含子智能体的列表。 -A: 通常与缓存和运行会话生命周期有关。确认删除接口成功返回后,新的图构建会使用最新配置。若是长会话,可重新开始一次运行。 +### 子智能体能否继承主 Agent 的模型或工具? -### Q3: 内置 SubAgent 为什么不能直接改? - -A: 内置项用于保证系统具备最小可用能力。若你需要完全自定义,建议新建自定义 SubAgent 并在提示词中引导主智能体优先使用。 +子智能体运行时使用自己的 Agent 配置。确实需要一致时,应在子智能体配置中显式选择相同模型、工具或 Skills;运行时只继承必要的父会话作用域,例如 uploads/outputs。 diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 133b2ffa..ea615c0e 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -28,7 +28,7 @@ - Provider 与模型配置收敛:移除旧版 v1 模型配置与 Ollama 支持,运行时模型统一使用 `provider_id:model_id` 与独立 provider 模块;自定义 provider 实现逻辑从文件移动到数据库,并从 config 文件迁移到 provider 模块。 - 智能体运行时语义收敛:用户可见的 `AgentConfig` 收敛为数据库持久化的一级 `Agent`,内置 Python Agent 改为智能体后端;聊天、运行任务、恢复审批和文件预览均从线程绑定的 Agent 解析运行时上下文,前端只提交 `agent_id`。 - 知识库能力边界收敛:移除 Upload 与 LightRAG 知识库/图谱能力,知识库类型收敛为 Milvus 与只读连接器;知识库 API 统一使用 `/{db_id}/xxx` 形式,并整合 mindmap / eval 等子接口。 -- Agent 资源默认选择与权限过滤:未显式配置工具、知识库、MCP、Skills、SubAgent 时默认启用当前用户可访问/可用的全部资源,显式保存空列表仍表示不启用对应资源;Agent 创建前统一完成最终资源权限过滤、知识库 `db_id` 可见范围派生和 Skill prompt/readable 依赖闭包派生。 +- Agent 资源默认选择与权限过滤:未显式配置工具、知识库、MCP、Skills 时默认启用当前用户可访问/可用的全部资源,子智能体默认不启用,显式保存空列表仍表示不启用对应资源;Agent 创建前统一完成最终资源权限过滤、知识库 `db_id` 可见范围派生和 Skill prompt/readable 依赖闭包派生。 - Skill 安装与权限模型收敛:Skill 元数据使用 `source_type/share_config/enabled` 表达来源、生效范围与启用状态;内置 Skill 启动或同步时自动写入数据库并默认全局启用,上传和远程添加统一改为解析草稿后确认安装,不保留旧直接安装兼容路径。 - 历史兼容层精简:移除 sandbox provisioner `local` 后端别名、ask_user_question 单问题旧协议、JWT 历史默认密钥特殊判断、内置 Skill `SKILLS.md` 文件名回退、运行事件数字 seq 兼容和前端旧字段回退。 - 用户身份命名收敛:原业务登录标识统一改为 `uid`,Agent/LangGraph runtime、conversation、agent_run、sandbox 路径和前端用户态均使用字符串 `uid`;`user_id` 仅保留给外部响应中的数值 `users.id` 或真实外键场景。 @@ -47,7 +47,7 @@ - 优化图谱抽取器配置:未配置时在图谱中心展示配置入口,抽取方案收敛为 LLM,前端仅保留“更多拓展中”占位;LLM 抽取器使用固定 Prompt + 自定义 Schema,并支持模型参数与并发队列数;已配置后允许修改参数并提示重置重抽风险。修复上传并入库新文件时旧内存 metadata 覆盖数据库图谱配置的问题。 - 新增 Milvus 图谱检索链路:Query 可召回图谱实体和三元组,结合 Chunk 命中实体构造 seed entity,读取 Neo4j 2-hop 子图后用 igraph 执行 PPR,最终以 Chunk 为产物并通过 RRF 与原 Chunk 召回融合;检索配置改为 dataclass 元数据生成,支持 `depend_on` 控制重排序和图检索参数展示。 - 收紧用户管理部门隔离:普通管理员创建用户时固定归属本部门,用户列表、访问选项、详情、更新和删除接口均限制在本部门范围内。 -- 调整 Agent 资源默认选择与运行时上下文:未显式配置工具、知识库、MCP、Skills、SubAgent 时默认启用当前用户可访问/可用的全部资源,显式保存空列表仍表示不启用对应资源;Agent 创建前统一完成最终资源权限过滤、知识库 `db_id` 可见范围派生和 Skill prompt/readable 依赖闭包派生,聊天运行时与文件系统预览复用同一结果。 +- 调整 Agent 资源默认选择与运行时上下文:未显式配置工具、知识库、MCP、Skills 时默认启用当前用户可访问/可用的全部资源,子智能体默认不启用,显式保存空列表仍表示不启用对应资源;Agent 创建前统一完成最终资源权限过滤、知识库 `db_id` 可见范围派生和 Skill prompt/readable 依赖闭包派生,聊天运行时与文件系统预览复用同一结果。 - 重构 Skills 权限与安装流程:Skill 增加 `source_type/share_config/enabled`,内置 Skill 作为启动同步入库的全局资源,不再保留前端安装/更新状态,支持启停但不允许删除;上传和远程添加统一为解析草稿后确认生效范围,管理端支持编辑生效范围与启停;Agent 运行时按当前用户可访问 Skills 派生 prompt/readable 依赖闭包并限制挂载/激活,Skills prompt 改为模型请求级注入以避免污染 runtime context。 - 精简历史兼容层:移除 sandbox provisioner `local` 后端别名、ask_user_question 单问题旧协议、JWT 历史默认密钥特殊判断、内置 Skill `SKILLS.md` 文件名回退、运行事件数字 seq 兼容和前端若干旧字段回退。 - 重构知识库共享权限:`share_config` 改为全局共享、部门共享、指定人可访问三档,部门共享必须包含当前用户部门,指定人可访问必须包含当前用户,并补充权限过滤测试。 @@ -72,6 +72,7 @@ - 收敛文件服务边界:文件预览判断抽为独立服务,Viewer 文件系统的 workspace 分支复用用户 workspace 服务,线程运行时上下文解析从泛化 `filesystem_service` 拆出为 agent runtime helper。 - 升级 DeepAgents 到 0.6.7 并适配新版文件系统协议:SubAgentMiddleware 改为显式 subagent spec,Skills prompt 补齐新版占位符;sandbox/skills backend 复用新版 `ReadResult`、`GlobResult`、`GrepResult` 等协议类型,文件权限在 backend 层明确区分 skills、uploads、outputs 与 workspace,保留最小 `CustomCompositeBackend` 以避免非 route glob 误扫其他 route;Agent 上下文压缩改为复用 DeepAgents SummarizationMiddleware,历史摘要与大工具结果统一 offload 到 outputs。 - 优化聊天输入 @ 文件提及:未创建 Thread 时可搜索用户 workspace,创建 Thread 后按当前对话文件优先、workspace 兜底的来源顺序搜索,并拆分 workspace/thread 缓存避免假 thread 与跨用户缓存污染;输入框与用户消息支持将 raw mention 渲染为带类型图标的引用单元,文件仅显示文件名且保留原始沙盒路径文本。 +- 重构子智能体为 Agent-backed 形态:移除旧 `subagents` 表与 `/api/system/subagents` 管理链路,子智能体改为 `agents.is_subagent=true` 且使用 `SubAgentBackend`,创建/编辑统一走 Agent 管理入口;内置后端收敛为 `ChatbotAgent` 与 `SubAgentBackend`,Context 分为 `BaseContext`、`ChatBotContext` 与 `SubAgentContext`;主 Agent 通过 Yuxi task middleware 启动真实子 Agent graph,子智能体不再嵌套调用子智能体。沙盒挂载同步拆分为 child checkpoint thread、父对话 uploads/outputs、用户级 workspace 与子 Agent skills scope;主线程状态记录 `subagent_runs` 并在前端 task 工具中展示子智能体名称、执行状态、child thread 和产物,真实流式 E2E 覆盖子智能体输出文件可由父线程文件/Viewer API 读取。流式链路参考 DeepAgents event streaming,后端将 LangGraph v3 raw event 归一化为 Yuxi semantic stream event,按父/子线程归属隔离 run SSE chunk,并支持通过 child thread state 拉取子智能体中间过程。 ---