Files
MoviePilot/tests/test_agent_request_budget.py

992 lines
33 KiB
Python
Raw 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.
"""Agent 最终模型请求预算测试。"""
import asyncio
from types import SimpleNamespace
from unittest.mock import patch
from langchain.agents import create_agent
from langchain.agents.middleware import AgentMiddleware, SummarizationMiddleware
from langchain.agents.middleware.types import ModelRequest, ModelResponse
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.tools import tool
from app.agent import MoviePilotAgent
from app.agent.middleware.tool_selection import ToolSelectorMiddleware
from app.agent.middleware.usage import UsageMiddleware
from app.chain.message import MessageChain
class _ToolBindingFakeModel(FakeMessagesListChatModel):
"""记录最终绑定工具,同时保留固定响应行为。"""
bound_tool_names: list[str] = []
def bind_tools(self, tools, **kwargs):
"""记录 LangChain 最终交给模型的工具集合。"""
self.bound_tool_names = [
item.get("function", {}).get("name") or item.get("name")
if isinstance(item, dict)
else item.name
for item in tools
]
return self
class _DynamicSystemMiddleware(AgentMiddleware):
"""模拟 MoviePilot 运行时动态追加系统上下文。"""
async def awrap_model_call(self, request, handler):
current = request.system_message.content if request.system_message else ""
return await handler(
request.override(
system_message=SystemMessage(
content=f"{current}\n{'动态系统上下文 ' * 200}"
)
)
)
def _request(
*,
messages=None,
system_message=None,
tools=None,
max_input_tokens=4096,
max_output_tokens=512,
model_settings=None,
) -> ModelRequest:
"""构造带模型窗口和最终请求组成的测试请求。"""
return ModelRequest(
model=SimpleNamespace(
model="small-model",
profile={
"max_input_tokens": max_input_tokens,
"max_output_tokens": max_output_tokens,
},
),
messages=list(messages or []),
system_message=system_message,
tools=list(tools or []),
state={},
runtime=None,
model_settings=model_settings,
)
def test_final_request_can_exceed_window_before_message_fraction_triggers():
"""动态系统提示词和工具定义可能在消息摘要阈值前耗尽输入窗口。"""
messages = [HumanMessage(content="用户上下文 " * 1000)]
model = SimpleNamespace(
_llm_type="test-chat",
profile={"max_input_tokens": 4096},
)
model.with_retry = lambda: model
summarizer = SummarizationMiddleware(
model=model,
trigger=("fraction", 0.85),
)
message_tokens = summarizer.token_counter(messages)
assert message_tokens < 4096 * 0.85
assert not summarizer._should_summarize(messages, message_tokens)
request = _request(
messages=messages,
system_message=SystemMessage(content="动态系统上下文 " * 1000),
tools=[
{
"type": "function",
"function": {
"name": "large_schema_tool",
"description": "工具业务说明 " * 800,
"parameters": {
"type": "object",
"properties": {
"value": {
"type": "string",
"description": "参数约束 " * 400,
}
},
},
},
}
],
)
snapshot = UsageMiddleware.estimate_request(request)
assert snapshot["has_estimate"]
assert snapshot["message_tokens"] < 4096 * 0.85
assert snapshot["system_tokens"] > 0
assert snapshot["tool_tokens"] > 0
assert snapshot["estimated_input_tokens"] > 4096
assert snapshot["estimated_over_input_limit"] is True
assert snapshot["model_max_output_tokens"] == 512
assert snapshot["configured_output_limit_tokens"] is None
assert "output_headroom_tokens" not in snapshot
def test_request_budget_marks_same_request_over_limit_after_switch_to_small_model():
"""相同请求切换到小窗口模型后,应仅改变预算判断而不改变估算输入。"""
messages = [HumanMessage(content="x" * 12000)]
large_snapshot = UsageMiddleware.estimate_request(
_request(messages=messages, max_input_tokens=128000)
)
small_snapshot = UsageMiddleware.estimate_request(
_request(messages=messages, max_input_tokens=2048)
)
assert large_snapshot["estimated_input_tokens"] == small_snapshot["estimated_input_tokens"]
assert large_snapshot["estimated_over_input_limit"] is False
assert small_snapshot["estimated_over_input_limit"] is True
def test_request_budget_reads_only_explicit_per_call_output_limit():
"""只有最终请求显式配置的输出上限才可视为单次调用限制。"""
request = _request(
messages=[HumanMessage(content="hello")],
max_output_tokens=8192,
model_settings={"max_completion_tokens": 1024},
)
snapshot = UsageMiddleware.estimate_request(request)
assert snapshot["model_max_output_tokens"] == 8192
assert snapshot["configured_output_limit_tokens"] == 1024
assert snapshot["estimated_input_tokens"] < snapshot["context_window_tokens"]
def test_request_budget_rejects_non_integer_token_limits():
"""近似观察不得把 bool、浮点或字符串误报为有效 token 上限。"""
for invalid_value in (True, False, 1.5, 0, -1, "1024"):
request = _request(
messages=[HumanMessage(content="hello")],
max_input_tokens=invalid_value,
max_output_tokens=invalid_value,
model_settings={"max_completion_tokens": invalid_value},
)
snapshot = UsageMiddleware.estimate_request(request)
assert snapshot["context_window_tokens"] is None
assert snapshot["model_max_output_tokens"] is None
assert snapshot["configured_output_limit_tokens"] is None
assert snapshot["estimated_input_ratio"] is None
assert snapshot["estimated_over_input_limit"] is None
def test_request_budget_uses_next_valid_output_limit_alias():
"""高优先字段无效时,应继续读取同一请求中的有效兼容字段。"""
request = _request(
messages=[HumanMessage(content="hello")],
model_settings={"max_completion_tokens": True, "max_tokens": 1024},
)
snapshot = UsageMiddleware.estimate_request(request)
assert snapshot["configured_output_limit_tokens"] == 1024
def test_request_budget_counts_multimodal_input_without_storing_content():
"""图片按固定成本计入估算,快照不得保留请求正文或工具定义。"""
secret_marker = "REQUEST_BUDGET_SECRET_MARKER"
request = _request(
messages=[
HumanMessage(
content=[
{"type": "text", "text": secret_marker},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,hidden"},
},
{"type": "file", "file_id": secret_marker},
]
)
],
system_message=SystemMessage(content=secret_marker),
tools=[
{
"type": "function",
"function": {
"name": "secret_tool",
"description": secret_marker,
"parameters": {"type": "object", "properties": {}},
},
}
],
)
snapshot = UsageMiddleware.estimate_request(request)
assert snapshot["image_count"] == 1
assert snapshot["unknown_multimodal_count"] == 1
assert snapshot["multimodal_tokens"] == 85
assert snapshot["estimated_input_tokens"] == (
snapshot["message_tokens"]
+ snapshot["system_tokens"]
+ snapshot["tool_tokens"]
)
assert snapshot["model"] == "small-model"
assert secret_marker not in repr(snapshot)
assert all(
value is None or isinstance(value, (bool, int, float))
for key, value in snapshot.items()
if key != "model"
)
def test_request_budget_counts_each_image_cost_exactly_once():
"""LangChain 的消息估算已包含图片固定成本,汇总预算不得重复相加。"""
text_only = UsageMiddleware.estimate_request(
_request(
messages=[HumanMessage(content=[{"type": "text", "text": "hello"}])]
)
)
with_image = UsageMiddleware.estimate_request(
_request(
messages=[
HumanMessage(
content=[
{"type": "text", "text": "hello"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,hidden"},
},
]
)
]
)
)
assert with_image["multimodal_tokens"] == 85
assert (
with_image["estimated_input_tokens"] - text_only["estimated_input_tokens"]
== 85
)
def test_request_budget_callback_failure_does_not_block_model_call():
"""预算观察失败不得改变模型请求和响应。"""
request = _request(messages=[HumanMessage(content="hello")])
response = ModelResponse(result=[AIMessage(content="ok")])
def _raise_callback(_snapshot):
raise RuntimeError("observer unavailable")
middleware = UsageMiddleware(on_request_budget=_raise_callback)
handled = []
async def _handler(received: ModelRequest):
handled.append(received)
return response
result = asyncio.run(middleware.awrap_model_call(request, _handler))
assert result is response
assert handled == [request]
def test_request_budget_callback_failure_clears_previous_estimate_state():
"""预算回调失败时,本轮 usage 不得与上一轮估算拼接。"""
agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
callback_count = 0
def _record_then_fail(snapshot):
nonlocal callback_count
callback_count += 1
if callback_count == 2:
raise RuntimeError("observer unavailable")
agent._record_request_budget(snapshot)
middleware = UsageMiddleware(
on_request_budget=_record_then_fail,
on_usage=agent._record_usage,
)
responses = iter(
[
ModelResponse(
result=[
AIMessage(
content="first",
usage_metadata={
"input_tokens": 10,
"output_tokens": 1,
"total_tokens": 11,
},
)
]
),
ModelResponse(
result=[
AIMessage(
content="second",
usage_metadata={
"input_tokens": 50,
"output_tokens": 2,
"total_tokens": 52,
},
)
]
),
]
)
async def _handler(_request):
return next(responses)
request = _request(messages=[HumanMessage(content="hello")])
asyncio.run(middleware.awrap_model_call(request, _handler))
assert agent.get_session_status()["last_request_estimate_available"] is True
asyncio.run(middleware.awrap_model_call(request, _handler))
status = agent.get_session_status()
assert status["last_request_sequence"] == 2
assert status["last_request_estimate_available"] is False
assert status["last_estimated_input_tokens"] is None
assert status["last_actual_input_tokens"] is None
assert status["last_estimate_error_tokens"] is None
assert status["model"] == "small-model"
assert status["context_window_tokens"] == 4096
assert status["last_input_tokens"] == 50
def test_request_budget_estimator_failure_reports_empty_snapshot_and_calls_model():
"""估算器异常应清除旧观测状态,并继续原模型调用。"""
budgets = []
middleware = UsageMiddleware(on_request_budget=budgets.append)
request = _request(messages=[HumanMessage(content="hello")])
response = ModelResponse(result=[AIMessage(content="ok")])
async def _handler(_request):
return response
with patch.object(
UsageMiddleware,
"estimate_request",
side_effect=RuntimeError("request content must not reach the snapshot"),
):
result = asyncio.run(middleware.awrap_model_call(request, _handler))
assert result is response
assert budgets == [
{
"request_sequence": 1,
"has_estimate": False,
"model": "small-model",
"context_window_tokens": 4096,
}
]
def test_request_budget_metadata_failure_still_calls_model():
"""模型元数据属性异常不得让预算观察器阻断真实模型调用。"""
class _BrokenMetadataModel:
@property
def model(self):
raise RuntimeError("model metadata unavailable")
@property
def model_name(self):
raise RuntimeError("model metadata unavailable")
@property
def model_id(self):
raise RuntimeError("model metadata unavailable")
@property
def profile(self):
raise RuntimeError("profile metadata unavailable")
budgets = []
middleware = UsageMiddleware(on_request_budget=budgets.append)
request = ModelRequest(
model=_BrokenMetadataModel(),
messages=[HumanMessage(content="hello")],
tools=[],
state={},
runtime=None,
)
response = ModelResponse(result=[AIMessage(content="ok")])
handled = []
async def _handler(received):
handled.append(received)
return response
with patch.object(
UsageMiddleware,
"estimate_request",
side_effect=RuntimeError("estimate unavailable"),
):
result = asyncio.run(middleware.awrap_model_call(request, _handler))
assert result is response
assert handled == [request]
assert budgets == [
{
"request_sequence": 1,
"has_estimate": False,
"model": None,
"context_window_tokens": None,
}
]
def test_request_sequence_callback_failure_still_calls_model():
"""会话序号分配异常时应放弃最近快照竞争,并继续真实模型调用。"""
budgets = []
def _raise_sequence():
raise RuntimeError("sequence unavailable")
middleware = UsageMiddleware(
on_request_budget=budgets.append,
next_request_sequence=_raise_sequence,
)
request = _request(messages=[HumanMessage(content="hello")])
response = ModelResponse(result=[AIMessage(content="ok")])
handled = []
async def _handler(received):
handled.append(received)
return response
result = asyncio.run(middleware.awrap_model_call(request, _handler))
assert result is response
assert handled == [request]
assert budgets[0]["request_sequence"] is None
def test_request_budget_and_actual_usage_share_one_request_sequence():
"""真实 usage 只能校准同一次成功模型调用产生的估算。"""
budgets = []
usages = []
middleware = UsageMiddleware(
on_request_budget=budgets.append,
on_usage=usages.append,
)
request = _request(messages=[HumanMessage(content="hello")])
response = ModelResponse(
result=[
AIMessage(
content="ok",
usage_metadata={
"input_tokens": 23,
"output_tokens": 4,
"total_tokens": 27,
},
)
]
)
async def _handler(_request):
return response
asyncio.run(middleware.awrap_model_call(request, _handler))
assert budgets[0]["request_sequence"] == 1
assert usages[0]["request_sequence"] == 1
assert usages[0]["request_budget_recorded"] is True
assert usages[0]["input_usage_available"] is True
assert usages[0]["estimated_input_tokens"] == budgets[0]["estimated_input_tokens"]
def test_partial_usage_without_input_does_not_calibrate_request_estimate():
"""仅有输出 usage 时,不得把缺失的真实输入误报为零。"""
agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
middleware = UsageMiddleware(
on_request_budget=agent._record_request_budget,
on_usage=agent._record_usage,
)
request = _request(messages=[HumanMessage(content="hello")])
response = ModelResponse(
result=[
AIMessage(
content="ok",
response_metadata={
"token_usage": {
"completion_tokens": 7,
"total_tokens": 7,
}
},
)
]
)
async def _handler(_request):
return response
asyncio.run(middleware.awrap_model_call(request, _handler))
status = agent.get_session_status()
assert status["last_request_estimate_available"] is True
assert status["last_input_usage_available"] is False
assert status["last_input_tokens"] is None
assert status["last_output_tokens"] == 7
assert status["last_actual_input_tokens"] is None
assert status["last_estimate_error_tokens"] is None
assert status["last_estimate_error_ratio"] is None
assert status["last_context_usage_ratio"] is None
status_text = MessageChain._format_session_status_text(status)
assert "最近一次上下文占用: 未知 / 4,096" in status_text
assert "最近一次 tokens: 输入 未知 / 输出 7 / 总计 7" in status_text
def test_missing_usage_clears_previous_last_call_values():
"""本轮没有 usage 时,最近一次状态不得保留上一轮实际值。"""
agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
agent._record_request_budget(
{
"request_sequence": 1,
"has_estimate": True,
"estimated_input_tokens": 10,
"context_window_tokens": 1000,
}
)
agent._record_usage(
{
"request_sequence": 1,
"request_budget_recorded": True,
"has_usage": True,
"input_usage_available": True,
"input_tokens": 12,
"output_tokens": 3,
"total_tokens": 15,
"cache_usage_available": True,
"cache_read_input_tokens": 4,
"cache_write_input_tokens": 0,
"uncached_input_tokens": 8,
}
)
agent._record_request_budget(
{
"request_sequence": 2,
"has_estimate": True,
"estimated_input_tokens": 20,
"context_window_tokens": 1000,
}
)
agent._record_usage(
{
"request_sequence": 2,
"request_budget_recorded": True,
"has_usage": False,
"input_usage_available": False,
}
)
status = agent.get_session_status()
assert status["last_request_sequence"] == 2
assert status["last_request_estimate_available"] is True
assert status["last_input_usage_available"] is False
assert status["last_input_tokens"] is None
assert status["last_output_tokens"] is None
assert status["last_total_tokens"] is None
assert status["last_context_usage_ratio"] is None
assert status["last_cache_usage_available"] is False
assert status["total_input_tokens"] == 12
assert status["total_output_tokens"] == 3
assert status["total_tokens"] == 15
status_text = MessageChain._format_session_status_text(status)
assert "最近一次 tokens: 输入 未知 / 输出 未知 / 总计 未知" in status_text
assert "最近一次缓存:" not in status_text
assert "当前会话累计缓存: 命中 4 / 写入 0 / 未命中 8" in status_text
def test_out_of_order_responses_keep_latest_request_snapshot():
"""较早请求晚返回时,只累计 usage不得覆盖较新请求的最近状态。"""
agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
middleware = UsageMiddleware(
on_request_budget=agent._record_request_budget,
on_usage=agent._record_usage,
)
first_request = _request(
messages=[HumanMessage(content="first")],
max_input_tokens=1000,
)
first_request.model.model = "first-model"
second_request = _request(
messages=[HumanMessage(content="second")],
max_input_tokens=2000,
)
second_request.model.model = "second-model"
first_started = asyncio.Event()
release_first = asyncio.Event()
async def _handler(request):
if request.model.model == "first-model":
first_started.set()
await release_first.wait()
return ModelResponse(
result=[
AIMessage(
content="first",
usage_metadata={
"input_tokens": 10,
"output_tokens": 1,
"total_tokens": 11,
},
)
]
)
return ModelResponse(
result=[
AIMessage(
content="second",
usage_metadata={
"input_tokens": 50,
"output_tokens": 2,
"total_tokens": 52,
},
)
]
)
async def _run_out_of_order():
first_task = asyncio.create_task(
middleware.awrap_model_call(first_request, _handler)
)
await first_started.wait()
await middleware.awrap_model_call(second_request, _handler)
release_first.set()
await first_task
asyncio.run(_run_out_of_order())
status = agent.get_session_status()
assert status["last_request_sequence"] == 2
assert status["model"] == "second-model"
assert status["context_window_tokens"] == 2000
assert status["last_input_tokens"] == 50
assert status["last_output_tokens"] == 2
assert status["last_total_tokens"] == 52
assert status["last_actual_input_tokens"] == 50
assert status["total_input_tokens"] == 60
assert status["total_output_tokens"] == 3
assert status["total_tokens"] == 63
assert status["model_call_count"] == 2
def test_sequence_failure_cannot_overwrite_next_request_snapshot():
"""无序号请求晚返回时只能累计 usage不能覆盖后续有序请求。"""
agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
allocation_count = 0
def _next_sequence():
nonlocal allocation_count
allocation_count += 1
if allocation_count == 1:
raise RuntimeError("sequence unavailable")
return agent._next_request_sequence()
middleware = UsageMiddleware(
on_request_budget=agent._record_request_budget,
on_usage=agent._record_usage,
next_request_sequence=_next_sequence,
)
first_request = _request(
messages=[HumanMessage(content="first")],
max_input_tokens=1000,
)
first_request.model.model = "first-model"
second_request = _request(
messages=[HumanMessage(content="second")],
max_input_tokens=2000,
)
second_request.model.model = "second-model"
first_started = asyncio.Event()
release_first = asyncio.Event()
async def _handler(request):
if request.model.model == "first-model":
first_started.set()
await release_first.wait()
input_tokens = 10
else:
input_tokens = 50
return ModelResponse(
result=[
AIMessage(
content=request.model.model,
usage_metadata={
"input_tokens": input_tokens,
"output_tokens": 1,
"total_tokens": input_tokens + 1,
},
)
]
)
async def _run_out_of_order():
first_task = asyncio.create_task(
middleware.awrap_model_call(first_request, _handler)
)
await first_started.wait()
await middleware.awrap_model_call(second_request, _handler)
release_first.set()
await first_task
asyncio.run(_run_out_of_order())
status = agent.get_session_status()
assert status["last_request_sequence"] == 1
assert status["model"] == "second-model"
assert status["context_window_tokens"] == 2000
assert status["last_input_tokens"] == 50
assert status["last_actual_input_tokens"] == 50
assert status["total_input_tokens"] == 60
assert status["model_call_count"] == 2
def test_failed_model_switch_keeps_model_and_window_from_same_request():
"""新模型请求即使失败,状态也不得混合旧模型名称与新窗口。"""
agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
agent._sync_model_profile(
SimpleNamespace(model="large-model", profile={"max_input_tokens": 128000})
)
middleware = UsageMiddleware(
on_request_budget=agent._record_request_budget,
on_usage=agent._record_usage,
)
request = _request(
messages=[HumanMessage(content="small")],
max_input_tokens=2048,
)
request.model.model = "small-model"
async def _failing_handler(_request):
raise RuntimeError("model unavailable")
async def _run_failure():
try:
await middleware.awrap_model_call(request, _failing_handler)
except RuntimeError:
pass
asyncio.run(_run_failure())
status = agent.get_session_status()
assert status["last_request_sequence"] == 1
assert status["model"] == "small-model"
assert status["context_window_tokens"] == 2048
def test_unknown_model_name_is_not_replaced_after_request_snapshot():
"""请求已开始后,未知模型名称不得与配置默认值拼成虚假快照。"""
agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
agent._record_request_budget(
{
"has_estimate": True,
"request_sequence": 1,
"model": None,
"estimated_input_tokens": 100,
"context_window_tokens": 2048,
}
)
status = agent.get_session_status()
assert status["model"] is None
assert status["context_window_tokens"] == 2048
def test_request_sequence_remains_monotonic_after_agent_graph_rebuild():
"""重建 Agent 图后,新观察器也必须延续当前会话的请求顺序。"""
agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
first_graph = UsageMiddleware(
on_request_budget=agent._record_request_budget,
on_usage=agent._record_usage,
next_request_sequence=agent._next_request_sequence,
)
rebuilt_graph = UsageMiddleware(
on_request_budget=agent._record_request_budget,
on_usage=agent._record_usage,
next_request_sequence=agent._next_request_sequence,
)
async def _handler(request):
return ModelResponse(
result=[
AIMessage(
content=request.model.model,
usage_metadata={
"input_tokens": request.model.profile["max_input_tokens"] // 10,
"output_tokens": 1,
"total_tokens": request.model.profile["max_input_tokens"] // 10 + 1,
},
)
]
)
first = _request(
messages=[HumanMessage(content="first")],
max_input_tokens=1000,
)
first.model.model = "first-model"
second = _request(
messages=[HumanMessage(content="second")],
max_input_tokens=2000,
)
second.model.model = "second-model"
asyncio.run(first_graph.awrap_model_call(first, _handler))
asyncio.run(rebuilt_graph.awrap_model_call(second, _handler))
status = agent.get_session_status()
assert status["last_request_sequence"] == 2
assert status["model"] == "second-model"
assert status["context_window_tokens"] == 2000
assert status["last_input_tokens"] == 200
def test_new_request_estimate_clears_stale_calibration_until_success():
"""新请求失败时不得沿用上一轮估算误差。"""
agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
first = {
"has_estimate": True,
"request_sequence": 1,
"estimated_input_tokens": 100,
"context_window_tokens": 1000,
}
second = {
"has_estimate": True,
"request_sequence": 2,
"estimated_input_tokens": 200,
"context_window_tokens": 1000,
}
agent._record_request_budget(first)
agent._record_usage(
{
"request_sequence": 1,
"request_budget_recorded": True,
"input_usage_available": True,
"estimated_input_tokens": 100,
"has_usage": True,
"input_tokens": 120,
"output_tokens": 1,
"total_tokens": 121,
}
)
assert agent.get_session_status()["last_estimate_error_tokens"] == 20
agent._record_request_budget(second)
status = agent.get_session_status()
assert status["last_request_sequence"] == 2
assert status["last_actual_input_tokens"] is None
assert status["last_estimate_error_tokens"] is None
assert status["last_estimate_error_ratio"] is None
def test_failed_estimate_clears_previous_request_components():
"""估算失败后状态不得继续展示上一轮的请求组成。"""
agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
agent._record_request_budget(
{
"has_estimate": True,
"request_sequence": 1,
"estimated_input_tokens": 100,
"message_tokens": 70,
"system_tokens": 20,
"tool_tokens": 10,
"message_count": 3,
"tool_count": 2,
}
)
agent._record_request_budget(
{"has_estimate": False, "request_sequence": 2}
)
status = agent.get_session_status()
assert status["last_request_sequence"] == 2
assert status["last_request_estimate_available"] is False
assert status["last_estimated_input_tokens"] is None
assert status["last_estimated_message_tokens"] is None
assert status["last_estimated_system_tokens"] is None
assert status["last_estimated_tool_tokens"] is None
assert status["last_message_count"] == 0
assert status["last_tool_count"] == 0
def test_new_request_with_unknown_window_clears_previous_model_window():
"""切换到窗口未知的模型后,不得继续展示上一模型的窗口。"""
agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
agent._sync_model_profile(
SimpleNamespace(model="large-model", profile={"max_input_tokens": 128000})
)
agent._record_request_budget(
{
"has_estimate": True,
"request_sequence": 1,
"estimated_input_tokens": 100,
"context_window_tokens": 128000,
}
)
agent._sync_model_profile(
SimpleNamespace(model="unknown-window-model", profile={"max_input_tokens": 0})
)
agent._record_request_budget(
{
"has_estimate": True,
"request_sequence": 2,
"estimated_input_tokens": 100,
"context_window_tokens": None,
}
)
status = agent.get_session_status()
assert status["model"] == "unknown-window-model"
assert status["context_window_tokens"] is None
assert status["last_estimated_input_ratio"] is None
assert status["last_estimated_over_input_limit"] is None
def test_real_agent_observer_sees_dynamic_system_and_selected_tools():
"""末尾观察器必须看到动态 system 和 ToolSelector 最终保留的工具。"""
@tool
def kept_tool(value: str) -> str:
"""应保留的测试工具。"""
return value
@tool
def removed_tool(value: str) -> str:
"""应被筛除的测试工具。"""
return value
selection_model = _ToolBindingFakeModel(
responses=[AIMessage(content='{"tools": []}')]
)
main_model = _ToolBindingFakeModel(responses=[AIMessage(content="done")])
budgets = []
graph = create_agent(
model=main_model,
tools=[kept_tool, removed_tool],
system_prompt="base",
middleware=[
_DynamicSystemMiddleware(),
ToolSelectorMiddleware(
model=selection_model,
selection_tools=[kept_tool, removed_tool],
max_tools=1,
always_include=["kept_tool"],
),
UsageMiddleware(on_request_budget=budgets.append),
],
)
asyncio.run(graph.ainvoke({"messages": [HumanMessage(content="hello")]}))
assert len(budgets) == 1
assert budgets[0]["tool_count"] == 1
assert budgets[0]["system_tokens"] > 100
assert main_model.bound_tool_names == ["kept_tool"]