155 lines
5.0 KiB
Python
155 lines
5.0 KiB
Python
# retoor <retoor@molodetz.nl>
|
|||
|
|
|
||
|
|
import json
|
||
|
|
|
||
|
|
from devplacepy.services.devii.agentic.compaction import (
|
||
|
|
compact_messages,
|
||
|
|
find_compaction_split,
|
||
|
|
is_context_length_error,
|
||
|
|
)
|
||
|
|
from devplacepy.services.devii.errors import LLMError
|
||
|
|
from tests.conftest import run_async
|
||
|
|
|
||
|
|
|
||
|
|
def _error(status, body):
|
||
|
|
return LLMError("Model endpoint returned error", status=status, body=body)
|
||
|
|
|
||
|
|
|
||
|
|
def test_openrouter_style_message_detected():
|
||
|
|
body = json.dumps(
|
||
|
|
{
|
||
|
|
"error": {
|
||
|
|
"message": (
|
||
|
|
"This endpoint's maximum context length is 131072 tokens. "
|
||
|
|
"However, you requested about 403355 tokens (349784 of text "
|
||
|
|
"input, 53571 of tool input). Please reduce the length of "
|
||
|
|
"either one, or use the context-compression plugin."
|
||
|
|
),
|
||
|
|
"code": 400,
|
||
|
|
"metadata": {"provider_name": None},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
)
|
||
|
|
assert is_context_length_error(_error(400, body)) is True
|
||
|
|
|
||
|
|
|
||
|
|
def test_openai_style_code_detected():
|
||
|
|
body = json.dumps(
|
||
|
|
{
|
||
|
|
"error": {
|
||
|
|
"message": "This model's maximum context length is 128000 tokens.",
|
||
|
|
"type": "invalid_request_error",
|
||
|
|
"param": None,
|
||
|
|
"code": "context_length_exceeded",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
)
|
||
|
|
assert is_context_length_error(_error(400, body)) is True
|
||
|
|
|
||
|
|
|
||
|
|
def test_unrelated_400_not_detected():
|
||
|
|
body = json.dumps({"error": {"message": "Invalid API key.", "code": 400}})
|
||
|
|
assert is_context_length_error(_error(400, body)) is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_non_400_status_not_detected_even_with_matching_text():
|
||
|
|
body = json.dumps(
|
||
|
|
{"error": {"message": "maximum context length is 131072 tokens"}}
|
||
|
|
)
|
||
|
|
assert is_context_length_error(_error(429, body)) is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_malformed_body_falls_back_to_phrase_match():
|
||
|
|
truncated = "maximum context length is 131072 tokens, please reduce the length"
|
||
|
|
assert is_context_length_error(_error(400, truncated)) is True
|
||
|
|
|
||
|
|
|
||
|
|
def test_malformed_body_with_no_match_is_false():
|
||
|
|
assert is_context_length_error(_error(400, "not valid json at all")) is False
|
||
|
|
|
||
|
|
|
||
|
|
class _StubLlm:
|
||
|
|
def __init__(self, summary="a concise summary of the earlier turns"):
|
||
|
|
self.summary = summary
|
||
|
|
self.calls = 0
|
||
|
|
|
||
|
|
async def summarize(self, prompt):
|
||
|
|
self.calls += 1
|
||
|
|
return self.summary
|
||
|
|
|
||
|
|
|
||
|
|
def _tool_call_message(name="run_tool"):
|
||
|
|
return {
|
||
|
|
"role": "assistant",
|
||
|
|
"content": "",
|
||
|
|
"tool_calls": [
|
||
|
|
{"id": "c1", "function": {"name": name, "arguments": "{}"}}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _tool_result_message(content="result"):
|
||
|
|
return {"role": "tool", "tool_call_id": "c1", "name": "run_tool", "content": content}
|
||
|
|
|
||
|
|
|
||
|
|
def _long_tool_heavy_conversation(rounds=20):
|
||
|
|
messages = [
|
||
|
|
{"role": "system", "content": "sys"},
|
||
|
|
{"role": "user", "content": "start the long task"},
|
||
|
|
]
|
||
|
|
for i in range(rounds):
|
||
|
|
messages.append(_tool_call_message())
|
||
|
|
messages.append(_tool_result_message(f"result {i}" * 200))
|
||
|
|
return messages
|
||
|
|
|
||
|
|
|
||
|
|
def test_find_compaction_split_prefers_a_user_message():
|
||
|
|
messages = [
|
||
|
|
{"role": "system", "content": "sys"},
|
||
|
|
{"role": "user", "content": "first"},
|
||
|
|
{"role": "assistant", "content": "reply"},
|
||
|
|
{"role": "user", "content": "second"},
|
||
|
|
{"role": "assistant", "content": "reply2"},
|
||
|
|
{"role": "user", "content": "third"},
|
||
|
|
{"role": "assistant", "content": "reply3"},
|
||
|
|
]
|
||
|
|
split = find_compaction_split(messages, keep_tail=2)
|
||
|
|
assert messages[split]["role"] == "user"
|
||
|
|
|
||
|
|
|
||
|
|
def test_find_compaction_split_falls_back_to_a_non_tool_boundary_without_a_recent_user_message():
|
||
|
|
messages = _long_tool_heavy_conversation(rounds=20)
|
||
|
|
split = find_compaction_split(messages, keep_tail=4)
|
||
|
|
assert split > 1
|
||
|
|
assert messages[split].get("role") != "tool"
|
||
|
|
|
||
|
|
|
||
|
|
def test_find_compaction_split_never_lands_inside_a_tool_result_run():
|
||
|
|
messages = _long_tool_heavy_conversation(rounds=30)
|
||
|
|
for keep_tail in (2, 3, 4, 5, 8, 10, 15):
|
||
|
|
split = find_compaction_split(messages, keep_tail)
|
||
|
|
assert messages[split].get("role") != "tool", (
|
||
|
|
f"keep_tail={keep_tail} split at a tool message, orphaning its tool_calls"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_compact_messages_shrinks_a_tool_heavy_conversation_with_no_recent_user_message():
|
||
|
|
messages = _long_tool_heavy_conversation(rounds=20)
|
||
|
|
original_len = len(messages)
|
||
|
|
llm = _StubLlm()
|
||
|
|
compacted = run_async(compact_messages(llm, messages, keep_tail=4))
|
||
|
|
assert llm.calls == 1
|
||
|
|
assert len(compacted) < original_len
|
||
|
|
assert compacted[0]["role"] == "system"
|
||
|
|
assert "[compacted earlier turns]" in compacted[1]["content"]
|
||
|
|
assert compacted[-1] == messages[-1]
|
||
|
|
|
||
|
|
|
||
|
|
def test_compact_messages_tail_never_starts_with_a_dangling_tool_result():
|
||
|
|
messages = _long_tool_heavy_conversation(rounds=25)
|
||
|
|
llm = _StubLlm()
|
||
|
|
compacted = run_async(compact_messages(llm, messages, keep_tail=6))
|
||
|
|
tail = compacted[2:]
|
||
|
|
assert tail
|
||
|
|
assert tail[0].get("role") != "tool"
|