refactor(agents): 智能体配置改为用户隔离并添加权限控制

- AgentConfigRepository 从 department_id 改为 uid
- 添加 Context 字段权限过滤功能
- 更新 sandbox paths 和 base agent 配置
This commit is contained in:
Wenjie Zhang 2026-05-18 09:40:19 +08:00
parent 78116ec59d
commit 96dfaf619a
5 changed files with 82 additions and 44 deletions

View File

@ -63,12 +63,21 @@ def sandbox_workspace_agents_prompt_file(thread_id: str, uid: str) -> Path:
return sandbox_workspace_dir(thread_id, uid) / WORKSPACE_AGENTS_DIR_NAME / WORKSPACE_AGENTS_PROMPT_FILE_NAME
def _chmod_writable(path: Path, *, dir: bool = False) -> None:
mode = 0o777 if dir else 0o666
try:
path.chmod(mode)
except OSError:
pass
def ensure_workspace_default_files(workspace_dir: Path) -> None:
agents_dir = workspace_dir / WORKSPACE_AGENTS_DIR_NAME
agents_file = agents_dir / WORKSPACE_AGENTS_PROMPT_FILE_NAME
try:
agents_dir.mkdir(parents=True, exist_ok=True)
_chmod_writable(agents_dir, dir=True)
except FileExistsError:
logger.warning("工作区默认 Agents 目录创建失败:路径已被文件占用")
return
@ -79,6 +88,7 @@ def ensure_workspace_default_files(workspace_dir: Path) -> None:
try:
with agents_file.open("xb"):
pass
_chmod_writable(agents_file)
except FileExistsError:
if agents_file.is_dir():
logger.warning("工作区默认 AGENTS.md 创建失败:路径已被目录占用")

View File

@ -41,12 +41,12 @@ class BaseAgent:
"""Get the agent's class name."""
return self.__class__.__name__
async def get_info(self, include_configurable_items: bool = True):
async def get_info(self, include_configurable_items: bool = True, user_role: str | None = None):
# metadata 固定在代码中,由各 Agent 的类属性提供
metadata = self.load_metadata()
configurable_items = {}
if include_configurable_items:
configurable_items = self.context_schema.get_configurable_items()
configurable_items = self.context_schema.get_configurable_items(user_role=user_role)
# Merge metadata with class attributes, metadata takes precedence
return {

View File

@ -11,21 +11,19 @@ PROMPT = f"""
专门用来回答用户的问题请根据用户提供的信息尽可能详细地回答问题
如果你不确定答案可以说你不知道但请尽量提供相关的信息或建议请保持礼貌和专业
<| 内部执行约束 |>
以下内容仅用于指导你的内部执行过程不属于面向用户的基本设定除非用户明确询问系统如何工作否则不要主动向用户说明工作区文件系统知识库路径工具调用方式等内部实现细节
<| 内部执行约束:重要 |>
以下内容仅用于指导你的内部执行过程不属于面向用户的基本设定除非用户明确询问系统如何工作
否则不要主动向用户说明工作区文件系统知识库路径工具调用方式等内部实现细节
<| 文件系统约束 |>
系统主要工作路径为 {VIRTUAL_PATH_PREFIX}但必须遵守规范
- {VIRTUAL_PATH_WORKSPACE}用于存放工作文件用户目录不要轻易写入
- {VIRTUAL_PATH_OUTPUTS}用于写入的文件夹
- {VIRTUAL_PATH_OUTPUTS}/tmp/用于存放中间结果或备份内容
- {VIRTUAL_PATH_UPLOADS}用于存放用户上传的文件
- {VIRTUAL_PATH_UPLOADS}用于存放用户上传的附件只读除非用户要求否则不得写入
- {VIRTUAL_PATH_WORKSPACE}用于存放用户文件用户私人目录除非用户要求否则不得写入
- 其他路径非必要不写入其他路径
非必要不写入其他路径
<| 知识库访问 |>
query_kb 中没有找到相关内容或者需要进一步基于检索结果获取更详细上下文时
使用 open_kb_document resource_id file_id 打开知识库文档
"""
# 效果不好,暂时不启用

View File

@ -7,6 +7,41 @@ from typing import get_origin
from yuxi import config as sys_config
def _role_can_access(auth: str | None, role: str | None) -> bool:
if not auth:
return True
if auth == "admin":
return role in {"admin", "superadmin"}
if auth == "superadmin":
return role == "superadmin"
return False
def filter_config_by_role(
config_json: dict,
role: str | None,
context_schema: type["BaseContext"] | None = None,
) -> dict:
"""按 Context 字段 metadata.auth 过滤 config_json.context。"""
if not isinstance(config_json, dict):
return {}
schema = context_schema or BaseContext
restricted_fields = {
f.name
for f in fields(schema)
if f.metadata.get("auth") and not _role_can_access(str(f.metadata.get("auth")), role)
}
if not restricted_fields:
return dict(config_json)
filtered = dict(config_json)
context = filtered.get("context")
if isinstance(context, dict):
filtered["context"] = {key: value for key, value in context.items() if key not in restricted_fields}
return filtered
@dataclass(kw_only=True)
class BaseContext:
"""
@ -123,11 +158,13 @@ class BaseContext:
)
@classmethod
def get_configurable_items(cls):
def get_configurable_items(cls, user_role: str | None = None):
"""实现一个可配置的参数列表,在 UI 上配置时使用"""
configurable_items = {}
for f in fields(cls):
if f.init and not f.metadata.get("hide", False):
if user_role is not None and not _role_can_access(f.metadata.get("auth"), user_role):
continue
if f.metadata.get("configurable", True):
type_name = cls._get_type_name(f.type)

View File

@ -28,10 +28,10 @@ class AgentConfigRepository:
def __init__(self, db_session: AsyncSession):
self.db = db_session
async def list_by_department_agent(self, *, department_id: int, agent_id: str) -> list[AgentConfig]:
async def list_by_user_agent(self, *, uid: str, agent_id: str) -> list[AgentConfig]:
result = await self.db.execute(
select(AgentConfig)
.where(AgentConfig.department_id == department_id, AgentConfig.agent_id == agent_id)
.where(AgentConfig.uid == uid, AgentConfig.agent_id == agent_id)
.order_by(AgentConfig.is_default.desc(), AgentConfig.id.asc())
)
return list(result.scalars().all())
@ -45,16 +45,13 @@ class AgentConfigRepository:
return result.scalar_one_or_none()
async def set_default(self, *, config: AgentConfig, updated_by: str | None = None) -> AgentConfig:
if config.is_default:
return config
now = utc_now_naive()
# 先清空该部门+智能体的所有默认配置
# 先清空该用户+智能体的所有默认配置
await self.db.execute(
update(AgentConfig)
.where(
AgentConfig.department_id == config.department_id,
AgentConfig.uid == config.uid,
AgentConfig.agent_id == config.agent_id,
)
.values(is_default=False, updated_at=now, updated_by=updated_by)
@ -69,10 +66,10 @@ class AgentConfigRepository:
await self.db.refresh(config)
return config
async def get_default(self, *, department_id: int, agent_id: str) -> AgentConfig | None:
async def get_default(self, *, uid: str, agent_id: str) -> AgentConfig | None:
result = await self.db.execute(
select(AgentConfig).where(
AgentConfig.department_id == department_id,
AgentConfig.uid == uid,
AgentConfig.agent_id == agent_id,
AgentConfig.is_default.is_(True),
)
@ -82,20 +79,20 @@ class AgentConfigRepository:
async def get_or_create_default(
self,
*,
department_id: int,
uid: str,
agent_id: str,
created_by: str | None = None,
) -> AgentConfig:
existing = await self.get_default(department_id=department_id, agent_id=agent_id)
existing = await self.get_default(uid=uid, agent_id=agent_id)
if existing:
return existing
items = await self.list_by_department_agent(department_id=department_id, agent_id=agent_id)
items = await self.list_by_user_agent(uid=uid, agent_id=agent_id)
if items:
return items[0]
config = AgentConfig(
department_id=department_id,
uid=uid,
agent_id=agent_id,
name=DEFAULT_CONFIG_NAME,
description=None,
@ -114,9 +111,9 @@ class AgentConfigRepository:
await self.db.refresh(config)
return config
async def _name_exists(self, *, department_id: int, agent_id: str, name: str, exclude_id: int | None) -> bool:
async def _name_exists(self, *, uid: str, agent_id: str, name: str, exclude_id: int | None) -> bool:
stmt = select(AgentConfig.id).where(
AgentConfig.department_id == department_id,
AgentConfig.uid == uid,
AgentConfig.agent_id == agent_id,
AgentConfig.name == name,
)
@ -128,36 +125,30 @@ class AgentConfigRepository:
async def ensure_unique_name(
self,
*,
department_id: int,
uid: str,
agent_id: str,
desired_name: str,
exclude_id: int | None = None,
) -> str:
candidate = desired_name.strip() or "未命名配置"
if not await self._name_exists(
department_id=department_id, agent_id=agent_id, name=candidate, exclude_id=exclude_id
):
if not await self._name_exists(uid=uid, agent_id=agent_id, name=candidate, exclude_id=exclude_id):
return candidate
base = f"{candidate}-副本"
if not await self._name_exists(
department_id=department_id, agent_id=agent_id, name=base, exclude_id=exclude_id
):
if not await self._name_exists(uid=uid, agent_id=agent_id, name=base, exclude_id=exclude_id):
return base
idx = 2
while True:
candidate2 = f"{base}{idx}"
if not await self._name_exists(
department_id=department_id, agent_id=agent_id, name=candidate2, exclude_id=exclude_id
):
if not await self._name_exists(uid=uid, agent_id=agent_id, name=candidate2, exclude_id=exclude_id):
return candidate2
idx += 1
async def create(
self,
*,
department_id: int,
uid: str,
agent_id: str,
name: str,
description: str | None = None,
@ -169,13 +160,13 @@ class AgentConfigRepository:
created_by: str | None = None,
) -> AgentConfig:
unique_name = await self.ensure_unique_name(
department_id=department_id,
uid=uid,
agent_id=agent_id,
desired_name=name,
exclude_id=None,
)
config = AgentConfig(
department_id=department_id,
uid=uid,
agent_id=agent_id,
name=unique_name,
description=description,
@ -183,7 +174,7 @@ class AgentConfigRepository:
pics=pics or [],
examples=examples or [],
config_json=config_json or {},
is_default=bool(is_default),
is_default=False,
created_by=created_by,
updated_by=created_by,
created_at=utc_now_naive(),
@ -192,6 +183,8 @@ class AgentConfigRepository:
self.db.add(config)
await self.db.commit()
await self.db.refresh(config)
if is_default:
return await self.set_default(config=config, updated_by=created_by)
return config
async def update(
@ -208,7 +201,7 @@ class AgentConfigRepository:
) -> AgentConfig:
if name is not None:
config.name = await self.ensure_unique_name(
department_id=config.department_id,
uid=config.uid,
agent_id=config.agent_id,
desired_name=name,
exclude_id=config.id,
@ -231,16 +224,16 @@ class AgentConfigRepository:
return config
async def delete(self, *, config: AgentConfig, updated_by: str | None = None) -> None:
department_id = config.department_id
uid = config.uid
agent_id = config.agent_id
was_default = bool(config.is_default)
await self.db.delete(config)
await self.db.commit()
remaining = await self.list_by_department_agent(department_id=department_id, agent_id=agent_id)
remaining = await self.list_by_user_agent(uid=uid, agent_id=agent_id)
if not remaining:
await self.get_or_create_default(department_id=department_id, agent_id=agent_id, created_by=updated_by)
await self.get_or_create_default(uid=uid, agent_id=agent_id, created_by=updated_by)
return
if was_default: