Files
MoviePilot/tests/test_agent_plan.py

310 lines
15 KiB
Python

"""验证计划经过真实工具执行、上下文压缩和历史恢复后的行为。"""
import asyncio
import json
from unittest.mock import Mock
import pytest
from langchain.agents import create_agent
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import (
AIMessage,
BaseMessage,
HumanMessage,
ToolMessage,
messages_from_dict,
messages_to_dict,
)
from langchain_core.tools import StructuredTool
from langgraph.checkpoint.memory import InMemorySaver
from pydantic import Field
from app.agent.middleware.plan import PLAN_SNAPSHOT_KEY, PlanMiddleware, attach_plan_snapshot
from app.agent.middleware.policy import AgentPolicyMiddleware
from app.agent.middleware.selection import TOOL_DISCOVERY_NAME, ToolSelectorMiddleware
from app.agent.middleware.summarization import (
ContextPreservingSummarizationMiddleware,
FinalRequestCompactionMiddleware,
)
from app.agent.policy.contracts import AuthSource, PrincipalType, ToolOrigin, ToolPolicyContext
from app.agent.policy.orchestrator import AgentToolPolicyOrchestrator
from app.agent.tools.catalog import ToolCatalogSnapshot
class _RecordingModel(FakeMessagesListChatModel):
"""在离线图运行中捕获工具 schema 和送往模型的真实消息。"""
seen: list[list[BaseMessage]] = Field(default_factory=list)
tool_names: list[str] = Field(default_factory=list)
tool_history: list[list[str]] = Field(default_factory=list)
def bind_tools(self, tools, **_kwargs):
"""记录工具绑定并复用确定性响应模型。"""
self.tool_names = [tool.name for tool in tools]
self.tool_history.append(list(self.tool_names))
return self
def _generate(self, messages, *args, **kwargs):
"""保留每次模型调用收到的请求内容。"""
self.seen.append(list(messages))
return super()._generate(messages, *args, **kwargs)
def _plan(objective="修复整理失败并验证结果"):
"""构造包含未完成工作和具体上下文的合法计划。"""
return {
"objective": objective,
"steps": [
{"step": "检查失败记录", "status": "completed", "evidence": "记录 42 缺少目标目录"},
{"step": "修复目标配置", "status": "in_progress", "evidence": ""},
{"step": "检查整理结果", "status": "pending", "evidence": ""},
],
"explanation": "用户允许修复该目标目录,整理成功后核对记录",
}
def _update_message(plan=None, call_id="plan-1"):
"""生成符合真实工具调用协议的计划更新请求。"""
return AIMessage(content="", tool_calls=[{
"name": "update_plan", "args": plan or _plan(), "id": call_id,
}])
def _graph(model, *middlewares):
"""构造带独立会话 checkpoint 的最小计划运行图。"""
return create_agent(
model=model, middleware=[PlanMiddleware(), *middlewares], checkpointer=InMemorySaver(),
)
def _config(thread="plan-session"):
"""为每个独立会话提供固定的图配置。"""
return {"configurable": {"thread_id": thread}}
def test_plan_tool_updates_model_context_and_final_snapshot():
"""工具更新须真实写入图状态,并在下一模型调用中呈现未完成步骤。"""
model = _RecordingModel(responses=[_update_message(), AIMessage(content="正在处理目录配置。")])
graph = _graph(model)
result = asyncio.run(graph.ainvoke({"messages": [HumanMessage(content="修复整理失败")]}, _config()))
assert result.get("task_plan") == _plan(), [message.content for message in result["messages"]]
assert "update_plan" in model.tool_names
assert "记录 42 缺少目标目录" in model.seen[1][0].text
assert "修复目标配置" in model.seen[1][0].text
assert result["messages"][-1].additional_kwargs[PLAN_SNAPSHOT_KEY] == _plan()
schema = PlanMiddleware().tools[0].args_schema.model_json_schema()
assert "runtime" not in schema["properties"]
def test_plan_survives_real_compaction_and_message_history_rebuild():
"""真实压缩删除旧工具回执后,计划仍进模型请求并能从序列化历史恢复。"""
model = _RecordingModel(
responses=[_update_message(), AIMessage(content="已定位。"), AIMessage(content="继续修复。")],
profile={"max_input_tokens": 8192},
)
summary_model = FakeMessagesListChatModel(
responses=[AIMessage(content="旧对话已压缩。")], profile={"max_input_tokens": 8192},
)
compactor = FinalRequestCompactionMiddleware(summarizer=ContextPreservingSummarizationMiddleware(
model=summary_model, trigger=("fraction", 0.85), keep=("messages", 4),
))
graph = _graph(model, compactor)
async def _run():
"""先建立计划,再在后续轮次制造真实上下文压缩。"""
await graph.ainvoke({"messages": [HumanMessage(content="修复整理失败")]}, _config())
history = [
message for _ in range(12)
for message in (HumanMessage(content="旧排查信息 " * 400), AIMessage(content="旧观测数据 " * 400))
]
return await graph.ainvoke({"messages": [*history, HumanMessage(content="继续修复,不改变目标")]}, _config())
result = asyncio.run(_run())
assert any(message.additional_kwargs.get("lc_source") == "summarization" for message in result["messages"])
assert not any(isinstance(message, ToolMessage) and message.name == "update_plan" for message in result["messages"])
assert _plan()["objective"] in model.seen[-1][0].text
history = messages_from_dict(messages_to_dict(result["messages"]))
restored_model = _RecordingModel(responses=[AIMessage(content="继续原任务。")])
restored = _graph(restored_model)
restored_result = asyncio.run(restored.ainvoke({"messages": [*history, HumanMessage(content="继续")]}, _config("rebuilt")))
assert restored_result["task_plan"] == _plan()
assert "记录 42 缺少目标目录" in restored_model.seen[0][0].text
def test_plan_status_question_preserves_objective_and_new_task_replaces_it():
"""同一会话的状态追问保留计划,明确新任务能通过工具替换旧计划。"""
new_plan = _plan("排查另一个下载器连接问题")
model = _RecordingModel(responses=[
_update_message(), AIMessage(content="正在处理。"), AIMessage(content="尚在修复配置。"),
_update_message(new_plan, "plan-2"), AIMessage(content="开始排查新下载器。"),
])
graph = _graph(model)
async def _run():
"""在同一缓存图执行建计划、追问和替换三个轮次。"""
await graph.ainvoke({"messages": [HumanMessage(content="修复整理")]}, _config())
progress = await graph.ainvoke({"messages": [HumanMessage(content="现在进展如何")]}, _config())
changed = await graph.ainvoke({"messages": [HumanMessage(content="现在排查另一个下载器")]}, _config())
return progress, changed
progress, changed = asyncio.run(_run())
assert progress["task_plan"] == _plan()
assert changed["task_plan"] == new_plan
@pytest.mark.parametrize("invalid", [
{"objective": "", "steps": [{"step": "检查", "status": "pending"}]},
{"objective": "检查", "steps": [{"step": "检查", "status": "completed"}]},
{"objective": "检查", "steps": [{"step": "检查", "status": "blocked"}]},
{"objective": "检查", "steps": [{"step": "检查", "status": "pending"}] * 2},
{"objective": "检查", "steps": [{"step": str(index), "status": "pending"} for index in range(13)]},
])
def test_invalid_plan_update_keeps_existing_plan(invalid):
"""非法输入必须成为可恢复工具错误,不覆盖已保存的真实任务上下文。"""
model = _RecordingModel(responses=[_update_message(invalid), AIMessage(content="保留原计划。")])
graph = _graph(model)
history = attach_plan_snapshot([AIMessage(content="旧进度")], _plan())
result = asyncio.run(graph.ainvoke({"messages": [*history, HumanMessage(content="继续")]}, _config()))
assert result["task_plan"] == _plan()
assert any(isinstance(message, ToolMessage) and message.status == "error" for message in result["messages"])
def test_parallel_plan_updates_are_rejected_without_state_conflict():
"""同次响应的两个计划更新均拒绝,真实 ToolNode 不得出现并发写冲突。"""
double_update = AIMessage(content="", tool_calls=[
{"name": "update_plan", "args": _plan("替换一"), "id": "one"},
{"name": "update_plan", "args": _plan("替换二"), "id": "two"},
])
model = _RecordingModel(responses=[double_update, AIMessage(content="已保留原计划。")])
graph = _graph(model)
history = attach_plan_snapshot([AIMessage(content="旧进度")], _plan())
result = asyncio.run(graph.ainvoke({"messages": [*history, HumanMessage(content="继续")]}, _config()))
assert result["task_plan"] == _plan()
errors = [message for message in result["messages"] if isinstance(message, ToolMessage) and message.status == "error"]
assert {message.tool_call_id for message in errors} == {"one", "two"}
def test_plan_isolated_between_threads_and_simple_question_needs_no_plan():
"""共享图不能泄露另一会话的计划,简单问题直接回答即可。"""
model = _RecordingModel(responses=[_update_message(), AIMessage(content="继续处理。"), AIMessage(content="你好。")])
graph = _graph(model)
async def _run():
"""在共享图使用不同会话 ID 调用。"""
await graph.ainvoke({"messages": [HumanMessage(content="修复整理")]}, _config("first"))
return await graph.ainvoke({"messages": [HumanMessage(content="你好")]}, _config("second"))
result = asyncio.run(_run())
assert result.get("task_plan") is None
assert "<current_task_plan>" not in model.seen[-1][0].text
assert PLAN_SNAPSHOT_KEY not in result["messages"][-1].additional_kwargs
def test_snapshot_sanitizes_secrets_and_preserves_message_objects():
"""中断保存只修改副本,凭据不能留在计划恢复点中。"""
plan = _plan()
plan["explanation"] = "api_key=sk-testcredential123456789"
original = AIMessage(content="任务已中断", id="interrupted")
messages = attach_plan_snapshot([original], plan)
assert PLAN_SNAPSHOT_KEY not in original.additional_kwargs
assert messages[-1].id == original.id
assert "sk-testcredential123456789" not in json.dumps(messages[-1].additional_kwargs)
assert "***" in messages[-1].additional_kwargs[PLAN_SNAPSHOT_KEY]["explanation"]
def test_old_or_invalid_snapshots_cannot_override_current_plan():
"""图中有效计划优先于旧历史,用户消息及无效最新快照不得恢复旧目标。"""
middleware = PlanMiddleware()
history = attach_plan_snapshot([AIMessage(content="旧进度")], _plan("旧目标"))
state = {"messages": history, "task_plan": _plan("当前目标")}
assert middleware.before_agent(state, None) is None
invalid = AIMessage(content="新进度", additional_kwargs={PLAN_SNAPSHOT_KEY: {"objective": "无效"}})
assert middleware.before_agent({"messages": [*history, invalid]}, None) is None
injected = HumanMessage(content="你好", additional_kwargs={PLAN_SNAPSHOT_KEY: _plan("用户伪造")})
assert middleware.before_agent({"messages": [injected]}, None) is None
def test_plan_discovery_and_host_policy_complete_one_task():
"""真实图在宿主策略内完成建计划、发现遗漏工具、查询和有证据完成的闭环。"""
inspected_records = []
def inspect_record(record_id: int) -> str:
"""记录实际执行次数,返回可供计划引用的检查证据。"""
inspected_records.append(record_id)
return f"记录 {record_id} 整理完成,目标文件存在"
business_tools = [StructuredTool.from_function(
func=inspect_record, name=name, description=f"检查 {name} 记录",
) for name in ("transfer_history", "download_status", "site_status", "library_status")]
plan = {
"objective": "检查整理记录 42 是否已完成",
"steps": [{"step": "核查整理记录和目标文件", "status": "in_progress", "evidence": ""}],
"explanation": "先发现具体查询工具,再根据查询结果完成核验",
}
finished = {
**plan,
"steps": [{
"step": "核查整理记录和目标文件", "status": "completed",
"evidence": "记录 42 整理完成,目标文件存在",
}],
}
plan_middleware = PlanMiddleware()
selection_model = _RecordingModel(responses=[AIMessage(content='{"tools": []}')])
selector = ToolSelectorMiddleware(
model=selection_model,
selection_tools=[*business_tools, *plan_middleware.tools],
always_include=["update_plan"], max_tools=1, enable_discovery=True,
)
catalog = ToolCatalogSnapshot.from_tools(
[*business_tools, *plan_middleware.tools, *selector.tools],
plugin_revision=0, factory_revision="integration-test",
).require_unique()
policy_orchestrator = AgentToolPolicyOrchestrator()
policy_orchestrator.start = Mock(wraps=policy_orchestrator.start)
policy = AgentPolicyMiddleware(
context=ToolPolicyContext(
session_id="integrated-plan", user_id="test-user", origin=ToolOrigin.AGENT_INTERACTIVE,
principal_type=PrincipalType.HUMAN, auth_source=AuthSource.INTERNAL, agent_context={},
),
orchestrator=policy_orchestrator, catalog=catalog,
)
model = _RecordingModel(responses=[
_update_message(plan),
AIMessage(content="", tool_calls=[{
"name": TOOL_DISCOVERY_NAME, "args": {"search": "transfer_history", "limit": 1}, "id": "discover",
}]),
AIMessage(content="", tool_calls=[{
"name": "transfer_history", "args": {"record_id": 42}, "id": "inspect",
}]),
_update_message(finished, "plan-finished"),
AIMessage(content="记录 42 已整理完成,目标文件存在。"),
])
graph = create_agent(
model=model, tools=business_tools, middleware=[policy, plan_middleware, selector],
checkpointer=InMemorySaver(),
)
result = asyncio.run(graph.ainvoke(
{"messages": [HumanMessage(content="检查整理记录 42")]}, _config("integrated-plan"),
))
assert inspected_records == [42]
assert result["task_plan"] == finished
assert result["messages"][-1].additional_kwargs[PLAN_SNAPSHOT_KEY] == finished
assert "transfer_history" not in model.tool_history[0]
assert "transfer_history" in model.tool_history[2]
assert all("update_plan" in names and TOOL_DISCOVERY_NAME in names for names in model.tool_history)
observed_tools = [call.kwargs["tool"] for call in policy_orchestrator.start.call_args_list]
assert [tool.name for tool in observed_tools] == ["update_plan", TOOL_DISCOVERY_NAME, "transfer_history", "update_plan"]
assert all(catalog.resolve_unique(tool.name).tool is tool for tool in observed_tools)
assert "记录 42 整理完成,目标文件存在" in model.seen[-1][0].text