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:
+73
-2
@@ -19,9 +19,11 @@ from lembas.db.session import session_scope
|
||||
from lembas.security import permissions
|
||||
from lembas.services import audio as audio_service
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import compaction as compaction_service
|
||||
from lembas.services import files as files_service
|
||||
from lembas.services import generation as generation_service
|
||||
from lembas.services import metrics as metrics_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import sse
|
||||
from lembas.services import tools as tools_service
|
||||
from lembas.services.markdown import escape_text, render_markdown
|
||||
@@ -202,6 +204,64 @@ def _pretty(payload: dict) -> str:
|
||||
return text
|
||||
|
||||
|
||||
@router.post("/{chat_id}/compact")
|
||||
async def compact_chat(request: Request, db: Db, user: RequiredUser, chat_id: str) -> Response:
|
||||
"""Summarise the earlier turns and stop sending them.
|
||||
|
||||
No permission of its own: compaction changes only what one chat sends
|
||||
upstream, and gating it would mean answering "why can this user not tidy
|
||||
their own conversation".
|
||||
"""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
|
||||
unfinished = db.scalar(
|
||||
select(Message).where(Message.chat_id == chat.id, Message.complete.is_(False))
|
||||
)
|
||||
if unfinished is not None:
|
||||
# Summarising a transcript that is still being written races
|
||||
# build_request. Queuing it is a state machine nobody asked for.
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, then compact."
|
||||
)
|
||||
|
||||
template = prompts_service.resolve(db, "task.compact")
|
||||
if not template.strip():
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"Compaction is turned off: its prompt is empty under Admin → Prompts.",
|
||||
)
|
||||
|
||||
upto = compaction_service.last_complete(db, chat)
|
||||
if upto is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "There is nothing here to summarise yet."
|
||||
)
|
||||
|
||||
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
|
||||
transcript = compaction_service.transcript(db, chat, upto=upto)
|
||||
previous = compaction_service.previous_summary_block(chat)
|
||||
|
||||
summary = await chat_service.summarise_for_compaction(
|
||||
endpoint,
|
||||
model_id,
|
||||
transcript=transcript,
|
||||
previous_summary=previous,
|
||||
template=template,
|
||||
)
|
||||
if not summary:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "The model returned no summary, so nothing changed."
|
||||
)
|
||||
|
||||
compaction_service.apply(chat, summary=summary, upto=upto)
|
||||
db.commit()
|
||||
log.info("chat %s compacted through %s", chat.id, upto.id)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request, "chat/_thread.html", {"request": request, **_thread_context(db, chat, user)}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{chat_id}/keep")
|
||||
async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
|
||||
"""Stop a temporary chat being temporary.
|
||||
@@ -380,6 +440,7 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
||||
if generation.content:
|
||||
yield sse.event("render", render_markdown(generation.text))
|
||||
yield sse.event("metrics", _metrics_html(generation))
|
||||
yield sse.event("status", escape_text(generation.status))
|
||||
last_frame = time.monotonic()
|
||||
|
||||
if generation.done:
|
||||
@@ -451,16 +512,18 @@ def _metrics_html(generation) -> str:
|
||||
|
||||
def _thread_context(db: DBSession, chat: Chat, user: User) -> dict:
|
||||
"""Everything chat/_thread.html needs to render the conversation."""
|
||||
messages = list(
|
||||
everything = list(
|
||||
db.scalars(select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at))
|
||||
)
|
||||
compacted, messages = compaction_service.split(db, chat, everything)
|
||||
return {
|
||||
"chat": chat,
|
||||
"user": user,
|
||||
"messages": messages,
|
||||
"compacted": compacted,
|
||||
"bodies": {
|
||||
m.id: render_markdown(m.content)
|
||||
for m in messages
|
||||
for m in everything
|
||||
if m.role == ROLE_ASSISTANT and m.content
|
||||
},
|
||||
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, user)},
|
||||
@@ -556,6 +619,14 @@ async def edit_message(
|
||||
discarded = _messages_after(db, message)
|
||||
for later in discarded:
|
||||
db.delete(later)
|
||||
|
||||
# A rewind to at or before the compaction boundary leaves that boundary
|
||||
# describing turns that no longer exist. There is no foreign key to null it
|
||||
# out on an upgraded database, so it is cleared here.
|
||||
cutoff = compaction_service.cutoff_message(db, chat)
|
||||
if cutoff is None or compaction_service.moment(message) <= compaction_service.moment(cutoff):
|
||||
compaction_service.reset(chat)
|
||||
|
||||
db.commit()
|
||||
|
||||
assistant = chat_service.create_message(
|
||||
|
||||
Reference in New Issue
Block a user