Files

430 lines
19 KiB
Python

"""Generated large-payload regressions; no credentials or external model needed."""
import asyncio
from types import SimpleNamespace
import pytest
from langchain.agents.middleware.types import ModelRequest, ModelResponse
from langchain_core.language_models.fake_chat_models import FakeListChatModel
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from langgraph.errors import GraphBubbleUp
from deerflow.agents.middlewares.context_window_guard_middleware import ContextWindowGuardMiddleware, estimate_model_request_tokens
from deerflow.agents.middlewares.llm_error_handling_middleware import LLMErrorHandlingMiddleware, is_llm_error_message
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware
from deerflow.models.context_recovery import CONTEXT_RECOVERY_MESSAGE, ContextRecoveryExhausted, is_context_length_error
def request_for(text="问题", **kwargs):
return ModelRequest(model=FakeListChatModel(responses=["ok"]), messages=[HumanMessage(content=text)], **kwargs)
def run_guard(guard, request, handler, asynchronous):
if not asynchronous:
return guard.wrap_model_call(request, handler)
async def call(req):
return handler(req)
return asyncio.run(guard.awrap_model_call(request, call))
@pytest.mark.parametrize("asynchronous", [False, True])
def test_unconfigured_window_does_not_trim_even_large_input(asynchronous):
request = request_for("中文材料" * 100_000)
seen = []
run_guard(ContextWindowGuardMiddleware(), request, lambda r: seen.append(r) or AIMessage(content="正常"), asynchronous)
assert seen == [request]
assert seen[0] is request
def test_unconfigured_upload_turn_keeps_manifest_and_file_tools_before_first_call():
upload_manifest = (
'<uploaded_files>{"format":"deerflow-upload-context-v2",'
'"current":[{"filename":"report.pdf","path":"/mnt/user-data/uploads/report.pdf.md"}]}'
"</uploaded_files>"
)
tool_names = ["read_file", "grep", "view_image", *[f"other_tool_{index}" for index in range(17)]]
tools = [
{
"type": "function",
"function": {
"name": name,
"description": f"Tool {name}",
"parameters": {"type": "object", "properties": {}},
},
}
for name in tool_names
]
request = request_for(f"{upload_manifest}\n请读取并总结文件。", tools=tools).override(
system_message=SystemMessage(content="S" * 66_000)
)
guarded = ContextWindowGuardMiddleware().guard_request(request)
assert guarded is request
assert guarded.messages[-1].content == request.messages[-1].content
assert upload_manifest in guarded.messages[-1].content
assert [tool["function"]["name"] for tool in guarded.tools] == tool_names
def test_bounded_upload_turn_trims_system_before_manifest_or_file_tools():
limit = 32_768
upload_manifest = (
'<uploaded_files>{"format":"deerflow-upload-context-v2",'
'"current":[{"filename":"report.pdf","path":"/mnt/user-data/uploads/report.pdf.md"}]}'
"</uploaded_files>"
)
file_tool_names = {"read_file", "grep", "view_image"}
tool_names = [*file_tool_names, *[f"other_tool_{index}" for index in range(17)]]
tools = [
{
"type": "function",
"function": {
"name": name,
"description": (f"Tool {name}. " * 10),
"parameters": {"type": "object", "properties": {}},
},
}
for name in tool_names
]
request = request_for(f"{upload_manifest}\n请读取并总结文件。", tools=tools).override(
system_message=SystemMessage(content="S" * 66_000)
)
guarded = ContextWindowGuardMiddleware().guard_request(request, limit)
assert estimate_model_request_tokens(guarded) <= limit
assert guarded.messages[-1].content == request.messages[-1].content
assert upload_manifest in guarded.messages[-1].content
kept_tool_names = {tool["function"]["name"] for tool in guarded.tools}
assert file_tool_names <= kept_tool_names
assert len(str(guarded.system_message.content)) < len(str(request.system_message.content))
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("unit", ["English research. ", "中文长材料:", "😀<>&\n"])
@pytest.mark.parametrize("size", [100, 10_000, 100_000])
def test_generated_context_pressure_recovers(asynchronous, unit, size):
original = request_for("问题开始:" + unit * size + "问题结束:请概括。")
snapshot = original.messages[0].model_dump()
sizes = []
def provider(request):
estimate = estimate_model_request_tokens(request)
sizes.append(estimate)
assert request.messages and request.messages[-1].content
if len(sizes) == 1 or estimate > 1000:
raise RuntimeError("context_length_exceeded")
return ModelResponse(result=[AIMessage(content="这是正常回答。")])
response = run_guard(ContextWindowGuardMiddleware(), original, provider, asynchronous)
assert response.result[0].content == "这是正常回答。"
assert is_llm_error_message(response.result[0]) is None
assert 2 <= len(sizes) <= 5
assert all(b < a for a, b in zip(sizes, sizes[1:]))
assert original.messages[0].model_dump() == snapshot
@pytest.mark.parametrize("asynchronous", [False, True])
def test_explicit_limit_applies_before_first_call(asynchronous):
sizes = []
run_guard(ContextWindowGuardMiddleware(max_input_tokens=2000), request_for("汉" * 50_000), lambda r: sizes.append(estimate_model_request_tokens(r)) or AIMessage(content="ok"), asynchronous)
assert len(sizes) == 1 and sizes[0] <= 2000
def test_profile_limit_is_used_without_inventing_default():
request = request_for("字" * 10000)
request.model.profile = {"max_input_tokens": 2000}
assert estimate_model_request_tokens(ContextWindowGuardMiddleware().guard_request(request)) <= 2000
@pytest.mark.parametrize("asynchronous", [False, True])
def test_persistent_context_rejection_returns_clarification_not_error(asynchronous):
seen = []
def fail(request):
seen.append(request)
raise RuntimeError("maximum context length is 1")
response = run_guard(ContextWindowGuardMiddleware(), request_for("长" * 100000), fail, asynchronous)
assert response.content == CONTEXT_RECOVERY_MESSAGE
assert is_llm_error_message(response) is None
assert len(seen) <= 5
assert all(r.messages and r.messages[0].content for r in seen)
@pytest.mark.parametrize("limit", [1, 16, 32])
def test_impossibly_small_config_never_sends_empty_request(limit):
seen = []
response = ContextWindowGuardMiddleware(max_input_tokens=limit).wrap_model_call(request_for("问题" * 1000), lambda r: seen.append(r))
assert not seen
assert response.content == CONTEXT_RECOVERY_MESSAGE
def test_required_tools_and_response_schema_are_not_silently_removed():
tool = {"type": "function", "function": {"name": "save", "description": "保存", "parameters": {"type": "object"}}}
schema = {"type": "object", "properties": {"answer": {"type": "string"}}}
request = request_for("长" * 10000, tools=[tool], tool_choice="required", response_format=schema)
guarded = ContextWindowGuardMiddleware().guard_request(request, 2000)
assert guarded.tools == [tool]
assert guarded.tool_choice == "required"
assert guarded.response_format == schema
with pytest.raises(ContextRecoveryExhausted):
ContextWindowGuardMiddleware(max_input_tokens=1).wrap_model_call(request, lambda r: None)
def test_tool_pair_and_hidden_latest_human_are_preserved_correctly():
request = request_for("分析检索结果")
request = request.override(
messages=[
HumanMessage(content="旧问题" * 1000),
AIMessage(content="旧回答" * 1000),
*request.messages,
AIMessage(content="", tool_calls=[{"id": "c1", "name": "search", "args": {}}]),
ToolMessage(content="检索结果" * 10000, tool_call_id="c1"),
HumanMessage(content="隐藏材料" * 10000, additional_kwargs={"hide_from_ui": True}),
]
)
before = [m.model_dump() for m in request.messages]
result = ContextWindowGuardMiddleware().guard_request(request, 1800)
assert result.messages[0].content == "分析检索结果"
assert [m.type for m in result.messages] == ["human", "ai", "tool"]
assert result.messages[1].tool_calls[0]["id"] == result.messages[2].tool_call_id
assert [m.model_dump() for m in request.messages] == before
def test_output_reservation_is_lowered_only_after_context_error():
request = request_for("问题" * 1000, model_settings={"max_tokens": 100000})
seen = []
def provider(req):
seen.append(req.model_settings["max_tokens"])
if seen[-1] > 3000:
raise RuntimeError("maximum context length exceeded")
return AIMessage(content="ok")
result = ContextWindowGuardMiddleware().wrap_model_call(request, provider)
assert result.content == "ok"
assert seen[0] == 100000 and seen[-1] <= 3000
assert request.model_settings["max_tokens"] == 100000
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("exception", [ValueError("bad parameter"), GraphBubbleUp(), asyncio.CancelledError()])
def test_other_errors_and_control_flow_propagate(asynchronous, exception):
calls = []
def provider(req):
calls.append(req)
raise exception
with pytest.raises(type(exception)):
run_guard(ContextWindowGuardMiddleware(), request_for(), provider, asynchronous)
assert len(calls) == 1
@pytest.mark.parametrize("asynchronous", [False, True])
def test_outer_error_handler_sees_success_after_inner_recovery(asynchronous):
errors = LLMErrorHandlingMiddleware(app_config=SimpleNamespace(circuit_breaker=SimpleNamespace(failure_threshold=3, recovery_timeout_sec=30)))
guard = ContextWindowGuardMiddleware()
calls = []
def provider(request):
calls.append(request)
if len(calls) == 1:
raise RuntimeError("maximum context length exceeded")
return AIMessage(content="正常回答")
request = request_for("汉字" * 10000)
if asynchronous:
async def invoke(req):
return provider(req)
async def inner(req):
return await guard.awrap_model_call(req, invoke)
response = asyncio.run(errors.awrap_model_call(request, inner))
else:
response = errors.wrap_model_call(request, lambda req: guard.wrap_model_call(req, provider))
assert response.content == "正常回答"
assert is_llm_error_message(response) is None
assert len(calls) == 2 and errors._circuit_failure_count == 0
class RejectingSummaryModel(FakeListChatModel):
sizes: list[int] = []
fail_always: bool = False
generic_error: bool = False
def _call(self, messages, stop=None, run_manager=None, **kwargs):
size = sum(len(str(m.content).encode("utf-8")) for m in messages)
self.sizes.append(size)
if self.generic_error:
raise RuntimeError("summary unavailable")
if self.fail_always or len(self.sizes) == 1 or size > 1200:
raise RuntimeError("context_length_exceeded")
return "摘要成功"
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("trim", [None, 4000])
def test_summary_rendered_prompt_shrinks_until_success(asynchronous, trim):
model = RejectingSummaryModel(responses=["unused"])
middleware = DeerFlowSummarizationMiddleware(model, trim_tokens_to_summarize=trim)
messages = [HumanMessage(content="<&中文材料>" * 30000)]
result = asyncio.run(middleware._acreate_summary(messages)) if asynchronous else middleware._create_summary(messages)
assert result == "摘要成功"
assert 2 <= len(model.sizes) <= 5
assert all(b < a for a, b in zip(model.sizes, model.sizes[1:]))
if trim:
assert model.sizes[0] <= trim
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("generic", [False, True])
def test_summary_failure_does_not_abort_main_answer_or_cache_error(asynchronous, generic):
model = RejectingSummaryModel(responses=["unused"], fail_always=True, generic_error=generic)
middleware = DeerFlowSummarizationMiddleware(model, trigger=("messages", 3), keep=("messages", 1))
request = request_for().override(
messages=[
HumanMessage(content="旧材料" * 10000, id="u1"),
AIMessage(content="旧回答", id="a1"),
HumanMessage(content="请继续分析", id="u2"),
]
)
seen = []
def main(req):
seen.append(req)
return ModelResponse(result=[AIMessage(content="正常主回答")])
response = run_guard(middleware, request, main, asynchronous)
assert response.result[0].content == "正常主回答"
assert seen[0].messages[-1].content == "请继续分析"
assert middleware._pending_state_cache_update is None
assert all("summary unavailable" not in str(m.content) for m in seen[0].messages)
def test_structured_context_code_detection():
error = RuntimeError("bad request")
error.body = {"code": "context_length_exceeded"}
assert is_context_length_error(error)
assert not is_context_length_error(RuntimeError("invalid request schema"))
def test_minimax_live_context_error_2013_is_recovered():
# Captured from the real configured MiniMax endpoint with 40,000 synthetic rows.
exc = RuntimeError("invalid params, context window exceeds limit (2013)")
assert is_context_length_error(exc)
assert not is_context_length_error(RuntimeError("invalid params (2013)"))
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("content", ["", " ", "<think>仅有思考内容</think>", "<think>未结束的思考"])
def test_recovery_does_not_return_empty_or_thinking_only_answer(asynchronous, content):
calls = []
def provider(request):
calls.append(request)
if len(calls) == 1:
raise RuntimeError("context_length_exceeded")
return ModelResponse(result=[AIMessage(content=content)])
response = run_guard(ContextWindowGuardMiddleware(), request_for("长内容" * 10000), provider, asynchronous)
assert response.content == CONTEXT_RECOVERY_MESSAGE
assert is_llm_error_message(response) is None
assert len(calls) == 2
def test_small_output_budget_is_not_halved_on_context_rejection():
request = request_for("长内容" * 10000, model_settings={"max_tokens": 1024})
smaller = ContextWindowGuardMiddleware()._retry_request(request, 1)
assert smaller.model_settings["max_tokens"] == 1024
def test_emergency_does_not_forget_completed_action_while_forcing_tools():
request = request_for("保存结果", tools=[{"type": "function", "function": {"name": "save", "parameters": {"type": "object"}}}], tool_choice="required")
request = request.override(
messages=[
*request.messages,
AIMessage(content="", tool_calls=[{"id": "save1", "name": "save", "args": {}}]),
ToolMessage(content="已保存成功", tool_call_id="save1"),
]
)
with pytest.raises(ContextRecoveryExhausted):
ContextWindowGuardMiddleware()._minimal_request(request, 512)
@pytest.mark.parametrize("asynchronous", [False, True])
def test_retry_deadline_stops_additional_attempts(asynchronous):
guard = ContextWindowGuardMiddleware()
guard.context_retry_timeout_sec = 0
seen = []
def provider(request):
seen.append(request)
raise RuntimeError("context_length_exceeded")
response = run_guard(guard, request_for("长内容" * 1000), provider, asynchronous)
assert response.content == CONTEXT_RECOVERY_MESSAGE
assert len(seen) == 1
def test_summary_bound_model_uses_explicit_smaller_window():
model = RejectingSummaryModel(responses=["unused"])
model.profile = {"max_input_tokens": 700}
middleware = DeerFlowSummarizationMiddleware(model.with_config(tags=["middleware:summarize"]), trim_tokens_to_summarize=4000)
result = middleware._create_summary([HumanMessage(content="长材料" * 10000)])
assert result == "摘要成功"
assert model.sizes[0] <= 700
def test_generated_mixed_payload_stress_preserves_input_and_protocol():
import random
rng = random.Random(20260911)
guard = ContextWindowGuardMiddleware()
for case in range(50):
request = request_for("请总结这段内容:" + "材料" * rng.randint(1, 20000))
request = request.override(
system_message=SystemMessage(content="按用户要求分析。" * rng.randint(1, 1000)),
messages=[HumanMessage(content="历史" * rng.randint(1, 3000)), AIMessage(content="答案" * rng.randint(1, 3000)), *request.messages],
)
if case % 2:
request = request.override(
messages=[
*request.messages,
AIMessage(content="", additional_kwargs={"reasoning_content": "推理" * rng.randint(1, 5000)}, tool_calls=[{"id": "t", "name": "search", "args": {"query": "q"}}]),
ToolMessage(content="检索" * rng.randint(1, 20000), tool_call_id="t"),
]
)
if case % 3 == 0:
request = request.override(messages=[*request.messages, HumanMessage(content="隐藏材料" * 1000, additional_kwargs={"hide_from_ui": True})])
before = [m.model_dump() for m in request.messages]
limit = rng.choice([512, 1024, 2048, 4096, 8192])
result = guard.guard_request(request, limit)
assert estimate_model_request_tokens(result) <= limit
assert any(isinstance(m, HumanMessage) and m.content for m in result.messages)
for index, message in enumerate(result.messages):
if isinstance(message, ToolMessage):
assert index and isinstance(result.messages[index - 1], AIMessage)
assert message.tool_call_id in {call["id"] for call in result.messages[index - 1].tool_calls}
assert [m.model_dump() for m in request.messages] == before
@pytest.mark.parametrize("asynchronous", [False, True])
def test_context_failure_releases_circuit_probe_without_counting_outage(asynchronous):
errors = LLMErrorHandlingMiddleware(app_config=SimpleNamespace(circuit_breaker=SimpleNamespace(failure_threshold=3, recovery_timeout_sec=30)))
errors._circuit_state = "half_open"
def fail(request):
raise ContextRecoveryExhausted()
result = run_guard(errors, request_for(), fail, asynchronous)
assert is_llm_error_message(result) == "context_length"
assert not errors._circuit_probe_in_flight
assert errors._circuit_failure_count == 0
assert "上下文" not in result.content