mirror of
https://github.com/andrewyng/openworker.git
synced 2026-11-04 21:37:21 +00:00
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.
191 lines
7.8 KiB
Python
191 lines
7.8 KiB
Python
"""OPE-186 change 3: the summariser can fire earlier (configurable cap), and the compacted
|
|
turns stay readable: the verbatim transcript is written to a file the model is told about
|
|
in the compacted block. Scripted provider, tiny forced window, no network."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
|
|
from coworker import compaction as C
|
|
from coworker.config import COMPACTION_CAP_TOKENS_ENV, load_config
|
|
from coworker.engine import TurnEngine
|
|
from coworker.events import EventType
|
|
from coworker.permissions import PermissionEngine
|
|
from coworker.providers import AssistantTurn, ModelCapabilities, ProviderClient
|
|
from coworker.tools import ToolRegistry
|
|
|
|
# Must clear the OPE-189 quality gate: all eight sections, past the minimum length.
|
|
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):
|
|
def __init__(self, turns):
|
|
self._turns = list(turns)
|
|
|
|
def complete(self, *, model, messages, tools=None, **settings):
|
|
if messages and "compacting an AI coworker" in str(messages[0].get("content", "")):
|
|
return AssistantTurn(text=SUMMARY, finish_reason="stop")
|
|
return self._turns.pop(0)
|
|
|
|
def capabilities(self, model):
|
|
return ModelCapabilities()
|
|
|
|
|
|
def _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,
|
|
"reasoning": "private thinking " + "r" * 200,
|
|
"tool_calls": [{"id": f"c{i}", "function": {"name": "run_shell", "arguments": '{"command": "ls -la"}'}}],
|
|
}
|
|
)
|
|
msgs.append({"role": "tool", "tool_call_id": f"c{i}", "content": f"listing {i}: file_{i}.txt", "ts": 1.0})
|
|
return msgs
|
|
|
|
|
|
def _collect(engine):
|
|
async def go():
|
|
return [e async for e in engine.run("continue")]
|
|
|
|
return asyncio.run(go())
|
|
|
|
|
|
def test_compaction_writes_transcript_and_tells_the_model(tmp_path):
|
|
spill = tmp_path / "spill"
|
|
engine = TurnEngine(
|
|
provider=CompactingProvider([AssistantTurn(text="done", finish_reason="stop")]),
|
|
registry=ToolRegistry(),
|
|
permissions=PermissionEngine(workspace_root=tmp_path),
|
|
model="gpt-5.5",
|
|
messages=_history(),
|
|
tool_result_spill_dir=spill,
|
|
)
|
|
engine.compaction_settings = lambda: {"cap_tokens": 400, "threshold_pct": 0.8, "context_window": 100_000}
|
|
events = _collect(engine)
|
|
assert any(e.type == EventType.COMPACTED for e in events)
|
|
state = engine.compaction_state
|
|
assert state is not None and state.transcript_path
|
|
files = list(spill.glob("compacted-transcript-upto-*.md"))
|
|
assert len(files) == 1 and str(files[0]) == state.transcript_path
|
|
text = files[0].read_text(encoding="utf-8")
|
|
# Verbatim content of the compacted turns: text, tool calls with arguments, results.
|
|
assert "request 0" in text and "answer 0" in text
|
|
assert "tool call: run_shell" in text and '"command": "ls -la"' in text
|
|
assert "listing 0: file_0.txt" in text
|
|
# Private reasoning is not written out.
|
|
assert "private thinking" not in text
|
|
# The compacted block the model receives names the file.
|
|
out = engine._outbound_messages()
|
|
assert state.transcript_path in out[1]["content"]
|
|
assert "read that file" in out[1]["content"]
|
|
# The record round-trips with the path.
|
|
assert C.CompactionState.from_dict(state.as_dict()).transcript_path == state.transcript_path
|
|
|
|
|
|
def test_no_spill_dir_means_no_transcript_and_no_mention(tmp_path):
|
|
engine = TurnEngine(
|
|
provider=CompactingProvider([AssistantTurn(text="done", finish_reason="stop")]),
|
|
registry=ToolRegistry(),
|
|
permissions=PermissionEngine(workspace_root=tmp_path),
|
|
model="gpt-5.5",
|
|
messages=_history(),
|
|
)
|
|
engine.compaction_settings = lambda: {"cap_tokens": 400, "threshold_pct": 0.8, "context_window": 100_000}
|
|
_collect(engine)
|
|
state = engine.compaction_state
|
|
assert state is not None and state.transcript_path == ""
|
|
assert "saved at" not in engine._outbound_messages()[1]["content"]
|
|
|
|
|
|
def test_compaction_cap_config_and_env(tmp_path, monkeypatch):
|
|
(tmp_path / ".coworker").mkdir()
|
|
(tmp_path / ".coworker" / "config.toml").write_text("compaction_cap_tokens = 60000\n", encoding="utf-8")
|
|
monkeypatch.delenv(COMPACTION_CAP_TOKENS_ENV, raising=False)
|
|
cfg = load_config(tmp_path, global_path=tmp_path / "no-global.toml")
|
|
assert cfg.compaction_cap_tokens == 60000 # a workspace config.toml may set it
|
|
monkeypatch.setenv(COMPACTION_CAP_TOKENS_ENV, "80000")
|
|
cfg = load_config(tmp_path, global_path=tmp_path / "no-global.toml")
|
|
assert cfg.compaction_cap_tokens == 80000
|
|
monkeypatch.setenv(COMPACTION_CAP_TOKENS_ENV, "0")
|
|
try:
|
|
load_config(tmp_path, global_path=tmp_path / "no-global.toml")
|
|
except ValueError as exc:
|
|
assert "compaction_cap_tokens" in str(exc)
|
|
else:
|
|
raise AssertionError("a zero cap must be rejected")
|
|
|
|
|
|
def test_summary_budget_config_and_env(tmp_path, monkeypatch):
|
|
"""OPE-189: the summariser's output ceiling is a setting, not a constant — on a
|
|
reasoning model it is shared with the model's thinking."""
|
|
from coworker.config import COMPACTION_SUMMARY_MAX_TOKENS_ENV
|
|
|
|
monkeypatch.delenv(COMPACTION_SUMMARY_MAX_TOKENS_ENV, raising=False)
|
|
(tmp_path / ".coworker").mkdir()
|
|
cfg_file = tmp_path / ".coworker" / "config.toml"
|
|
cfg_file.write_text("compaction_summary_max_tokens = 24000\n", encoding="utf-8")
|
|
cfg = load_config(tmp_path, global_path=tmp_path / "no-global.toml")
|
|
assert cfg.compaction_summary_max_tokens == 24000
|
|
monkeypatch.setenv(COMPACTION_SUMMARY_MAX_TOKENS_ENV, "9000")
|
|
cfg = load_config(tmp_path, global_path=tmp_path / "no-global.toml")
|
|
assert cfg.compaction_summary_max_tokens == 9000 # env wins
|
|
monkeypatch.setenv(COMPACTION_SUMMARY_MAX_TOKENS_ENV, "0")
|
|
try:
|
|
load_config(tmp_path, global_path=tmp_path / "no-global.toml")
|
|
except ValueError as exc:
|
|
assert "compaction_summary_max_tokens" in str(exc)
|
|
else:
|
|
raise AssertionError("a zero budget must be rejected")
|
|
|
|
|
|
def test_engine_passes_the_configured_budgets_to_the_summarizer(tmp_path):
|
|
"""The trigger drives both derived budgets, and the summariser call gets the ceiling."""
|
|
seen: dict = {}
|
|
|
|
class Recorder(CompactingProvider):
|
|
def complete(self, *, model, messages, tools=None, **settings):
|
|
seen.update(settings)
|
|
return super().complete(model=model, messages=messages, tools=tools, **settings)
|
|
|
|
engine = TurnEngine(
|
|
provider=Recorder([AssistantTurn(text="done", finish_reason="stop")]),
|
|
registry=ToolRegistry(),
|
|
permissions=PermissionEngine(workspace_root=tmp_path),
|
|
model="gpt-5.5",
|
|
messages=_history(),
|
|
)
|
|
engine.compaction_settings = lambda: {
|
|
"cap_tokens": 400,
|
|
"threshold_pct": 0.8,
|
|
"context_window": 100_000,
|
|
"summary_max_tokens": 7_777,
|
|
}
|
|
_collect(engine)
|
|
assert seen.get("max_tokens") == 7_777
|
|
assert C.user_message_budget(400) == C._USER_BUDGET_MIN # floor at a tiny trigger
|
|
|
|
|
|
def test_render_transcript_is_readable_and_skips_system():
|
|
text = C.render_transcript(_history(turns=2), upto=7)
|
|
assert text.startswith("# Compacted transcript")
|
|
assert "be helpful" not in text # system prompt is not part of the compacted span
|
|
assert "## [1] user" in text and "## [2] assistant" in text and "## [3] tool" in text
|