mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。 按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩, CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。 修复空值、异步和供应方类型边界,保持插件与 API 兼容合同, 并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
399 lines
19 KiB
Python
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())
|