diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d0517eac7..8a78227f6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -105,7 +105,7 @@ Yuxi 是一个面向 RAG、知识图谱和多智能体工作流的知识库平 - 跨 repository 的身份管理用例只有一个 service 事务 Owner;Department、User 与强制 OperationLog 同一提交。API Key 由独立服务端主密钥和客户端幂等 ID 确定性派生,只保存 hash;原始创建意图使用不可变指纹校验,撤销保留 request-id tombstone,同一请求可恢复响应但不能复活已撤销凭据。 - 前端 API 调用集中在 `web/src/apis`,组件不要散落拼接普通 HTTP 接口。 - 智能体能力通过 context、middleware、toolkits、Skills、MCP 和 backends 组合;不要把知识库、沙盒或扩展逻辑硬编码进单个页面或路由。 -- Skill 依赖工具只有在对应 Skill 激活后才对模型开放;基础工具与受 Skill 门控的工具要保持边界。 +- Skill 的依赖工具只有在对应 Skill 被显式预加载或动态激活后才对模型开放;基础工具与受 Skill 门控的工具保持边界。 - LITE 模式必须允许跳过知识库、图谱和评估等重依赖能力,新增导入、路由和启动逻辑时要尊重该边界。 - 文件边界只使用三种跨层路径:数据库 `projects/`、Viewer 当前 scope 相对 `/foo`、Agent/artifact runtime 绝对 `/home/gem/user-data/...`;宿主 `Path` 由 `yuxi.workspace` 或显式 v0.7.1 storage migration 内部持有,普通 Service/Repository 不得取得。 - 沙盒虚拟路径由当前 Project Workdir、User Data 与共享 Skills 根共同约束;个人 Skill 保存在 UserWorkspace 的 `agents/skills`,共享与内置 Skill 才投影到只读 `/home/gem/skills`。用户可见路径、对象存储 URL 与宿主机真实路径不能混用。 diff --git a/backend/package/yuxi/agents/context.py b/backend/package/yuxi/agents/context.py index 566d3c8bf..aabf508f0 100644 --- a/backend/package/yuxi/agents/context.py +++ b/backend/package/yuxi/agents/context.py @@ -139,9 +139,6 @@ def _lite_mode_enabled() -> bool: return lite_mode_enabled() -_LITE_DISABLED_SKILL_SLUGS = frozenset({"knowledge-base"}) - - @dataclass(kw_only=True) class BaseContext: """ @@ -269,6 +266,17 @@ def update(self, data: dict): }, ) + preload_skills: list[str] = field( + default_factory=list, + metadata={ + "name": "预加载 Skills", + "options": [], + "description": "创建 Agent Graph 时加载完整 Skill 说明,并从首轮开放其依赖工具。默认不预加载。", + "type": "list", + "kind": "skills", + }, + ) + summary_threshold: int = field( default=DEFAULT_SUMMARY_THRESHOLD_K, metadata={ @@ -498,13 +506,14 @@ async def resolve_agent_resource_options( if server.slug in enabled_slugs ] if "skills" in fields_to_load: + from yuxi.agents.skills.runtime import is_skill_allowed_in_runtime_mode from yuxi.agents.skills.service import list_accessible_skills skills = await list_accessible_skills(db, user) options["skills"] = [ _resource_option(skill.slug, skill.name, skill.description) for skill in skills - if skill.slug and not (_lite_mode_enabled() and skill.slug in _LITE_DISABLED_SKILL_SLUGS) + if skill.slug and is_skill_allowed_in_runtime_mode(skill.slug) ] if "subagents" in fields_to_load: from yuxi.repositories.agent_repository import AgentRepository @@ -530,25 +539,26 @@ async def normalize_agent_context_config( normalized = dict(filtered.get("context") or {}) field_names = {item.name for item in fields(schema)} resource_fields = _AGENT_RESOURCE_FIELDS & field_names - if not resource_fields: - return normalized - fields_to_load = _resource_fields_requiring_available_keys(normalized, resource_fields) - if not fields_to_load: - return normalized + if fields_to_load: + resource_options = await resolve_agent_resource_options(fields_to_load, db=db, user=user) + available = { + field_name: [option["key"] for option in field_options] + for field_name, field_options in resource_options.items() + } - resource_options = await resolve_agent_resource_options(fields_to_load, db=db, user=user) - available = { - field_name: [option["key"] for option in field_options] - for field_name, field_options in resource_options.items() - } + for field_name, available_keys in available.items(): + current = normalized.get(field_name) + if current is None: + normalized[field_name] = available_keys + else: + normalized[field_name] = _normalize_selected_resource_keys(current, available_keys) - for field_name, available_keys in available.items(): - current = normalized.get(field_name) - if current is None: - normalized[field_name] = available_keys - else: - normalized[field_name] = _normalize_selected_resource_keys(current, available_keys) + if "preload_skills" in field_names: + normalized["preload_skills"] = _normalize_selected_resource_keys( + normalized.get("preload_skills"), + normalized.get("skills", []), + ) return normalized @@ -569,22 +579,25 @@ async def prepare_agent_runtime_context( from yuxi.storage.postgres.manager import pg_manager resource_fields = _AGENT_RESOURCE_FIELDS + context_resource_fields = resource_fields | {"preload_skills"} async with pg_manager.get_async_session_context() as db: if not str(getattr(context, "model", "") or "").strip(): setattr(context, "model", (await system_options.get(db))["default_model"]) user = await UserRepository().get_by_uid_with_db(db, uid) if user is None: - for field_name in resource_fields: + for field_name in context_resource_fields: if hasattr(context, field_name): setattr(context, field_name, []) setattr(context, "_visible_knowledge_bases", []) setattr(context, "_effective_skill_slugs", []) setattr(context, "_runtime_skills", {}) + setattr(context, "_preloaded_skills", []) + setattr(context, "_preloaded_skill_contents", {}) return context raw_resources = { field_name: getattr(context, field_name, None) - for field_name in resource_fields + for field_name in context_resource_fields if hasattr(context, field_name) } normalized = await normalize_agent_context_config( @@ -593,7 +606,7 @@ async def prepare_agent_runtime_context( user=user, context_schema=schema, ) - for field_name in resource_fields: + for field_name in context_resource_fields: if hasattr(context, field_name): setattr(context, field_name, normalized.get(field_name, [])) @@ -604,9 +617,14 @@ async def prepare_agent_runtime_context( from yuxi.agents.backends.knowledge_base_backend import resolve_visible_knowledge_bases_for_context await resolve_visible_knowledge_bases_for_context(context) - skill_scope = await resolve_runtime_skills_for_context(context, db=db, user=user) + skill_scope = getattr(context, "_skill_runtime_snapshot", None) + if not isinstance(skill_scope, dict): + skill_scope = await resolve_runtime_skills_for_context(context, db=db, user=user) context.skills = skill_scope["context_skills"] + context.preload_skills = skill_scope["context_preload_skills"] setattr(context, "_effective_skill_slugs", skill_scope["effective_skills"]) setattr(context, "_runtime_skills", skill_scope["runtime_skills"]) + setattr(context, "_preloaded_skills", skill_scope["preloaded_skills"]) + setattr(context, "_preloaded_skill_contents", skill_scope["preloaded_skill_contents"]) return context diff --git a/backend/package/yuxi/agents/middlewares/skills.py b/backend/package/yuxi/agents/middlewares/skills.py index 8df732f0e..e7b0f369b 100644 --- a/backend/package/yuxi/agents/middlewares/skills.py +++ b/backend/package/yuxi/agents/middlewares/skills.py @@ -33,12 +33,12 @@ class SkillsState(AgentState): class SkillsMiddleware(AgentMiddleware): - """Skills 中间件 - 处理 skills 提示词注入、依赖展开、动态激活 + """Skills 中间件 - 处理提示词注入、预加载、依赖展开和动态激活 职责: - - Skills 提示词注入(直接从数据库加载) - - 依赖展开(用户配置 + 动态激活) - - 工具/MCP 动态加载 + - Skills 摘要提示与预加载完整说明注入 + - 依赖展开(预加载配置 + 动态激活) + - 本地/MCP 依赖工具的模型可见性门控 """ state_schema = SkillsState @@ -72,14 +72,21 @@ async def awrap_model_call( effective_skills = getattr(runtime_context, "_effective_skill_slugs", None) if isinstance(effective_skills, list): effective_skills = normalize_string_list(effective_skills) - if effective_skills: - skills_meta = self._collect_prompt_metadata(effective_skills, runtime_context) + preloaded_skills = self._get_preloaded_skills(runtime_context) + preloaded_set = set(preloaded_skills) + prompt_sections: list[str] = [] + lazy_skills = [slug for slug in effective_skills if slug not in preloaded_set] + if lazy_skills: + skills_meta = self._collect_prompt_metadata(lazy_skills, runtime_context) if skills_meta: - skills_section = self._build_skills_section(skills_meta) - system_message = append_to_system_message( - getattr(request, "system_message", None), skills_section - ) - request = request.override(system_message=system_message) + prompt_sections.append(self._build_skills_section(skills_meta)) + if preloaded_skills: + prompt_sections.append(self._build_preloaded_skills_section(preloaded_skills, runtime_context)) + if prompt_sections: + system_message = append_to_system_message( + getattr(request, "system_message", None), "\n\n".join(prompt_sections) + ) + request = request.override(system_message=system_message) state = request.state if isinstance(request.state, dict) else {} activated = state.get("activated_skills", []) or [] @@ -88,6 +95,7 @@ async def awrap_model_call( effective_skills = self._get_effective_skills(runtime_context) activated = [slug for slug in normalize_string_list(activated) if slug in effective_skills] + activated = _activated_skills_reducer(self._get_preloaded_skills(runtime_context), activated) deps_bundle = build_dependency_bundle(activated, self._get_runtime_skills(runtime_context)) activated_tool_names = set(deps_bundle["tools"]) @@ -100,22 +108,34 @@ async def awrap_model_call( if gated_tool_names: model_tools = [t for t in model_tools if t.name not in gated_tool_names] - # 追加已激活 Skill 的依赖工具:本地工具确保绑定给模型,MCP 工具按需加载 + # 追加已激活或预加载 Skill 的依赖工具。 enabled_tools = [] + active_mcp_tools = [] if activated_tool_names: enabled_tools = [t for t in get_all_tool_instances() if t.name in activated_tool_names] if deps_bundle["mcps"]: - enabled_tools.extend( - await self._get_mcp_tools_from_context(runtime_context, extra_mcps=deps_bundle["mcps"]) - ) + active_mcp_tools = await self._get_mcp_tools_from_context(runtime_context, extra_mcps=deps_bundle["mcps"]) + active_mcp_tools_by_name = {} + for tool in active_mcp_tools: + existing = active_mcp_tools_by_name.get(tool.name) + if existing is not None and existing is not tool: + raise RuntimeError(f"Skill MCP 工具名冲突:{tool.name}") + active_mcp_tools_by_name[tool.name] = tool + setattr(runtime_context, "_active_skill_mcp_tools", active_mcp_tools_by_name) existing_tool_names = {t.name for t in model_tools} for t in enabled_tools: - if t.name not in existing_tool_names: - model_tools.append(t) - existing_tool_names.add(t.name) - - if gated_tool_names or enabled_tools: + if t.name in existing_tool_names: + continue + model_tools.append(t) + existing_tool_names.add(t.name) + for t in active_mcp_tools: + if t.name in existing_tool_names: + raise RuntimeError(f"Skill MCP 工具名冲突:{t.name}") + model_tools.append(t) + existing_tool_names.add(t.name) + + if gated_tool_names or enabled_tools or active_mcp_tools: request = request.override(tools=model_tools) return await handler(request) @@ -130,18 +150,6 @@ def _resolve_gated_tool_names(self, runtime_context) -> set[str]: gated.update(runtime_skills.get(slug, {}).get("tools", [])) return gated - base_tool_names - def _collect_prompt_metadata(self, slugs: list[str], runtime_context) -> list[RuntimeSkill]: - """收集指定 slugs 的提示词元数据""" - runtime_skills = self._get_runtime_skills(runtime_context) - result: list[RuntimeSkill] = [] - for slug in slugs: - item = runtime_skills.get(slug) - if not item: - logger.debug(f"Skill slug not found in prompt metadata, skip: {slug}") - continue - result.append(dict(item)) - return result - async def _get_mcp_tools_from_context( self, context, @@ -151,14 +159,11 @@ async def _get_mcp_tools_from_context( """从上下文配置中获取 MCP 工具列表""" import asyncio - # MCP 工具(并行加载) - mcps = getattr(context, "mcps", None) or [] + # 显式 MCP 已在 Graph 基础工具中注册,这里只加载 Skill 新增依赖。 + configured_mcps = set(normalize_string_list(getattr(context, "mcps", None))) all_mcp_names: list[str] = [] - for server_name in mcps: - if isinstance(server_name, str): - all_mcp_names.append(server_name) for server_name in extra_mcps or []: - if isinstance(server_name, str): + if isinstance(server_name, str) and server_name not in configured_mcps: all_mcp_names.append(server_name) # 去重 @@ -183,6 +188,18 @@ async def load_mcp_tools(server_name: str) -> list: return selected_tools + def _collect_prompt_metadata(self, slugs: list[str], runtime_context) -> list[RuntimeSkill]: + """收集指定 slugs 的提示词元数据""" + runtime_skills = self._get_runtime_skills(runtime_context) + result: list[RuntimeSkill] = [] + for slug in slugs: + item = runtime_skills.get(slug) + if not item: + logger.debug(f"Skill slug not found in prompt metadata, skip: {slug}") + continue + result.append(dict(item)) + return result + def _process_tool_call_result(self, result: Any, request: ToolCallRequest) -> Any: """处理工具调用结果,检查并处理 skill 动态激活""" if request.tool_call.get("name") != "read_file": @@ -208,6 +225,7 @@ async def awrap_tool_call( handler: Callable[[ToolCallRequest], Any], ): """包装工具调用,处理 skill 动态激活""" + request = self._bind_active_mcp_tool(request) result = await handler(request) return self._process_tool_call_result(result, request) @@ -217,9 +235,22 @@ def wrap_tool_call( handler: Callable[[ToolCallRequest], Any], ): """同步版本的工具调用包装""" + request = self._bind_active_mcp_tool(request) result = handler(request) return self._process_tool_call_result(result, request) + def _bind_active_mcp_tool(self, request: ToolCallRequest) -> ToolCallRequest: + """为当前模型轮次已开放的动态 MCP 调用绑定真实工具。""" + + if request.tool is not None: + return request + context = request.runtime.context + active_tools = getattr(context, "_active_skill_mcp_tools", {}) + if not isinstance(active_tools, dict): + return request + tool = active_tools.get(request.tool_call.get("name")) + return request.override(tool=tool) if tool is not None else request + def _extract_skill_slug_from_skill_md_path(self, file_path: Any) -> str | None: """从共享投影或个人 UserWorkspace 的 SKILL.md 路径中提取 slug。""" if not isinstance(file_path, str): @@ -245,6 +276,26 @@ def _get_runtime_skills(self, runtime_context) -> dict[str, RuntimeSkill]: runtime_skills = getattr(runtime_context, "_runtime_skills", {}) return runtime_skills if isinstance(runtime_skills, dict) else {} + def _get_preloaded_skills(self, runtime_context) -> list[str]: + selected = getattr(runtime_context, "_preloaded_skills", []) + effective = self._get_effective_skills(runtime_context) + return [ + slug for slug in normalize_string_list(selected if isinstance(selected, list) else []) if slug in effective + ] + + def _build_preloaded_skills_section(self, slugs: list[str], runtime_context) -> str: + """构建已预加载 Skill 的完整系统提示段。""" + + contents = getattr(runtime_context, "_preloaded_skill_contents", {}) + if not isinstance(contents, dict): + contents = {} + sections = ["# Preloaded Skills", "The following Skill instructions are already loaded and active."] + for slug in slugs: + content = contents.get(slug) + if isinstance(content, str): + sections.append(f'\n{content}\n') + return "\n\n".join(sections) + def _merge_activated_skill_update(self, result: Any, slug: str): """合并动态激活的 skill 更新""" from langchain_core.messages import ToolMessage diff --git a/backend/package/yuxi/agents/skills/runtime.py b/backend/package/yuxi/agents/skills/runtime.py index 2019b9f68..8498d3cd1 100644 --- a/backend/package/yuxi/agents/skills/runtime.py +++ b/backend/package/yuxi/agents/skills/runtime.py @@ -2,15 +2,20 @@ from __future__ import annotations -from typing import TypedDict +import asyncio +import os +from pathlib import Path +from typing import Any, TypedDict from sqlalchemy.ext.asyncio import AsyncSession from yuxi.agents.backends.paths import VIRTUAL_PERSONAL_SKILLS_PATH, VIRTUAL_SKILLS_PATH from yuxi.agents.skills.service import list_accessible_skills, normalize_string_list from yuxi.agents.toolkits import get_all_tool_instances +from yuxi.config.runtime import lite_mode_enabled from yuxi.storage.postgres.models_business import User from yuxi.utils.logging_config import logger +from yuxi.utils.paths import open_regular_file_fd class RuntimeSkill(TypedDict): @@ -24,6 +29,15 @@ class RuntimeSkill(TypedDict): skills: list[str] +_LITE_DISABLED_SKILL_SLUGS = frozenset({"knowledge-base"}) + + +def is_skill_allowed_in_runtime_mode(slug: str) -> bool: + """判断 Skill 是否属于当前部署模式允许的运行时能力。""" + + return not (lite_mode_enabled() and slug in _LITE_DISABLED_SKILL_SLUGS) + + def build_runtime_skills(skills: list) -> dict[str, RuntimeSkill]: """从已授权 Skill 构建运行时信息。""" result: dict[str, RuntimeSkill] = {} @@ -86,20 +100,57 @@ async def resolve_runtime_skills_for_context( db: AsyncSession, user: User, ) -> dict: - """从已授权 Skill 派生当前 Agent Run 的运行时 scope。""" - skill_items = await list_accessible_skills(db, user) + """从已授权 Skill 派生当前 Agent Run 的运行时 scope 与预加载快照。""" + skill_items = [ + item + for item in await list_accessible_skills(db, user) + if item.slug and is_skill_allowed_in_runtime_mode(item.slug) + ] runtime_skills = build_runtime_skills(skill_items) available = set(runtime_skills) selected = normalize_string_list(getattr(context, "skills", None)) context_skills = [slug for slug in selected if slug in available] effective_skills = expand_skill_closure(context_skills, runtime_skills) + configured_preloads = normalize_string_list(getattr(context, "preload_skills", None)) + context_preload_skills = [slug for slug in configured_preloads if slug in context_skills] + preloaded_skills = expand_skill_closure(context_preload_skills, runtime_skills) + items_by_slug = {item.slug: item for item in skill_items} + preloaded_contents = ( + await asyncio.to_thread(_read_preloaded_skill_contents, preloaded_skills, items_by_slug) + if preloaded_skills + else {} + ) return { "context_skills": context_skills, + "context_preload_skills": context_preload_skills, "effective_skills": effective_skills, "runtime_skills": runtime_skills, + "runtime_skill_source_scopes": {slug: items_by_slug[slug].source_scope for slug in effective_skills}, + "preloaded_skills": preloaded_skills, + "preloaded_skill_contents": preloaded_contents, } +def _read_preloaded_skill_contents(slugs: list[str], skill_items: dict[str, Any]) -> dict[str, str]: + """从授权解析得到的真实来源读取根级 SKILL.md。""" + + contents: dict[str, str] = {} + for slug in slugs: + try: + source_dir = Path(skill_items[slug].source_dir) + if not source_dir.is_absolute() or ".." in source_dir.parts: + raise OSError("Skill 来源目录必须是规范化绝对路径") + with open_regular_file_fd( + Path(source_dir.anchor), + (*source_dir.parts[1:], "SKILL.md"), + ) as (file_fd, _file_stat): + with os.fdopen(os.dup(file_fd), encoding="utf-8") as skill_file: + contents[slug] = skill_file.read() + except (OSError, UnicodeError) as exc: + raise RuntimeError(f"预加载 Skill '{slug}' 失败:根级 SKILL.md 不可读") from exc + return contents + + def resolve_skill_gated_tools(context) -> list: """解析所有可见 Skill 依赖且需注册到 ToolNode 的本地工具。""" runtime_skills = getattr(context, "_runtime_skills", {}) or {} diff --git a/backend/package/yuxi/agents/toolkits/service.py b/backend/package/yuxi/agents/toolkits/service.py index 2971bf7cf..e8025d08f 100644 --- a/backend/package/yuxi/agents/toolkits/service.py +++ b/backend/package/yuxi/agents/toolkits/service.py @@ -99,6 +99,7 @@ async def resolve_configured_runtime_tools(context) -> list[Any]: selected_tools = [] selected_tool_names: set[str] = set() + selected_tool_sources: dict[str, str] = {} buildin_tools = {tool.name: tool for tool in get_tool_instances_by_category("buildin")} for tool_name in getattr(context, "tools", None) or []: @@ -110,6 +111,7 @@ async def resolve_configured_runtime_tools(context) -> list[Any]: continue selected_tools.append(tool) selected_tool_names.add(tool_name) + selected_tool_sources[tool_name] = "local" selected_mcp_servers: set[str] = set() for server_name in getattr(context, "mcps", None) or []: @@ -126,9 +128,12 @@ async def resolve_configured_runtime_tools(context) -> list[Any]: continue for tool in mcp_tools: if tool.name in selected_tool_names: - continue + raise RuntimeError( + f"工具名冲突:MCP '{server_name}' 的 '{tool.name}' 与 {selected_tool_sources[tool.name]} 工具同名" + ) selected_tools.append(tool) selected_tool_names.add(tool.name) + selected_tool_sources[tool.name] = f"MCP '{server_name}'" # Skill 依赖的本地工具:必须随基础工具一起注册进 create_agent 的 ToolNode 才可执行, # 否则 Skill 激活后模型虽能发起调用,执行器仍报 "not a valid tool"。 @@ -137,8 +142,12 @@ async def resolve_configured_runtime_tools(context) -> list[Any]: for tool in resolve_skill_gated_tools(context): if tool.name in selected_tool_names: + if selected_tool_sources[tool.name] != "local": + source = selected_tool_sources[tool.name] + raise RuntimeError(f"工具名冲突:Skill 本地工具 '{tool.name}' 与 {source} 同名") continue selected_tools.append(tool) selected_tool_names.add(tool.name) + selected_tool_sources[tool.name] = "local" return selected_tools diff --git a/backend/package/yuxi/services/agent_run_manifest_service.py b/backend/package/yuxi/services/agent_run_manifest_service.py index 32f685981..45b697727 100644 --- a/backend/package/yuxi/services/agent_run_manifest_service.py +++ b/backend/package/yuxi/services/agent_run_manifest_service.py @@ -11,12 +11,15 @@ import hashlib import json import os +from dataclasses import dataclass from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from yuxi.agents.buildin import agent_manager from yuxi.agents.context import normalize_agent_context_config +from yuxi.agents.skills.runtime import resolve_runtime_skills_for_context +from yuxi.agents.skills.service import PERSONAL_SKILL_SOURCE_TYPE from yuxi.repositories.agent_repository import AgentRepository from yuxi.storage.postgres.models_business import AgentRun, Skill, User @@ -32,6 +35,15 @@ ) +@dataclass(frozen=True) +class RunManifestBuildResult: + """同时返回持久化清单与本次执行复用的内存快照。""" + + manifest: dict + normalized_context: dict + skill_runtime_snapshot: dict[str, Any] + + def canonical_json(payload: Any) -> str: """键排序 + 紧凑分隔符的确定性序列化,保证字段顺序不影响指纹。""" return json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str) @@ -92,29 +104,68 @@ def build_manifest_payload( } -async def resolve_skill_entries(db: AsyncSession, skill_slugs: list[str]) -> list[dict]: +async def resolve_skill_entries( + db: AsyncSession, + skill_slugs: list[str], + *, + preload_content_hashes: dict[str, str] | None = None, + personal_skill_slugs: set[str] | None = None, +) -> list[dict]: """按执行时数据库状态读取 Skill 稳定标识;缺失信息显式为 None,不伪造版本。""" + preload_hashes = preload_content_hashes or {} + personal_slugs = personal_skill_slugs or set() entries: list[dict] = [] for slug in skill_slugs: row = (await db.execute(select(Skill.version, Skill.content_hash).where(Skill.slug == slug))).first() - entries.append( - { - "slug": slug, - "version": row.version if row else None, - "content_hash": row.content_hash if row else None, - } - ) + is_personal = slug in personal_slugs + entry = { + "slug": slug, + "version": None if is_personal else (row.version if row else None), + "content_hash": None if is_personal else (row.content_hash if row else None), + } + if slug in preload_hashes: + entry["preload_content_hash"] = preload_hashes[slug] + entries.append(entry) return entries +def _manifest_skill_scope(normalized_context: dict, runtime_scope: dict) -> tuple[list[str], dict[str, str], set[str]]: + """合并配置 Skill 与预加载闭包,并标识真实个人来源。""" + slugs = list( + dict.fromkeys( + [ + *_resource_keys(normalized_context.get("skills")), + *_resource_keys(runtime_scope.get("preloaded_skills")), + ] + ) + ) + contents = runtime_scope.get("preloaded_skill_contents") + hashes = ( + { + slug: hashlib.sha256(content.encode("utf-8")).hexdigest() + for slug, content in contents.items() + if slug in slugs and isinstance(content, str) + } + if isinstance(contents, dict) + else {} + ) + source_scopes = runtime_scope.get("runtime_skill_source_scopes") + personal_slugs = ( + {slug for slug in slugs if source_scopes.get(slug) == PERSONAL_SKILL_SOURCE_TYPE} + if isinstance(source_scopes, dict) + else set() + ) + return slugs, hashes, personal_slugs + + def resolve_code_revision() -> str | None: """读取部署环境提供的代码 revision;缺失时由 build_manifest_payload 显式记为 unresolved。""" revision = os.getenv("YUXI_CODE_REVISION", "").strip() return revision or None -async def build_run_manifest(*, run: AgentRun, user: User, db: AsyncSession) -> dict: - """在执行边界解析本次运行实际采用的运行资产并构建脱敏 manifest。""" +async def build_run_manifest_result(*, run: AgentRun, user: User, db: AsyncSession) -> RunManifestBuildResult: + """在执行边界构建 manifest 与不可分叉的运行时快照。""" agent_item = await AgentRepository(db).get_visible_by_slug( slug=run.agent_slug, user=user, @@ -130,9 +181,19 @@ async def build_run_manifest(*, run: AgentRun, user: User, db: AsyncSession) -> context_schema=backend.context_schema, ) + runtime_skill_snapshot: dict[str, Any] = {} + skill_slugs = _resource_keys(normalized_context.get("skills")) + preload_hashes: dict[str, str] = {} + personal_slugs: set[str] = set() + if backend: + context_instance = backend.context_schema() + context_instance.update_from_dict(dict(normalized_context)) + runtime_skill_snapshot = await resolve_runtime_skills_for_context(context_instance, db=db, user=user) + skill_slugs, preload_hashes, personal_slugs = _manifest_skill_scope(normalized_context, runtime_skill_snapshot) + payload = run.input_payload if isinstance(run.input_payload, dict) else {} effective_limits = _effective_limits(backend, normalized_context) - return build_manifest_payload( + manifest = build_manifest_payload( run_type=run.run_type, agent_slug=run.agent_slug, backend_id=agent_item.backend_id if agent_item else None, @@ -140,9 +201,24 @@ async def build_run_manifest(*, run: AgentRun, user: User, db: AsyncSession) -> tool_approval_mode=payload.get("tool_approval_mode"), normalized_context=normalized_context, limits=effective_limits, - skill_entries=await resolve_skill_entries(db, _resource_keys(normalized_context.get("skills"))), + skill_entries=await resolve_skill_entries( + db, + skill_slugs, + preload_content_hashes=preload_hashes, + personal_skill_slugs=personal_slugs, + ), code_revision=resolve_code_revision(), ) + return RunManifestBuildResult( + manifest=manifest, + normalized_context=normalized_context, + skill_runtime_snapshot=runtime_skill_snapshot, + ) + + +async def build_run_manifest(*, run: AgentRun, user: User, db: AsyncSession) -> dict: + """构建只含稳定标识和摘要的持久化运行清单。""" + return (await build_run_manifest_result(run=run, user=user, db=db)).manifest def _effective_limits(backend, normalized_context: dict) -> dict: diff --git a/backend/package/yuxi/services/chat_service.py b/backend/package/yuxi/services/chat_service.py index 2600badf2..7db249f7d 100644 --- a/backend/package/yuxi/services/chat_service.py +++ b/backend/package/yuxi/services/chat_service.py @@ -236,6 +236,12 @@ def _apply_subagent_runtime_context(input_context: dict, meta: dict | None) -> N input_context["is_subagent_runtime"] = True +def _runtime_agent_config(agent_config: dict | None, execution_snapshot: dict | None) -> dict: + """优先使用 manifest 同次解析的配置,再由调用方追加工作区上下文。""" + snapshot_context = execution_snapshot.get("normalized_context") if isinstance(execution_snapshot, dict) else None + return snapshot_context if isinstance(snapshot_context, dict) else dict(agent_config or {}) + + 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) @@ -922,6 +928,7 @@ async def stream_agent_chat( current_user, db, save_user_message: bool = True, + execution_snapshot: dict | None = None, ) -> AsyncIterator[bytes]: start_time = asyncio.get_event_loop().time() @@ -1000,7 +1007,7 @@ def make_chunk(content=None, **kwargs): agent_item=agent_item, ) input_context = await build_agent_input_context( - agent_config, + _runtime_agent_config(agent_config, execution_snapshot), thread_id=thread_id, uid=uid, run_id=meta.get("run_id"), @@ -1019,6 +1026,8 @@ def make_chunk(content=None, **kwargs): meta["workdir_path"] = input_context["workdir_path"] _apply_subagent_runtime_context(input_context, meta) context = _build_agent_context(agent, input_context) + if isinstance(execution_snapshot, dict): + setattr(context, "_skill_runtime_snapshot", execution_snapshot.get("skill_runtime_snapshot")) langfuse_run = _build_langfuse_run_context( current_user=current_user, thread_id=thread_id, @@ -1304,6 +1313,7 @@ async def stream_agent_resume( meta: dict, current_user, db, + execution_snapshot: dict | None = None, ) -> AsyncIterator[bytes]: start_time = asyncio.get_event_loop().time() @@ -1353,7 +1363,7 @@ def make_resume_chunk(content=None, **kwargs): workdir_path=conversation.workdir_path, ) input_context = await build_agent_input_context( - agent_config or {}, + _runtime_agent_config(agent_config, execution_snapshot), thread_id=thread_id, uid=uid, run_id=meta.get("run_id"), @@ -1365,6 +1375,8 @@ def make_resume_chunk(content=None, **kwargs): input_context["workdir_relative_path"] = conversation.workdir_path input_context["workdir_path"] = meta["workdir_path"] context = _build_agent_context(agent, input_context) + if isinstance(execution_snapshot, dict): + setattr(context, "_skill_runtime_snapshot", execution_snapshot.get("skill_runtime_snapshot")) langfuse_run = _build_langfuse_run_context( current_user=current_user, thread_id=thread_id, diff --git a/backend/package/yuxi/services/run_worker.py b/backend/package/yuxi/services/run_worker.py index f4e9e149b..37a4a90aa 100644 --- a/backend/package/yuxi/services/run_worker.py +++ b/backend/package/yuxi/services/run_worker.py @@ -22,7 +22,7 @@ dispatch_next_request, recover_pending_dispatches, ) -from yuxi.services.agent_run_manifest_service import build_run_manifest, compute_manifest_fingerprint +from yuxi.services.agent_run_manifest_service import build_run_manifest_result, compute_manifest_fingerprint from yuxi.services.chat_service import get_agent_state_view, stream_agent_chat, stream_agent_resume from yuxi.services.input_message_service import restore_chat_input_message from yuxi.services.run_queue_service import ( @@ -499,17 +499,30 @@ async def reconcile_pending_runtime_cleanups() -> list[str]: return cleaned -async def persist_run_manifest(*, run: AgentRun, user, worker_id: str) -> None: +def _require_persisted_manifest_match(persisted_run: AgentRun | None, *, recorded: bool, fingerprint: str) -> None: + """重试只能复用与 write-once manifest 完全一致的运行资产。""" + if recorded: + return + if persisted_run is None or persisted_run.manifest_fingerprint != fingerprint: + raise RuntimeError("运行资产已在重试前变化,与已固化 manifest 不一致") + + +async def persist_run_manifest(*, run: AgentRun, user, worker_id: str) -> dict: """在执行上下文构造前固化运行清单与指纹;固化失败由调用方显式失败。""" async with pg_manager.get_async_session_context() as db: - manifest = await build_run_manifest(run=run, user=user, db=db) - fingerprint = compute_manifest_fingerprint(manifest) - await AgentRunRepository(db).record_run_manifest( + result = await build_run_manifest_result(run=run, user=user, db=db) + fingerprint = compute_manifest_fingerprint(result.manifest) + persisted_run, recorded = await AgentRunRepository(db).record_run_manifest( run.id, - manifest=manifest, + manifest=result.manifest, fingerprint=fingerprint, worker_id=worker_id, ) + _require_persisted_manifest_match(persisted_run, recorded=recorded, fingerprint=fingerprint) + return { + "normalized_context": result.normalized_context, + "skill_runtime_snapshot": result.skill_runtime_snapshot, + } async def _load_user(uid: str): @@ -909,7 +922,7 @@ async def process_agent_run(ctx, run_id: str): # 运行清单必须在真正构造执行上下文前固化;固化失败时执行不得开始。 try: - await persist_run_manifest(run=run, user=user, worker_id=worker_id) + execution_snapshot = await persist_run_manifest(run=run, user=user, worker_id=worker_id) except Exception as manifest_error: logger.error(f"Failed to persist AgentRun manifest: run={run_id}", exc_info=True) await mark_run_terminal( @@ -978,6 +991,7 @@ async def process_agent_run(ctx, run_id: str): meta=meta, current_user=user, db=db, + execution_snapshot=execution_snapshot, ) elif run_type in {"chat", "subagent"}: stream = stream_agent_chat( @@ -988,6 +1002,7 @@ async def process_agent_run(ctx, run_id: str): current_user=user, db=db, save_user_message=False, + execution_snapshot=execution_snapshot, ) else: raise RuntimeError(f"unsupported run_type after validation: {run_type}") diff --git a/backend/package/yuxi/storage_migrations/v071_workdirs.py b/backend/package/yuxi/storage_migrations/v071_workdirs.py index 1431ae3c3..2e5725108 100644 --- a/backend/package/yuxi/storage_migrations/v071_workdirs.py +++ b/backend/package/yuxi/storage_migrations/v071_workdirs.py @@ -355,7 +355,11 @@ def _merge_tree(source: Path, target: Path) -> None: _merge_tree(entry, destination) elif entry.is_file(): if destination.is_symlink() or destination.exists(): - if destination.is_symlink() or not destination.is_file() or _file_digest(destination) != _file_digest(entry): + if ( + destination.is_symlink() + or not destination.is_file() + or _file_digest(destination) != _file_digest(entry) + ): raise RuntimeError(f"旧 Workdir 文件冲突: {entry.name}") else: shutil.copy2(entry, destination, follow_symlinks=False) diff --git a/backend/test/e2e/test_deterministic_agent_path_e2e.py b/backend/test/e2e/test_deterministic_agent_path_e2e.py index b57ac8054..912b051dd 100644 --- a/backend/test/e2e/test_deterministic_agent_path_e2e.py +++ b/backend/test/e2e/test_deterministic_agent_path_e2e.py @@ -22,6 +22,9 @@ pytestmark = [pytest.mark.asyncio, pytest.mark.e2e, pytest.mark.slow] EXPECTED_OUTPUT = "DETERMINISTIC_AGENT_E2E_OK" +EXPECTED_PRELOADED_SKILL_MARKER = "# 图片生成技能" +EXPECTED_PRELOADED_TOOL = "present_artifacts" +EXPECTED_TOOL_CALL_ID = "call-preloaded-tool" PROVIDER_ID = "ci-replay" MODEL_SPEC = f"{PROVIDER_ID}:deterministic-chat" @@ -30,7 +33,11 @@ async def test_replay_rejects_requests_outside_deterministic_contract() -> None: valid_body = { "model": "deterministic-chat", "stream": True, - "messages": [{"role": "user", "content": EXPECTED_OUTPUT}], + "messages": [ + {"role": "system", "content": EXPECTED_PRELOADED_SKILL_MARKER}, + {"role": "user", "content": EXPECTED_OUTPUT}, + ], + "tools": [{"type": "function", "function": {"name": EXPECTED_PRELOADED_TOOL}}], } cases = [ ({}, valid_body, "invalid_authorization"), @@ -49,6 +56,34 @@ async def test_replay_rejects_requests_outside_deterministic_contract() -> None: {**valid_body, "messages": [{"role": "user", "content": "wrong"}]}, "expected_input_missing", ), + ( + {"Authorization": "Bearer ci-replay-key"}, + { + **valid_body, + "messages": [{"role": "user", "content": EXPECTED_OUTPUT}], + }, + "preloaded_skill_missing", + ), + ( + {"Authorization": "Bearer ci-replay-key"}, + {**valid_body, "tools": []}, + "preloaded_tool_missing", + ), + ( + {"Authorization": "Bearer ci-replay-key"}, + { + **valid_body, + "messages": [ + *valid_body["messages"], + { + "role": "tool", + "tool_call_id": EXPECTED_TOOL_CALL_ID, + "content": "unexpected result", + }, + ], + }, + "tool_execution_result_missing", + ), ] async with httpx.AsyncClient(base_url="http://localhost:8765", timeout=5) as client: @@ -134,7 +169,8 @@ async def _create_agent(client: httpx.AsyncClient, headers: dict[str, str], uid: "tools": [], "knowledges": [], "mcps": [], - "skills": [], + "skills": ["image-gen"], + "preload_skills": ["image-gen"], "subagents": [], } }, @@ -177,6 +213,21 @@ async def _assert_persisted_causality(run_id: str, request_id: str) -> None: assert row["output_run_id"] == run_id assert row["output_request_id"] == request_id assert row["output_content"] == EXPECTED_OUTPUT + + tool_call = await conn.fetchrow( + """ + SELECT tc.langgraph_tool_call_id, tc.tool_name, tc.status, tc.tool_output + FROM tool_calls tc + JOIN messages message ON message.id = tc.message_id + WHERE message.run_id = $1 + """, + run_id, + ) + assert tool_call, "预加载工具必须经过真实 ToolNode 执行并持久化" + assert tool_call["langgraph_tool_call_id"] == EXPECTED_TOOL_CALL_ID + assert tool_call["tool_name"] == EXPECTED_PRELOADED_TOOL + assert tool_call["status"] == "success" + assert tool_call["tool_output"] finally: await conn.close() @@ -222,7 +273,9 @@ async def _assert_persisted_execution_facts(run_id: str, agent_slug: str) -> Non assert manifest["manifest_version"] == 1 assert manifest["agent"] == {"slug": agent_slug, "backend_id": "ChatbotAgent"} assert manifest["model"] == {"spec": MODEL_SPEC} - assert manifest["resources"]["skills"] == [] + assert len(manifest["resources"]["skills"]) == 1 + assert manifest["resources"]["skills"][0]["slug"] == "image-gen" + assert manifest["resources"]["skills"][0]["content_hash"] assert row["manifest_recorded_at"] is not None assert row["manifest_recorded_at"] >= row["started_at"] @@ -231,6 +284,7 @@ async def _assert_persisted_execution_facts(run_id: str, agent_slug: str) -> Non assert EXPECTED_OUTPUT not in serialized assert "不要调用工具" not in serialized assert "ci-replay-key" not in serialized + assert EXPECTED_PRELOADED_SKILL_MARKER not in serialized assert len(manifest["config_digest"]) == 64 expected_fingerprint = hashlib.sha256( diff --git a/backend/test/support/openai_replay_server.py b/backend/test/support/openai_replay_server.py index 784ea6819..8c917a470 100644 --- a/backend/test/support/openai_replay_server.py +++ b/backend/test/support/openai_replay_server.py @@ -10,6 +10,10 @@ EXPECTED_OUTPUT = "DETERMINISTIC_AGENT_E2E_OK" EXPECTED_AUTHORIZATION = "Bearer ci-replay-key" EXPECTED_MODEL = "deterministic-chat" +EXPECTED_PRELOADED_SKILL_MARKER = "# 图片生成技能" +EXPECTED_PRELOADED_TOOL = "present_artifacts" +EXPECTED_TOOL_CALL_ID = "call-preloaded-tool" +EXPECTED_TOOL_RESULT_MARKER = "已将交付物展示给用户" def _validate_request(authorization: str | None, request: dict) -> str | None: @@ -24,33 +28,83 @@ def _validate_request(authorization: str | None, request: dict) -> str | None: messages = request.get("messages") if not isinstance(messages, list) or not messages: return "messages_required" - if EXPECTED_OUTPUT not in json.dumps(messages, ensure_ascii=False): + serialized_messages = json.dumps(messages, ensure_ascii=False) + if EXPECTED_OUTPUT not in serialized_messages: return "expected_input_missing" + if EXPECTED_PRELOADED_SKILL_MARKER not in serialized_messages: + return "preloaded_skill_missing" + tools = request.get("tools") + tool_names = { + item.get("function", {}).get("name") + for item in tools or [] + if isinstance(item, dict) and isinstance(item.get("function"), dict) + } + if EXPECTED_PRELOADED_TOOL not in tool_names: + return "preloaded_tool_missing" + tool_messages = [message for message in messages if isinstance(message, dict) and message.get("role") == "tool"] + if tool_messages and not any( + message.get("tool_call_id") == EXPECTED_TOOL_CALL_ID + and EXPECTED_TOOL_RESULT_MARKER in str(message.get("content", "")) + for message in tool_messages + ): + return "tool_execution_result_missing" return None -def _stream_payloads(model: str) -> list[dict]: +def _stream_payloads(model: str, messages: list[dict]) -> list[dict]: common = { "id": "chatcmpl-yuxi-deterministic", "object": "chat.completion.chunk", "created": int(time.time()), "model": model, } + if any(message.get("role") == "tool" for message in messages if isinstance(message, dict)): + return [ + { + **common, + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": EXPECTED_OUTPUT}, + "finish_reason": None, + } + ], + }, + { + **common, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 8, "completion_tokens": 4, "total_tokens": 12}, + }, + ] + return [ { **common, "choices": [ { "index": 0, - "delta": {"role": "assistant", "content": EXPECTED_OUTPUT}, + "delta": { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "id": EXPECTED_TOOL_CALL_ID, + "type": "function", + "function": { + "name": EXPECTED_PRELOADED_TOOL, + "arguments": '{"filepaths": []}', + }, + } + ], + }, "finish_reason": None, } ], }, { **common, - "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], - "usage": {"prompt_tokens": 8, "completion_tokens": 4, "total_tokens": 12}, + "choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}], + "usage": {"prompt_tokens": 8, "completion_tokens": 2, "total_tokens": 10}, }, ] @@ -89,7 +143,7 @@ def do_POST(self) -> None: # noqa: N802 self.send_header("Cache-Control", "no-cache") self.send_header("Connection", "close") self.end_headers() - for payload in _stream_payloads(model): + for payload in _stream_payloads(model, request["messages"]): self.wfile.write(f"data: {json.dumps(payload)}\n\n".encode()) self.wfile.flush() self.wfile.write(b"data: [DONE]\n\n") diff --git a/backend/test/unit/agents/skills/test_skill_runtime.py b/backend/test/unit/agents/skills/test_skill_runtime.py index f7a61bc1f..d672f504e 100644 --- a/backend/test/unit/agents/skills/test_skill_runtime.py +++ b/backend/test/unit/agents/skills/test_skill_runtime.py @@ -6,6 +6,22 @@ from yuxi.agents.skills.runtime import build_dependency_bundle, expand_skill_closure, resolve_runtime_skills_for_context +def _skill(tmp_path, slug: str, *, dependencies: list[str] | None = None, content: str | None = None): + source_dir = tmp_path / slug + source_dir.mkdir() + (source_dir / "SKILL.md").write_text(content or f"# {slug}", encoding="utf-8") + return SimpleNamespace( + slug=slug, + name=slug.title(), + description=f"{slug} desc", + source_scope="shared", + source_dir=source_dir, + tool_dependencies=[], + mcp_dependencies=[], + skill_dependencies=dependencies or [], + ) + + @pytest.mark.asyncio async def test_resolve_runtime_skills_derives_authorized_scope(monkeypatch): """运行时 scope 只保留授权选择,并按依赖闭包区分共享与个人来源。""" @@ -73,3 +89,70 @@ def test_dependency_bundle_returns_only_consumed_dependencies(): assert bundle == {"tools": ["tool-a", "tool-b"], "mcps": ["mcp-a", "mcp-b"]} assert "skills" not in bundle + + +@pytest.mark.asyncio +async def test_preload_reads_authorized_dependency_closure(tmp_path, monkeypatch): + skills = [ + _skill(tmp_path, "alpha", dependencies=["beta"], content="# Alpha\nUSE_ALPHA"), + _skill(tmp_path, "beta", content="# Beta\nUSE_BETA"), + ] + + async def fake_list_accessible_skills(_db, _user): + return skills + + monkeypatch.setattr(skill_runtime, "list_accessible_skills", fake_list_accessible_skills) + scope = await resolve_runtime_skills_for_context( + SimpleNamespace(skills=["alpha"], preload_skills=["alpha", "beta", "missing"]), + db=object(), + user=object(), + ) + + assert scope["context_preload_skills"] == ["alpha"] + assert scope["preloaded_skills"] == ["alpha", "beta"] + assert scope["preloaded_skill_contents"] == { + "alpha": "# Alpha\nUSE_ALPHA", + "beta": "# Beta\nUSE_BETA", + } + + +@pytest.mark.asyncio +async def test_preload_rejects_symlinked_source_ancestor(tmp_path, monkeypatch): + real_parent = tmp_path / "real" + real_parent.mkdir() + item = _skill(real_parent, "alpha") + linked_parent = tmp_path / "linked" + linked_parent.symlink_to(real_parent, target_is_directory=True) + item.source_dir = linked_parent / "alpha" + + async def fake_list_accessible_skills(_db, _user): + return [item] + + monkeypatch.setattr(skill_runtime, "list_accessible_skills", fake_list_accessible_skills) + + with pytest.raises(RuntimeError, match="根级 SKILL.md 不可读"): + await resolve_runtime_skills_for_context( + SimpleNamespace(skills=["alpha"], preload_skills=["alpha"]), + db=object(), + user=object(), + ) + + +@pytest.mark.asyncio +async def test_lite_mode_excludes_knowledge_base_from_preload(tmp_path, monkeypatch): + item = _skill(tmp_path, "knowledge-base") + + async def fake_list_accessible_skills(_db, _user): + return [item] + + monkeypatch.setattr(skill_runtime, "list_accessible_skills", fake_list_accessible_skills) + monkeypatch.setattr(skill_runtime, "lite_mode_enabled", lambda: True) + scope = await resolve_runtime_skills_for_context( + SimpleNamespace(skills=["knowledge-base"], preload_skills=["knowledge-base"]), + db=object(), + user=object(), + ) + + assert scope["context_skills"] == [] + assert scope["preloaded_skills"] == [] + assert scope["preloaded_skill_contents"] == {} diff --git a/backend/test/unit/agents/test_context_auth.py b/backend/test/unit/agents/test_context_auth.py index 66f18c70a..8b7c1b497 100644 --- a/backend/test/unit/agents/test_context_auth.py +++ b/backend/test/unit/agents/test_context_auth.py @@ -49,6 +49,8 @@ def test_get_configurable_items_filters_admin_fields_for_user(): items = BaseContext.get_configurable_items(user_role="user") assert "system_prompt" in items + assert items["preload_skills"]["default"] == [] + assert items["preload_skills"]["kind"] == "skills" assert "summary_threshold" not in items assert "summary_keep_messages" not in items assert "summary_prompt" not in items @@ -157,6 +159,22 @@ async def fake_list_skills(_db, _user): "skills": [{"key": "skill-a", "name": "Skill A", "description": ""}], } + normalized = await normalize_agent_context_config( + { + "tools": [], + "knowledges": [], + "mcps": [], + "skills": None, + "preload_skills": ["knowledge-base"], + }, + db=object(), + user=types.SimpleNamespace(role="user", uid="u1", department_id=None), + context_schema=BaseContext, + ) + + assert normalized["skills"] == ["skill-a"] + assert normalized["preload_skills"] == [] + @pytest.mark.asyncio async def test_normalize_agent_context_config_expands_null_and_filters_explicit_lists(monkeypatch): @@ -230,6 +248,7 @@ async def list_visible_subagents(self, *, user): "knowledges": ["kb-b", "missing", "kb-b"], "mcps": None, "skills": [], + "preload_skills": ["skill-a"], "subagents": ["research-agent", "missing"], "summary_threshold": 10, "summary_keep_messages": 8, @@ -246,6 +265,7 @@ async def list_visible_subagents(self, *, user): assert normalized["knowledges"] == ["kb-b"] assert normalized["mcps"] == ["mcp-a"] assert normalized["skills"] == [] + assert normalized["preload_skills"] == [] assert normalized["subagents"] == ["research-agent"] assert "summary_threshold" not in normalized assert "summary_keep_messages" not in normalized @@ -262,6 +282,23 @@ async def list_visible_subagents(self, *, user): assert empty_subagents_normalized["subagents"] == ["research-agent", "critique-agent"] + preloaded_normalized = await normalize_agent_context_config( + { + "tools": [], + "knowledges": [], + "mcps": [], + "skills": ["skill-a"], + "preload_skills": ["skill-b", "skill-a", "skill-a", "missing"], + "subagents": ["research-agent"], + }, + db=object(), + user=types.SimpleNamespace(role="user", uid="u1", department_id=None), + context_schema=ChatBotContext, + ) + + assert preloaded_normalized["skills"] == ["skill-a"] + assert preloaded_normalized["preload_skills"] == ["skill-a"] + @pytest.mark.asyncio async def test_prepare_agent_runtime_context_filters_resources_and_derives_runtime_scope(monkeypatch): @@ -286,12 +323,19 @@ async def fake_resolve_visible_knowledge_bases(context): context._visible_knowledge_bases = [{"slug": "kb-a", "name": "Docs A"}] return context._visible_knowledge_bases - async def fake_resolve_runtime_skills_for_context(context, *, db=None, user=None): + async def fake_resolve_runtime_skills_for_context( + context, + *, + db=None, + user=None, + ): del db assert user.uid == "u1" assert context.skills == ["skill-a"] + assert context.preload_skills == ["skill-a"] return { "context_skills": ["skill-a"], + "context_preload_skills": ["skill-a"], "effective_skills": ["skill-a", "skill-b"], "runtime_skills": { "skill-a": { @@ -303,6 +347,8 @@ async def fake_resolve_runtime_skills_for_context(context, *, db=None, user=None "skills": ["skill-b"], } }, + "preloaded_skills": ["skill-a", "skill-b"], + "preloaded_skill_contents": {"skill-a": "# Skill A", "skill-b": "# Skill B"}, } class FakeSessionContext: @@ -340,7 +386,10 @@ async def list_visible_subagents(self, *, user): monkeypatch.setitem( sys.modules, "yuxi.agents.skills.runtime", - types.SimpleNamespace(resolve_runtime_skills_for_context=fake_resolve_runtime_skills_for_context), + types.SimpleNamespace( + is_skill_allowed_in_runtime_mode=lambda _slug: True, + resolve_runtime_skills_for_context=fake_resolve_runtime_skills_for_context, + ), ) monkeypatch.setitem( sys.modules, @@ -390,6 +439,7 @@ async def list_visible_subagents(self, *, user): knowledges=["kb-a", "missing"], mcps=None, skills=["skill-a", "missing"], + preload_skills=["skill-a", "missing"], subagents=[], ) @@ -399,11 +449,13 @@ async def list_visible_subagents(self, *, user): assert prepared.knowledges == ["kb-a"] assert prepared.mcps == ["mcp-a"] assert prepared.skills == ["skill-a"] + assert prepared.preload_skills == ["skill-a"] assert prepared.subagents == ["research-agent"] assert prepared._visible_knowledge_bases == [{"slug": "kb-a", "name": "Docs A"}] assert prepared._effective_skill_slugs == ["skill-a", "skill-b"] assert prepared._runtime_skills["skill-a"]["name"] == "Skill A" assert prepared._runtime_skills["skill-a"]["skills"] == ["skill-b"] + assert prepared._preloaded_skills == ["skill-a", "skill-b"] @pytest.mark.asyncio @@ -453,6 +505,7 @@ async def get_by_uid_with_db(self, _db, _uid): knowledges=["kb"], mcps=["mcp"], skills=["skill"], + preload_skills=["skill"], subagents=["agent"], ) @@ -462,6 +515,7 @@ async def get_by_uid_with_db(self, _db, _uid): assert prepared.knowledges == [] assert prepared.mcps == [] assert prepared.skills == [] + assert prepared.preload_skills == [] assert prepared.subagents == [] assert prepared._visible_knowledge_bases == [] assert prepared._effective_skill_slugs == [] diff --git a/backend/test/unit/middlewares/test_skills_middleware.py b/backend/test/unit/middlewares/test_skills_middleware.py index 6626fa97c..5eb079e87 100644 --- a/backend/test/unit/middlewares/test_skills_middleware.py +++ b/backend/test/unit/middlewares/test_skills_middleware.py @@ -1,9 +1,11 @@ from __future__ import annotations from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest from langchain_core.messages import SystemMessage, ToolMessage +from langchain_core.tools import tool from langgraph.types import Command import yuxi.agents.middlewares.skills as skills_middleware @@ -30,13 +32,14 @@ def _runtime_skill( name: str | None = None, description: str = "", tools: list[str] | None = None, + mcps: list[str] | None = None, ) -> dict: return { "name": name or slug, "description": description, "path": f"/home/gem/skills/{slug}/SKILL.md", "tools": tools or [], - "mcps": [], + "mcps": mcps or [], "skills": [], } @@ -88,6 +91,50 @@ async def handler(request): assert not hasattr(context, "_visible_skills") +@pytest.mark.asyncio +async def test_preloaded_skill_injects_full_instructions_once_and_hides_lazy_read_hint(): + context = SimpleNamespace( + _effective_skill_slugs=["alpha", "beta"], + _preloaded_skills=["alpha"], + _preloaded_skill_contents={"alpha": "# Alpha full instructions\nUSE_ALPHA_TOOL"}, + _runtime_skills={ + "alpha": _runtime_skill("alpha", name="Alpha", description="alpha desc"), + "beta": _runtime_skill("beta", name="Beta", description="beta desc"), + }, + tools=[], + mcps=[], + ) + + class FakeRequest: + def __init__(self, *, system_message=None, tools=None): + self.runtime = SimpleNamespace(context=context) + self.state = {} + self.tools = tools or [] + self.system_message = system_message or SystemMessage(content="base") + + def override(self, **kwargs): + return FakeRequest( + system_message=kwargs.get("system_message", self.system_message), + tools=kwargs.get("tools", self.tools), + ) + + captured = [] + + async def handler(request): + captured.append(_system_message_text(request.system_message)) + return "ok" + + middleware = SkillsMiddleware() + original_request = FakeRequest() + await middleware.awrap_model_call(original_request, handler) + await middleware.awrap_model_call(original_request, handler) + + assert len(captured) == 2 + assert all(text.count("USE_ALPHA_TOOL") == 1 for text in captured) + assert all("Read `/home/gem/skills/alpha/SKILL.md`" not in text for text in captured) + assert all("Read `/home/gem/skills/beta/SKILL.md`" in text for text in captured) + + @pytest.mark.asyncio async def test_awrap_model_call_mounts_dependencies_only_for_readable_activated_skills(monkeypatch): monkeypatch.setattr( @@ -196,7 +243,161 @@ async def test_resolve_skill_gated_tools_registers_kb_tools(): assert _KB_TOOL_NAMES <= {tool.name for tool in runtime_tools} -def _make_gated_request(activated): +@pytest.mark.asyncio +async def test_preloaded_skill_rejects_duplicate_mcp_tool_names(monkeypatch): + @tool("chart_tool") + async def chart_tool(value: int) -> str: + """渲染测试图表。""" + + return f"rendered:{value}" + + @tool("chart_tool") + async def conflicting_chart_tool(value: int) -> str: + """同名但来自另一服务的测试工具。""" + + return f"wrong-service:{value}" + + async def fake_get_enabled_mcp_tools(server_name): + return [chart_tool] if server_name == "charts" else [conflicting_chart_tool] + + monkeypatch.setattr(skills_middleware, "get_enabled_mcp_tools", fake_get_enabled_mcp_tools) + context = SimpleNamespace( + tools=[], + mcps=[], + _effective_skill_slugs=["report"], + _preloaded_skills=["report"], + _runtime_skills={"report": _runtime_skill("report", mcps=["charts", "conflicting-charts"])}, + ) + + class FakeRequest: + def __init__(self, tools): + self.runtime = SimpleNamespace(context=context) + self.state = {} + self.tools = tools + + def override(self, *, tools): + request = FakeRequest(tools) + request.runtime = self.runtime + return request + + middleware = SkillsMiddleware(enable_skills_prompt=False) + with pytest.raises(RuntimeError, match="Skill MCP 工具名冲突"): + await middleware.awrap_model_call(FakeRequest([]), AsyncMock()) + + +@pytest.mark.asyncio +async def test_preloaded_skill_exposes_mcp_tool_on_first_model_call(monkeypatch): + @tool("chart_tool") + async def chart_tool(value: int) -> str: + """渲染测试图表。""" + + return f"rendered:{value}" + + async def fake_get_enabled_mcp_tools(server_name): + assert server_name == "charts" + return [chart_tool] + + monkeypatch.setattr(skills_middleware, "get_enabled_mcp_tools", fake_get_enabled_mcp_tools) + context = SimpleNamespace( + tools=[], + mcps=[], + _effective_skill_slugs=["report"], + _preloaded_skills=["report"], + _runtime_skills={"report": _runtime_skill("report", mcps=["charts"])}, + ) + + class FakeRequest: + def __init__(self, tools): + self.runtime = SimpleNamespace(context=context) + self.state = {} + self.tools = tools + + def override(self, *, tools): + request = FakeRequest(tools) + request.runtime = self.runtime + return request + + captured = [] + + async def handler(request): + captured.append([item.name for item in request.tools]) + return "ok" + + assert await SkillsMiddleware(enable_skills_prompt=False).awrap_model_call(FakeRequest([]), handler) == "ok" + assert captured == [["chart_tool"]] + + +@pytest.mark.asyncio +async def test_skill_reusing_explicit_mcp_server_does_not_duplicate_registered_tool(monkeypatch): + @tool("chart_tool") + async def chart_tool(value: int) -> str: + """渲染测试图表。""" + + return f"rendered:{value}" + + calls = [] + + async def fake_get_enabled_mcp_tools(server_name): + calls.append(server_name) + return [chart_tool] + + monkeypatch.setattr(skills_middleware, "get_enabled_mcp_tools", fake_get_enabled_mcp_tools) + context = SimpleNamespace( + tools=[], + mcps=["charts"], + _effective_skill_slugs=["report"], + _preloaded_skills=["report"], + _runtime_skills={"report": _runtime_skill("report", mcps=["charts"])}, + ) + + class FakeRequest: + def __init__(self, tools): + self.runtime = SimpleNamespace(context=context) + self.state = {} + self.tools = tools + + def override(self, *, tools): + request = FakeRequest(tools) + request.runtime = self.runtime + return request + + captured = [] + + async def handler(request): + captured.append([item.name for item in request.tools]) + return "ok" + + middleware = SkillsMiddleware(enable_skills_prompt=False) + assert await middleware.awrap_model_call(FakeRequest([chart_tool]), handler) == "ok" + assert captured == [["chart_tool"]] + assert calls == [] + + +@pytest.mark.asyncio +async def test_explicit_mcp_rejects_skill_local_tool_name_collision(monkeypatch): + @tool("list_kbs") + async def conflicting_list_kbs() -> str: + """模拟与 Skill 本地依赖同名的显式 MCP 工具。""" + + return "wrong-source" + + async def fake_get_enabled_mcp_tools(server_name): + assert server_name == "configured" + return [conflicting_list_kbs] + + monkeypatch.setattr("yuxi.agents.mcp.service.get_enabled_mcp_tools", fake_get_enabled_mcp_tools) + context = SimpleNamespace( + tools=[], + mcps=["configured"], + _effective_skill_slugs=["knowledge-base"], + _runtime_skills={"knowledge-base": _runtime_skill("knowledge-base", tools=["list_kbs"])}, + ) + + with pytest.raises(RuntimeError, match="Skill 本地工具 'list_kbs'"): + await resolve_configured_runtime_tools(context) + + +def _make_gated_request(activated, *, preloaded=None): base = SimpleNamespace(name="read_file") gated = [SimpleNamespace(name="list_kbs"), SimpleNamespace(name="query_kb")] diff --git a/backend/test/unit/services/test_agent_run_manifest_service.py b/backend/test/unit/services/test_agent_run_manifest_service.py index 6d5860418..fb12f28b1 100644 --- a/backend/test/unit/services/test_agent_run_manifest_service.py +++ b/backend/test/unit/services/test_agent_run_manifest_service.py @@ -5,10 +5,12 @@ import pytest from yuxi.services.agent_run_manifest_service import ( + _manifest_skill_scope, build_manifest_payload, canonical_json, compute_config_digest, compute_manifest_fingerprint, + resolve_skill_entries, ) @@ -72,6 +74,86 @@ def test_different_assets_produce_different_fingerprint(): assert compute_manifest_fingerprint(_manifest()) != compute_manifest_fingerprint(changed) +def test_preload_skill_config_changes_config_digest(): + base_context = _manifest()["config_digest"] + changed_context = { + **{ + "model": "siliconflow-cn:Pro/MiniMaxAI/MiniMax-M2.5", + "tools": ["fs", "web"], + "mcps": [], + "skills": ["code-review"], + "max_execution_steps": 150, + "model_retry_times": 2, + "system_prompt": "You are a reviewer.", + "summary_prompt": "Summarize: {messages}", + }, + "preload_skills": ["code-review"], + } + + assert base_context != compute_config_digest(changed_context) + + +def test_preloaded_dependency_content_changes_manifest_fingerprint(): + normalized_context = {"skills": ["parent"], "preload_skills": ["parent"]} + first_slugs, first_hashes, _ = _manifest_skill_scope( + normalized_context, + { + "preloaded_skills": ["parent", "dependency"], + "preloaded_skill_contents": {"parent": "first", "dependency": "dependency"}, + }, + ) + second_slugs, second_hashes, _ = _manifest_skill_scope( + normalized_context, + { + "preloaded_skills": ["parent", "dependency"], + "preloaded_skill_contents": {"parent": "changed", "dependency": "dependency"}, + }, + ) + + assert first_slugs == second_slugs == ["parent", "dependency"] + first = _manifest( + skill_entries=[ + {"slug": slug, "version": None, "content_hash": None, "preload_content_hash": first_hashes[slug]} + for slug in first_slugs + ] + ) + second = _manifest( + skill_entries=[ + {"slug": slug, "version": None, "content_hash": None, "preload_content_hash": second_hashes[slug]} + for slug in second_slugs + ] + ) + assert compute_manifest_fingerprint(first) != compute_manifest_fingerprint(second) + + +@pytest.mark.asyncio +async def test_personal_preloaded_skill_does_not_borrow_shadowed_database_identity(): + class FakeResult: + def first(self): + return type("Row", (), {"version": "shared-v1", "content_hash": "shared-hash"})() + + class FakeDB: + async def execute(self, statement): + del statement + return FakeResult() + + entries = await resolve_skill_entries( + FakeDB(), + ["shadowed"], + preload_content_hashes={"shadowed": "personal-root-hash"}, + personal_skill_slugs={"shadowed"}, + ) + + assert entries == [ + { + "slug": "shadowed", + "version": None, + "content_hash": None, + "preload_content_hash": "personal-root-hash", + } + ] + + def test_manifest_excludes_prompts_and_secret_shaped_values(): context = { "system_prompt": "SECRET-PROMPT-BODY", diff --git a/backend/test/unit/services/test_chat_service_sync.py b/backend/test/unit/services/test_chat_service_sync.py index 37e0c549d..e89820dbf 100644 --- a/backend/test/unit/services/test_chat_service_sync.py +++ b/backend/test/unit/services/test_chat_service_sync.py @@ -445,6 +445,25 @@ def fake_agent_context(_uid: str) -> str: assert context["uid"] == "user-1" +@pytest.mark.asyncio +async def test_manifest_snapshot_prompt_keeps_workspace_agent_context(monkeypatch: pytest.MonkeyPatch): + async def fake_to_thread(func, *args): + del func, args + return "用户工作区 agents/AGENTS.md 内容:\nWORKSPACE-MARKER" + + monkeypatch.setattr(agent_context.asyncio, "to_thread", fake_to_thread) + config = svc._runtime_agent_config( + {"system_prompt": "CURRENT-CONFIG"}, + {"normalized_context": {"system_prompt": "MANIFEST-CONFIG"}}, + ) + + context = await agent_context.build_agent_input_context(config, thread_id="thread-1", uid="user-1") + + assert context["system_prompt"] == "MANIFEST-CONFIG\n\n用户工作区 agents/AGENTS.md 内容:\nWORKSPACE-MARKER" + assert context["thread_id"] == "thread-1" + assert context["uid"] == "user-1" + + @pytest.mark.asyncio async def test_get_agent_state_view_rejects_async_subagent_without_child_conversation( monkeypatch: pytest.MonkeyPatch, diff --git a/backend/test/unit/services/test_run_worker.py b/backend/test/unit/services/test_run_worker.py index bf690ef0b..a9fab16b1 100644 --- a/backend/test/unit/services/test_run_worker.py +++ b/backend/test/unit/services/test_run_worker.py @@ -1384,3 +1384,11 @@ def fake_stream_agent_chat(**kwargs): assert terminal_calls[0]["status"] == "failed" assert terminal_calls[0]["error_type"] == "manifest_persist_failed" assert "执行未开始" in terminal_calls[0]["error_message"] + + +def test_retry_requires_new_manifest_fingerprint_to_match_write_once_fact(): + persisted = SimpleNamespace(manifest_fingerprint="a" * 64) + + run_worker._require_persisted_manifest_match(persisted, recorded=False, fingerprint="a" * 64) + with pytest.raises(RuntimeError, match="运行资产已在重试前变化"): + run_worker._require_persisted_manifest_match(persisted, recorded=False, fingerprint="b" * 64) diff --git a/docs/agents/middleware.md b/docs/agents/middleware.md index 75b41dcf2..84cccc16b 100644 --- a/docs/agents/middleware.md +++ b/docs/agents/middleware.md @@ -11,7 +11,7 @@ - `prepare_agent_runtime_context`:按当前用户权限过滤工具、知识库、MCP、Skills 和子智能体,并派生 `_visible_knowledge_bases`、`_effective_skill_slugs` 与 `_runtime_skills` - `build_prompt_with_context`:基于 Context 生成系统提示词 - `load_chat_model(context.model)`:加载主模型 -- `resolve_configured_runtime_tools(context)`:加载已配置的内置工具和 MCP 工具 +- `resolve_configured_runtime_tools(context)`:加载已配置的内置/MCP 工具,并把可读 Skill 的本地依赖注册进静态 ToolNode;Skill MCP 依赖由 Middleware 在激活轮次动态绑定 中间件直接消费归一化后的 runtime context。资源授权和可见性过滤由前置准备阶段完成,产生副作用的工具仍需在执行边界校验具体目标。 @@ -43,14 +43,14 @@ ## Skills 注入与激活 -`SkillsMiddleware` 分两步工作: +`SkillsMiddleware` 支持渐进加载和显式预加载: -1. 模型调用前读取 `_effective_skill_slugs`,把有效 Skill 的名称、描述和 `SKILL.md` 路径追加到系统提示。 +1. 模型调用前读取 `_effective_skill_slugs`。普通 Skill 注入名称、描述和 `SKILL.md` 路径;`preload_skills` 及其依赖闭包直接注入根级完整说明。 2. 工具调用后检查模型是否读取了共享投影 `/home/gem/skills//SKILL.md` 或个人 UserWorkspace `/home/gem/user-data/agents/skills//SKILL.md`。如果该 Skill 在 `_effective_skill_slugs` 范围内,就把它写入 `activated_skills`,并在后续模型调用中追加它声明的工具和 MCP 依赖。 -模型首先看到 Skill 说明;读取并激活 Skill 后,依赖工具才加入后续模型请求。该顺序控制初始工具 schema 的规模。 +普通 Skill 在读取并激活后开放依赖;预加载 Skill 从首轮模型请求开始开放依赖。默认空配置保持初始工具 schema 不变。 ## 附件与文件系统 diff --git a/docs/agents/skills-management.md b/docs/agents/skills-management.md index 234eeb450..0ffd8ade5 100644 --- a/docs/agents/skills-management.md +++ b/docs/agents/skills-management.md @@ -201,8 +201,8 @@ Skill 可以声明工具、MCP 服务和其他 Skill 依赖;运行时根据依 | 依赖类型 | 说明 | 加载时机 | |----------|------|----------| -| `tool_dependencies` | 需要的内置工具 | 激活后按需加载 | -| `mcp_dependencies` | 需要的 MCP 服务 | 激活后按需加载 | +| `tool_dependencies` | 需要的内置工具 | 预加载或激活后按需加载 | +| `mcp_dependencies` | 需要的 MCP 服务 | 预加载或激活后按需加载 | | `skill_dependencies` | 依赖的其他 Skill | 会话启动即生效 | ### 渐进式加载机制 @@ -220,6 +220,10 @@ Skill 加载分为三个阶段: 当前用户授权的共享、内置 Skill 进入 `/home/gem/skills` 只读投影,个人 Skill 保留在 `/home/gem/user-data/agents/skills`。 +Agent 配置还可以用 `preload_skills` 指定需要首轮直接使用的少量 Skill。该字段默认为空,只能选择 +`context.skills` 中当前用户可读的根 Skill;系统会展开其依赖闭包,在 Graph 创建前读取每个 Skill 的根级 +`SKILL.md`,并将完整说明注入首个模型请求。文件缺失或不可读时创建会显式失败,不会退回普通渐进加载。 + **阶段二:技能激活** 当 Agent 通过 `read_file` 读取共享路径 `/home/gem/skills//SKILL.md` 或个人路径 @@ -231,11 +235,11 @@ Skill 加载分为三个阶段: **阶段三:按需加载** 每次模型调用时,系统会: -1. 检查 `activated_skills` 中的技能 +1. 合并预加载闭包与 `activated_skills` 中的技能 2. 收集这些技能的 `tool_dependencies` 和 `mcp_dependencies` -3. 动态将需要的工具和 MCP 服务添加到可用工具集中 +3. 向当前模型请求开放对应本地工具和 MCP schema;未预加载或激活的依赖继续隐藏 -会话启动阶段只注入 Skill 说明。工具和 MCP 依赖在 Skill 激活后按需加入模型请求,从而控制初始工具 schema 的规模。 +会话启动阶段只处理预加载 Skill;其工具和 MCP 依赖从首轮开放。未预加载 Skill 的依赖仍在读取根级说明并激活后按需加入模型请求,从而控制初始工具 schema 的规模。 ### 依赖声明示例 @@ -250,6 +254,8 @@ Skill 加载分为三个阶段: 2. 文件系统仍可读当前用户授权的其他 Skill,但它们不会因此进入 Prompt 或变成可激活工具 3. 当 Agent 读取 `pro-skill/SKILL.md` 时:触发激活,工具和 MCP 依赖被加载 +若同时将 `pro-skill` 加入 `preload_skills`,启动阶段会读取三个 Skill 各自的根级 `SKILL.md`,首轮模型调用即可看到完整说明及其工具和 MCP 依赖,不需要先调用 `read_file`。 + ## 权限管理 数据库中的共享与内置 Skills 使用 `source_type`、`share_config` 和 `enabled` 控制来源、共享范围和启用状态。个人 Skill 由 UserWorkspace 的 uid 目录隔离,不携带 `share_config`,避免与数据库“指定用户共享”语义混淆。 diff --git a/docs/agents/tools-system.md b/docs/agents/tools-system.md index 4927511ca..813dd9761 100644 --- a/docs/agents/tools-system.md +++ b/docs/agents/tools-system.md @@ -76,7 +76,7 @@ kb_tools = get_common_kb_tools() 1. **基础工具**:从 `context.tools` 中按名称筛选 2. **MCP 工具**:根据 `context.mcps` 加载 MCP 服务器工具 -3. **Skill 依赖工具**:由 `SkillsMiddleware` 在 Skill 激活后按需追加,包括 `knowledge-base` 绑定的知识库工具 +3. **Skill 依赖工具**:本地依赖在创建 Graph 时注册,`SkillsMiddleware` 在 Skill 被预加载或动态激活后向模型开放对应本地与 MCP 工具,包括 `knowledge-base` 绑定的知识库工具 ```python from yuxi.agents.context import prepare_agent_runtime_context diff --git a/docs/develop-guides/decisions/implemented/2026-08-17-skill-preload.md b/docs/develop-guides/decisions/implemented/2026-08-17-skill-preload.md new file mode 100644 index 000000000..8f661d3b9 --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-08-17-skill-preload.md @@ -0,0 +1,46 @@ +# Skill 预加载 + +状态:implemented +类型:feature +Owner:backend/package/yuxi/agents/skills/runtime.py + +## 问题 + +Agent 的普通 Skill 只在系统提示中提供名称、描述和 `SKILL.md` 路径。模型通过 `read_file` 读取根级说明后,Skill 才进入动态激活状态并向后续模型调用开放依赖工具。对于 `knowledge-base` 这类需要从首轮稳定可用的能力,该流程会增加一次模型判断和工具往返,也可能因模型没有主动读取说明而无法使用已配置能力。 + +系统需要允许 Agent 显式指定少量预加载 Skill,使完整根级说明和依赖工具从首轮模型调用起可见,同时保持现有可见范围、权限过滤、LITE 边界和其余 Skill 的渐进加载语义。 + +## 决策 + +`BaseContext.preload_skills` 是默认空列表,并复用 `kind="skills"` 的配置选项。它不是新的资源授权入口:配置归一化先解析 `skills`,再把预加载根 Skill 限制为该列表的子集。`prepare_agent_runtime_context` 先按当前部署模式过滤运行时 Skill,再沿依赖图展开预加载闭包,从本次权限解析得到的真实 `source_dir` 读取每个 Skill 的根级 `SKILL.md`,并把有序内容快照保存在本次 Graph 的私有 Context 字段中。读取从文件系统根目录描述符开始逐段使用 `O_NOFOLLOW` 打开来源目录,再以同样约束打开根文件并验证为普通文件;缓存命中后任一祖先目录或根文件被替换成符号链接都会 fail-closed。 + +`agents.skills.runtime` 拥有授权后的 Skill scope、依赖闭包和预加载内容读取。`SkillsMiddleware` 只在每次模型调用中基于原始 request 注入预加载完整说明,并把预加载闭包与 checkpoint 中仍可读的动态激活 Skill 合并为本轮有效激活集合;它不修改持久配置、`context.system_prompt` 或 checkpoint。依赖工具沿用现有本地工具注册与 MCP 加载路径;不同来源的同名本地或 MCP 工具在 Graph 构建期显式失败,避免模型 schema 与 ToolNode 执行对象分叉。 + +预加载只包含根级 `SKILL.md`,不递归拼接 references、scripts 或 assets。配置为空时不读取文件、不注入完整说明,也不改变工具可见性。预加载文件不可读时 Graph 创建显式失败,不静默退回懒加载。`knowledge-base` 是使用场景而非硬编码默认值;现有 Agent 配置不迁移,LITE 继续从可用 Skill 集合排除它。 + +运行清单沿用现有 schema。`preload_skills` 作为规范化 context 的一部分进入 `config_digest`,共享 Skill 的目录摘要由 `resources.skills[].content_hash` 记录,实际预加载根说明以 `preload_content_hash` 记录;个人 Skill 不借用同 slug 共享记录的版本和摘要。worker 在固化 manifest 时生成同一份内存执行快照并传给 Graph,重试重新解析后的 fingerprint 必须与 write-once manifest 一致,否则在执行前 fail-closed。 + +## 替代方案 + +- 新增独立预加载 Middleware:会与 `SkillsMiddleware` 重复拥有提示注入和依赖工具门控,形成两套激活语义。 +- 在 `build_prompt_with_context` 中拼接 Skill:该函数不拥有 Skill 权限、依赖和工具可见性,也会让主 Agent 与 SubAgent 的装配分叉。 +- 把预加载 Skill 写入 LangGraph `activated_skills`:预加载来自当前 Agent 配置,动态激活属于 checkpoint 状态;持久化两份事实会使配置变化和 resume 产生歧义。 +- 默认预加载全部可用 Skill:会破坏渐进加载契约并稳定放大 prompt 与工具 schema,因此默认保持为空。 + +## 后果 + +每个预加载 Skill 会增加每轮模型输入,并从首轮暴露其依赖工具;调用方应只选择确实需要稳定首轮可用的少量能力。完整说明不做静默截断,过大的 Skill 应由内容 Owner 拆分 references 或取消预加载。 + +预加载内容只能来自权限解析后的共享投影或个人工作区真实来源,用户传入的 slug 不参与宿主机路径拼接。旧 Agent 缺少字段时由 schema 默认得到空列表,不需要数据库迁移。主 Agent 与 SubAgent 复用同一 Context 和 Middleware 语义,SubAgent 原有的禁用工具过滤仍在模型调用前执行。默认空不读取 Skill 文件,也不增加首轮 prompt 或工具 schema。 + +## 验证 + +| 验收主张 | 直接证据 | 结果 | +|---|---|---| +| 默认空配置保持渐进加载;预加载从首轮注入完整说明并开放依赖工具 | `UV_PYTHON=3.13 uv run --directory backend --group test pytest test/unit/agents/skills/test_skill_runtime.py test/unit/middlewares/test_skills_middleware.py -q` | Passed;覆盖默认空、依赖闭包、完整说明注入和首轮 MCP 工具可见性 | +| 预加载不能越过 `skills`、用户权限或 LITE 边界,文件来源拒绝祖先目录 symlink | `UV_PYTHON=3.13 uv run --directory backend --group test pytest test/unit/agents/skills/test_skill_runtime.py test/unit/agents/test_context_auth.py -q` | Passed;覆盖未选择 Skill、授权后闭包、LITE 排除和 no-follow 读取 | +| `preload_skills` 作为规范化配置进入现有 Run 指纹,不新增 manifest schema | `UV_PYTHON=3.13 uv run --directory backend --group test pytest test/unit/services/test_agent_run_manifest_service.py -q` | Passed;仅改变预加载配置时现有 `config_digest` 变化 | +| 首个模型请求必须同时包含完整 Skill 说明和依赖工具 schema,真实 shipping 链路仍需服务环境复核 | `NO_PROXY=localhost,127.0.0.1 UV_PYTHON=3.13 uv run --directory backend --group test pytest test/e2e/test_deterministic_agent_path_e2e.py -q` | Replay contract Passed;依赖 API、worker、PostgreSQL 和 sandbox 的 2 个用例在当前环境 skipped,不能记为链路通过 | +| 受影响 Python 文件满足静态检查与格式约束 | `UV_PYTHON=3.13 uv run --directory backend ruff check <受影响文件>`;`ruff format --check <受影响文件>` | Passed | +| 主 Agent、SubAgent、工具门控及其余后端 unit 回归保持成立 | `UV_PYTHON=3.13 uv run --directory backend --group test pytest test/unit -m "not slow" -q` | Passed:1463 tests | +| 工程契约和正式文档保持可验证 | `python3 scripts/verify_engineering_contracts.py`;`python3 -m unittest scripts.test_verify_engineering_contracts`;`pnpm exec vitepress build --outDir <临时目录>` | Passed:25 decisions / 5 workflows / 4 agents files / 66 docs;61 contract tests;docs build 成功,只有既有构建 warning |