Compaction: a button, and automatically when the window fills
A long conversation eventually just stops working. Compaction summarises the earlier turns and sends the summary in their place. The messages are kept. They stay in the transcript behind a collapsed divider and simply stop being part of the request, which is what makes the button safe to press and automatic compaction safe to have at all: a summary that came out badly is a bad turn, not a lost conversation. Stored on the Chat, not as a synthetic Message. A synthetic row needs a role -- `system` breaks the one-system-message rule the moment build_messages emits it beside the harness, and user/assistant makes it a turn people can edit, regenerate from and copy, indistinguishable from a real one in all four places a bubble is rendered. Worse, "editing rewinds, it does not branch" would silently delete it and leave no marker that compaction had happened at all. The summary goes out as a user turn and an assistant turn, not one. A leading assistant breaks templates requiring the first non-system message to be user; a lone leading user produces user, user whenever the kept history starts on a user turn -- which it always does, because the cutoff lands on a finished reply. compacted_through_id is a plain id rather than a foreign key: migrations.py compiles only the column type, so a REFERENCES clause would exist on a fresh database and not on an upgraded one, and a constraint half the fleet has is worse than none. cutoff_message validates it on every read instead, and a rewind past the boundary clears it. Compacting again summarises only the delta, with the previous summary supplied to be subsumed. Re-summarising the whole chat each time grows quadratically and eventually exceeds the window it is protecting. Automatically at the top of _run, not in post_message: that route's contract is to return immediately and leave the slow part to a resumable connection, and it also means build_request is called once, after compaction, with no second assembly path. The trigger is the last reply's recorded usage plus an estimate of the new turn -- retrospective because true prompt_tokens are only knowable after a response, plus the delta because otherwise fifty thousand characters pasted into the composer overflow a window that read 90% last turn. It never fires when the context length is unknown. It does fire on estimated counts, which is safe here precisely because nothing is lost. _maybe_compact never raises: a failure logs and sends the uncompacted request. A `status` event says "Summarising earlier messages…" in the meantime, because a silent multi-second pause before the first token is what a hang looks like. The wording is three fragments under Admin - Prompts. Clearing task.compact turns compaction off entirely. Also adds compaction.moment(): SQLite does not store the offset, so a row loaded from disk is naive while one in the session's identity map keeps its tzinfo, and comparing the two raises. Every comparison here is between exactly those. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -174,11 +174,31 @@ def build_messages(
|
||||
resolved, which is how the harness gets in front of the authored prompt
|
||||
without this function knowing anything about tools.
|
||||
"""
|
||||
from lembas.services import compaction as compaction_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
|
||||
payload: list[dict[str, Any]] = []
|
||||
system = effective_system_prompt(db, chat) if system_prompt is None else system_prompt
|
||||
if system:
|
||||
payload.append({"role": ROLE_SYSTEM, "content": system})
|
||||
|
||||
# Compacted turns are replaced by a summary carried in two turns rather than
|
||||
# one. A leading `assistant` breaks templates that require the first
|
||||
# non-system message to be `user`; a lone leading `user` produces user, user
|
||||
# whenever the kept history starts on a user turn -- which it always does,
|
||||
# because the cutoff lands on a finished reply. The pair alternates
|
||||
# correctly in both directions and keeps exactly one system message.
|
||||
cutoff = compaction_service.cutoff_message(db, chat)
|
||||
if cutoff is not None:
|
||||
lead = prompts_service.resolve(db, "task.compact_lead").strip()
|
||||
ack = prompts_service.resolve(db, "task.compact_ack").strip()
|
||||
summary = chat.compact_summary.strip()
|
||||
payload.append(
|
||||
{"role": ROLE_USER, "content": f"{lead}\n\n{summary}" if lead else summary}
|
||||
)
|
||||
if ack:
|
||||
payload.append({"role": ROLE_ASSISTANT, "content": ack})
|
||||
|
||||
history = db.scalars(
|
||||
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
|
||||
).all()
|
||||
@@ -186,6 +206,10 @@ def build_messages(
|
||||
for message in history:
|
||||
if upto is not None and message.id == upto.id:
|
||||
break
|
||||
if cutoff is not None and compaction_service.moment(
|
||||
message
|
||||
) <= compaction_service.moment(cutoff):
|
||||
continue
|
||||
# Skip turns that failed or produced nothing -- but a message carrying
|
||||
# only an attachment has no text and must still be sent.
|
||||
if message.error:
|
||||
@@ -391,6 +415,42 @@ def create_message(
|
||||
return message
|
||||
|
||||
|
||||
async def summarise_for_compaction(
|
||||
endpoint: Endpoint,
|
||||
model_id: str,
|
||||
*,
|
||||
transcript: str,
|
||||
previous_summary: str,
|
||||
template: str,
|
||||
) -> str:
|
||||
"""Ask the model to summarise the earlier turns.
|
||||
|
||||
`template` is passed in for the same reason `generate_title`'s is: this runs
|
||||
after the generation's session has closed, and opening another one there is
|
||||
how you get a session that outlives its scope. An empty template means an
|
||||
administrator cleared the fragment, and nothing is asked of anyone.
|
||||
"""
|
||||
from lembas.services import prompts as prompts_service
|
||||
|
||||
if not template.strip() or not transcript.strip():
|
||||
return ""
|
||||
|
||||
prompt = prompts_service.substitute(
|
||||
template, {"transcript": transcript, "previous_summary": previous_summary}
|
||||
)
|
||||
raw = await complete(
|
||||
endpoint,
|
||||
{
|
||||
"model": model_id,
|
||||
"messages": [{"role": ROLE_USER, "content": prompt}],
|
||||
"max_tokens": 1200,
|
||||
# Low, but not zero: this is recall, not invention.
|
||||
"temperature": 0.3,
|
||||
},
|
||||
)
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def sweep_temporary(db: DBSession, older_than: timedelta = TEMPORARY_LIFETIME) -> int:
|
||||
"""Delete temporary chats nobody has touched for a day.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user