feat: 重构子智能体运行链路

This commit is contained in:
Wenjie Zhang 2026-06-01 22:28:45 +08:00
parent 3fe5d307bf
commit b26bb5d12e
44 changed files with 2577 additions and 1905 deletions

View File

@ -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

View File

@ -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" },

View File

@ -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()

View File

@ -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"]

View File

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

View File

@ -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(),
)

View File

@ -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]

View File

@ -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 的深度分析智能体"

View File

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

View File

@ -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

View File

@ -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 一个主题不要传递多个子问题
以下是撰写最终报告的说明
<report_instructions>
关键确保答案的语言与人类信息的语言相同如果你制定了一个待办事项计划你应该在计划中注明报告应该使用什么语言
注意报告应该使用的语言是问题所在的语言而不是问题所涉及的国家/地区的语言
请根据整体研究简报创建一个详细的答案该答案应
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/文件路径
- 引用非常重要请确保包含这些内容并特别注意确保其正确性用户通常会使用这些引文来查找更多信息
</引用规则>
</report_instructions>
你可以使用一些工具
系统主要工作路径为 {VIRTUAL_PATH_PREFIX}但必须遵守规范
- {VIRTUAL_PATH_WORKSPACE}用于存放工作文件用户目录不要轻易写入
- {VIRTUAL_PATH_OUTPUTS}用于写入的文件夹
- {VIRTUAL_PATH_OUTPUTS}/tmp/用于存放中间结果或备份内容
- {VIRTUAL_PATH_UPLOADS}用于存放用户上传的文件
"""

View File

@ -0,0 +1,4 @@
from .context import SubAgentContext
from .graph import SubAgentBackend
__all__ = ["SubAgentBackend", "SubAgentContext"]

View File

@ -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},
)

View File

@ -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(),
)

View File

@ -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):

View File

@ -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` 中写清目标上下文和期望输出
- 不要通过 shellcurlHTTP 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)

View File

@ -28,3 +28,4 @@ class AgentStatePayload(TypedDict):
todos: list
files: dict
artifacts: list[str]
subagent_runs: list[dict]

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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 {},

View File

@ -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")

View File

@ -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 模型"""

View File

@ -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/* 工作台文件系统视图

View File

@ -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):

View File

@ -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

View File

@ -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)

View File

@ -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")

View File

@ -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)

View File

@ -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"],

View File

@ -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": [],
}
]

View File

@ -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"]

View File

@ -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"}]

View File

@ -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:

View File

@ -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,

View File

@ -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 函数"""

View File

@ -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] = []

View File

@ -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)

View File

@ -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例如 `<parent_thread_id>_sub_<slug>_<uuid8>`
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。

View File

@ -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 specSkills prompt 补齐新版占位符sandbox/skills backend 复用新版 `ReadResult`、`GlobResult`、`GrepResult` 等协议类型,文件权限在 backend 层明确区分 skills、uploads、outputs 与 workspace保留最小 `CustomCompositeBackend` 以避免非 route glob 误扫其他 routeAgent 上下文压缩改为复用 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 拉取子智能体中间过程。
---