Files
OpenJarvis/tests/test_swe_compaction.py
T
Andrew Park 081839ee7b hybrid: SWE trajectory compaction + OpenAI adapter bug fixes
- _loop_local: two-stage compaction at 22k tokens (tiktoken cl100k).
  Stage 1 elides old tool messages to [tool output elided: N chars, exit=X],
  preserving tool_call_id. Stage 2 folds turn pairs into a synthesized
  system note. Last 3 turns + system + initial user always intact.
  Emergency-compact retry on a 32k BadRequest from vLLM.
- _loop_cloud_openai: assistant content is "" not None on tool-only turns
  (OpenAI 400's on null). Bash output decoded with errors="replace" and
  stubbed to [binary output: N bytes, exit=X] on >5% replacement or NUL.
- _openai_retry: local-vLLM branch now retries APIConnectionError /
  APITimeoutError / InternalServerError 3x with 1/2/4s backoff.
- Tests: 18 cases covering compaction shape, token budget, tool_call_id
  preservation, null-content guard, binary decode, local retry.

Addresses ctx-overflow in Gemma-31B SWE n=100 cells (61 + 19 errored
rows) and the OpenAI/Gemini adapter errors across ~19 cells in the
n=100 hybrid sweep.
2026-05-26 00:13:47 -07:00

154 lines
5.3 KiB
Python

"""Tests for trajectory compaction in mini_swe_agent._compact_local_messages."""
from __future__ import annotations
import copy
from typing import Any, Dict, List
import pytest
from openjarvis.agents.hybrid.mini_swe_agent import (
_compact_local_messages,
_estimate_prompt_tokens,
_get_tiktoken_enc,
)
TIKTOKEN_OK = bool(_get_tiktoken_enc())
def _validate_openai_message_shape(messages: List[Dict[str, Any]]) -> None:
seen_tool_call_ids: set[str] = set()
for i, m in enumerate(messages):
role = m.get("role")
assert role in {"system", "user", "assistant", "tool"}, f"bad role at {i}: {role}"
if role == "assistant":
for tc in (m.get("tool_calls") or []):
tid = tc.get("id")
assert tid, f"assistant tool_call missing id at msg {i}"
seen_tool_call_ids.add(tid)
elif role == "tool":
tid = m.get("tool_call_id")
assert tid, f"tool message at {i} missing tool_call_id"
assert tid in seen_tool_call_ids, (
f"tool message at {i} references unknown tool_call_id={tid!r}"
)
assert m.get("content"), f"tool message at {i} has empty content"
def _make_synthetic_messages() -> List[Dict[str, Any]]:
messages: List[Dict[str, Any]] = [
{"role": "system", "content": "You are a coding agent."},
{"role": "user", "content": "Fix the bug in foo.py."},
]
big_idx = 7 # one of the assistant+tool pairs gets a giant blob
for t in range(19):
tc_id = f"call_{t:03d}"
messages.append({
"role": "assistant",
"content": f"Turn {t}: I'll run a command.",
"tool_calls": [{
"id": tc_id,
"type": "function",
"function": {
"name": "bash",
"arguments": '{"command": "ls -la dir_' + str(t) + '"}',
},
}],
})
if t == big_idx:
body = "OUTPUT " + ("x" * 29950)
obs = f"$ ls\n{body}\nexit_code=0"
else:
body = ("line " + str(t) + " ") * 800 # ~8000 chars
obs = f"$ ls\n{body}\nexit_code=0"
messages.append({
"role": "tool",
"tool_call_id": tc_id,
"content": obs[:8000] if t != big_idx else obs[:30000],
})
return messages
def test_compaction_token_budget_and_shape():
messages = _make_synthetic_messages()
orig = copy.deepcopy(messages)
before = _estimate_prompt_tokens(messages)
assert before > 24_000, f"sanity: synthetic should exceed budget, got {before}"
compacted = _compact_local_messages(
messages,
client=None, # forces stage-2 summary fallback (no API call)
model="dummy",
keep_last=4,
trace_prefix="test",
compact_at_tokens=24_000,
)
_validate_openai_message_shape(compacted)
if TIKTOKEN_OK:
after = _estimate_prompt_tokens(compacted)
assert after <= 24_000, f"after compaction tokens={after} > 24_000"
# System + initial user intact.
assert compacted[0] == orig[0]
assert compacted[1] == orig[1]
# Recent 4 turns (assistant + its tool replies) intact and deep-equal.
# In synthetic input, every turn is exactly 1 assistant + 1 tool, so the
# last 4 turns = last 8 messages.
assert compacted[-8:] == orig[-8:], "recent 4 turns must be intact"
def test_stage1_only_when_sufficient():
"""If stage 1 alone drops us below the budget, stage 2 must not run."""
# Build a smaller input that exceeds budget only because of giant tool outputs.
messages: List[Dict[str, Any]] = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "task"},
]
for t in range(8):
tc_id = f"call_{t}"
messages.append({
"role": "assistant",
"content": f"t{t}",
"tool_calls": [{
"id": tc_id, "type": "function",
"function": {"name": "bash", "arguments": "{}"},
}],
})
# Use random-ish text to avoid BPE collapse from repetition.
import random
rng = random.Random(t)
body = " ".join(rng.choice(["alpha", "beta", "gamma", "delta", "lambda",
"foo", "bar", "baz", "qux", "zeta"])
for _ in range(3500))
messages.append({
"role": "tool",
"tool_call_id": tc_id,
"content": body + " exit_code=0",
})
before = _estimate_prompt_tokens(messages)
if not TIKTOKEN_OK:
pytest.skip("tiktoken unavailable; stage-1-only ordering is timing-sensitive without it")
assert before > 24_000
compacted = _compact_local_messages(
messages, client=None, model="dummy",
keep_last=4, trace_prefix="test", compact_at_tokens=24_000,
)
_validate_openai_message_shape(compacted)
# No stage-2 fold → length identical (only tool contents shortened).
assert len(compacted) == len(messages)
# The synthetic stage-2 system stub should NOT be present.
for m in compacted:
if m.get("role") == "system":
assert not str(m.get("content", "")).startswith("[turns "), \
"stage 2 should not have run"
if __name__ == "__main__":
import sys
sys.exit(pytest.main([__file__, "-v"]))