| """Tests for LangGraph message context trimming / compaction helpers.""" |
|
|
| from __future__ import annotations |
|
|
| import pytest |
|
|
| pytest.importorskip("langchain_core") |
|
|
| from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage |
| from langchain_core.messages.utils import count_tokens_approximately |
| from pi_test_support import ensure_agent_redact_paths |
|
|
| ensure_agent_redact_paths() |
|
|
| from redaction_langgraph.message_context import ( |
| build_pre_model_hook, |
| get_trim_stats, |
| is_context_overflow_error, |
| langgraph_compaction_enabled, |
| langgraph_llm_input_max_tokens, |
| reset_trim_stats, |
| set_aggressive_trim, |
| trim_messages_for_llm, |
| ) |
|
|
|
|
| def _long_tool_history(n_rounds: int = 40) -> list: |
| messages: list = [SystemMessage(content="You are a redaction assistant.")] |
| messages.append(HumanMessage(content="Redact the uploaded PDF end to end.")) |
| blob = "x" * 4000 |
| for i in range(n_rounds): |
| messages.append( |
| AIMessage( |
| content=f"Calling tool round {i}", |
| tool_calls=[ |
| { |
| "name": "read_workspace_text", |
| "args": {"relative_path": f"file_{i}.csv"}, |
| "id": f"call_{i}", |
| "type": "tool_call", |
| } |
| ], |
| ) |
| ) |
| messages.append( |
| ToolMessage( |
| content=f"preview {i}: {blob}", |
| tool_call_id=f"call_{i}", |
| name="read_workspace_text", |
| ) |
| ) |
| messages.append(HumanMessage(content="Continue the workflow.")) |
| return messages |
|
|
|
|
| def test_trim_preserves_system_and_shrinks(monkeypatch): |
| monkeypatch.setenv("AGENT_LLAMA_CONTEXT_WINDOW", "114688") |
| monkeypatch.setenv("LANGGRAPH_COMPACTION_RESERVE_TOKENS", "28672") |
| monkeypatch.setenv("LANGGRAPH_MAX_OUTPUT_TOKENS", "8192") |
| messages = _long_tool_history(50) |
| budget = 8_000 |
| before = count_tokens_approximately(messages) |
| assert before > budget |
| trimmed = trim_messages_for_llm(messages, max_tokens=budget) |
| after = count_tokens_approximately(trimmed) |
| assert len(trimmed) < len(messages) |
| assert after <= budget + 500 |
| assert isinstance(trimmed[0], SystemMessage) |
| assert "redaction assistant" in str(trimmed[0].content) |
|
|
|
|
| def test_aggressive_budget_smaller_than_normal(monkeypatch): |
| monkeypatch.setenv("AGENT_LLAMA_CONTEXT_WINDOW", "114688") |
| monkeypatch.setenv("LANGGRAPH_COMPACTION_RESERVE_TOKENS", "28672") |
| monkeypatch.setenv("LANGGRAPH_MAX_OUTPUT_TOKENS", "8192") |
| normal = langgraph_llm_input_max_tokens(aggressive=False) |
| aggressive = langgraph_llm_input_max_tokens(aggressive=True) |
| floor = max(2_048, 114688 // 8) |
| assert aggressive < normal |
| assert aggressive >= floor |
| assert aggressive == max(floor, normal // 2) |
|
|
|
|
| def test_langgraph_llm_input_max_tokens_env_overrides(monkeypatch): |
| monkeypatch.setenv("AGENT_LLAMA_CONTEXT_WINDOW", "65536") |
| monkeypatch.setenv("LANGGRAPH_COMPACTION_RESERVE_TOKENS", "10000") |
| monkeypatch.setenv("LANGGRAPH_MAX_OUTPUT_TOKENS", "4096") |
| |
| assert langgraph_llm_input_max_tokens() == 51440 |
|
|
|
|
| def test_pre_model_hook_returns_llm_input_messages(monkeypatch): |
| monkeypatch.setenv("AGENT_LLAMA_CONTEXT_WINDOW", "32000") |
| monkeypatch.setenv("LANGGRAPH_COMPACTION_RESERVE_TOKENS", "8000") |
| monkeypatch.setenv("LANGGRAPH_MAX_OUTPUT_TOKENS", "2048") |
| reset_trim_stats() |
| hook = build_pre_model_hook() |
| messages = _long_tool_history(30) |
| result = hook({"messages": messages}) |
| assert "llm_input_messages" in result |
| assert "messages" not in result |
| trimmed = result["llm_input_messages"] |
| assert len(trimmed) < len(messages) |
| stats = get_trim_stats() |
| assert stats is not None |
| assert stats.trimmed |
| assert stats.messages_before == len(messages) |
| assert stats.messages_after == len(trimmed) |
|
|
|
|
| def test_aggressive_hook_trims_more(monkeypatch): |
| monkeypatch.setenv("AGENT_LLAMA_CONTEXT_WINDOW", "64000") |
| monkeypatch.setenv("LANGGRAPH_COMPACTION_RESERVE_TOKENS", "8000") |
| monkeypatch.setenv("LANGGRAPH_MAX_OUTPUT_TOKENS", "2048") |
| messages = _long_tool_history(40) |
| normal = build_pre_model_hook(aggressive=False)({"messages": messages})[ |
| "llm_input_messages" |
| ] |
| aggressive = build_pre_model_hook(aggressive=True)({"messages": messages})[ |
| "llm_input_messages" |
| ] |
| assert count_tokens_approximately(aggressive) <= count_tokens_approximately(normal) |
| assert len(aggressive) <= len(normal) |
|
|
|
|
| def test_thread_local_aggressive_override(monkeypatch): |
| monkeypatch.setenv("AGENT_LLAMA_CONTEXT_WINDOW", "64000") |
| monkeypatch.setenv("LANGGRAPH_COMPACTION_RESERVE_TOKENS", "8000") |
| monkeypatch.setenv("LANGGRAPH_MAX_OUTPUT_TOKENS", "2048") |
| messages = _long_tool_history(40) |
| hook = build_pre_model_hook(aggressive=False) |
| set_aggressive_trim(False) |
| normal_len = len(hook({"messages": messages})["llm_input_messages"]) |
| set_aggressive_trim(True) |
| try: |
| aggressive_len = len(hook({"messages": messages})["llm_input_messages"]) |
| finally: |
| set_aggressive_trim(False) |
| assert aggressive_len <= normal_len |
|
|
|
|
| def test_compaction_enabled_default_and_override(monkeypatch): |
| monkeypatch.delenv("LANGGRAPH_COMPACTION_ENABLED", raising=False) |
| assert langgraph_compaction_enabled() is True |
| monkeypatch.setenv("LANGGRAPH_COMPACTION_ENABLED", "false") |
| assert langgraph_compaction_enabled() is False |
| monkeypatch.setenv("LANGGRAPH_COMPACTION_ENABLED", "1") |
| assert langgraph_compaction_enabled() is True |
|
|
|
|
| @pytest.mark.parametrize( |
| "text", |
| [ |
| "Error code: 400 - {'error': {'message': 'request (114870 tokens) exceeds the available context size (114688 tokens)', 'type': 'exceed_context_size_error'}}", |
| "context_length_exceeded", |
| "This model's maximum context length is 8192 tokens", |
| ], |
| ) |
| def test_is_context_overflow_error(text): |
| assert is_context_overflow_error(RuntimeError(text)) is True |
|
|
|
|
| def test_is_context_overflow_error_negative(): |
| assert is_context_overflow_error(RuntimeError("connection refused")) is False |
|
|
|
|
| @pytest.mark.parametrize( |
| "text", |
| [ |
| "Error code: 500 - {'error': {'code': 500, 'message': 'Failed to parse tool call arguments as JSON: [json.exception.parse_error.101] parse error at line 1, column 6673: syntax error while parsing value - invalid string: missing closing quote', 'type': 'server_error'}}", |
| "failed to parse tool call arguments as json", |
| "json.exception.parse_error.101", |
| "invalid string: missing closing quote", |
| ], |
| ) |
| def test_is_tool_call_json_parse_error(text): |
| from redaction_langgraph.message_context import is_tool_call_json_parse_error |
|
|
| assert is_tool_call_json_parse_error(RuntimeError(text)) is True |
|
|
|
|
| def test_is_tool_call_json_parse_error_negative(): |
| from redaction_langgraph.message_context import is_tool_call_json_parse_error |
|
|
| assert is_tool_call_json_parse_error(RuntimeError("connection refused")) is False |
| assert ( |
| is_tool_call_json_parse_error(RuntimeError("context_length_exceeded")) is False |
| ) |
|
|