mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-18 06:00:23 +08:00
Configure subagent profiles from runtime files
This commit is contained in:
@@ -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.
|
||||
35
app/agent/defaults/subagents/general-purpose/SUBAGENT.md
Normal file
35
app/agent/defaults/subagents/general-purpose/SUBAGENT.md
Normal file
@@ -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.
|
||||
19
app/agent/defaults/subagents/media-researcher/SUBAGENT.md
Normal file
19
app/agent/defaults/subagents/media-researcher/SUBAGENT.md
Normal file
@@ -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.
|
||||
19
app/agent/defaults/subagents/moviepilot-explorer/SUBAGENT.md
Normal file
19
app/agent/defaults/subagents/moviepilot-explorer/SUBAGENT.md
Normal file
@@ -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.
|
||||
18
app/agent/defaults/subagents/resource-searcher/SUBAGENT.md
Normal file
18
app/agent/defaults/subagents/resource-searcher/SUBAGENT.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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("</agent_runtime_config>")
|
||||
|
||||
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,
|
||||
*,
|
||||
|
||||
@@ -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("<agent_persona>", 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()
|
||||
|
||||
172
tests/test_agent_subagent_runtime.py
Normal file
172
tests/test_agent_subagent_runtime.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user