Refactor movie pilot config and test coverage

This commit is contained in:
jxxghp
2026-06-23 10:05:45 +08:00
parent dc773337d3
commit 0cd049bfc2
40 changed files with 1481 additions and 828 deletions

View File

@@ -7,12 +7,14 @@
"""
import json
import os
import re
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta
from pathlib import Path
from typing import Annotated, Any, NotRequired, Optional, TypedDict
import anyio
from anyio import Path as AsyncPath
from langchain.agents.middleware.types import (
AgentMiddleware,
@@ -579,14 +581,29 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
entry = f"- **{now_str}** {summary}\n"
try:
if await log_path.exists():
existing = await log_path.read_text(encoding="utf-8", errors="replace")
await log_path.write_text(existing + entry, encoding="utf-8")
async with await anyio.open_file(
log_path,
mode="a",
encoding="utf-8",
) as stream:
await stream.write(entry)
else:
header = f"# {today_str} 活动日志\n\n"
await log_path.write_text(header + entry, encoding="utf-8")
logger.debug("Activity logged: %s", summary[:80])
try:
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
except FileExistsError:
async with await anyio.open_file(
log_path,
mode="a",
encoding="utf-8",
) as stream:
await stream.write(entry)
else:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
stream.write(header + entry)
logger.debug(f"Activity logged: {summary[:80]}")
except Exception as e:
logger.warning("Failed to append activity log: %s", e)
logger.warning(f"Failed to append activity log: {e}")
async def _cleanup_old_logs(self) -> None:
"""清理超过保留天数的旧日志文件。"""
@@ -608,20 +625,16 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
file_date = datetime.strptime(match.group(1), "%Y-%m-%d").date()
if file_date < cutoff_date:
await path.unlink()
logger.debug("Cleaned up old activity log: %s", path.name)
logger.debug(f"Cleaned up old activity log: {path.name}")
except ValueError:
continue
except Exception as e:
logger.warning("Failed to cleanup old activity logs: %s", e)
logger.warning(f"Failed to cleanup old activity logs: {e}")
async def abefore_agent(
self, state: ActivityLogState, runtime: Runtime
) -> Optional[ActivityLogStateUpdate]:
"""在 Agent 执行前加载近期活动日志。"""
# 如果已经加载则跳过
if "activity_log_contents" in state:
return None
contents = await self._load_recent_logs()
# 趁机清理旧日志(低频操作,不影响性能)
@@ -709,7 +722,7 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
if summary:
await self._append_activity(summary)
except Exception as e:
logger.warning("Failed to record activity: %s", e)
logger.warning(f"Failed to record activity: {e}")
return None

View File

@@ -283,12 +283,7 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
) -> JobsStateUpdate | None:
"""在 Agent 执行前异步加载任务元数据。
每个会话仅加载一次。若 state 中已有则跳过。
"""
# 如果 state 中已存在元数据则跳过
if "jobs_metadata" in state:
return None
return JobsStateUpdate(
jobs_metadata=await load_jobs_metadata(self.sources)
)

View File

@@ -302,7 +302,6 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
"""在代理执行前扫描记忆目录并加载所有 .md 文件的内容。
自动发现目录下所有 `.md` 文件并加载其内容到状态中。
如果状态中尚未存在则进行加载。
同时检测记忆文件是否为空,设置 memory_empty 标志位,
以便在系统提示词中触发初始化引导流程。
@@ -314,10 +313,6 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
返回:
填充了 memory_contents 和 memory_empty 的状态更新。
"""
# 如果已经加载则跳过
if "memory_contents" in state:
return None
# 扫描目录下所有 .md 文件
md_files = await self._scan_memory_files()

View File

@@ -322,7 +322,7 @@ def _extract_version(skill_md: Path) -> int:
try:
content = skill_md.read_text(encoding="utf-8", errors="replace")
except Exception as err:
print(err)
logger.debug(f"读取技能版本失败: {err}")
return 0
match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
if not match:
@@ -627,13 +627,8 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
) -> SkillsStateUpdate | None: # ty: ignore[invalid-method-override]
"""在 Agent 执行前异步加载技能元数据。
每个会话仅加载一次。若 state 中已有则跳过。
首次加载时,会先将内置技能同步到用户目录(如不存在)。
"""
# 如果 state 中已存在元数据则跳过
if "skills_metadata" in state:
return None
self._sync_bundled_skills()
all_skills: dict[str, SkillMetadata] = {}

View File

@@ -197,18 +197,44 @@ def is_subagent_stream_metadata(metadata: Any) -> bool:
) == SUBAGENT_STREAM_MARKER_VALUE:
return True
return bool(metadata.get("lc_agent_name") in builtin_subagent_names())
return bool(
metadata.get("lc_agent_name")
in builtin_subagent_names(agent_runtime_manager.current_signature())
)
@lru_cache(maxsize=1)
def builtin_subagent_names() -> frozenset[str]:
def builtin_subagent_names(
runtime_signature: Optional[tuple[tuple[str, int, int], ...]] = None,
) -> frozenset[str]:
"""返回内置子代理名称集合。"""
return frozenset(profile.name for profile in _builtin_subagent_profiles())
runtime_signature = runtime_signature or agent_runtime_manager.current_signature()
return _cached_builtin_subagent_names(runtime_signature)
@lru_cache(maxsize=1)
def _builtin_subagent_profiles() -> tuple[_SubAgentProfile, ...]:
@lru_cache(maxsize=8)
def _cached_builtin_subagent_names(
runtime_signature: tuple[tuple[str, int, int], ...],
) -> frozenset[str]:
"""按运行时签名缓存内置子代理名称集合。"""
return frozenset(
profile.name
for profile in _builtin_subagent_profiles(runtime_signature)
)
def _builtin_subagent_profiles(
runtime_signature: Optional[tuple[tuple[str, int, int], ...]] = None,
) -> tuple[_SubAgentProfile, ...]:
"""从运行时配置目录加载 MoviePilot 子代理定义。"""
runtime_signature = runtime_signature or agent_runtime_manager.current_signature()
return _cached_builtin_subagent_profiles(runtime_signature)
@lru_cache(maxsize=8)
def _cached_builtin_subagent_profiles(
runtime_signature: tuple[tuple[str, int, int], ...],
) -> tuple[_SubAgentProfile, ...]:
"""按运行时签名缓存 MoviePilot 子代理定义。"""
definitions = agent_runtime_manager.list_subagents()
profiles = tuple(
_profile_from_runtime_definition(definition)
@@ -237,6 +263,10 @@ def _builtin_subagent_profiles() -> tuple[_SubAgentProfile, ...]:
)
builtin_subagent_names.cache_clear = _cached_builtin_subagent_names.cache_clear
_builtin_subagent_profiles.cache_clear = _cached_builtin_subagent_profiles.cache_clear
def _profile_from_runtime_definition(
definition: SubAgentDefinition,
) -> _SubAgentProfile:
@@ -1044,6 +1074,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
if unfinished_records:
logger.info(f"Agent 结束,取消未完成子代理任务: tasks={len(unfinished_records)}")
await self._cancel_records(unfinished_records)
self._tasks.clear()
async def awrap_tool_call(
self,
@@ -1083,9 +1114,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()
runtime_signature = agent_runtime_manager.current_signature()
profiles = _builtin_subagent_profiles(runtime_signature)
subagent_middleware = MoviePilotSubAgentMiddleware(
model=model,
profiles=profiles,

View File

@@ -592,22 +592,6 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
这样后续多轮 `model -> tools -> model` 循环都只复用这一次结果,
不会为每次模型回合重复追加一笔 selector LLM 开销。
"""
if "selected_tool_names" in state:
self._log_selection_attempt(
_ToolSelectionAttempt(
request=ModelRequest(
model=self.model,
tools=list(self.selection_tools),
messages=state["messages"],
state=state,
runtime=runtime,
),
selected_tool_names=state.get("selected_tool_names") or [],
status="reused",
)
)
return None
if not self.selection_tools or self.model is None:
detail = "没有可筛选工具" if not self.selection_tools else "未配置筛选模型"
self._log_selection_attempt(