Files
openworker/tests/test_compaction_engine.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

291 lines
12 KiB
Python

"""OPE-27 engine hook: the mid-run trigger, the outbound view, the usage signal, the
failure policy (attended prompt / unattended auto-trim), raw-overflow routing, and the
session persistence round-trip. Scripted providers, tiny forced windows, no network."""
import asyncio
from coworker.engine import TurnEngine
from coworker.events import EventType
from coworker.permissions import PermissionEngine
from coworker.providers import (
AssistantTurn,
ModelCapabilities,
ProviderClient,
ToolCall,
)
from coworker.providers.base import TokenUsage
from coworker.tools import ToolRegistry
# Must clear the OPE-189 quality gate: all eight sections, past the minimum length. A
# summary that fails it is refused and the engine falls back to trimming, which is what
# these tests would otherwise be asserting against.
SUMMARY = "\n".join(
f"## {name}\nenough detail in this section to clear the summary minimum length"
for name in (
"Primary request and intent",
"Key concepts and decisions",
"Artifacts and files",
"Errors and fixes",
"All user messages",
"Pending tasks",
"Current work",
"Next step",
)
)
class CompactingProvider(ProviderClient):
"""Scripted main turns; summarizer calls (recognized by the compaction system prompt)
are answered out-of-band so they never consume the main script."""
def __init__(self, turns, *, summary=SUMMARY, summary_fails=0, main_overflows=0):
self._turns = list(turns)
self.summary = summary
self.summary_fails = summary_fails
self.main_overflows = main_overflows
self.summary_calls = []
self.main_calls = 0
def complete(self, *, model, messages, tools=None, **settings):
if messages and "compacting an AI coworker" in str(
messages[0].get("content", "")
):
self.summary_calls.append({"model": model, "messages": messages})
if self.summary_fails > 0:
self.summary_fails -= 1
raise RuntimeError("summarizer down")
return AssistantTurn(text=self.summary, finish_reason="stop")
self.main_calls += 1
if self.main_overflows > 0:
self.main_overflows -= 1
raise RuntimeError(
"Error 400: maximum context length is 100000 tokens, request used more"
)
return self._turns.pop(0)
def capabilities(self, model):
return ModelCapabilities()
def long_history(turns=8, bulk=1500):
msgs = [{"role": "system", "content": "be helpful"}]
for i in range(turns):
msgs.append({"role": "user", "content": f"request {i}", "ts": 1.0})
msgs.append(
{"role": "assistant", "content": f"answer {i} " + "x" * bulk, "ts": 1.0}
)
return msgs
def make_engine(tmp_path, provider, *, messages=None, cap=400):
engine = TurnEngine(
provider=provider,
registry=ToolRegistry(),
permissions=PermissionEngine(workspace_root=tmp_path),
model="gpt-5.5",
messages=messages,
)
engine.compaction_settings = lambda: {
"cap_tokens": cap,
"threshold_pct": 0.8,
"context_window": 100_000,
}
return engine
def collect(engine, text="continue"):
async def _run():
return [e async for e in engine.run(text)]
return asyncio.run(_run())
def test_compacts_before_the_turn_when_estimate_crosses(tmp_path):
provider = CompactingProvider([AssistantTurn(text="done", finish_reason="stop")])
engine = make_engine(tmp_path,provider, messages=long_history(), cap=400)
events = collect(engine)
assert any(e.type == EventType.COMPACTED for e in events)
assert not any(e.type == EventType.ERROR for e in events)
state = engine.compaction_state
assert state is not None and not state.trimmed
assert provider.summary_calls[0]["model"] == "gpt-5.5" # session's own model
# Outbound view: system survives, the block stands in for the old turns, the
# canonical transcript is untouched, and the persisted notice marks the spot.
out = engine._outbound_messages()
assert out[0]["role"] == "system"
assert "<compacted-history>" in out[1]["content"]
assert SUMMARY.splitlines()[-1] in out[1]["content"]
assert "request 0" in out[1]["content"] # mechanical user-message list
assert any("answer 0" in str(m.get("content")) for m in engine.messages)
assert any(
m.get("role") == "notice" and m.get("kind") == "compacted"
for m in engine.messages
)
def test_usage_signal_triggers_between_tool_turns(tmp_path):
# History too small for the estimate path — only the reported usage crosses the
# trigger, after iteration 1's round-trip. The compaction runs before iteration 2.
provider = CompactingProvider(
[
AssistantTurn(
tool_calls=[ToolCall(id="c1", name="nonexistent_tool", arguments={})],
finish_reason="tool_calls",
usage=TokenUsage(input=90_000, output=10),
),
AssistantTurn(text="done", finish_reason="stop"),
]
)
engine = make_engine(tmp_path,provider, messages=long_history(turns=2, bulk=10), cap=400)
events = collect(engine)
assert any(e.type == EventType.COMPACTED for e in events)
assert provider.summary_calls # driven by usage, not the (tiny) estimate
assert engine._last_context_tokens is None # reset once the view shrank
def test_summarizer_failure_unattended_auto_trims(tmp_path):
provider = CompactingProvider(
[AssistantTurn(text="done", finish_reason="stop")], summary_fails=99
)
engine = make_engine(tmp_path,provider, messages=long_history(), cap=400)
events = collect(engine) # is_attended is None → unattended policy
compacted = [e for e in events if e.type == EventType.COMPACTED]
assert compacted and "trimmed" in compacted[0].data["text"].lower()
assert engine.compaction_state is not None and engine.compaction_state.trimmed
assert len(provider.summary_calls) == 2 # the one unconditional retry, then trim
def test_summarizer_failure_attended_prompts_retry_then_succeeds(tmp_path):
provider = CompactingProvider(
[AssistantTurn(text="done", finish_reason="stop")], summary_fails=2
)
engine = make_engine(tmp_path,provider, messages=long_history(), cap=400)
engine.is_attended = lambda: True
asked = []
async def asker(args, tool_call_id=None):
asked.append(args)
return {"answer": "Retry"}
engine.question_asker = asker
collect(engine)
assert asked and asked[0]["options"] == ["Retry", "Trim oldest 10%"]
assert engine.compaction_state is not None and not engine.compaction_state.trimmed
def test_summarizer_failure_attended_choose_trim(tmp_path):
provider = CompactingProvider(
[AssistantTurn(text="done", finish_reason="stop")], summary_fails=99
)
engine = make_engine(tmp_path,provider, messages=long_history(), cap=400)
engine.is_attended = lambda: True
async def asker(args, tool_call_id=None):
return {"answer": "Trim oldest 10%"}
engine.question_asker = asker
collect(engine)
assert engine.compaction_state is not None and engine.compaction_state.trimmed
def test_raw_overflow_routes_into_compaction_and_retries(tmp_path):
# Trigger never fires (huge cap) — the provider 400 is the only signal. The engine
# must compact (force) and retry the call instead of surfacing the error.
provider = CompactingProvider(
[AssistantTurn(text="recovered", finish_reason="stop")], main_overflows=1
)
engine = make_engine(tmp_path,provider, messages=long_history(), cap=1_000_000)
events = collect(engine)
assert any(e.type == EventType.COMPACTED for e in events)
assert not any(e.type == EventType.ERROR for e in events)
finals = [e for e in events if e.type == EventType.ASSISTANT_MESSAGE]
assert finals and finals[-1].data["text"] == "recovered"
assert provider.main_calls == 2
def test_non_overflow_provider_errors_still_surface(tmp_path):
class FailingProvider(CompactingProvider):
def complete(self, *, model, messages, tools=None, **settings):
raise RuntimeError("rate limit exceeded")
engine = make_engine(tmp_path,FailingProvider([]), messages=long_history(turns=1), cap=1_000_000)
events = collect(engine)
assert any(e.type == EventType.ERROR for e in events)
assert not any(e.type == EventType.COMPACTED for e in events)
def test_set_compaction_settings_validates_and_round_trips(tmp_path):
from coworker.server.manager import SessionManager
class Provider(ProviderClient):
def complete(self, *, model, messages, tools=None, **settings):
return AssistantTurn(text="hi")
def capabilities(self, model):
return ModelCapabilities()
mgr = SessionManager(workspace=tmp_path, provider=Provider())
out = mgr.set_compaction_settings(
threshold_pct=0.5, cap_tokens=100_000, model="gpt-4o-mini"
)
assert out["ok"] and out["threshold_pct"] == 0.5 and out["cap_tokens"] == 100_000
assert mgr.compaction_settings()["model"] == "gpt-4o-mini"
# validation: out-of-range % and non-numeric cap are rejected, tiny caps clamp up
assert mgr.set_compaction_settings(threshold_pct=0.05)["ok"] is False
assert mgr.set_compaction_settings(cap_tokens="lots")["ok"] is False
assert mgr.set_compaction_settings(cap_tokens=1)["cap_tokens"] == 10_000
# the flat /v1/settings names
payload = mgr.compaction_settings_payload()
assert payload["compaction_threshold_pct"] == 0.5
assert payload["compaction_model"] == "gpt-4o-mini"
def test_compaction_state_survives_save_and_rebuild(tmp_path):
from coworker.compaction import CompactionState
from coworker.server.manager import SessionManager
class Provider(ProviderClient):
def complete(self, *, model, messages, tools=None, **settings):
return AssistantTurn(text="hi", finish_reason="stop")
def capabilities(self, model):
return ModelCapabilities()
mgr = SessionManager(workspace=tmp_path, provider=Provider())
sid = "compact-persist"
engine = mgr.get_engine(sid, agent="cowork", workspace=str(tmp_path))
assert callable(engine.compaction_settings) # live Settings getter is wired
assert engine.compaction_settings()["threshold_pct"] == 0.8
engine.messages += long_history(turns=3)[1:]
engine.compaction_state = CompactionState(
boundary_index=3, summary_text="the gist", working_state="", user_messages=["u"]
)
mgr.save(sid, engine)
mgr._engines.pop(sid)
rebuilt = mgr.get_engine(sid, agent="cowork", workspace=str(tmp_path))
assert rebuilt.compaction_state == engine.compaction_state
def test_compacting_signal_precedes_the_compacted_marker(tmp_path):
# The transient-progress contract: COMPACTING fires before the (slow) summarizer
# call, COMPACTED after — surfaces key the "Compacting context…" spinner on it.
provider = CompactingProvider([AssistantTurn(text="done", finish_reason="stop")])
engine = make_engine(tmp_path, provider, messages=long_history(), cap=400)
events = collect(engine)
types = [e.type for e in events]
assert EventType.COMPACTING in types
assert types.index(EventType.COMPACTING) < types.index(EventType.COMPACTED)
# The signal is not persisted — only the compacted marker lands in the transcript.
assert not any(
m.get("role") == "notice" and m.get("kind") == "compacting"
for m in engine.messages
)