diff --git a/app/agent/defaults/subagents/download-diagnostician/SUBAGENT.md b/app/agent/defaults/subagents/download-diagnostician/SUBAGENT.md new file mode 100644 index 00000000..b7cab82b --- /dev/null +++ b/app/agent/defaults/subagents/download-diagnostician/SUBAGENT.md @@ -0,0 +1,20 @@ +--- +version: 1 +subagent_id: download-diagnostician +label: 下载诊断 +description: Download and transfer diagnosis subagent for downloaders, download tasks, transfer history, and library status. +include_tags: + - download + - transfer + - library + - directory + - file + - media +exclude_tags: + - write + - message + - user_interaction +--- +# SUBAGENT + +You specialize in downloaders, download tasks, transfer history, directory settings, and library ingestion state. diff --git a/app/agent/defaults/subagents/general-purpose/SUBAGENT.md b/app/agent/defaults/subagents/general-purpose/SUBAGENT.md new file mode 100644 index 00000000..d8dfbd16 --- /dev/null +++ b/app/agent/defaults/subagents/general-purpose/SUBAGENT.md @@ -0,0 +1,35 @@ +--- +version: 1 +subagent_id: general-purpose +label: 通用调查 +description: General read-only investigation subagent for cross-domain MoviePilot analysis and execution recommendations. +include_tags: + - media + - resource + - site + - subscription + - download + - library + - transfer + - system + - settings + - plugin + - workflow + - scheduler + - file + - directory + - web + - command + - filter_rule + - persona + - slash_command + - recommendation + - metadata +exclude_tags: + - write + - message + - user_interaction +--- +# SUBAGENT + +You specialize in synthesizing media, site, subscription, download, and system status signals. diff --git a/app/agent/defaults/subagents/media-researcher/SUBAGENT.md b/app/agent/defaults/subagents/media-researcher/SUBAGENT.md new file mode 100644 index 00000000..95c66898 --- /dev/null +++ b/app/agent/defaults/subagents/media-researcher/SUBAGENT.md @@ -0,0 +1,19 @@ +--- +version: 1 +subagent_id: media-researcher +label: 媒体研究 +description: Media research subagent for title recognition, people, episodes, metadata, and library existence checks. +include_tags: + - media + - library + - recommendation + - metadata + - web +exclude_tags: + - write + - message + - user_interaction +--- +# SUBAGENT + +You specialize in media identity resolution, metadata validation, person credits, and library status analysis. diff --git a/app/agent/defaults/subagents/moviepilot-explorer/SUBAGENT.md b/app/agent/defaults/subagents/moviepilot-explorer/SUBAGENT.md new file mode 100644 index 00000000..5c527ff4 --- /dev/null +++ b/app/agent/defaults/subagents/moviepilot-explorer/SUBAGENT.md @@ -0,0 +1,19 @@ +--- +version: 1 +subagent_id: moviepilot-explorer +label: 代码探索 +description: MoviePilot exploration subagent for source-code inspection, configuration structure analysis, logs, and code-level troubleshooting clues. +include_tags: + - system + - settings + - file + - directory + - command +exclude_tags: + - write + - message + - user_interaction +--- +# SUBAGENT + +You specialize in MoviePilot source-code structure, local configuration files, directory layout, logs or read-only command output, and code-level root-cause troubleshooting. Prefer reading relevant code paths before judging behavior, and distinguish code/config evidence from runtime system state. diff --git a/app/agent/defaults/subagents/resource-searcher/SUBAGENT.md b/app/agent/defaults/subagents/resource-searcher/SUBAGENT.md new file mode 100644 index 00000000..680eb577 --- /dev/null +++ b/app/agent/defaults/subagents/resource-searcher/SUBAGENT.md @@ -0,0 +1,18 @@ +--- +version: 1 +subagent_id: resource-searcher +label: 资源搜索 +description: Site and resource search subagent for site checks, torrent search, and resource quality analysis. +include_tags: + - resource + - site + - web + - media +exclude_tags: + - write + - message + - user_interaction +--- +# SUBAGENT + +You specialize in site status, site user data, torrent search results, and resource quality judgment. diff --git a/app/agent/defaults/subagents/subscription-analyst/SUBAGENT.md b/app/agent/defaults/subagents/subscription-analyst/SUBAGENT.md new file mode 100644 index 00000000..80f0bae3 --- /dev/null +++ b/app/agent/defaults/subagents/subscription-analyst/SUBAGENT.md @@ -0,0 +1,18 @@ +--- +version: 1 +subagent_id: subscription-analyst +label: 订阅分析 +description: Subscription analysis subagent for subscriptions, history, filter rules, and custom identifiers. +include_tags: + - subscription + - filter_rule + - settings + - media +exclude_tags: + - write + - message + - user_interaction +--- +# SUBAGENT + +You specialize in current subscription state, subscription history, filter rules, and subscription optimization suggestions. diff --git a/app/agent/defaults/subagents/system-diagnostician/SUBAGENT.md b/app/agent/defaults/subagents/system-diagnostician/SUBAGENT.md new file mode 100644 index 00000000..12580ee7 --- /dev/null +++ b/app/agent/defaults/subagents/system-diagnostician/SUBAGENT.md @@ -0,0 +1,25 @@ +--- +version: 1 +subagent_id: system-diagnostician +label: 系统诊断 +description: System diagnosis subagent for read-only inspection of settings, schedulers, workflows, plugins, directories, and command output. +include_tags: + - system + - settings + - plugin + - workflow + - scheduler + - file + - directory + - web + - command + - persona + - slash_command +exclude_tags: + - write + - message + - user_interaction +--- +# SUBAGENT + +You specialize in settings, plugins, scheduled tasks, workflows, directories, and read-only command diagnostics. diff --git a/app/agent/middleware/subagents.py b/app/agent/middleware/subagents.py index ec29695a..42778707 100644 --- a/app/agent/middleware/subagents.py +++ b/app/agent/middleware/subagents.py @@ -24,6 +24,7 @@ from langchain_core.tools import BaseTool, StructuredTool from pydantic import BaseModel, Field from app.agent.middleware.utils import append_to_system_message +from app.agent.runtime import SubAgentDefinition, agent_runtime_manager from app.agent.tools.tags import ToolTag from app.log import logger @@ -87,7 +88,7 @@ Requirements: @dataclass(frozen=True) class _SubAgentProfile: - """内置子代理定义。""" + """子代理运行时定义。""" name: str description: str @@ -197,40 +198,15 @@ def builtin_subagent_names() -> frozenset[str]: @lru_cache(maxsize=1) def _builtin_subagent_profiles() -> tuple[_SubAgentProfile, ...]: - """构建 MoviePilot 默认内置子代理定义。""" - default_exclude_tags = frozenset( - { - ToolTag.Write.value, - ToolTag.Message.value, - ToolTag.UserInteraction.value, - } + """从运行时配置目录加载 MoviePilot 子代理定义。""" + definitions = agent_runtime_manager.list_subagents() + profiles = tuple( + _profile_from_runtime_definition(definition) + for definition in definitions ) - general_tags = frozenset( - { - ToolTag.Media.value, - ToolTag.Resource.value, - ToolTag.Site.value, - ToolTag.Subscription.value, - ToolTag.Download.value, - ToolTag.Library.value, - ToolTag.Transfer.value, - ToolTag.System.value, - ToolTag.Settings.value, - ToolTag.Plugin.value, - ToolTag.Workflow.value, - ToolTag.Scheduler.value, - ToolTag.File.value, - ToolTag.Directory.value, - ToolTag.Web.value, - ToolTag.Command.value, - ToolTag.FilterRule.value, - ToolTag.Persona.value, - ToolTag.SlashCommand.value, - ToolTag.Recommendation.value, - ToolTag.Metadata.value, - } - ) - + if profiles: + return profiles + logger.warning("未加载到任何子代理定义,使用通用兜底子代理。") return ( _SubAgentProfile( name="general-purpose", @@ -239,126 +215,34 @@ def _builtin_subagent_profiles() -> tuple[_SubAgentProfile, ...]: f"{SUBAGENT_BASE_PROMPT}\n" "You specialize in synthesizing media, site, subscription, download, and system status signals." ), - include_tags=general_tags, - exclude_tags=default_exclude_tags, - ), - _SubAgentProfile( - name="media-researcher", - description="Media research subagent for title recognition, people, episodes, metadata, and library existence checks.", - prompt=( - f"{SUBAGENT_BASE_PROMPT}\n" - "You specialize in media identity resolution, metadata validation, person credits, and library status analysis." - ), - include_tags=frozenset( + include_tags=frozenset(tag.value for tag in ToolTag), + exclude_tags=frozenset( { - ToolTag.Media.value, - ToolTag.Library.value, - ToolTag.Recommendation.value, - ToolTag.Metadata.value, - ToolTag.Web.value, + ToolTag.Write.value, + ToolTag.Message.value, + ToolTag.UserInteraction.value, } ), - exclude_tags=default_exclude_tags, - ), - _SubAgentProfile( - name="moviepilot-explorer", - description="MoviePilot exploration subagent for source-code inspection, configuration structure analysis, logs, and code-level troubleshooting clues.", - prompt=( - f"{SUBAGENT_BASE_PROMPT}\n" - "You specialize in MoviePilot source-code structure, local configuration files, directory layout, logs or read-only command output, and code-level root-cause troubleshooting. " - "Prefer reading relevant code paths before judging behavior, and distinguish code/config evidence from runtime system state." - ), - include_tags=frozenset( - { - ToolTag.System.value, - ToolTag.Settings.value, - ToolTag.File.value, - ToolTag.Directory.value, - ToolTag.Command.value, - } - ), - exclude_tags=default_exclude_tags, - ), - _SubAgentProfile( - name="resource-searcher", - description="Site and resource search subagent for site checks, torrent search, and resource quality analysis.", - prompt=( - f"{SUBAGENT_BASE_PROMPT}\n" - "You specialize in site status, site user data, torrent search results, and resource quality judgment." - ), - include_tags=frozenset( - { - ToolTag.Resource.value, - ToolTag.Site.value, - ToolTag.Web.value, - ToolTag.Media.value, - } - ), - exclude_tags=default_exclude_tags, - ), - _SubAgentProfile( - name="subscription-analyst", - description="Subscription analysis subagent for subscriptions, history, filter rules, and custom identifiers.", - prompt=( - f"{SUBAGENT_BASE_PROMPT}\n" - "You specialize in current subscription state, subscription history, filter rules, and subscription optimization suggestions." - ), - include_tags=frozenset( - { - ToolTag.Subscription.value, - ToolTag.FilterRule.value, - ToolTag.Settings.value, - ToolTag.Media.value, - } - ), - exclude_tags=default_exclude_tags, - ), - _SubAgentProfile( - name="system-diagnostician", - description="System diagnosis subagent for read-only inspection of settings, schedulers, workflows, plugins, directories, and command output.", - prompt=( - f"{SUBAGENT_BASE_PROMPT}\n" - "You specialize in settings, plugins, scheduled tasks, workflows, directories, and read-only command diagnostics." - ), - include_tags=frozenset( - { - ToolTag.System.value, - ToolTag.Settings.value, - ToolTag.Plugin.value, - ToolTag.Workflow.value, - ToolTag.Scheduler.value, - ToolTag.File.value, - ToolTag.Directory.value, - ToolTag.Web.value, - ToolTag.Command.value, - ToolTag.Persona.value, - ToolTag.SlashCommand.value, - } - ), - exclude_tags=default_exclude_tags, - ), - _SubAgentProfile( - name="download-diagnostician", - description="Download and transfer diagnosis subagent for downloaders, download tasks, transfer history, and library status.", - prompt=( - f"{SUBAGENT_BASE_PROMPT}\n" - "You specialize in downloaders, download tasks, transfer history, directory settings, and library ingestion state." - ), - include_tags=frozenset( - { - ToolTag.Download.value, - ToolTag.Transfer.value, - ToolTag.Library.value, - ToolTag.Directory.value, - ToolTag.File.value, - ToolTag.Media.value, - } - ), - exclude_tags=default_exclude_tags, ), ) +def _profile_from_runtime_definition( + definition: SubAgentDefinition, +) -> _SubAgentProfile: + """把运行时子代理定义转换为中间件可用的 profile。""" + prompt_parts = [SUBAGENT_BASE_PROMPT] + if definition.text.strip(): + prompt_parts.append(definition.text.strip()) + return _SubAgentProfile( + name=definition.subagent_id, + description=definition.description, + prompt="\n".join(prompt_parts), + include_tags=frozenset(definition.include_tags), + exclude_tags=frozenset(definition.exclude_tags), + ) + + def _tool_tag_values(tool: BaseTool) -> set[str]: """读取工具实例上的标签集合。""" tags = getattr(tool, "tags", None) or [] @@ -1055,6 +939,8 @@ def create_subagent_middlewares( stream_handler: Any = None, ) -> tuple[list[AgentMiddleware], list[BaseTool]]: """创建子代理中间件列表和任务工具列表。""" + _builtin_subagent_profiles.cache_clear() + builtin_subagent_names.cache_clear() profiles = _builtin_subagent_profiles() subagent_middleware = _try_create_deepagents_middleware( profiles=profiles, diff --git a/app/agent/runtime.py b/app/agent/runtime.py index 18b66c68..c2fea12b 100644 --- a/app/agent/runtime.py +++ b/app/agent/runtime.py @@ -22,8 +22,11 @@ JOBS_DIR = "jobs" ACTIVITY_DIR = "activity" PERSONAS_DIR = "personas" PERSONA_FILE = "PERSONA.md" +SUBAGENTS_DIR = "subagents" +SUBAGENT_FILE = "SUBAGENT.md" CURRENT_PERSONA_SCHEMA_VERSION = 3 PERSONA_SCHEMA_VERSION = 1 +SUBAGENT_SCHEMA_VERSION = 1 DEFAULT_PERSONA_ID = "default" PERSONA_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") @@ -111,6 +114,41 @@ class PersonaDefinition: } +@dataclass +class SubAgentDefinition: + """单个子代理定义。""" + + subagent_id: str + path: Path + description: str + text: str + include_tags: list[str] + exclude_tags: list[str] + version: int = SUBAGENT_SCHEMA_VERSION + label: str = "" + + def summary_line(self) -> str: + """渲染可读的一行子代理摘要。""" + parts = [f"`{self.subagent_id}`"] + if self.label and self.label != self.subagent_id: + parts.append(self.label) + if self.description: + parts.append(self.description) + return " - ".join(parts) + + def to_dict(self) -> dict[str, Any]: + """输出给查询或调试入口的结构化信息。""" + return { + "subagent_id": self.subagent_id, + "label": self.label, + "description": self.description, + "include_tags": self.include_tags, + "exclude_tags": self.exclude_tags, + "version": self.version, + "path": str(self.path), + } + + @dataclass class AgentRuntimeConfig: """一次加载后的根层配置快照。""" @@ -120,6 +158,7 @@ class AgentRuntimeConfig: current_persona_path: Path persona: PersonaDefinition available_personas: list[PersonaDefinition] + available_subagents: list[SubAgentDefinition] extra_context_paths: list[Path] extra_contexts: list[tuple[Path, str]] warnings: list[str] = field(default_factory=list) @@ -135,6 +174,12 @@ class AgentRuntimeConfig: if self.available_personas: sections.append("- Available personas:") sections.extend(f" - {persona.summary_line()}" for persona in self.available_personas) + if self.available_subagents: + sections.append("- Available subagents:") + sections.extend( + f" - {subagent.summary_line()}" + for subagent in self.available_subagents + ) sections.append("") if self.warnings: @@ -201,6 +246,7 @@ class AgentRuntimeManager: self.skills_dir = self.agent_root_dir / SKILLS_DIR self.jobs_dir = self.agent_root_dir / JOBS_DIR self.activity_dir = self.agent_root_dir / ACTIVITY_DIR + self.subagents_dir = self.runtime_dir / SUBAGENTS_DIR self.bundled_defaults_dir = bundled_defaults_dir or ( Path(__file__).parent / "defaults" ) @@ -216,6 +262,7 @@ class AgentRuntimeManager: self.skills_dir.mkdir(parents=True, exist_ok=True) self.jobs_dir.mkdir(parents=True, exist_ok=True) self.activity_dir.mkdir(parents=True, exist_ok=True) + self.subagents_dir.mkdir(parents=True, exist_ok=True) self._migrate_root_runtime_files() self._remove_obsolete_runtime_files() self._sync_bundled_defaults() @@ -278,6 +325,10 @@ class AgentRuntimeManager: """列出当前可用人格。""" return self.load_runtime_config().available_personas + def list_subagents(self) -> list[SubAgentDefinition]: + """列出当前可用子代理。""" + return self.load_runtime_config().available_subagents + def update_persona_definition( self, persona_query: str, @@ -382,7 +433,7 @@ class AgentRuntimeManager: return tuple(entries) def _sync_bundled_defaults(self) -> None: - """仅复制缺失的默认运行时文件,避免覆盖用户自定义。""" + """同步默认运行时文件,并按版本更新内置子代理定义。""" if not self.bundled_defaults_dir.exists(): return for path in sorted(self.bundled_defaults_dir.rglob("*")): @@ -392,11 +443,43 @@ class AgentRuntimeManager: target.mkdir(parents=True, exist_ok=True) continue if target.exists(): + if self._should_update_bundled_subagent(relative, path, target): + shutil.copy2(path, target) + logger.info(f"已更新默认 Agent 子代理定义: {target}") continue target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(path, target) logger.info("已同步默认 Agent 运行时文件: %s", target) + @classmethod + def _should_update_bundled_subagent( + cls, + relative_path: Path, + source_path: Path, + target_path: Path, + ) -> bool: + """判断是否需要用更高版本的内置子代理定义覆盖用户目录副本。""" + parts = relative_path.parts + if len(parts) < 3 or parts[0] != SUBAGENTS_DIR or relative_path.name != SUBAGENT_FILE: + return False + + source_version = cls._read_markdown_version(source_path) + target_version = cls._read_markdown_version(target_path) + return source_version > target_version + + @staticmethod + def _read_markdown_version(path: Path) -> int: + """读取 Markdown frontmatter 中的整数版本,失败时按 0 处理。""" + try: + document = AgentRuntimeManager._read_markdown(path) + except AgentRuntimeConfigError as err: + logger.warning(f"读取 Agent 运行时文件版本失败 {path}: {err}") + return 0 + return AgentRuntimeManager._coerce_int_metadata( + document.metadata.get("version"), + default=0, + ) + def _migrate_root_runtime_files(self) -> None: """兼容早期直接放在 `config/agent` 根目录的 CURRENT_PERSONA。""" source = self.agent_root_dir / CURRENT_PERSONA_FILE @@ -451,6 +534,7 @@ class AgentRuntimeManager: available_personas = self._load_personas(root) persona = self._resolve_persona_definition(active_persona, available_personas) + available_subagents = self._load_subagents(root) extra_contexts = [ (path, self._read_markdown(path).body) for path in extra_context_paths @@ -468,6 +552,7 @@ class AgentRuntimeManager: current_persona_path=current_persona_path, persona=persona, available_personas=available_personas, + available_subagents=available_subagents, extra_context_paths=extra_context_paths, extra_contexts=extra_contexts, warnings=warnings, @@ -513,6 +598,71 @@ class AgentRuntimeManager: raise AgentRuntimeConfigError(f"{personas_root} 中未找到任何人格定义") return personas + def _load_subagents(self, root: Path) -> list[SubAgentDefinition]: + """扫描并解析所有可用子代理。""" + subagents_root = root / SUBAGENTS_DIR + if not subagents_root.exists(): + raise AgentRuntimeConfigError(f"缺少 subagents 目录: {subagents_root}") + + subagents: list[SubAgentDefinition] = [] + seen_ids: set[str] = set() + for subagent_dir in sorted(subagents_root.iterdir()): + if not subagent_dir.is_dir(): + continue + subagent_path = subagent_dir / SUBAGENT_FILE + if not subagent_path.exists(): + continue + document = self._read_markdown(subagent_path) + subagent_id = str( + document.metadata.get("subagent_id") or subagent_dir.name + ).strip() + if not subagent_id: + raise AgentRuntimeConfigError(f"{subagent_path} 缺少 subagent_id") + if not PERSONA_ID_PATTERN.fullmatch(subagent_id): + raise AgentRuntimeConfigError( + f"{subagent_path} 的 subagent_id 只能使用小写字母、数字、下划线和中划线,且必须以字母或数字开头" + ) + if subagent_id in seen_ids: + raise AgentRuntimeConfigError(f"检测到重复的子代理 ID: {subagent_id}") + seen_ids.add(subagent_id) + + description = str(document.metadata.get("description") or "").strip() + if not description: + raise AgentRuntimeConfigError(f"{subagent_path} 缺少 description") + include_tags = self._normalize_string_list( + document.metadata.get("include_tags"), + f"{subagent_path}.include_tags", + ) + if not include_tags: + raise AgentRuntimeConfigError(f"{subagent_path} 缺少 include_tags") + exclude_tags = self._normalize_string_list( + document.metadata.get("exclude_tags"), + f"{subagent_path}.exclude_tags", + ) + text = self._normalize_subagent_body(document.body) + if not text: + raise AgentRuntimeConfigError(f"{subagent_path} 子代理正文不能为空") + + subagents.append( + SubAgentDefinition( + subagent_id=subagent_id, + path=subagent_path, + label=str(document.metadata.get("label") or subagent_id).strip(), + description=description, + text=text, + include_tags=include_tags, + exclude_tags=exclude_tags, + version=self._coerce_int_metadata( + document.metadata.get("version"), + default=SUBAGENT_SCHEMA_VERSION, + ), + ) + ) + + if not subagents: + raise AgentRuntimeConfigError(f"{subagents_root} 中未找到任何子代理定义") + return subagents + @staticmethod def _resolve_persona_definition( persona_query: str, @@ -653,6 +803,27 @@ class AgentRuntimeManager: return remainder.strip() return normalized + @staticmethod + def _normalize_subagent_body(body: Optional[str]) -> str: + """去掉重复的 SUBAGENT 标题,保持正文可安全加载。""" + normalized = (body or "").strip() + if not normalized: + return "" + if normalized.startswith("# SUBAGENT"): + _, _, remainder = normalized.partition("\n") + return remainder.strip() + return normalized + + @staticmethod + def _coerce_int_metadata(value: Any, *, default: int = 0) -> int: + """将 frontmatter 中的整数型元数据规范化。""" + if value is None: + return default + try: + return int(value) + except (TypeError, ValueError): + return default + def _validate_runtime_config( self, *, diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py index 5b45088b..ded43d69 100644 --- a/tests/test_agent_runtime.py +++ b/tests/test_agent_runtime.py @@ -45,6 +45,22 @@ class TestAgentRuntimeConfig(unittest.TestCase): / "PERSONA.md" ).exists() ) + self.assertTrue( + ( + self.agent_root + / "runtime" + / "subagents" + / "general-purpose" + / "SUBAGENT.md" + ).exists() + ) + self.assertIn( + "media-researcher", + [ + subagent.subagent_id + for subagent in runtime_config.available_subagents + ], + ) def test_legacy_root_markdown_is_migrated_to_memory_directory(self): self.agent_root.mkdir(parents=True, exist_ok=True) @@ -109,6 +125,8 @@ class TestAgentRuntimeConfig(unittest.TestCase): self.assertIn("", sections) self.assertIn("Active persona: `default`", sections) self.assertIn("`guide`", sections) + self.assertIn("Available subagents:", sections) + self.assertIn("`media-researcher`", sections) def test_set_active_persona_supports_id_and_alias(self): manager = self._manager() diff --git a/tests/test_agent_subagent_runtime.py b/tests/test_agent_subagent_runtime.py new file mode 100644 index 00000000..f6d1a18e --- /dev/null +++ b/tests/test_agent_subagent_runtime.py @@ -0,0 +1,172 @@ +import textwrap +from pathlib import Path + +from app.agent.runtime import AgentRuntimeManager +import app.agent.middleware.subagents as subagent_module + + +def _write_current_persona(defaults_root: Path) -> None: + """写入最小可用的人格激活配置。""" + defaults_root.mkdir(parents=True, exist_ok=True) + (defaults_root / "CURRENT_PERSONA.md").write_text( + textwrap.dedent( + """\ + --- + version: 3 + active_persona: default + extra_context_files: [] + deprecated_phrases: [] + --- + # CURRENT_PERSONA + """ + ), + encoding="utf-8", + ) + persona_dir = defaults_root / "personas" / "default" + persona_dir.mkdir(parents=True, exist_ok=True) + (persona_dir / "PERSONA.md").write_text( + textwrap.dedent( + """\ + --- + version: 1 + persona_id: default + label: 默认 + description: 默认人格 + aliases: [] + --- + # PERSONA + + 测试人格。 + """ + ), + encoding="utf-8", + ) + + +def _write_subagent( + root: Path, + subagent_id: str, + *, + version: int = 1, + description: str = "测试子代理", + body: str = "测试子代理提示。", +) -> Path: + """写入一个子代理定义文件。""" + subagent_dir = root / "subagents" / subagent_id + subagent_dir.mkdir(parents=True, exist_ok=True) + path = subagent_dir / "SUBAGENT.md" + path.write_text( + textwrap.dedent( + f"""\ + --- + version: {version} + subagent_id: {subagent_id} + label: 测试 + description: {description} + include_tags: + - media + - web + exclude_tags: + - write + - message + --- + # SUBAGENT + + {body} + """ + ), + encoding="utf-8", + ) + return path + + +def test_runtime_syncs_and_parses_subagent_definitions(tmp_path): + """运行时应同步并解析 defaults/subagents 下的子代理定义。""" + defaults_root = tmp_path / "defaults" + _write_current_persona(defaults_root) + _write_subagent( + defaults_root, + "custom-reader", + description="Custom reader subagent.", + body="Only inspect custom media signals.", + ) + + manager = AgentRuntimeManager( + agent_root_dir=tmp_path / "agent", + bundled_defaults_dir=defaults_root, + ) + runtime_config = manager.load_runtime_config() + + copied_path = ( + tmp_path + / "agent" + / "runtime" + / "subagents" + / "custom-reader" + / "SUBAGENT.md" + ) + subagent = runtime_config.available_subagents[0] + assert copied_path.exists() + assert subagent.subagent_id == "custom-reader" + assert subagent.description == "Custom reader subagent." + assert subagent.include_tags == ["media", "web"] + assert subagent.exclude_tags == ["write", "message"] + assert "Only inspect custom media signals." in subagent.text + + +def test_runtime_updates_bundled_subagent_when_version_increases(tmp_path): + """内置子代理版本升高时应覆盖用户目录里的旧版副本。""" + defaults_root = tmp_path / "defaults" + _write_current_persona(defaults_root) + _write_subagent( + defaults_root, + "custom-reader", + version=1, + body="version one", + ) + + manager = AgentRuntimeManager( + agent_root_dir=tmp_path / "agent", + bundled_defaults_dir=defaults_root, + ) + manager.ensure_layout() + + _write_subagent( + defaults_root, + "custom-reader", + version=2, + body="version two", + ) + manager.invalidate_cache() + runtime_config = manager.load_runtime_config() + + subagent = runtime_config.available_subagents[0] + assert subagent.version == 2 + assert "version two" in subagent.text + + +def test_middleware_profiles_are_loaded_from_runtime_config(monkeypatch, tmp_path): + """子代理中间件应从运行时 YAML 定义生成 profile。""" + defaults_root = tmp_path / "defaults" + _write_current_persona(defaults_root) + _write_subagent( + defaults_root, + "custom-reader", + description="Runtime custom reader.", + body="Runtime-only prompt.", + ) + manager = AgentRuntimeManager( + agent_root_dir=tmp_path / "agent", + bundled_defaults_dir=defaults_root, + ) + + monkeypatch.setattr(subagent_module, "agent_runtime_manager", manager) + subagent_module._builtin_subagent_profiles.cache_clear() + subagent_module.builtin_subagent_names.cache_clear() + + profiles = subagent_module._builtin_subagent_profiles() + + assert [profile.name for profile in profiles] == ["custom-reader"] + assert profiles[0].description == "Runtime custom reader." + assert profiles[0].include_tags == frozenset({"media", "web"}) + assert "Runtime-only prompt." in profiles[0].prompt