Files
MoviePilot/tests/test_agent_tool_discovery.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

358 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
工具按需发现的真实图执行与目录边界回归测试。
"""
import asyncio
import json
from types import SimpleNamespace
from typing import Any, Optional
import pytest
from langchain.agents import create_agent
from langchain.agents.middleware.types import ModelRequest
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.messages.utils import count_tokens_approximately
from langchain_core.tools import StructuredTool
from langgraph.checkpoint.memory import InMemorySaver
from pydantic import Field, ValidationError
from app.agent.middleware.selection import (
TOOL_DISCOVERY_DESCRIPTION_CHARS,
TOOL_DISCOVERY_MAX_RESULTS,
TOOL_DISCOVERY_NAME,
TOOL_DISCOVERY_SCHEMA_TOKENS,
TOOL_DISCOVERY_WINDOW_SIZE,
ToolSelectorMiddleware,
)
from app.agent.tools.catalog import ToolCatalogSnapshot
class _RecordingModel(FakeMessagesListChatModel):
"""
记录实际绑定工具的离线模型,驱动真实模型和工具循环。
"""
tool_history: list[list[Any]] = Field(default_factory=list)
message_history: list[list[Any]] = Field(default_factory=list)
invocation_count: int = 0
def bind_tools(self, tools: list[Any], **kwargs: Any) -> "_RecordingModel":
"""
记录每轮精确工具实例,便于核实发现后的完整参数契约。
"""
self.tool_history.append(list(tools))
return self
def _generate(self, messages: list[Any], stop: Optional[list[str]] = None, run_manager: Any = None, **kwargs: Any) -> Any:
"""
统计真实模型调用次数,确认发现工具不会增加额外筛选模型请求。
"""
self.invocation_count += 1
self.message_history.append(list(messages))
return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
def _make_tool(name: str, description: str, tags: Optional[list[str]] = None) -> StructuredTool:
"""
创建完全离线的测试能力,携带真实结构化输入参数。
"""
def invoke(record_id: int) -> str:
"""
用传入标识生成观察结果,验证发现后可以实际调用工具。
"""
return f"{name}:{record_id}"
return StructuredTool.from_function(
func=invoke,
name=name,
description=description,
tags=tags or [],
)
def _tool_call(name: str, arguments: dict[str, Any], call_id: str) -> dict[str, Any]:
"""
构造模型发出的标准工具调用。
"""
return {"name": name, "args": arguments, "id": call_id, "type": "tool_call"}
def test_discovery_enables_exact_schemas_merges_parallel_calls_and_resets_turns():
"""
真实图并行发现遗漏能力后可调用,且同会话新请求与其他会话均重新筛选。
"""
status = _make_tool("status", "运行状态")
transfer = _make_tool("transfer_history", "Inspect organization history", ["transfer"])
subscribe = _make_tool("subscribe_media", "管理媒体订阅", ["subscription"])
hidden = _make_tool("delete_library", "删除媒体库")
selection_model = _RecordingModel(responses=[AIMessage(content='{"tools": []}')])
model = _RecordingModel(responses=[
AIMessage(content="", tool_calls=[
_tool_call(TOOL_DISCOVERY_NAME, {"search": "帮我查询整理失败的记录", "limit": 1}, "find-transfer"),
_tool_call(TOOL_DISCOVERY_NAME, {"search": "Please find subscriptions", "limit": 1}, "find-subscribe"),
]),
AIMessage(content="", tool_calls=[
_tool_call("transfer_history", {"record_id": 42}, "read-history"),
]),
AIMessage(content="已查询整理历史"),
AIMessage(content="新请求"),
AIMessage(content="其他会话"),
])
original_tools = [status, transfer, subscribe, hidden]
middleware = ToolSelectorMiddleware(
model=selection_model,
selection_tools=original_tools,
always_include=["status"],
max_tools=1,
enable_discovery=True,
)
graph = create_agent(
model=model,
tools=original_tools,
middleware=[middleware],
checkpointer=InMemorySaver(),
)
async def execute() -> None:
"""
串行执行多个用户请求并读取持久化图状态。
"""
first_config = {"configurable": {"thread_id": "first-session"}}
result = await graph.ainvoke({"messages": [HumanMessage(content="检查系统")]}, first_config)
assert any(message.content == "transfer_history:42" for message in result["messages"])
state = await graph.aget_state(first_config)
assert set(state.values["discovered_tool_names"]) == {"transfer_history", "subscribe_media"}
await graph.ainvoke({"messages": [HumanMessage(content="新的请求")]}, first_config)
state = await graph.aget_state(first_config)
assert state.values["discovered_tool_names"] == []
await graph.ainvoke(
{"messages": [HumanMessage(content="其他会话")]},
{"configurable": {"thread_id": "second-session"}},
)
asyncio.run(execute())
bound_names = [{tool.name for tool in tools} for tools in model.tool_history]
assert bound_names[0] == {"status", TOOL_DISCOVERY_NAME}
assert bound_names[1] == {"status", TOOL_DISCOVERY_NAME, "transfer_history", "subscribe_media"}
assert bound_names[2] == bound_names[1]
assert bound_names[3] == bound_names[4] == bound_names[0]
assert transfer in model.tool_history[1]
assert transfer.tool_call_schema.model_json_schema()["properties"]["record_id"]["type"] == "integer"
assert "delete_library" not in set.union(*bound_names)
assert selection_model.invocation_count == 3
assert original_tools == [status, transfer, subscribe, hidden]
notice = str(model.message_history[1][0].content)
assert '"transfer_history": "enabled"' in notice
assert '"subscribe_media": "enabled"' in notice
def test_discovery_catalog_is_bounded_and_matches_name_description_and_tags():
"""
发现仅返回已授权目录的有界说明,并支持名称、中文说明和能力标签检索。
"""
exact = _make_tool("transfer_history", "查询失败整理历史", ["transfer"])
subscriptions = [
_make_tool(f"subscription_{index}", "媒体订阅" * 300, ["subscription"])
for index in range(12)
]
middleware = ToolSelectorMiddleware(selection_tools=[*subscriptions, exact], enable_discovery=True)
discovery = middleware.tools[0]
async def search(query: str, limit: int = TOOL_DISCOVERY_MAX_RESULTS) -> Any:
"""
直接调用已注册工具以覆盖参数校验和状态增量结果。
"""
return await discovery.ainvoke({
"search": query,
"limit": limit,
"runtime": SimpleNamespace(tool_call_id="discovery"),
})
result = asyncio.run(search("subscription"))
payload = json.loads(result.update["messages"][0].content)
assert len(payload["tools"]) == TOOL_DISCOVERY_MAX_RESULTS
assert len(result.update["discovered_tool_names"]) == TOOL_DISCOVERY_MAX_RESULTS
assert all(len(tool["description"]) <= TOOL_DISCOVERY_DESCRIPTION_CHARS for tool in payload["tools"])
assert asyncio.run(search("TRANSFER_HISTORY", 1)).update["discovered_tool_names"] == ["transfer_history"]
assert asyncio.run(search("失败整理", 1)).update["discovered_tool_names"] == ["transfer_history"]
assert asyncio.run(search("transfer", 1)).update["discovered_tool_names"] == ["transfer_history"]
assert asyncio.run(search("unavailable_secret_tool")).update["discovered_tool_names"] == []
assert asyncio.run(search(" !!! ")).update["discovered_tool_names"] == []
@pytest.mark.parametrize("arguments", [{"search": ""}, {"search": "x" * 161}, {"search": "media", "limit": 9}])
def test_discovery_rejects_unbounded_queries(arguments: dict[str, Any]):
"""
超界请求被明确拒绝,避免模型通过参数扩大目录输出。
"""
middleware = ToolSelectorMiddleware(enable_discovery=True)
with pytest.raises(ValidationError):
middleware.tools[0].args_schema.model_validate(arguments)
def test_discovery_preserves_provider_and_mandatory_tools_without_fabricating_unknown_tools():
"""
按需启用与首轮工具上限独立,保留强制工具和 provider 字典且拒绝不存在的名称。
"""
selected = _make_tool("status", "运行状态")
mandatory = _make_tool("read_skill", "读取技能")
discovered = _make_tool("transfer_history", "整理历史")
provider = {"type": "web_search_preview"}
middleware = ToolSelectorMiddleware(
selection_tools=[selected, mandatory, discovered],
always_include=["read_skill"],
max_tools=1,
enable_discovery=True,
)
request = ModelRequest(
model=_RecordingModel(responses=[AIMessage(content="完成")]),
tools=[selected, mandatory, discovered, *middleware.tools, provider],
messages=[HumanMessage(content="查询记录")],
state={"selected_tool_names": ["status"], "discovered_tool_names": ["transfer_history", "fabricated"]},
runtime=None,
)
async def handler(updated: ModelRequest) -> ModelRequest:
"""
捕获最终模型绑定请求。
"""
return updated
result = asyncio.run(middleware.awrap_model_call(request, handler))
assert result.tools == [selected, discovered, mandatory, middleware.tools[0], provider]
def test_discovery_has_serializable_private_catalog_identity_and_injected_runtime():
"""
严格目录可以稳定签名发现工具,模型参数中不暴露宿主运行时状态。
"""
middleware = ToolSelectorMiddleware(selection_tools=[], enable_discovery=True)
catalog = ToolCatalogSnapshot.from_tools(middleware.tools, plugin_revision=1, factory_revision="test")
assert catalog.require_unique() is catalog
assert catalog.entries[0].source == "middleware:selection"
assert set(middleware.tools[0].tool_call_schema.model_json_schema()["properties"]) == {"search", "limit"}
def test_discovery_exact_names_outrank_aliases_and_english_substrings_do_not_match():
"""
精确名称始终优先,有限别名支持双语能力检索且不把英文子串当单词。
"""
exact = _make_tool("subscription", "Exact legacy capability")
tagged = _make_tool("follow_series", "维护追剧任务", ["subscription"])
unrelated = _make_tool("generate_art", "Generate artwork")
middleware = ToolSelectorMiddleware(selection_tools=[tagged, unrelated, exact], enable_discovery=True)
assert middleware._find_discovery_tools("subscription", 1) == [exact]
assert middleware._find_discovery_tools("我要追剧", 1) == [tagged]
assert middleware._find_discovery_tools("Please find subscriptions", 1) == [tagged]
assert middleware._find_discovery_tools("rate", 8) == []
def test_discovery_real_graph_evicts_oldest_and_refreshes_recent_tools():
"""
真实图累计发现受窗口约束,重新发现可恢复已淘汰工具并刷新最近顺序。
"""
tools = [_make_tool(f"{group}_{index:02}", "Inspect records") for group in ("alpha", "beta", "gamma") for index in range(8)]
selector = _RecordingModel(responses=[AIMessage(content='{"tools": []}')])
model = _RecordingModel(responses=[
*[AIMessage(content="", tool_calls=[
_tool_call(TOOL_DISCOVERY_NAME, {"search": query, "limit": 1 if query == "alpha_00" else 8}, f"search-{query}"),
]) for query in ("alpha", "beta", "gamma", "alpha_00")],
AIMessage(content="完成"),
])
middleware = ToolSelectorMiddleware(model=selector, selection_tools=tools, max_tools=1, enable_discovery=True)
graph = create_agent(model=model, tools=tools, middleware=[middleware], checkpointer=InMemorySaver())
async def execute() -> Any:
"""
执行多次发现并读取最终有界状态。
"""
config = {"configurable": {"thread_id": "window"}}
await graph.ainvoke({"messages": [HumanMessage(content="连续查询不同记录")]}, config)
return await graph.aget_state(config)
state = asyncio.run(execute())
names = [{tool.name for tool in batch} for batch in model.tool_history]
assert len(names[2] - {TOOL_DISCOVERY_NAME}) == TOOL_DISCOVERY_WINDOW_SIZE
assert names[3] == {TOOL_DISCOVERY_NAME, *[tool.name for tool in tools if not tool.name.startswith("alpha")]}
assert "alpha_00" in names[4]
assert "beta_07" not in names[4]
assert "beta_06" in names[4]
assert len(names[4] - {TOOL_DISCOVERY_NAME}) == TOOL_DISCOVERY_WINDOW_SIZE
assert len(state.values["discovered_tool_names"]) == TOOL_DISCOVERY_WINDOW_SIZE
assert state.values["discovered_tool_names"][-1] == "alpha_00"
assert selector.invocation_count == 1
@pytest.mark.parametrize("window,expected_enabled", [(4000, False), (64000, True), (None, True)])
def test_discovery_real_graph_limits_schema_budget_by_model_window(window: Optional[int], expected_enabled: bool):
"""
额外 schema 使用模型窗口比例预算,无窗口元数据时仍有固定成本上限。
"""
medium = _make_tool("medium_transfer", "contract " * 500, ["transfer"])
huge = _make_tool("huge_transfer", "contract " * 5000, ["transfer"])
mandatory = _make_tool("status", "运行状态")
model = _RecordingModel(responses=[
AIMessage(content="", tool_calls=[_tool_call(TOOL_DISCOVERY_NAME, {"search": "整理"}, "search")]),
AIMessage(content="完成"),
], profile={"max_input_tokens": window} if window else {})
selector = _RecordingModel(responses=[AIMessage(content='{"tools": []}')])
middleware = ToolSelectorMiddleware(
model=selector, selection_tools=[medium, huge, mandatory], max_tools=1,
always_include=["status"], enable_discovery=True,
)
graph = create_agent(model=model, tools=[medium, huge, mandatory], middleware=[middleware])
asyncio.run(graph.ainvoke({"messages": [HumanMessage(content="查询整理历史")]}))
names = {tool.name for tool in model.tool_history[1]}
assert ("medium_transfer" in names) is expected_enabled
assert "huge_transfer" not in names
assert {"status", TOOL_DISCOVERY_NAME} <= names
extras = [tool for tool in model.tool_history[1] if tool.name not in {"status", TOOL_DISCOVERY_NAME}]
assert count_tokens_approximately([], tools=extras, use_usage_metadata_scaling=False) <= TOOL_DISCOVERY_SCHEMA_TOKENS
notice = str(model.message_history[1][0].content)
assert '"huge_transfer": "schema_budget_exceeded"' in notice
assert f'"medium_transfer": "{"enabled" if expected_enabled else "schema_budget_exceeded"}"' in notice
def test_discovery_real_graph_preserves_initial_tools_when_context_is_full():
"""
上下文不足只禁用新增 schema,保留初选和强制工具并明确说明原因。
"""
extra = _make_tool("inspect_transfer", "Inspect records", ["transfer"])
initial = _make_tool("status", "运行状态")
model = _RecordingModel(responses=[
AIMessage(content="", tool_calls=[_tool_call(TOOL_DISCOVERY_NAME, {"search": "整理"}, "search")]),
AIMessage(content="完成"),
], profile={"max_input_tokens": 4000})
selector = _RecordingModel(responses=[AIMessage(content='{"tools": ["status"]}')])
middleware = ToolSelectorMiddleware(model=selector, selection_tools=[extra, initial], max_tools=1, enable_discovery=True)
graph = create_agent(model=model, tools=[extra, initial], middleware=[middleware])
asyncio.run(graph.ainvoke({"messages": [HumanMessage(content="history " * 3000)]}))
assert {tool.name for tool in model.tool_history[1]} == {"status", TOOL_DISCOVERY_NAME}
assert '"inspect_transfer": "context_budget_exceeded"' in str(model.message_history[1][0].content)
def test_discovery_parallel_requests_share_one_cumulative_schema_budget():
"""
并行发现合并后统一计算累计 schema 成本,不能分别消耗整份预算。
"""
tools = [_make_tool(f"{group}_{index:02}", "contract " * 400) for group in ("alpha", "beta") for index in range(8)]
model = _RecordingModel(responses=[
AIMessage(content="", tool_calls=[
_tool_call(TOOL_DISCOVERY_NAME, {"search": group, "limit": 8}, f"search-{group}")
for group in ("alpha", "beta")
]),
AIMessage(content="完成"),
])
selector = _RecordingModel(responses=[AIMessage(content='{"tools": []}')])
middleware = ToolSelectorMiddleware(model=selector, selection_tools=tools, max_tools=1, enable_discovery=True)
graph = create_agent(model=model, tools=tools, middleware=[middleware])
asyncio.run(graph.ainvoke({"messages": [HumanMessage(content="检索两组能力")]}))
extras = [tool for tool in model.tool_history[1] if tool.name != TOOL_DISCOVERY_NAME]
assert 0 < len(extras) < len(tools)
assert count_tokens_approximately([], tools=extras, use_usage_metadata_scaling=False) <= TOOL_DISCOVERY_SCHEMA_TOKENS
assert "schema_budget_exceeded" in str(model.message_history[1][0].content)
assert selector.invocation_count == 1