Files
MoviePilot/tests/test_agent_learning_review.py
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

399 lines
19 KiB
Python

"""
用确定性模型核对隔离复盘、缓存前缀、执行白名单与生命周期。
"""
import asyncio
import json
from copy import deepcopy
from typing import Any
from unittest.mock import AsyncMock
import pytest
from langchain.agents import create_agent
from langchain.agents.middleware.types import ExtendedModelResponse, ModelRequest, ModelResponse
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.tools import StructuredTool
from langgraph.types import Command
from pydantic import Field
from app.agent.learning.review import ReviewLoop, ReviewSnapshot
from app.agent.learning.session import LearningSession
from app.agent.middleware.learning import LearningCaptureMiddleware, LearningMiddleware
from app.agent.middleware.memory import MemoryMiddleware
from app.agent.middleware.skills import SKILLS_SYSTEM_PROMPT
from app.agent.prompt import PromptManager
from app.runtime.tasks import TaskRegistry
class ReviewModel(BaseChatModel):
"""
固定响应模型记录实际绑定 schema 和消息,测试不发任何网络请求。
"""
responses: list[AIMessage] = Field(default_factory=list)
requests: list[Any] = Field(default_factory=list)
index: int = 0
@property
def _llm_type(self):
"""
返回测试模型类型。
"""
return 'learning-test'
def bind_tools(self, tools, *, tool_choice=None, **kwargs):
"""
保持真实 LangChain 工具绑定路径,记录双方广告 schema。
"""
from langchain_core.utils.function_calling import convert_to_openai_tool
return self.bind(tools=[convert_to_openai_tool(tool) for tool in tools], tool_choice=tool_choice, **kwargs)
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
"""
同步假模型只返回预录制工具调用及 usage。
"""
del stop, run_manager
self.requests.append((deepcopy(messages), deepcopy(kwargs)))
message = self.responses[min(self.index, len(self.responses) - 1)].model_copy(deep=True)
self.index += 1
message.usage_metadata = dict(input_tokens=100, output_tokens=10, total_tokens=110)
return ChatResult(generations=[ChatGeneration(message=message)])
def session(tmp_path, on_usage=lambda _usage: None):
"""
为测试构造隔离目录中的已认证用户会话。
"""
return LearningSession(session_id='alice-session', skill_root=tmp_path / 'skills', memory_root=tmp_path / 'memory',
public_roots=(), on_usage=on_usage)
def response(name, arguments, identifier='call'):
"""
生成供应商返回形态的工具调用。
"""
return AIMessage(content='', tool_calls=[dict(name=name, args=arguments, id=identifier)])
def test_review_keeps_prefix_schema_and_denies_business_tools(tmp_path):
"""
广告保持一致,派发不会执行业务写;记忆成果来自新工具回执且主快照不变。
"""
async def scenario():
"""
完整跑过复盘的拒绝、合法记忆写及最终响应。
"""
usage = []
owner = session(tmp_path, usage.append)
called = []
async def forbidden():
"""
任何误派发都会在断言中暴露。
"""
called.append(True)
return '业务已执行'
tool = StructuredTool.from_function(coroutine=forbidden, name='danger', description='business write')
model = ReviewModel(responses=[response('danger', {}), response('memory', dict(action='add', content='验证过的环境事实'), 'save'), AIMessage(content='done')])
original = [HumanMessage(content='任务'), AIMessage(content='任务已完成')]
system = PromptManager().get_agent_prompt('webagent') + SKILLS_SYSTEM_PROMPT.format(skills_list='(No skills available yet.)')
request = ModelRequest(model=model, messages=original[:-1], system_message=SystemMessage(content=system), tools=[*owner.tools.tools, tool])
snapshot = ReviewSnapshot.capture(request, ModelResponse(result=original[-1:]))
loop = ReviewLoop(snapshot, owner.tools, review_memory=True, review_skills=False, cancelled=lambda: False, on_usage=usage.append)
result = await loop.run()
assert not called
assert snapshot.messages == original and len(original) == 2
assert len(result) == 1 and result[0]['tool'] == 'memory'
assert len(usage) == 3 and loop.consumed == 300
sent, arguments = model.requests[0]
assert sent[:3] == [SystemMessage(content=system), *original]
assert 'host-started background learning review' in sent[0].content
assert 'This review permits memory maintenance only' in sent[-1].content
assert 'Do not create or rewrite skills unless' not in sent[0].content
assert arguments['tools'] == snapshot.schemas
assert (tmp_path / 'memory/MEMORY.md').read_text() == '验证过的环境事实'
assert '不能执行' in model.requests[1][0][-1].content
asyncio.run(scenario())
def test_review_compaction_uses_its_own_command_state(tmp_path, monkeypatch):
"""
复盘压缩返回的消息状态替换隔离循环,不修改捕获的父对话快照。
"""
owner = session(tmp_path)
model = ReviewModel(responses=[response('memory', {'action': 'list'})])
original = [HumanMessage(content='已交付任务')]
snapshot = ReviewSnapshot(model, SystemMessage(content='system'), original, [], {}, None)
loop = ReviewLoop(snapshot, owner.tools, review_memory=True, review_skills=False,
cancelled=lambda: False, on_usage=lambda _usage: None)
compacted = [SystemMessage(content='system'), HumanMessage(content='压缩事实'), AIMessage(content='done')]
result = ExtendedModelResponse(model_response=ModelResponse(result=[compacted[-1]]),
command=Command(update={'messages': compacted}))
monkeypatch.setattr(loop.compaction, 'awrap_model_call', AsyncMock(return_value=result))
assert asyncio.run(loop.run()) == []
assert loop.messages == compacted[1:]
assert snapshot.messages == original
@pytest.mark.parametrize('review_memory,review_skills,scope', [
(True, False, 'memory maintenance only'),
(False, True, 'personal skill maintenance only'),
(True, True, 'memory and personal skill maintenance'),
])
def test_review_without_new_evidence_is_a_noop(tmp_path, review_memory, review_skills, scope):
"""
三种复盘均允许无成果结束,传入的提示词明确维护范围且不要求凑学习记录。
"""
async def scenario():
"""
检查实际发送的复盘指令及无工具写入的终态。
"""
owner = session(tmp_path)
model = ReviewModel(responses=[AIMessage(content='Nothing to save.')])
snapshot = ReviewSnapshot.capture(
ModelRequest(model=model, messages=[], tools=owner.tools.tools),
ModelResponse(result=[AIMessage(content='常规查询已完成')]),
)
loop = ReviewLoop(snapshot, owner.tools, review_memory=review_memory,
review_skills=review_skills, cancelled=lambda: False, on_usage=lambda _: None)
assert await loop.run() == []
assert len(model.requests) == 1
prompt = model.requests[0][0][-1].content
assert scope in prompt
assert 'There is no update quota' in prompt
assert 'A no-op is a successful review' in prompt
assert 'most sessions produce at least one skill update' not in prompt
assert not list(tmp_path.rglob('*.md'))
asyncio.run(scenario())
def test_real_graph_capture_is_final_and_does_not_share_state(tmp_path):
"""
真实 LangChain 图的返回快照包含动态 system 和最终回复,复盘改动不回写图。
"""
async def scenario():
"""
构造真实图并验证实例解耦。
"""
owner = session(tmp_path)
model = ReviewModel(responses=[AIMessage(content='完成')])
graph = create_agent(model=model, middleware=[LearningMiddleware(owner), LearningCaptureMiddleware(owner)], system_prompt='基础')
result = await graph.ainvoke({'messages': [HumanMessage(content='请求')]})
assert owner.snapshot.messages[-1].content == '完成'
assert '<personal_skills>' in owner.snapshot.system.text
owner.snapshot.messages[-1].content = '仅复盘副本'
assert result['messages'][-1].content == '完成'
assert owner.snapshot.model is not model
asyncio.run(scenario())
def test_delivered_graph_review_is_consumed_by_next_user_session(tmp_path, monkeypatch):
"""
真实工具迭代触发隔离复盘,落盘成果由下一会话读取且不会泄漏给另一用户。
"""
async def scenario():
"""
只脚本化模型决定;图、计数、复盘、存储与下一轮加载都使用实际实现。
"""
registry = TaskRegistry()
monkeypatch.setattr('app.agent.learning.session.get_task_registry', lambda: registry)
owner = session(tmp_path / 'alice')
fact = '订阅记录应按媒体来源与媒体编号核对。'
document = '---\nname: subscription-check\ndescription: 核对订阅记录\n---\n读取全部分页,按媒体来源和编号核对。\n'
observed = []
async def inspect_subscription(index: int) -> str:
"""
返回隔离业务快照,十次真实工具回合不能直接伪造计数。
"""
observed.append(index)
return json.dumps({'success': True, 'media_source': 'themoviedb', 'media_id': str(index)})
model = ReviewModel(responses=[
*[response('inspect_subscription', {'index': index}, f'inspect-{index}') for index in range(10)],
AIMessage(content='已核对十条记录。'),
response('memory', {'action': 'add', 'content': fact}, 'save-memory'),
response('skill_manage', {'operations': [{'action': 'create', 'name': 'subscription-check',
'content': document}]}, 'save-skill'),
AIMessage(content='复盘完成。'),
])
messages = [HumanMessage(content=f'请核对记录 {index}', id=f'user-{index}') for index in range(10)]
await owner.begin(messages)
graph = create_agent(model=model, tools=[StructuredTool.from_function(coroutine=inspect_subscription)],
middleware=[LearningMiddleware(owner), LearningCaptureMiddleware(owner)])
result = await graph.ainvoke({'messages': messages}, {'recursion_limit': 100})
before = deepcopy(result['messages'])
assert observed == list(range(10)) and owner.iterations == 10 and owner.memory_due
owner.finish(delivered=True)
assert owner.run and owner.run.task
await owner.run.task
assert result['messages'] == before
assert [action['tool'] for action in owner.last_actions] == ['memory', 'skill_manage']
assert (owner.memory_root / 'MEMORY.md').read_text() == fact
assert (owner.skill_root / 'subscription-check' / 'SKILL.md').read_text() == document
for username in ('alice', 'bob'):
following = session(tmp_path / username)
responses = ([response('skill_view', {'name': 'subscription-check'}, 'load-skill')]
if username == 'alice' else [])
next_model = ReviewModel(responses=[*responses, AIMessage(content='下一任务')])
next_graph = create_agent(model=next_model, middleware=[
MemoryMiddleware(memory_dir=str(tmp_path / 'public'), user_memory_dir=str(following.memory_root),
store=following.tools.memory),
LearningMiddleware(following), LearningCaptureMiddleware(following),
])
await next_graph.ainvoke({'messages': [HumanMessage(content='再次核对订阅')]})
sent = '\n'.join(message.text for request, _kwargs in next_model.requests for message in request)
assert (fact in sent) == (username == 'alice')
assert ('subscription-check' in sent) == (username == 'alice')
assert ('读取全部分页,按媒体来源和编号核对。' in sent) == (username == 'alice')
assert await registry.shutdown()
asyncio.run(scenario())
def test_budget_iteration_limit_and_skill_only_memory_gate(tmp_path):
"""
重复拒绝也消耗迭代预算,技能复盘不能借广告中的 memory 自动改记忆。
"""
async def scenario():
"""
使用每次都发 memory 的假模型触达两种独立预算。
"""
owner = session(tmp_path)
model = ReviewModel(responses=[response('memory', dict(action='add', content='不应写入'))])
snapshot = ReviewSnapshot.capture(ModelRequest(model=model, messages=[], tools=owner.tools.tools), ModelResponse(result=[AIMessage(content='任务结束')]))
loop = ReviewLoop(snapshot, owner.tools, review_memory=False, review_skills=True, cancelled=lambda: False, on_usage=lambda _: None)
assert await loop.run() == []
assert len(model.requests) == 16
assert not (tmp_path / 'memory/MEMORY.md').exists()
loop = ReviewLoop(snapshot, owner.tools, review_memory=False, review_skills=True, cancelled=lambda: False, on_usage=lambda _: None)
loop.budget = 150
await loop.run()
assert loop.consumed == 200
assert len(model.requests) == 18
asyncio.run(scenario())
def test_cancel_before_start_clears_owner_and_no_unrequested_review(tmp_path, monkeypatch):
"""
阈值、完成状态和取消前封门都由宿主判定,取消未启动任务不留下 owner。
"""
async def scenario():
"""
使用真实 TaskRegistry 观察取消与回调清理。
"""
owner = session(tmp_path)
registry = TaskRegistry()
monkeypatch.setattr('app.agent.learning.session.get_task_registry', lambda: registry)
model = ReviewModel(responses=[AIMessage(content='Nothing to save')])
snapshot = ReviewSnapshot.capture(ModelRequest(model=model, messages=[], tools=owner.tools.tools), ModelResponse(result=[AIMessage(content='完成')]))
owner.snapshot = snapshot
owner.finish(delivered=True)
assert owner.run is None
owner.iterations = 10
owner.finish(delivered=False)
assert owner.run is None
owner.finish(delivered=True)
run = owner.run
assert run is not None and len(registry.records) == 1
await owner.wait_cancelled()
await asyncio.sleep(0)
assert run.cancelled.is_set() and owner.run is None
assert not model.requests
assert await registry.shutdown()
asyncio.run(scenario())
def test_memory_interval_resume_counter_and_native_tools_skip(tmp_path):
"""
恢复轮次按历史用户数初始化;技能维护重置工具计数,原生工具不冒充可控派发。
"""
async def scenario():
"""
核对记忆和技能复盘的默认触发计数边界。
"""
owner = session(tmp_path)
await owner.begin([HumanMessage(content=str(index)) for index in range(10)])
assert owner.memory_due
model = ReviewModel(responses=[AIMessage(content='done')])
request = ModelRequest(model=model, messages=[], tools=owner.tools.tools)
owner.iterations = 9
owner.observe(request, ModelResponse(result=[response('skills_list', {})]))
assert owner.iterations == 10
owner.observe(request, ModelResponse(result=[response('skill_manage', {})]))
assert owner.iterations == 0
assert ReviewSnapshot.capture(request.override(tools=[{'type': 'web_search_preview'}]), ModelResponse(result=[AIMessage(content='done')])) is None
await owner.begin([HumanMessage(content='下一轮')])
assert not owner.memory_due
asyncio.run(scenario())
def test_control_commands_are_host_only_and_reject_quoted_requests(tmp_path):
"""
确认入口不在工具 schema 中;只有完整用户命令可以应用指定提案。
"""
async def scenario():
"""
模拟后台提出删除及前台用户的拒绝/确认过程。
"""
from app.agent.learning.memory import MemoryStore
from app.agent.learning.schema import MemoryInput
owner = session(tmp_path)
owner.tools.memory.manage(MemoryInput(action='add', content='旧规则'))
proposal = MemoryStore(owner.memory_root, background=True).manage(MemoryInput(action='replace', old_text='旧规则', content='新规则'))
command = f"/memory approve {proposal['pending_id']}"
assert await owner.command('模型建议:' + command) is None
assert (owner.memory_root / 'MEMORY.md').read_text() == '旧规则'
assert not any(tool.name in {'approve', 'resolve'} for tool in owner.tools.tools)
assert json.loads(await owner.command(command))['approved']
assert (owner.memory_root / 'MEMORY.md').read_text() == '新规则'
asyncio.run(scenario())
def test_review_cancellation_fences_running_model_and_preserves_parent(tmp_path, monkeypatch):
"""
模型请求在运行时取消,任务退出后不会发生学习写入或覆盖主快照。
"""
async def scenario():
"""
借真实 Registry 发起可取消模型等待,观察终态与存储。
"""
registry = TaskRegistry()
monkeypatch.setattr('app.agent.learning.session.get_task_registry', lambda: registry)
started = asyncio.Event()
owner = session(tmp_path)
model = ReviewModel(responses=[AIMessage(content='done')])
owner.snapshot = ReviewSnapshot.capture(ModelRequest(model=model, messages=[], tools=owner.tools.tools), ModelResponse(result=[AIMessage(content='主回复')]))
before = deepcopy(owner.snapshot.messages)
async def block(_self):
"""
等待取消前不产生副作用。
"""
started.set()
await asyncio.Event().wait()
monkeypatch.setattr(ReviewLoop, 'run', block)
owner.memory_due = True
owner.finish(delivered=True)
await asyncio.wait_for(started.wait(), timeout=1)
assert await owner.wait_cancelled()
await asyncio.sleep(0)
assert owner.run is None and owner.snapshot.messages == before
assert not (owner.memory_root / 'MEMORY.md').exists()
assert await registry.shutdown()
asyncio.run(scenario())