Files
openworker/tests/test_compaction.py
Devika Verma 72e62e87b0 Make the compaction summary reliable, and stop losing the task statement (OPE-189)
The OPE-186 trial (15 long tasks) compacted 64 times and gave a usable summary on 7 of 103
summariser calls. Two independent causes, both fixed here.

1. The summariser was asked last and read the session last. `summarizer_messages`
   sent the ~1,800-character instruction as the system message and then tens of
   thousands of characters of transcript, ending wherever the session happened to
   be — usually on a raw tool result. The model continued that voice instead of
   summarising: 13 of 16 short replies were a next-action sentence ("The output
   got truncated. Let me re-run the remaining checks." — 61 characters standing in
   for 75 turns), each stopping voluntarily with most of its budget unused. The
   transcript is now delimited and the instruction restated after it, in bold,
   ending on the exact first heading to write.

2. The budget was 3,000 tokens, shared with the model's reasoning. On 60 of 103
   calls Kimi K3 spent it thinking and the summary was cut off or never started.
   SUMMARY_MAX_TOKENS is now 16,000 and is a setting: `compaction_summary_max_tokens`
   in config.toml, COWORKER_COMPACTION_SUMMARY_MAX_TOKENS in the environment.

Measured by replaying 20 recorded compactions from that trial against Kimi K3,
scored by the gate below: the old shape at 3,000 tokens gives 4/20 usable; moving
the instruction alone gives 18/20; with the 16,000 budget, 20/20. Disabling
reasoning for the call adds nothing over that, so it is not done.

Also:

* A quality gate before a summary is accepted. "Not empty" let a 61-character
  sentence become the coworker's whole memory of a span. `summary_quality_problem`
  refuses empty, under 400 characters, or missing the section headings, and
  `summarize_span` raises — so the engine's existing single retry runs before the
  no-summary trim fallback.
* The first user message is pinned in the compacted block, whole, capped at 4,000
  tokens. `_cap_user_messages` kept the newest 40, so any session with 41 or more
  user messages silently dropped the opening mandate — the one message that never
  goes stale. Omissions now come from the middle, and the block says so there.
* Per-message clipping is replaced by a token budget filled backwards from the
  newest. Each preserved user message was cut at 600 characters; 8 of the 15 trial
  task prompts are longer, up to 1,424, so after every compaction the agent's
  verbatim record of its job was cut mid-sentence. On video-processing that
  discarded the output path and both field names the task checks. The budget
  is 8% of the compaction trigger, bounded to [2,000, 20,000] — a fraction, so
  lowering the trigger to save tokens is not undone by the block, and 20,000 at
  the default trigger is a generous allowance.

The trigger itself is unchanged: min(80% of the context window, cap), cap 250,000,
overridden by `compaction_cap_tokens`. Lowering the default should follow a working
summariser, not precede it.

Unrelated, included here to keep the tree clean: model-matrix rows for Claude Opus
4.6, Opus 4.5 and Sonnet 4.5. These are added so OpenWorker can be measured on
those models against published evaluations of the same models.

Tests: tests/test_compaction.py, tests/test_compaction_engine.py and
tests/test_compaction_transcript.py cover the new request shape, the gate, the pin,
the budget and the config/env plumbing. Their fake summarisers were returning
strings the gate now refuses, and were updated to real-shaped summaries.
2026-09-16 15:31:06 +05:30

432 lines
17 KiB
Python

"""OPE-27 — auto-compaction pure functions: trigger math, boundary picking, mechanical
extraction, summarizer seam, trim fallback, outbound view. No engine involved."""
import json
import pytest
from coworker import compaction as C
from coworker.compaction import (
CompactionState,
DEFAULT_CAP_TOKENS,
DEFAULT_CONTEXT_WINDOW,
apply_to_outbound,
build_state,
compacted_block,
estimate_tokens,
extract_user_messages,
extract_working_state,
is_context_overflow,
pick_boundary,
should_compact,
summarize_span,
summarizer_messages,
trigger_tokens,
trim_state,
)
# -- message builders ---------------------------------------------------------
def user(text):
return {"role": "user", "content": text, "ts": 1.0}
_call_seq = 0
def assistant(text="", tool_calls=None):
global _call_seq
msg = {"role": "assistant", "content": text, "ts": 1.0}
if tool_calls:
calls = []
for name, args in tool_calls:
calls.append(
{
"id": f"c{_call_seq}",
"type": "function",
"function": {"name": name, "arguments": json.dumps(args)},
}
)
_call_seq += 1
msg["tool_calls"] = calls
return msg
def tool(call_id, content):
return {
"role": "tool",
"tool_call_id": call_id,
"content": content if isinstance(content, str) else json.dumps(content),
"ts": 1.0,
}
def tool_turn(name, args, result):
"""[assistant tool-call, matching tool result] with a properly paired call id."""
a = assistant(tool_calls=[(name, args)])
return [a, tool(a["tool_calls"][0]["id"], result)]
def convo(turns=6, bulk=2000):
"""system + N user/assistant turns with bulky assistant text."""
msgs = [{"role": "system", "content": "You are a coworker."}]
for i in range(turns):
msgs.append(user(f"request {i}"))
msgs.append(assistant(f"answer {i} " + "x" * bulk))
return msgs
class FakeSummarizer:
# The default must satisfy the OPE-189 quality gate (all eight sections, past the
# minimum length) — an unusable summary is now refused, not stored. GOOD_SUMMARY is
# defined further down the module and resolved when a fake is constructed.
def __init__(self, text=None, fail_times=0):
self.text = GOOD_SUMMARY if text is None else text
self.fail_times = fail_times
self.calls = []
def complete(self, *, model, messages, tools=None, **settings):
self.calls.append({"model": model, "messages": messages, "tools": tools, **settings})
if self.fail_times > 0:
self.fail_times -= 1
raise RuntimeError("summarizer down")
class Turn:
pass
t = Turn()
t.text = self.text
return t
# -- trigger math -------------------------------------------------------------
def test_trigger_is_min_of_pct_and_cap():
assert trigger_tokens(100_000) == 80_000
assert trigger_tokens(1_000_000) == DEFAULT_CAP_TOKENS # the 250k cap wins
assert trigger_tokens(None) == int(0.8 * DEFAULT_CONTEXT_WINDOW)
# both knobs are user-overridable
assert trigger_tokens(100_000, threshold_pct=0.5, cap_tokens=40_000) == 40_000
assert trigger_tokens(100_000, threshold_pct=0.5, cap_tokens=999_999) == 50_000
def test_should_compact_crosses_threshold():
assert not should_compact(79_999, 100_000)
assert should_compact(80_000, 100_000)
def test_estimate_tokens_is_chars_over_four():
msgs = [user("a" * 400)]
est = estimate_tokens(msgs)
assert 100 <= est <= 120 # 400 chars of content + json overhead, /4
# -- boundary -----------------------------------------------------------------
def test_boundary_prefers_earliest_user_turn_that_fits():
msgs = convo(turns=6)
per_turn = estimate_tokens(msgs[1:3])
boundary = pick_boundary(msgs, keep_tokens=per_turn * 2 + 10)
assert msgs[boundary]["role"] == "user"
assert msgs[boundary]["content"] == "request 4" # newest two turns survive
def test_boundary_falls_inside_a_giant_final_turn():
# One user turn followed by a huge tool loop: the turn alone exceeds the budget,
# so the cut lands on an assistant (iteration) boundary inside it — never a tool row.
msgs = [{"role": "system", "content": "s"}, user("go")]
for i in range(8):
a = assistant("step " + "y" * 3000, tool_calls=[("run_shell", {"command": f"cmd{i}"})])
msgs += [a, tool(a["tool_calls"][0]["id"], {"exit_code": 0, "out": "z" * 3000})]
boundary = pick_boundary(msgs, keep_tokens=estimate_tokens(msgs[-3:]))
assert msgs[boundary]["role"] == "assistant"
def test_boundary_none_when_nothing_to_summarize():
msgs = [{"role": "system", "content": "s"}, user("hi"), assistant("hello")]
assert pick_boundary(msgs, keep_tokens=10_000_000) is None
# -- mechanical extraction ----------------------------------------------------
def test_working_state_files_commands_tools():
span = [
user("write it"),
*tool_turn("write_file", {"path": "a.py", "content": "x"}, {"ok": True}),
*tool_turn("run_shell", {"command": "pytest -q"}, {"exit_code": 1}),
*tool_turn("write_file", {"path": "b.py", "content": "y"}, {"ok": True}),
*tool_turn("write_file", {"path": "a.py", "content": "x2"}, {"ok": True}),
]
block = extract_working_state(span)
# deduped, most recent first
assert block.index("- a.py") < block.index("- b.py")
assert block.count("a.py") == 1
assert "pytest -q" in block and "[exit 1]" in block
assert "run_shell" in block and "write_file" in block
def test_working_state_empty_span():
assert extract_working_state([user("hi"), assistant("yo")]) == ""
def test_user_messages_extracted_verbatim_and_uncut():
# OPE-189: extraction no longer clips — a 1,400-char task statement reached the block
# cut mid-sentence, losing the output path and the field names the grader checks.
span = [
user("first ask"),
assistant("a"),
user([{"type": "text", "text": "second"}, {"type": "image_url", "image_url": {}}]),
assistant("b"),
user("bulk " + "z" * 2000),
]
out = extract_user_messages(span)
assert out[0] == "first ask"
assert out[1] == "second [image]"
assert out[2] == "bulk " + "z" * 2000 # verbatim; fitting is fit_user_messages' job
# -- summarizer seam ----------------------------------------------------------
def test_summarizer_messages_clip_tool_results_and_fold_prior():
span = [user("go"), *tool_turn("read_file", {"path": "big.txt"}, "huge " * 500)]
msgs = summarizer_messages(span, prior_summary="OLD SUMMARY")
body = msgs[1]["content"]
assert "OLD SUMMARY" in body
# The 2500-char tool result got clipped hard; the tail instruction is the only other bulk.
assert len(body) < 3000 + len(C.SUMMARY_TAIL_INSTRUCTION)
assert msgs[0]["role"] == "system" and "Primary request and intent" in msgs[0]["content"]
def test_summarizer_instruction_comes_after_the_transcript():
"""OPE-189: recency decides. With the instruction only up front, the last thing the
model read was the coworker mid-task and it continued that voice instead of
summarizing. The transcript is delimited and the instruction restated last."""
span = [user("go"), *tool_turn("run_shell", {"command": "ls"}, "a.txt b.txt")]
body = summarizer_messages(span)[1]["content"]
assert body.startswith("**--- BEGIN TRANSCRIPT TO SUMMARIZE ---**")
assert body.rstrip().endswith(C.SUMMARY_TAIL_INSTRUCTION)
assert body.index("[tool result]") < body.index("**--- END OF TRANSCRIPT ---**")
assert "## 1. Primary request and intent" in C.SUMMARY_TAIL_INSTRUCTION
GOOD_SUMMARY = "\n".join(
f"## {i}. {name}\ndetail line with enough substance to clear the minimum length bar"
for i, name in enumerate(
[
"Primary request and intent",
"Key concepts and decisions",
"Artifacts and files",
"Errors and fixes",
"All user messages",
"Pending tasks",
"Current work",
"Next step",
],
start=1,
)
)
def test_summarize_span_passes_model_and_budget():
fake = FakeSummarizer(text=GOOD_SUMMARY)
out = summarize_span(fake, "prov:model-x", [user("hi")], max_tokens=9999)
assert out == GOOD_SUMMARY
assert fake.calls[0]["model"] == "prov:model-x"
assert fake.calls[0]["tools"] is None
assert fake.calls[0]["max_tokens"] == 9999
def test_summarize_span_refuses_an_unusable_summary():
"""OPE-189: 'not empty' accepted a 61-char next-action sentence as the coworker's whole
memory of 75 turns. Empty, too short, and role-slipped replies are all refused so the
caller's retry (then trim) policy runs."""
for bad in (
" ",
"The output got truncated. Let me re-run the remaining checks.",
"## Artifacts and files\n" + "detail " * 200, # long, but not the anchor section
):
with pytest.raises(RuntimeError):
summarize_span(FakeSummarizer(text=bad), "m", [user("hi")])
def test_summary_quality_problem_names_the_fault():
assert C.summary_quality_problem(GOOD_SUMMARY) is None
assert C.summary_quality_problem("") == "empty"
assert "too short" in C.summary_quality_problem("## 1. Primary request and intent")
slipped = "Let me re-run the remaining checks. " * 20
assert "does not open the sections" in C.summary_quality_problem(slipped)
# -- build + repeated compaction ----------------------------------------------
def test_build_state_and_outbound_view():
msgs = convo(turns=6)
fake = FakeSummarizer(text=GOOD_SUMMARY + "\nthe gist")
state = build_state(
msgs, provider=fake, model="m", keep_tokens=estimate_tokens(msgs[-4:]) + 10
)
assert state is not None and not state.trimmed
assert state.user_messages[0] == "request 0"
out = apply_to_outbound(msgs, state)
assert out[0]["role"] == "system" # instructions survive
assert "<compacted-history>" in out[1]["content"]
assert "the gist" in out[1]["content"]
assert "request 0" in out[1]["content"] # mechanical user-message list
assert out[2] is msgs[state.boundary_index] # verbatim tail, canonical untouched
assert len(msgs) == 13 # canonical history unchanged
def test_repeated_compaction_summarizes_prior_plus_new_turns():
msgs = convo(turns=4)
fake = FakeSummarizer()
first = build_state(msgs, provider=fake, model="m", keep_tokens=estimate_tokens(msgs[-4:]) + 10)
# session grows
for i in range(4, 8):
msgs.append(user(f"request {i}"))
msgs.append(assistant(f"answer {i} " + "x" * 2000))
second = build_state(
msgs, provider=fake, model="m",
keep_tokens=estimate_tokens(msgs[-4:]) + 10, prior=first,
)
assert second is not None and second.boundary_index > first.boundary_index
# the second summarizer call folds the prior summary in
assert "previous compaction summary" in fake.calls[1]["messages"][1]["content"]
# user messages accumulate across compactions
assert "request 0" in second.user_messages[0]
assert any("request 5" in u for u in second.user_messages)
def test_build_state_none_when_boundary_stale():
msgs = convo(turns=3)
fake = FakeSummarizer()
state = build_state(msgs, provider=fake, model="m", keep_tokens=estimate_tokens(msgs[-2:]) + 10)
again = build_state(
msgs, provider=fake, model="m",
keep_tokens=10_000_000, prior=state,
)
assert again is None # nothing new fits below the prior boundary
# -- trim fallback ------------------------------------------------------------
def test_trim_advances_boundary_and_keeps_user_messages():
msgs = convo(turns=10)
state = trim_state(msgs)
assert state is not None and state.trimmed
assert msgs[state.boundary_index]["role"] in ("user", "assistant")
assert state.user_messages # preserved mechanically even without a summary
assert "trimmed" in state.summary_text
out = apply_to_outbound(msgs, state)
assert len(out) < len(msgs) + 1
def test_trim_from_prior_state_never_lands_on_tool_row():
msgs = [{"role": "system", "content": "s"}, user("go")]
for i in range(10):
msgs += tool_turn("run_shell", {"command": f"c{i}"}, {"exit_code": 0})
prior = trim_state(msgs)
later = trim_state(msgs, prior=prior)
assert later.boundary_index > prior.boundary_index
assert msgs[later.boundary_index]["role"] != "tool"
def test_trim_none_when_too_small():
assert trim_state([user("hi"), assistant("yo")]) is None
# -- state round-trip + overflow detection ------------------------------------
def test_state_dict_round_trip():
state = CompactionState(
boundary_index=7, summary_text="s", working_state="w",
user_messages=["u1"], created_at=1.5, model_used="m", trimmed=True,
)
assert CompactionState.from_dict(state.as_dict()) == state
assert CompactionState.from_dict(None) is None
assert CompactionState.from_dict({}) is None
def test_apply_to_outbound_noop_on_stale_or_missing_state():
msgs = convo(turns=2)
assert apply_to_outbound(msgs, None) is msgs
stale = CompactionState(boundary_index=999, summary_text="s", working_state="")
assert apply_to_outbound(msgs, stale) is msgs
def test_is_context_overflow():
assert is_context_overflow(Exception("Error 400: maximum context length is 128000 tokens"))
assert is_context_overflow(Exception("context_length_exceeded"))
assert is_context_overflow(Exception("Prompt is too long: 210000 tokens > limit"))
assert not is_context_overflow(Exception("rate limit exceeded"))
assert not is_context_overflow(Exception("connection reset"))
def test_user_messages_pin_the_first_and_budget_the_rest():
"""OPE-189: the list must not grow forever, but the OLD rule (newest 40) silently threw
away the opening message — the one that says why the session exists. Now the first is
pinned and the newest fill a token budget; the gap is in the middle."""
msgs = [{"role": "system", "content": "s"}]
msgs.append({"role": "user", "content": "THE ORIGINAL TASK: " + "spec " * 100})
msgs.append({"role": "assistant", "content": "starting"})
for i in range(300):
msgs.append({"role": "user", "content": f"ask {i} " + "filler " * 40})
msgs.append({"role": "assistant", "content": f"answer {i}"})
state = None
while True:
nxt = trim_state(msgs, prior=state, fraction=0.4, user_budget_tokens=2_000)
if nxt is None:
break
state = nxt
assert state is not None
assert state.user_messages[0].startswith("THE ORIGINAL TASK:") # pinned, never dropped
# The newest preserved message is from the end of the run (the very last turns stay in
# the verbatim tail rather than the block, so this is the newest that was compacted).
assert state.user_messages[-1].startswith("ask 29")
assert state.user_messages_dropped > 0 # the middle is what gives
block = compacted_block(state)
assert "older user messages omitted here" in block
assert block.index("THE ORIGINAL TASK") < block.index("omitted here")
restored = CompactionState.from_dict(state.as_dict())
assert restored.user_messages == state.user_messages
assert restored.user_messages_dropped == state.user_messages_dropped
def test_fit_user_messages_pins_first_clips_giants_and_counts_drops():
budget = 100 # tokens, i.e. ~400 chars
msgs = ["FIRST " + "a" * 200, "old " + "b" * 400, "mid", "newest"]
kept, dropped = C.fit_user_messages(msgs, prior_dropped=3, budget_tokens=budget)
assert kept[0] == msgs[0] # pinned whole: well under the pin cap
assert kept[-1] == "newest"
assert dropped >= 3 # running total carries forward
assert "mid" in kept # cheap messages still fit
# A first message far over the pin cap is clipped, never dropped.
huge = "X" * 100_000
kept, _ = C.fit_user_messages([huge, "later"], prior_dropped=0, budget_tokens=budget)
assert kept[0].startswith("XXX") and kept[0].endswith("…")
assert len(kept[0]) <= C._USER_PIN_MAX_TOKENS * 4
# A single newest message over budget is clipped to fit, not dropped.
kept, _ = C.fit_user_messages(["first", huge], prior_dropped=0, budget_tokens=budget)
assert len(kept) == 2 and kept[1].endswith("…")
def test_user_message_budget_scales_with_the_trigger():
assert C.user_message_budget(250_000) == 20_000 # at the default trigger
assert C.user_message_budget(60_000) == 4_800 # a lowered trigger keeps its saving
assert C.user_message_budget(1_000) == C._USER_BUDGET_MIN # floor
assert C.user_message_budget(10_000_000) == C._USER_BUDGET_MAX # ceiling