mirror of
https://github.com/razzant/ouroboros.git
synced 2026-08-10 11:03:16 +00:00
Motivated by benchmark forensics (terminal-bench/SWE-bench/GAIA): most trial deaths were harness infrastructure, not agent inability. All three sub-blocks are general-purpose robustness for normal users. 1a. Per-class same-model transient retry (loop_llm_call.py): - Transient classes (finish_reason=null / empty-response shapes, provider_transient 429/5xx/overloaded) retry the SAME model with a larger budget: transient_retry_max() (OUROBOROS_TRANSIENT_RETRY_MAX, SSOT default 6 in SETTINGS_DEFAULTS + apply_settings_to_env, floored at the caller budget), exponential backoff capped 60s. - Backoff sleeps are deadline-bounded (task_metadata.deadline_at threaded as deadline_ts through the main loop, budget-limit and round-limit wrap-up calls); stopping emits a durable llm_retry_deadline_exhausted event from BOTH transient paths. - Permanent classes (auth/quota/bad_request/request_too_large) fail fast unchanged. NO cross-model fallback is introduced: single-model setups (all slots one model, empty fallback) die only after the real budget, and the failure text reports actual attempts used. 1b. Encrypted-reasoning strip-retry (llm.py): - _is_openrouter_signature_error also matches "encrypted reasoning", "encrypted content for item" (observed gpt-5 shape "...for item rs_..."), "reasoning item", "reasoning_details" - reusing the existing one-shot roundtrip-metadata strip-and-retry on the same model. The allow_fallbacks pin is untouched. 1c. Compaction robustness (context_compaction.py + loop.py): - Per-batch isolation: a failed batch leaves only its own rounds raw; the old whole-pass try/except discarded every successful summary. - Per-round degradation: a missing summary leaves that round raw instead of the all-or-nothing completeness ValueError. - Structured emit_round_summaries tool protocol (tool_choice=required, reliable round_id keying) with text-protocol fallback for local light models or prose answers; spend from failed batches is accounted (_BatchSummaryError carries usage, including across fallback failures). - Warning protection scans the first two non-empty lines (autocorrect notes can prefix the marker); SHELL_EXIT_ERROR rounds are deliberately compactable - trial-and-error history must compact, with the first error line preserved by summarizer instruction. - Emergency compaction adapts keep_recent to min(50, max(6, spans//2), max(1, spans-1)) so oversized transcripts with few huge rounds actually compact instead of no-opping. Review of record: triad+scope rounds 1-4 via run_external_review.py; round 4 blocked=False with zero criticals (scope fable-5 responded, 851,542 real tokens). Remaining advisory (param count on two pre-existing over-limit signatures) is documented pre-existing debt; context-object consolidation is out of block scope. Carriers: VERSION, pyproject.toml, web/package.json, api_types.js GATEWAY_CONTRACT_VERSION, README badge+history (oldest minor row trimmed per P9 cap), ARCHITECTURE.md header + retry/compaction docs.
382 lines
15 KiB
Python
382 lines
15 KiB
Python
"""Tests for tool-history compaction protection (context_compaction.py)."""
|
|
from ouroboros.context_compaction import compact_tool_history
|
|
|
|
|
|
def _make_messages(tool_name: str, result_content: str, num_rounds: int = 8):
|
|
"""Build a message list with num_rounds of tool calls, all using the same tool."""
|
|
messages = [{"role": "system", "content": [{"type": "text", "text": "system"}]}]
|
|
for i in range(num_rounds):
|
|
tc_id = f"call_{i}"
|
|
messages.append({
|
|
"role": "assistant",
|
|
"content": f"Round {i}",
|
|
"tool_calls": [{
|
|
"id": tc_id,
|
|
"function": {"name": tool_name, "arguments": "{}"},
|
|
}],
|
|
})
|
|
messages.append({
|
|
"role": "tool",
|
|
"tool_call_id": tc_id,
|
|
"content": result_content,
|
|
})
|
|
return messages
|
|
|
|
|
|
def _make_large_arg_messages(tool_name: str, num_rounds: int = 8):
|
|
"""Build messages whose old assistant tool-call payloads should compact."""
|
|
messages = [{"role": "system", "content": [{"type": "text", "text": "system"}]}]
|
|
large_args = '{"content": "' + ("x" * 1000) + '"}'
|
|
for i in range(num_rounds):
|
|
tc_id = f"call_{i}"
|
|
messages.append({
|
|
"role": "assistant",
|
|
"content": f"Round {i}",
|
|
"tool_calls": [{
|
|
"id": tc_id,
|
|
"function": {"name": tool_name, "arguments": large_args},
|
|
}],
|
|
})
|
|
messages.append({
|
|
"role": "tool",
|
|
"tool_call_id": tc_id,
|
|
"content": "ok",
|
|
})
|
|
return messages
|
|
|
|
|
|
def test_protected_tool_results_survive_compaction():
|
|
"""repo_commit results must not be truncated even in old rounds."""
|
|
original_result = "OK: committed to ouroboros: v3.19.0 review feedback applied"
|
|
msgs = _make_messages("commit_reviewed", original_result, num_rounds=10)
|
|
compacted = compact_tool_history(msgs, keep_recent=3)
|
|
|
|
commit_results = [
|
|
m["content"] for m in compacted
|
|
if m.get("role") == "tool" and m["content"] == original_result
|
|
]
|
|
assert len(commit_results) == 10, "All repo_commit results must survive compaction"
|
|
|
|
|
|
def test_warning_results_survive_compaction():
|
|
"""Results starting with warning emoji must not be truncated."""
|
|
warn_result = "\u26a0\ufe0f REVIEW_BLOCKED: tests failed, commit rejected. Fix errors first."
|
|
msgs = _make_messages("run_command", warn_result, num_rounds=10)
|
|
compacted = compact_tool_history(msgs, keep_recent=3)
|
|
|
|
warning_results = [
|
|
m["content"] for m in compacted
|
|
if m.get("role") == "tool" and m["content"] == warn_result
|
|
]
|
|
assert len(warning_results) == 10, "Warning-prefixed results must survive compaction"
|
|
|
|
|
|
def test_old_assistant_tool_payloads_are_compacted():
|
|
"""Fallback compaction should compact oversized old assistant tool-call payloads."""
|
|
msgs = _make_large_arg_messages("write_file", num_rounds=10)
|
|
compacted = compact_tool_history(msgs, keep_recent=3)
|
|
|
|
compacted_assistants = [
|
|
m for m in compacted
|
|
if m.get("role") == "assistant"
|
|
and m.get("tool_calls")
|
|
and "<<CONTENT_OMITTED len=" in m["tool_calls"][0]["function"]["arguments"]
|
|
]
|
|
assert len(compacted_assistants) >= 4, "Old oversized assistant tool-call payloads should be compacted"
|
|
|
|
|
|
# ── Protected-content detection ──────────────────────────────────────────────
|
|
#
|
|
# v4.34.0: the structured-reflection checkpoint ceremony was retired, so
|
|
# assistant messages with `CHECKPOINT_REFLECTION` / `CHECKPOINT_ANOMALY`
|
|
# text no longer need compaction protection — they no longer exist. The
|
|
# remaining protected-content rule covers tool-result messages for
|
|
# critical tools and explicit error markers (`⚠️`-prefixed tool output).
|
|
|
|
|
|
def test_round_has_protected_content_ignores_normal_assistant_text():
|
|
"""Normal assistant messages (no tool role, no error marker) must not be protected.
|
|
|
|
Previously the function also protected `CHECKPOINT_REFLECTION` /
|
|
`CHECKPOINT_ANOMALY` markers; that branch was removed in v4.34.0 along
|
|
with the audit-only checkpoint ceremony. This test guards against a
|
|
regression that would re-introduce any checkpoint-text protection.
|
|
"""
|
|
from ouroboros.context_compaction import _round_has_protected_content
|
|
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": "Normal reasoning without any reflection marker",
|
|
"tool_calls": [{"id": "c1", "function": {"name": "read_file", "arguments": "{}"}}],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "c1",
|
|
"content": "file content",
|
|
},
|
|
]
|
|
assert _round_has_protected_content(messages, 0, 1) is False
|
|
|
|
|
|
def test_round_has_protected_content_does_not_protect_checkpoint_text():
|
|
"""v4.34.0 regression guard: legacy CHECKPOINT_REFLECTION text is no longer
|
|
protected. A future edit that accidentally re-adds the assistant-content
|
|
detection would silently bloat transcripts with stale audit artifacts.
|
|
"""
|
|
from ouroboros.context_compaction import _round_has_protected_content
|
|
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": "CHECKPOINT_REFLECTION:\n- Known: x\n- Blocker: none",
|
|
"tool_calls": [{"id": "c1", "function": {"name": "read_file", "arguments": "{}"}}],
|
|
},
|
|
{"role": "tool", "tool_call_id": "c1", "content": "ok"},
|
|
]
|
|
assert _round_has_protected_content(messages, 0, 1) is False
|
|
|
|
|
|
def test_round_has_protected_content_protects_error_tool_results():
|
|
"""Tool-result messages prefixed with ⚠️ remain protected from compaction —
|
|
this was the other half of the pre-v4.34.0 rule and is unaffected by the
|
|
checkpoint refactor.
|
|
"""
|
|
from ouroboros.context_compaction import _round_has_protected_content
|
|
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [{"id": "c1", "function": {"name": "read_file", "arguments": "{}"}}],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "c1",
|
|
"content": "⚠️ failed to read path: permission denied",
|
|
},
|
|
]
|
|
assert _round_has_protected_content(messages, 0, 1) is True
|
|
|
|
|
|
def _round_with_result(content: str):
|
|
return [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [{"id": "c1", "function": {"name": "run_command", "arguments": "{}"}}],
|
|
},
|
|
{"role": "tool", "tool_call_id": "c1", "content": content},
|
|
]
|
|
|
|
|
|
def test_autocorrect_prefixed_warning_is_still_protected():
|
|
"""shell can prepend an autocorrect note BEFORE the ⚠️ line; the old
|
|
startswith check silently unprotected such warnings."""
|
|
from ouroboros.context_compaction import _round_has_protected_content
|
|
|
|
messages = _round_with_result(
|
|
"Note: autocorrected 'gti' -> 'git'\n⚠️ REVIEW_BLOCKED: tests failed"
|
|
)
|
|
assert _round_has_protected_content(messages, 0, 1) is True
|
|
|
|
|
|
def test_blank_line_separated_warning_is_still_protected():
|
|
"""A blank line between the autocorrect note and the ⚠️ marker must not
|
|
defeat protection — the scan counts non-empty lines only."""
|
|
from ouroboros.context_compaction import _round_has_protected_content
|
|
|
|
messages = _round_with_result(
|
|
"Note: autocorrected 'gti' -> 'git'\n\n⚠️ REVIEW_BLOCKED: tests failed"
|
|
)
|
|
assert _round_has_protected_content(messages, 0, 1) is True
|
|
|
|
|
|
def test_failed_text_fallback_still_carries_structured_spend(monkeypatch, tmp_path):
|
|
"""Structured-call spend survives a text-protocol fallback failure via
|
|
_BatchSummaryError so the caller can account it."""
|
|
import pytest
|
|
|
|
from ouroboros import context_compaction, llm_observability
|
|
|
|
calls = []
|
|
|
|
def fake_chat_observed(_client, **kwargs):
|
|
calls.append(kwargs)
|
|
if "tools" in kwargs:
|
|
# Structured call: spend incurred, but no parseable summaries.
|
|
return {"content": "free-form prose without round markers"}, {"prompt_tokens": 11}
|
|
raise RuntimeError("text protocol call exploded")
|
|
|
|
monkeypatch.setattr(llm_observability, "chat_observed", fake_chat_observed)
|
|
monkeypatch.delenv("USE_LOCAL_LIGHT", raising=False)
|
|
|
|
with pytest.raises(context_compaction._BatchSummaryError) as exc_info:
|
|
context_compaction._summarize_round_batch(
|
|
[(2, "TOOL_CALL x: {}")], drive_root=tmp_path, task_id="t"
|
|
)
|
|
assert exc_info.value.usage == {"prompt_tokens": 11}
|
|
assert len(calls) == 2
|
|
|
|
|
|
def test_shell_exit_error_rounds_are_compactable():
|
|
"""SHELL_EXIT_ERROR rounds are trial-and-error history that MUST compact
|
|
(the summarizer keeps the first error line); plain and autocorrect-prefixed
|
|
shapes are both exempt from ⚠️ protection."""
|
|
from ouroboros.context_compaction import _round_has_protected_content
|
|
|
|
plain = _round_with_result("⚠️ SHELL_EXIT_ERROR exit=1\nTraceback (most recent call last): ...")
|
|
assert _round_has_protected_content(plain, 0, 1) is False
|
|
|
|
prefixed = _round_with_result(
|
|
"Note: autocorrected 'pyhton' -> 'python'\n⚠️ SHELL_EXIT_ERROR exit=127\ncommand not found"
|
|
)
|
|
assert _round_has_protected_content(prefixed, 0, 1) is False
|
|
|
|
|
|
# ── LLM compaction batch isolation + structured protocol ────────────────────
|
|
|
|
|
|
def _make_llm_round_messages(num_rounds: int):
|
|
messages = [{"role": "system", "content": "system"}]
|
|
for i in range(num_rounds):
|
|
tc_id = f"call_{i}"
|
|
messages.append({
|
|
"role": "assistant",
|
|
"content": f"Round {i}",
|
|
"tool_calls": [{"id": tc_id, "function": {"name": "read_file", "arguments": "{}"}}],
|
|
})
|
|
messages.append({"role": "tool", "tool_call_id": tc_id, "content": f"result {i}"})
|
|
return messages
|
|
|
|
|
|
def test_failed_batch_keeps_other_batches(monkeypatch, tmp_path):
|
|
"""One failed batch leaves only ITS rounds raw; other batches still
|
|
compact, and spend from the failed batch is still accounted."""
|
|
from ouroboros import context_compaction
|
|
|
|
calls = []
|
|
|
|
def fake_batch(rendered_blocks, *, drive_root, task_id):
|
|
calls.append([start for start, _ in rendered_blocks])
|
|
if len(calls) == 1:
|
|
raise context_compaction._BatchSummaryError(
|
|
"boom", usage={"prompt_tokens": 7, "cost": 0.01}
|
|
)
|
|
return (
|
|
{start: f"summary-{start}" for start, _ in rendered_blocks},
|
|
{"prompt_tokens": 3, "cost": 0.02},
|
|
)
|
|
|
|
monkeypatch.setattr(context_compaction, "_summarize_round_batch", fake_batch)
|
|
messages = _make_llm_round_messages(20) # 16 compactable -> 2 batches of 8
|
|
|
|
compacted, usage = context_compaction.compact_tool_history_llm(
|
|
messages, keep_recent=4, drive_root=tmp_path, task_id="t"
|
|
)
|
|
|
|
assert len(calls) == 2
|
|
summaries = [m for m in compacted if str(m.get("content") or "").startswith("[Compacted reasoning block]")]
|
|
assert len(summaries) == 8 # second batch compacted
|
|
raw_rounds = [m for m in compacted if m.get("role") == "assistant" and m.get("tool_calls")]
|
|
assert len(raw_rounds) == 12 # 8 raw from failed batch + 4 kept recent
|
|
# Spend from BOTH the failed and the successful batch is accounted.
|
|
assert usage["prompt_tokens"] == 10
|
|
assert abs(usage["cost"] - 0.03) < 1e-9
|
|
|
|
|
|
def test_missing_round_summary_degrades_only_that_round(monkeypatch, tmp_path):
|
|
"""A summary missing for one round leaves that round raw instead of
|
|
failing the whole batch (the old completeness ValueError)."""
|
|
from ouroboros import context_compaction
|
|
|
|
def fake_batch(rendered_blocks, *, drive_root, task_id):
|
|
starts = [start for start, _ in rendered_blocks]
|
|
return (
|
|
{start: f"summary-{start}" for start in starts if start != starts[0]},
|
|
{"prompt_tokens": 1},
|
|
)
|
|
|
|
monkeypatch.setattr(context_compaction, "_summarize_round_batch", fake_batch)
|
|
messages = _make_llm_round_messages(10) # 6 compactable -> 1 batch
|
|
|
|
compacted, _usage = context_compaction.compact_tool_history_llm(
|
|
messages, keep_recent=4, drive_root=tmp_path, task_id="t"
|
|
)
|
|
|
|
summaries = [m for m in compacted if str(m.get("content") or "").startswith("[Compacted reasoning block]")]
|
|
assert len(summaries) == 5 # all but the degraded round
|
|
raw_rounds = [m for m in compacted if m.get("role") == "assistant" and m.get("tool_calls")]
|
|
assert len(raw_rounds) == 5 # 1 degraded + 4 kept recent
|
|
|
|
|
|
def test_structured_protocol_parses_pinned_tool_call(monkeypatch, tmp_path):
|
|
"""The structured emit_round_summaries protocol is preferred and parsed
|
|
from the pinned tool call."""
|
|
import json as _json
|
|
|
|
from ouroboros import context_compaction, llm_observability
|
|
|
|
seen = {}
|
|
|
|
def fake_chat_observed(_client, **kwargs):
|
|
seen.update(kwargs)
|
|
return (
|
|
{
|
|
"content": "",
|
|
"tool_calls": [{
|
|
"id": "tc1",
|
|
"function": {
|
|
"name": "emit_round_summaries",
|
|
"arguments": _json.dumps({
|
|
"summaries": [
|
|
{"round_id": 1, "summary": "did a thing"},
|
|
{"round_id": 3, "summary": "did another"},
|
|
]
|
|
}),
|
|
},
|
|
}],
|
|
},
|
|
{"prompt_tokens": 5},
|
|
)
|
|
|
|
monkeypatch.setattr(llm_observability, "chat_observed", fake_chat_observed)
|
|
monkeypatch.delenv("USE_LOCAL_LIGHT", raising=False)
|
|
|
|
summary_map, usage = context_compaction._summarize_round_batch(
|
|
[(1, "TOOL_CALL x: {}"), (3, "TOOL_CALL y: {}")],
|
|
drive_root=tmp_path,
|
|
task_id="t",
|
|
)
|
|
|
|
assert summary_map == {1: "did a thing", 3: "did another"}
|
|
assert usage == {"prompt_tokens": 5}
|
|
assert seen["tools"] == [context_compaction._ROUND_SUMMARIES_TOOL]
|
|
assert seen["tool_choice"] == "required"
|
|
|
|
|
|
def test_structured_failure_falls_back_to_text_protocol(monkeypatch, tmp_path):
|
|
"""If the structured call raises (provider rejects tools), the text
|
|
protocol retry still summarizes and usage from BOTH calls is merged."""
|
|
from ouroboros import context_compaction, llm_observability
|
|
|
|
calls = []
|
|
|
|
def fake_chat_observed(_client, **kwargs):
|
|
calls.append(kwargs)
|
|
if "tools" in kwargs:
|
|
raise RuntimeError("provider rejects tool_choice=required")
|
|
return {"content": "[round:2]\nrecovered summary"}, {"prompt_tokens": 4}
|
|
|
|
monkeypatch.setattr(llm_observability, "chat_observed", fake_chat_observed)
|
|
monkeypatch.delenv("USE_LOCAL_LIGHT", raising=False)
|
|
|
|
summary_map, usage = context_compaction._summarize_round_batch(
|
|
[(2, "TOOL_CALL x: {}")],
|
|
drive_root=tmp_path,
|
|
task_id="t",
|
|
)
|
|
|
|
assert summary_map == {2: "recovered summary"}
|
|
assert len(calls) == 2
|
|
assert usage == {"prompt_tokens": 4} # structured call raised before usage
|