mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 13:00:37 +00:00
compaction: Settings overrides + GUI divider (OPE-27 3/4)
Settings -> Models grows a Context compaction card next to Token savings: the trigger % of the context window (10-95), the absolute token cap (clamped 10k-2M), and the summarizer-model pin (default: the session's own model). POST /v1/settings/compaction persists them; engines read the knobs live per check, so changes apply to running sessions immediately. The "context compacted" divider rides the existing notice machinery: the persisted `compacted` notice replays on reload (itemsFromMessages) and the live COMPACTED event appends the same info notice mid-turn. The transcript itself stays intact - outbound-only by construction. Covered by vitest (marker replay), a settings-card e2e (defaults + clamped POSTs + model pin), and a mid-session divider e2e driven by the fixtures' scripted `compacted` event.
This commit is contained in:
@@ -1378,6 +1378,17 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
max_mb=b.get("pdf_max_mb"),
|
||||
)
|
||||
|
||||
@app.post("/v1/settings/compaction")
|
||||
def settings_set_compaction(body: dict) -> dict[str, Any]:
|
||||
# Auto-compaction overrides (OPE-27): threshold % of the context window, the
|
||||
# absolute token cap, and the summarizer-model pin ("" → session's own model).
|
||||
b = body or {}
|
||||
return manager.set_compaction_settings(
|
||||
threshold_pct=b.get("compaction_threshold_pct"),
|
||||
cap_tokens=b.get("compaction_cap_tokens"),
|
||||
model=b.get("compaction_model"),
|
||||
)
|
||||
|
||||
@app.post("/v1/attachments/inspect-pdf")
|
||||
def attachments_inspect_pdf(body: dict) -> dict[str, Any]:
|
||||
# Attach-time page/size probe for the composer's threshold check. Local only.
|
||||
|
||||
@@ -1791,6 +1791,7 @@ class SessionManager:
|
||||
# hardcoded POSIX one (Windows -> %APPDATA%\coworker, macOS/Linux -> ~/.config).
|
||||
"secrets_path": str(self.secrets.path),
|
||||
**self.pdf_settings(),
|
||||
**self.compaction_settings_payload(),
|
||||
}
|
||||
|
||||
def _surfaces(self) -> dict[str, bool]:
|
||||
@@ -1884,6 +1885,48 @@ class SessionManager:
|
||||
"model": str(self._prefs.get("compaction_model") or ""),
|
||||
}
|
||||
|
||||
def compaction_settings_payload(self) -> dict[str, Any]:
|
||||
"""The same knobs under REST-facing names (prefixed to keep /v1/settings flat)."""
|
||||
settings = self.compaction_settings()
|
||||
return {
|
||||
"compaction_threshold_pct": settings["threshold_pct"],
|
||||
"compaction_cap_tokens": settings["cap_tokens"],
|
||||
"compaction_model": settings["model"],
|
||||
}
|
||||
|
||||
def set_compaction_settings(
|
||||
self,
|
||||
threshold_pct: Any = None,
|
||||
cap_tokens: Any = None,
|
||||
model: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Persist the auto-compaction overrides (OPE-27). Threshold is a percentage of
|
||||
the model's context window (10–95); the cap is an absolute token ceiling; model
|
||||
pins the summarizer ('' → the session's own model). Engines read these live via
|
||||
`compaction_settings()`, so changes apply to running sessions immediately."""
|
||||
if threshold_pct is not None:
|
||||
try:
|
||||
pct = float(threshold_pct)
|
||||
except (TypeError, ValueError):
|
||||
return {"ok": False, "error": "compaction_threshold_pct must be a number"}
|
||||
if not 0.10 <= pct <= 0.95:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "compaction_threshold_pct must be between 0.10 and 0.95",
|
||||
}
|
||||
self._prefs["compaction_threshold_pct"] = pct
|
||||
if cap_tokens is not None:
|
||||
try:
|
||||
self._prefs["compaction_cap_tokens"] = max(
|
||||
10_000, min(int(cap_tokens), 2_000_000)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return {"ok": False, "error": "compaction_cap_tokens must be a number"}
|
||||
if model is not None:
|
||||
self._prefs["compaction_model"] = str(model)
|
||||
self._save_prefs()
|
||||
return {"ok": True, **self.compaction_settings()}
|
||||
|
||||
def set_pdf_settings(
|
||||
self,
|
||||
fallback: Any = None,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// OPE-27 — auto-compaction GUI: the Settings card's two overrides + summarizer-model
|
||||
// pin POST through, and the "context compacted" divider renders inline mid-session
|
||||
// (driven by the fixtures' scripted `compacted` event) without touching the transcript.
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
|
||||
test("Settings: Context compaction card edits threshold, cap, and summarizer model", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("account-row").click();
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Models", exact: true }).click();
|
||||
|
||||
const card = page.getByTestId("compaction-card");
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.getByText("Context compaction")).toBeVisible();
|
||||
|
||||
// Defaults render when the backend doesn't send the fields (older-backend robustness).
|
||||
await expect(card.getByTestId("compaction-threshold")).toHaveValue("80");
|
||||
await expect(card.getByTestId("compaction-cap")).toHaveValue("250000");
|
||||
await expect(card.getByTestId("compaction-model")).toHaveValue("");
|
||||
|
||||
// Threshold edits POST as a fraction, clamped to 10–95%.
|
||||
const [req] = await Promise.all([
|
||||
page.waitForRequest(
|
||||
(r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST",
|
||||
),
|
||||
card.getByTestId("compaction-threshold").fill("70"),
|
||||
]);
|
||||
expect(req.postDataJSON()).toEqual({ compaction_threshold_pct: 0.7 });
|
||||
|
||||
const [req2] = await Promise.all([
|
||||
page.waitForRequest(
|
||||
(r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST",
|
||||
),
|
||||
card.getByTestId("compaction-cap").fill("100000"),
|
||||
]);
|
||||
expect(req2.postDataJSON()).toEqual({ compaction_cap_tokens: 100000 });
|
||||
|
||||
// Summarizer pin: the picker offers the session-default plus the configured models.
|
||||
const [req3] = await Promise.all([
|
||||
page.waitForRequest(
|
||||
(r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST",
|
||||
),
|
||||
card.getByTestId("compaction-model").selectOption("gpt-4o-mini"),
|
||||
]);
|
||||
expect(req3.postDataJSON()).toEqual({ compaction_model: "gpt-4o-mini" });
|
||||
});
|
||||
|
||||
test("the compacted divider renders mid-session and the transcript stays intact", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByText("Draft the launch note").first().click();
|
||||
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||
|
||||
// An earlier exchange that must survive the compaction marker (transcript intact).
|
||||
await box.fill("remember the launch date");
|
||||
await box.press("Enter");
|
||||
await expect(page.getByText("Echo: remember the launch date").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await box.fill("compact the context");
|
||||
await box.press("Enter");
|
||||
await expect(
|
||||
page.getByText("Context compacted — earlier turns were summarized").first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page.getByText("Still on it — continuing where I left off.").first(),
|
||||
).toBeVisible();
|
||||
// Outbound-only: everything before the divider is still on screen.
|
||||
await expect(page.getByText("Echo: remember the launch date").first()).toBeVisible();
|
||||
});
|
||||
@@ -681,6 +681,14 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
}, 120);
|
||||
return;
|
||||
}
|
||||
// Auto-compaction (OPE-27): the server compacts mid-run and emits the marker,
|
||||
// then the turn continues normally — the divider must render inline.
|
||||
if (/compact the context/i.test(msg.text)) {
|
||||
send("compacted", { text: "Context compacted — earlier turns were summarized" });
|
||||
send("assistant_message", { text: "Still on it — continuing where I left off." });
|
||||
send("turn_done");
|
||||
return;
|
||||
}
|
||||
// A turn that dies on a provider error; the follow-up {type:"retry"} recovers.
|
||||
if (/fail the turn/i.test(msg.text)) {
|
||||
send("error", { error: "model unreachable" });
|
||||
|
||||
@@ -709,6 +709,11 @@ export function App() {
|
||||
if (d.model) setModel(d.model);
|
||||
setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || "Model switched" }]);
|
||||
break;
|
||||
case "compacted":
|
||||
// Auto-compaction marker (OPE-27): outbound-only — the transcript stays intact,
|
||||
// this divider just shows where the model's memory was summarized.
|
||||
setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || "Context compacted" }]);
|
||||
break;
|
||||
case "interrupted":
|
||||
flushPartialStream();
|
||||
setItems((p) => [...p, { kind: "notice", tone: "warn", text: "Interrupted." }]);
|
||||
|
||||
@@ -702,6 +702,12 @@ export interface ModelSettings {
|
||||
pdf_fallback?: "text" | "images";
|
||||
pdf_max_pages?: number; // default 20, 1–100
|
||||
pdf_max_mb?: number; // default 10, 1–10
|
||||
// Auto-compaction of long histories (OPE-27): trigger = min(threshold% × context
|
||||
// window, cap tokens); model pins the summarizer ("" → the session's own model).
|
||||
// Optional so the GUI is robust to an older backend.
|
||||
compaction_threshold_pct?: number; // default 0.8, 0.10–0.95
|
||||
compaction_cap_tokens?: number; // default 250000
|
||||
compaction_model?: string;
|
||||
}
|
||||
|
||||
export interface PdfSettings {
|
||||
@@ -722,6 +728,24 @@ export async function setPdfSettings(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface CompactionSettings {
|
||||
compaction_threshold_pct: number;
|
||||
compaction_cap_tokens: number;
|
||||
compaction_model: string;
|
||||
}
|
||||
|
||||
/** Persist the auto-compaction overrides (threshold %, token cap, summarizer model). */
|
||||
export async function setCompactionSettings(
|
||||
patch: Partial<CompactionSettings>,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const res = await fetch(`${httpBase()}/v1/settings/compaction`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Local page/size probe for a PDF data URL — the composer's attach-time threshold check. */
|
||||
export async function inspectPdf(
|
||||
dataUrl: string,
|
||||
|
||||
@@ -2,11 +2,13 @@ import { useEffect, useState } from "react";
|
||||
import {
|
||||
getSettings,
|
||||
getTrustedWorkspaces,
|
||||
setCompactionSettings,
|
||||
setOnboarded,
|
||||
setPdfSettings,
|
||||
setScratchBase,
|
||||
setSessionsPeek,
|
||||
setWorkspaceTrusted,
|
||||
type CompactionSettings,
|
||||
type ModelSettings,
|
||||
type PdfSettings,
|
||||
type WorkspaceCommandTrust,
|
||||
@@ -118,6 +120,7 @@ export function SettingsView({
|
||||
not under General. */}
|
||||
<div className="mt-6">
|
||||
<TokenSavingsCard />
|
||||
<CompactionCard />
|
||||
</div>
|
||||
</section>
|
||||
) : tab === "voice" ? (
|
||||
@@ -584,9 +587,9 @@ function UpdateInline() {
|
||||
// -- Sidebar density -------------------------------------------------------------
|
||||
// -- Token savings (PDF attachments; owner ask, 2026-07-17) ---------------------
|
||||
// Attachments replay with EVERY turn, so a big PDF quietly multiplies token spend.
|
||||
// Auto-compaction of long histories is a planned follow-up (punchlist §7) — until
|
||||
// then this card is the user's dial: attach thresholds + the fallback for models
|
||||
// without native PDF support.
|
||||
// This card is the attachment dial: attach thresholds + the fallback for models
|
||||
// without native PDF support. (Long-history spend is handled by auto-compaction —
|
||||
// the CompactionCard below, OPE-27.)
|
||||
function TokenSavingsCard() {
|
||||
const [pdf, setPdf] = useState<PdfSettings | null>(null);
|
||||
|
||||
@@ -672,6 +675,121 @@ function TokenSavingsCard() {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Context compaction (OPE-27) ------------------------------------------------
|
||||
// Long sessions are summarized automatically when they approach the model's context
|
||||
// limit, so work continues instead of hitting a raw provider error. Two spec'd
|
||||
// overrides (trigger % + token cap) and the summarizer-model pin — nothing more.
|
||||
function CompactionCard() {
|
||||
const [cfg, setCfg] = useState<CompactionSettings | null>(null);
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [labels, setLabels] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => {
|
||||
setCfg({
|
||||
compaction_threshold_pct: s.compaction_threshold_pct ?? 0.8,
|
||||
compaction_cap_tokens: s.compaction_cap_tokens ?? 250_000,
|
||||
compaction_model: s.compaction_model ?? "",
|
||||
});
|
||||
setModels(s.models || []);
|
||||
setLabels(s.model_labels || {});
|
||||
})
|
||||
.catch(() =>
|
||||
setCfg({
|
||||
compaction_threshold_pct: 0.8,
|
||||
compaction_cap_tokens: 250_000,
|
||||
compaction_model: "",
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const save = async (patch: Partial<CompactionSettings>) => {
|
||||
setCfg((p) => (p ? { ...p, ...patch } : p));
|
||||
await setCompactionSettings(patch);
|
||||
};
|
||||
|
||||
if (!cfg) return null;
|
||||
const modelLabel = (id: string) => labels[id]?.split(" · ")[0] || id;
|
||||
return (
|
||||
<div className={CARD + " p-4 mb-4"} data-testid="compaction-card">
|
||||
<div className={FIELD_LABEL}>Context compaction</div>
|
||||
<div className={FIELD_HELP}>
|
||||
Long sessions are compacted automatically: older turns are summarized so the
|
||||
coworker keeps working instead of running out of context. Your visible transcript
|
||||
is never changed — a small marker shows where compaction happened.
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-5 flex-wrap">
|
||||
<label className="flex items-center gap-2.5">
|
||||
<span className="text-[13px] text-ink">Compact at</span>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
max={95}
|
||||
value={Math.round(cfg.compaction_threshold_pct * 100)}
|
||||
data-testid="compaction-threshold"
|
||||
className="w-16 px-2 py-1.5 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent"
|
||||
onChange={(e) =>
|
||||
save({
|
||||
compaction_threshold_pct:
|
||||
Math.max(10, Math.min(Number(e.target.value) || 80, 95)) / 100,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-[12.5px] text-muted">% of the context window</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5">
|
||||
<span className="text-[13px] text-ink">or at</span>
|
||||
<input
|
||||
type="number"
|
||||
min={10_000}
|
||||
max={2_000_000}
|
||||
step={10_000}
|
||||
value={cfg.compaction_cap_tokens}
|
||||
data-testid="compaction-cap"
|
||||
className="w-28 px-2 py-1.5 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent"
|
||||
onChange={(e) =>
|
||||
save({
|
||||
compaction_cap_tokens: Math.max(
|
||||
10_000,
|
||||
Math.min(Number(e.target.value) || 250_000, 2_000_000),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-[12.5px] text-muted">tokens, whichever is smaller</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className={FIELD_HELP}>
|
||||
The cap makes very-large-context models compact early — quality and speed degrade
|
||||
well before their nominal limit.
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2.5">
|
||||
<span className="text-[13px] text-ink">Summarizer model</span>
|
||||
<select
|
||||
value={cfg.compaction_model}
|
||||
data-testid="compaction-model"
|
||||
className="px-2 py-1.5 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent"
|
||||
onChange={(e) => save({ compaction_model: e.target.value })}
|
||||
>
|
||||
<option value="">Session’s own model (default)</option>
|
||||
{models.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{modelLabel(m)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className={FIELD_HELP}>
|
||||
The summary is written by this model. The default follows whatever model the
|
||||
session is using.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarCard() {
|
||||
const [peek, setPeek] = useState<number | null>(null);
|
||||
|
||||
|
||||
@@ -83,6 +83,20 @@ describe("itemsFromMessages model switch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("itemsFromMessages compaction", () => {
|
||||
it("replays the persisted compacted marker as an info notice (the divider)", () => {
|
||||
const items = itemsFromMessages([
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "notice", kind: "compacted", text: "Context compacted — earlier turns were summarized" },
|
||||
] as any);
|
||||
expect(items[1]).toEqual({
|
||||
kind: "notice",
|
||||
tone: "info",
|
||||
text: "Context compacted — earlier turns were summarized",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("itemsFromMessages reasoning", () => {
|
||||
it("attaches the reasoning sidecar to assistant items; thinking-only messages still render", () => {
|
||||
const items = itemsFromMessages([
|
||||
|
||||
@@ -72,7 +72,10 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] {
|
||||
? { kind: "notice", tone: "warn", text: "Interrupted." }
|
||||
: m.kind === "model_switch"
|
||||
? { kind: "notice", tone: "info", text: m.text || "Model switched" }
|
||||
: { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true },
|
||||
: m.kind === "compacted"
|
||||
? // The subtle "compacted here" divider (OPE-27) — the transcript itself is intact.
|
||||
{ kind: "notice", tone: "info", text: m.text || "Context compacted" }
|
||||
: { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true },
|
||||
);
|
||||
}
|
||||
// system messages are omitted; tool-result messages are folded into the tool row above
|
||||
|
||||
@@ -18,6 +18,7 @@ export type EventType =
|
||||
| "input_rejected"
|
||||
| "interrupted"
|
||||
| "model_changed"
|
||||
| "compacted"
|
||||
| "turn_done";
|
||||
|
||||
export interface WsEvent {
|
||||
|
||||
@@ -204,6 +204,32 @@ def test_non_overflow_provider_errors_still_surface(tmp_path):
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user