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:
Jaroslav Beneš
2026-08-01 01:02:02 +02:00
parent aa0bbe524a
commit 3b1632069c
15 changed files with 1009 additions and 9 deletions
+7
View File
@@ -58,6 +58,7 @@ async def save_general(
instance_name: str = Form("LLeMbas"), instance_name: str = Form("LLeMbas"),
allow_signup: bool = Form(False), allow_signup: bool = Form(False),
system_prompt: str = Form(""), system_prompt: str = Form(""),
compact_threshold: int = Form(95),
) -> Response: ) -> Response:
"""Save instance settings. """Save instance settings.
@@ -70,6 +71,12 @@ async def save_general(
"instance_name": instance_name.strip()[:120] or "LLeMbas", "instance_name": instance_name.strip()[:120] or "LLeMbas",
"allow_signup": allow_signup, "allow_signup": allow_signup,
"system_prompt": system_prompt.strip()[:8000], "system_prompt": system_prompt.strip()[:8000],
# 0 is "never"; anything else is clamped into a band where it can
# do some good. 100 is useless -- you cannot compact after
# overflowing -- and below 50 it fires while there is plenty left.
"compact_threshold": (
0 if compact_threshold <= 0 else min(max(compact_threshold, 50), 99)
),
}, },
) )
log.info("registration %s by %s", "opened" if allow_signup else "closed", user.email) log.info("registration %s by %s", "opened" if allow_signup else "closed", user.email)
+73 -2
View File
@@ -19,9 +19,11 @@ from lembas.db.session import session_scope
from lembas.security import permissions from lembas.security import permissions
from lembas.services import audio as audio_service from lembas.services import audio as audio_service
from lembas.services import chat as chat_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 files as files_service
from lembas.services import generation as generation_service from lembas.services import generation as generation_service
from lembas.services import metrics as metrics_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 sse
from lembas.services import tools as tools_service from lembas.services import tools as tools_service
from lembas.services.markdown import escape_text, render_markdown from lembas.services.markdown import escape_text, render_markdown
@@ -202,6 +204,64 @@ def _pretty(payload: dict) -> str:
return text 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") @router.post("/{chat_id}/keep")
async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response: async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
"""Stop a temporary chat being temporary. """Stop a temporary chat being temporary.
@@ -380,6 +440,7 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
if generation.content: if generation.content:
yield sse.event("render", render_markdown(generation.text)) yield sse.event("render", render_markdown(generation.text))
yield sse.event("metrics", _metrics_html(generation)) yield sse.event("metrics", _metrics_html(generation))
yield sse.event("status", escape_text(generation.status))
last_frame = time.monotonic() last_frame = time.monotonic()
if generation.done: if generation.done:
@@ -451,16 +512,18 @@ def _metrics_html(generation) -> str:
def _thread_context(db: DBSession, chat: Chat, user: User) -> dict: def _thread_context(db: DBSession, chat: Chat, user: User) -> dict:
"""Everything chat/_thread.html needs to render the conversation.""" """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)) db.scalars(select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at))
) )
compacted, messages = compaction_service.split(db, chat, everything)
return { return {
"chat": chat, "chat": chat,
"user": user, "user": user,
"messages": messages, "messages": messages,
"compacted": compacted,
"bodies": { "bodies": {
m.id: render_markdown(m.content) m.id: render_markdown(m.content)
for m in messages for m in everything
if m.role == ROLE_ASSISTANT and m.content if m.role == ROLE_ASSISTANT and m.content
}, },
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, user)}, "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) discarded = _messages_after(db, message)
for later in discarded: for later in discarded:
db.delete(later) 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() db.commit()
assistant = chat_service.create_message( assistant = chat_service.create_message(
+7 -2
View File
@@ -12,6 +12,7 @@ from lembas.db.models import Chat, Folder, KnowledgeBase, Message, User
from lembas.security import permissions from lembas.security import permissions
from lembas.services import audio as audio_service from lembas.services import audio as audio_service
from lembas.services import chat as chat_service from lembas.services import chat as chat_service
from lembas.services import compaction as compaction_service
from lembas.services import settings_store from lembas.services import settings_store
from lembas.services import suggestions as suggestions_service from lembas.services import suggestions as suggestions_service
from lembas.services.library import documents as documents_service from lembas.services.library import documents as documents_service
@@ -220,18 +221,21 @@ async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str
chat.unread_notified = False chat.unread_notified = False
db.commit() db.commit()
messages = list( everything = list(
db.scalars( db.scalars(
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at) select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
) )
) )
# Summarised turns are kept and still rendered, behind a divider -- they
# have only stopped being part of the request.
compacted, messages = compaction_service.split(db, chat, everything)
# Markdown is rendered once here rather than in the template so the same # Markdown is rendered once here rather than in the template so the same
# helper produces the page and the streamed final frame -- one code path, # helper produces the page and the streamed final frame -- one code path,
# no chance of the two disagreeing. # no chance of the two disagreeing.
bodies = { bodies = {
message.id: render_markdown(message.content) message.id: render_markdown(message.content)
for message in messages for message in everything
if message.role == "assistant" and message.content if message.role == "assistant" and message.content
} }
@@ -255,6 +259,7 @@ async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str
{ {
"chat": chat, "chat": chat,
"messages": messages, "messages": messages,
"compacted": compacted,
"bodies": bodies, "bodies": bodies,
"inherited_prompt": inherited, "inherited_prompt": inherited,
"inherited_from": inherited_from, "inherited_from": inherited_from,
+15 -1
View File
@@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
@@ -108,6 +109,19 @@ class Chat(UUIDPrimaryKey, Timestamps, Base):
unread: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) unread: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
unread_notified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) unread_notified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# --- Compaction ----------------------------------------------------------
# A summary of the turns up to `compacted_through_id`, sent in their place.
# The messages themselves are kept and still shown; they simply stop being
# part of the request. See services/compaction.py.
compact_summary: Mapped[str] = mapped_column(Text, default="")
# A plain id, deliberately not a ForeignKey: db/migrations.py compiles only
# the column type, so a REFERENCES clause would exist on a freshly created
# database and not on an upgraded one, and a constraint half the fleet has
# is worse than none. It is validated on every read instead -- the same
# reasoning `model_id` above carries.
compacted_through_id: Mapped[str | None] = mapped_column(String(32))
compacted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
folder: Mapped[Folder | None] = relationship(back_populates="chats") folder: Mapped[Folder | None] = relationship(back_populates="chats")
messages: Mapped[list[Message]] = relationship( messages: Mapped[list[Message]] = relationship(
back_populates="chat", back_populates="chat",
+60
View File
@@ -174,11 +174,31 @@ def build_messages(
resolved, which is how the harness gets in front of the authored prompt resolved, which is how the harness gets in front of the authored prompt
without this function knowing anything about tools. 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]] = [] payload: list[dict[str, Any]] = []
system = effective_system_prompt(db, chat) if system_prompt is None else system_prompt system = effective_system_prompt(db, chat) if system_prompt is None else system_prompt
if system: if system:
payload.append({"role": ROLE_SYSTEM, "content": 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( history = db.scalars(
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at) select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
).all() ).all()
@@ -186,6 +206,10 @@ def build_messages(
for message in history: for message in history:
if upto is not None and message.id == upto.id: if upto is not None and message.id == upto.id:
break 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 # Skip turns that failed or produced nothing -- but a message carrying
# only an attachment has no text and must still be sent. # only an attachment has no text and must still be sent.
if message.error: if message.error:
@@ -391,6 +415,42 @@ def create_message(
return 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: def sweep_temporary(db: DBSession, older_than: timedelta = TEMPORARY_LIFETIME) -> int:
"""Delete temporary chats nobody has touched for a day. """Delete temporary chats nobody has touched for a day.
+192
View File
@@ -0,0 +1,192 @@
"""Carrying a long conversation forward without carrying all of it.
Past a certain length every chat stops working: the window fills, and the only
options are to lose the beginning or to start again. Compaction summarises the
earlier turns and sends the summary in their place.
**The messages are kept.** They stay in the transcript, collapsed behind a
divider, and simply stop being part of the request. A summary that turned out
badly is then a bad turn rather than a lost conversation, which is what makes
the button safe to press and automatic compaction safe to have at all.
**Stored on the Chat, not as a synthetic Message.** A synthetic row would need 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 ever happened.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import ROLE_ASSISTANT, Chat, Message
from lembas.services import metrics as metrics_service
from lembas.services import settings_store, tokens
log = logging.getLogger(__name__)
# What the summariser is shown. Past this the oldest turns are dropped with a
# marker: a transcript that does not fit the window it is protecting is no use.
MAX_TRANSCRIPT_CHARS = 24_000
# Settings key, in the GENERAL group. 0 turns automatic compaction off; the
# button still works, because a person asking for it does not need a threshold.
THRESHOLD_KEY = "compact_threshold"
DEFAULT_THRESHOLD = 95
def threshold(db: DBSession) -> int:
value = settings_store.get(db, THRESHOLD_KEY)
return int(value) if isinstance(value, (int, float)) else DEFAULT_THRESHOLD
def moment(message: Message) -> datetime:
"""A message's timestamp, always comparable.
SQLite does not store the offset, so a row loaded from disk comes back naive
while one still in the session's identity map keeps the tzinfo it was
created with. Comparing the two raises, and every comparison here is between
exactly those: a cutoff fetched by id against history loaded in bulk.
`files.sweep_orphans` already normalises for the same reason.
"""
created = message.created_at
return created if created.tzinfo is not None else created.replace(tzinfo=UTC)
def cutoff_message(db: DBSession, chat: Chat) -> Message | None:
"""The message compaction reached, or None if it never has.
There is no foreign key to null this out on an upgraded database, so the
check is load-bearing rather than defensive: an id pointing at a message
that has been deleted means the boundary no longer describes anything, and
the chat has to read as uncompacted.
"""
if not chat.compact_summary or not chat.compacted_through_id:
return None
message = db.get(Message, chat.compacted_through_id)
if message is None or message.chat_id != chat.id:
return None
return message
def reset(chat: Chat) -> None:
"""Forget that this chat was ever compacted."""
chat.compact_summary = ""
chat.compacted_through_id = None
chat.compacted_at = None
def apply(chat: Chat, *, summary: str, upto: Message) -> None:
"""Record a summary and move the boundary. Caller commits."""
chat.compact_summary = summary.strip()
chat.compacted_through_id = upto.id
chat.compacted_at = datetime.now(UTC)
def split(
db: DBSession, chat: Chat, messages: list[Message]
) -> tuple[list[Message], list[Message]]:
"""(summarised, live) -- what is behind the divider, and what is not."""
cutoff = cutoff_message(db, chat)
if cutoff is None:
return [], list(messages)
boundary = moment(cutoff)
return (
[m for m in messages if moment(m) <= boundary],
[m for m in messages if moment(m) > boundary],
)
def last_complete(db: DBSession, chat: Chat) -> Message | None:
"""The newest finished assistant turn: where compaction should stop.
Landing on a reply rather than a question means the kept history starts on a
user turn, which is what every chat template expects.
"""
return db.scalar(
select(Message)
.where(
Message.chat_id == chat.id,
Message.role == ROLE_ASSISTANT,
Message.complete.is_(True),
)
.order_by(Message.created_at.desc())
)
def transcript(db: DBSession, chat: Chat, *, upto: Message) -> str:
"""The turns to summarise, oldest first, as plain text.
Only the delta since the last compaction: the previous summary is supplied
separately, and the instruction asks for one record, so each summary
subsumes the one before it. Re-summarising the whole chat every time grows
quadratically and eventually exceeds the very window this protects.
"""
previous = cutoff_message(db, chat)
query = select(Message).where(
Message.chat_id == chat.id,
Message.created_at <= upto.created_at,
Message.error == "",
)
if previous is not None:
query = query.where(Message.created_at > previous.created_at)
lines: list[str] = []
for message in db.scalars(query.order_by(Message.created_at)):
body = message.content.strip()
if not body:
continue
lines.append(f"{message.role}: {body}")
text = "\n\n".join(lines)
if len(text) > MAX_TRANSCRIPT_CHARS:
# Keep the most recent part: the older it is, the more likely the
# previous summary already covers it.
text = "[earlier turns omitted]\n\n" + text[-MAX_TRANSCRIPT_CHARS:]
return text
def previous_summary_block(chat: Chat) -> str:
"""The earlier summary, headed, or "" on a first compaction.
Empty is fine to pass straight through: `prompts.substitute` drops a line
that held a known variable and expanded to nothing, so the prompt does not
end up with a hole where a heading was.
"""
if not chat.compact_summary.strip():
return ""
return "## Summary of even earlier turns\n\n" + chat.compact_summary.strip()
def should_compact(db: DBSession, chat: Chat, *, pending: str = "") -> bool:
"""Whether the next request should be summarised first.
Judged from the last reply's recorded usage plus an estimate of the new
turn. True prompt_tokens are only knowable after a response, so a
retrospective figure is the honest basis -- but on its own it is one turn
stale, and fifty thousand characters pasted into the composer would overflow
a window that measured 90% last time. The estimator covers only that delta.
Never fires when the model's context length is unknown. Acting on a number
nobody supplied is exactly what the 0-means-unknown rule exists to prevent.
"""
limit = threshold(db)
if limit <= 0:
return False
last = last_complete(db, chat)
if last is None:
return False
usage = metrics_service.from_message(last.usage_json)
if usage.context_limit <= 0 or usage.context_tokens <= 0:
return False
projected = usage.context_tokens + tokens.estimate(pending)
return projected >= usage.context_limit * limit / 100
+85
View File
@@ -22,9 +22,12 @@ import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message, User from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message, User
from lembas.db.session import session_scope from lembas.db.session import session_scope
from lembas.services import chat as chat_service from lembas.services import chat as chat_service
from lembas.services import compaction as compaction_service
from lembas.services import metrics as metrics_service from lembas.services import metrics as metrics_service
from lembas.services import prompts as prompts_service from lembas.services import prompts as prompts_service
from lembas.services import tokens from lembas.services import tokens
@@ -95,6 +98,10 @@ class Generation:
# woken individually: with a 100ms cadence a short poll is simpler than # woken individually: with a 100ms cadence a short poll is simpler than
# future bookkeeping, and cannot drop a wakeup. # future bookkeeping, and cannot drop a wakeup.
version: int = 0 version: int = 0
# What the reply is doing when it is not producing tokens. Shown in the
# streaming bubble, because a silent multi-second pause before the first
# token is what a hang looks like.
status: str = ""
# Number of browsers currently watching. Decides whether a finished reply # Number of browsers currently watching. Decides whether a finished reply
# counts as unread. # counts as unread.
followers: int = 0 followers: int = 0
@@ -214,6 +221,13 @@ async def _run(generation: Generation) -> None:
title_prompt = "" title_prompt = ""
try: try:
# Before the request is assembled, so build_request is called once and
# what goes out is the compacted conversation -- there is no second
# assembly path. Here rather than in post_message because that route's
# whole contract is to return immediately, and a three-second
# summarisation in front of it would break exactly that.
await _maybe_compact(generation)
with session_scope() as db: with session_scope() as db:
chat = db.get(Chat, generation.chat_id) chat = db.get(Chat, generation.chat_id)
message = db.get(Message, generation.message_id) message = db.get(Message, generation.message_id)
@@ -387,6 +401,77 @@ async def _run(generation: Generation) -> None:
generation.touch() generation.touch()
async def _maybe_compact(generation: Generation) -> None:
"""Summarise the earlier turns if the window is about to be full.
Never raises. A failed compaction logs and sends the uncompacted request,
which either works or fails upstream with a message that says what actually
happened -- refusing to answer because the summariser was unavailable would
be a worse trade.
The awaited call is deliberately outside any session, the same shape titling
uses: read everything needed, close, ask, reopen to write.
"""
try:
with session_scope() as db:
chat = db.get(Chat, generation.chat_id)
message = db.get(Message, generation.message_id)
if chat is None or message is None:
return
pending = _pending_text(db, message)
if not compaction_service.should_compact(db, chat, pending=pending):
return
template = prompts_service.resolve(db, "task.compact")
upto = compaction_service.last_complete(db, chat)
if not template.strip() or upto is None:
return
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
transcript = compaction_service.transcript(db, chat, upto=upto)
previous = compaction_service.previous_summary_block(chat)
upto_id = upto.id
generation.status = "Summarising earlier messages…"
generation.touch()
summary = await chat_service.summarise_for_compaction(
endpoint,
model_id,
transcript=transcript,
previous_summary=previous,
template=template,
)
if not summary:
return
with session_scope() as db:
chat = db.get(Chat, generation.chat_id)
upto = db.get(Message, upto_id)
if chat is None or upto is None:
return
compaction_service.apply(chat, summary=summary, upto=upto)
db.commit()
log.info("chat %s compacted automatically through %s", chat.id, upto_id)
except Exception: # noqa: BLE001 - the reply matters more than the tidy-up
log.exception("automatic compaction failed for chat %s", generation.chat_id)
finally:
generation.status = ""
generation.touch()
def _pending_text(db, message: Message) -> str:
"""The user turn this reply is answering, for the size estimate."""
previous = db.scalars(
select(Message)
.where(Message.chat_id == message.chat_id, Message.created_at < message.created_at)
.order_by(Message.created_at.desc())
.limit(1)
).first()
return previous.content if previous is not None else ""
def _question_from(payload: dict) -> str: def _question_from(payload: dict) -> str:
"""The last thing the user said, for auto-titling.""" """The last thing the user said, for auto-titling."""
for entry in reversed(payload.get("messages", [])): for entry in reversed(payload.get("messages", [])):
+81
View File
@@ -156,6 +156,16 @@ VARIABLES: tuple[Variable, ...] = (
), ),
Variable("question", "Question", "The first message. Chat title task only."), Variable("question", "Question", "The first message. Chat title task only."),
Variable("answer", "Answer", "The first reply. Chat title task only."), Variable("answer", "Answer", "The first reply. Chat title task only."),
Variable(
"transcript",
"Transcript",
"The turns being summarised, oldest first. Compaction task only.",
),
Variable(
"previous_summary",
"Earlier summary",
"The summary from a previous compaction, if there was one. Compaction task only.",
),
) )
VARIABLE_NAMES = frozenset(variable.name for variable in VARIABLES) VARIABLE_NAMES = frozenset(variable.name for variable in VARIABLES)
@@ -764,6 +774,77 @@ BUILTIN: tuple[Fragment, ...] = (
"Assistant: {{answer}}" "Assistant: {{answer}}"
), ),
), ),
Fragment(
key="task.compact",
label="Compaction summary",
group=GROUP_TASKS,
order=410,
variables=("transcript", "previous_summary"),
hint="A separate one-message request, not part of any chat. Clear it to "
"turn compaction off entirely: the button says so and nothing is "
"summarised automatically.",
default=(
"Summarise the conversation below so it can be carried forward after the "
"earlier turns are dropped from your context. This is a working record, "
"not a report for a reader.\n"
"\n"
"Keep, under these headings and in this order:\n"
"\n"
"## What we are doing\n"
"The goal, and where we have got to.\n"
"\n"
"## Decisions\n"
"Anything settled, and why. A decision without its reason gets argued "
"again.\n"
"\n"
"## Facts established\n"
"Names, numbers, versions, file paths, URLs and identifiers, copied "
"exactly. Do not round them, paraphrase them or reconstruct one from "
"memory — if it is not in the transcript, leave it out.\n"
"\n"
"## Open threads\n"
"What is unfinished, and what was about to happen next.\n"
"\n"
"Leave out pleasantries, retracted ideas and anything already superseded. "
"Do not answer the conversation: you are recording it. Write in the "
"language of the conversation, and stay under 500 words.\n"
"\n"
"{{previous_summary}}\n"
"\n"
"## Transcript\n"
"\n"
"{{transcript}}"
),
),
Fragment(
key="task.compact_lead",
label="How a summary is introduced",
group=GROUP_TASKS,
order=420,
hint="Sits in front of the summary, in the turn that replaces the "
"messages no longer being sent. Without it a model reads the summary as "
"something the person has just typed.",
default=(
"Here is a summary of the earlier part of this conversation. Those "
"messages are no longer in your context. Treat this summary as an "
"accurate record of them and rely on it rather than on what you can no "
"longer see; if it does not cover something you need, say so instead of "
"filling the gap."
),
),
Fragment(
key="task.compact_ack",
label="The model's acknowledgement",
group=GROUP_TASKS,
order=430,
hint="One assistant turn after the summary, so the conversation still "
"alternates user, assistant, user. Several chat templates reject a "
"history that does not.",
default=(
"Understood. I have the summary of the earlier turns and will carry on "
"from there."
),
),
) )
register_source(_builtin_source) register_source(_builtin_source)
+6
View File
@@ -35,6 +35,12 @@ def _general_defaults() -> dict[str, Any]:
# Applied to every chat that has no model or chat prompt of its # Applied to every chat that has no model or chat prompt of its
# own. See services.chat.effective_system_prompt. # own. See services.chat.effective_system_prompt.
"system_prompt": "", "system_prompt": "",
# Percentage of a model's context length at which the earlier turns are
# summarised automatically. 0 turns it off; the Compact button still
# works, because a person asking for it does not need a threshold.
# Never fires for a model whose context_length is 0, since that is
# "unknown" rather than "small". See services/compaction.py.
"compact_threshold": 95,
} }
+42
View File
@@ -167,6 +167,45 @@
.reasoning__summary::-webkit-details-marker { display: none; } .reasoning__summary::-webkit-details-marker { display: none; }
.reasoning__summary:hover { color: var(--ink); background: var(--surface-hover); } .reasoning__summary:hover { color: var(--ink); background: var(--surface-hover); }
/* --- Compacted turns -------------------------------------------------------
Summarised messages, kept and readable but out of the way. Collapsed by
default: the point of compacting was that they stopped mattering.
*/
.compacted {
border: 1px dashed var(--border-strong);
border-radius: var(--radius);
background: color-mix(in srgb, var(--surface) 50%, transparent);
}
.compacted__summary {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
cursor: pointer;
color: var(--ink-muted);
font-size: var(--text-sm);
list-style: none;
user-select: none;
}
.compacted__summary::-webkit-details-marker { display: none; }
.compacted__summary:hover { color: var(--ink); }
.compacted[open] .reasoning__chevron { transform: rotate(180deg); }
.compacted__note {
margin: 0;
padding: 0 var(--sp-3);
font-size: var(--text-xs);
color: var(--ink-faint);
line-height: var(--leading-normal);
}
.compacted__body {
display: flex;
flex-direction: column;
gap: var(--sp-6);
padding: var(--sp-4) var(--sp-3);
opacity: 0.75;
}
/* --- Suggestions ----------------------------------------------------------- /* --- Suggestions -----------------------------------------------------------
Starting points on the empty screen. Cards rather than a list, because they Starting points on the empty screen. Cards rather than a list, because they
are things to press. are things to press.
@@ -333,6 +372,9 @@
} }
/* --- Stop, notes and editing ---------------------------------------------- */ /* --- Stop, notes and editing ---------------------------------------------- */
.msg__status { font-size: var(--text-xs); color: var(--ink-faint); font-style: italic; }
.msg__status:empty { display: none; }
.msg__waiting { .msg__waiting {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -41,6 +41,29 @@
</div> </div>
</section> </section>
<section class="card">
<h2 class="card__title">Compaction</h2>
<p class="card__lede">
A long conversation eventually fills the model's context. When it gets
close, the earlier turns are summarised and the summary is sent in their
place. The messages themselves are kept and stay readable in the
transcript — they simply stop being sent.
</p>
<div class="field">
<label class="field__label" for="compact-threshold">Compact at</label>
<input class="input" id="compact-threshold" name="compact_threshold" type="number"
min="0" max="99" value="{{ values.compact_threshold }}">
<p class="field__hint">
Percent of the model's context length. <code>0</code> turns automatic
compaction off; the button in each chat still works. Nothing happens for
a model whose context length is unset under
<a href="/admin/models">Models</a> — that is "unknown", not "small", and
this will not act on a number nobody supplied. The wording of the
summary is under <a href="/admin/prompts">Prompts</a>.
</p>
</div>
</section>
<section class="card"> <section class="card">
<h2 class="card__title"> <h2 class="card__title">
Registration Registration
@@ -119,6 +119,10 @@
reply is being written, which is where the hand already is. #} reply is being written, which is where the hand already is. #}
<div class="msg__waiting"> <div class="msg__waiting">
<span class="dots"><i></i><i></i><i></i></span> <span class="dots"><i></i><i></i><i></i></span>
{# What the reply is doing when it is not producing tokens. A silent
multi-second pause before the first token is what a hang looks
like. #}
<span class="msg__status" sse-swap="status" hx-swap="innerHTML"></span>
</div> </div>
{# Counts as the reply is written. Everything is an estimate until the {# Counts as the reply is written. Everything is an estimate until the
usage chunk lands at the very end, and the chips say so. #} usage chunk lands at the very end, and the chips say so. #}
+30 -4
View File
@@ -1,12 +1,38 @@
{% from "_macros.html" import icon %}
{# {#
The whole thread. Returned after a rewind, which changes an arbitrary number The whole thread. Returned after a rewind or a compaction, either of which
of messages at once -- replacing the lot is simpler and less error-prone than changes an arbitrary number of messages at once -- replacing the lot is
working out which individual bubbles to remove. Also included by simpler and less error-prone than working out which individual bubbles to
chat/index.html, so the conversation is described in exactly one place. remove. Also included by chat/index.html, so the conversation is described in
exactly one place.
Deliberately has no root element: it is swapped with innerHTML into #thread, Deliberately has no root element: it is swapped with innerHTML into #thread,
and an outerHTML swap would take the container with it. and an outerHTML swap would take the container with it.
#} #}
{% if compacted %}
{# Summarised turns are kept and still readable -- they have only stopped being
sent. A compaction that summarised badly is then a bad turn rather than a
lost conversation. #}
<details class="compacted">
<summary class="compacted__summary">
{{ icon("archive", "icon--sm") }}
<span>{{ compacted | length }} earlier messages, summarised</span>
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
</summary>
<p class="compacted__note">
These are no longer sent to the model; a summary of them goes instead. They
are kept here so nothing is lost.
</p>
<div class="compacted__body">
{% for message in compacted %}
{% with body_html = bodies.get(message.id, "") %}
{% include "chat/_message.html" %}
{% endwith %}
{% endfor %}
</div>
</details>
{% endif %}
{% for message in messages %} {% for message in messages %}
{% with body_html = bodies.get(message.id, "") %} {% with body_html = bodies.get(message.id, "") %}
{% include "chat/_message.html" %} {% include "chat/_message.html" %}
+12
View File
@@ -66,6 +66,18 @@
</button> </button>
{% endif %} {% endif %}
{% if chat and messages %}
<button class="btn btn--icon" type="button" aria-label="Compact this chat"
title="Summarise the earlier messages so they stop taking up context"
hx-post="/api/chats/{{ chat.id }}/compact"
hx-target="#thread" hx-swap="innerHTML"
hx-confirm="Summarise everything before the last reply? The messages stay in the transcript; they just stop being sent to the model."
data-confirm-title="Compact this chat"
data-confirm-label="Compact">
{{ icon("archive") }}
</button>
{% endif %}
{% if chat and user.is_admin %} {% if chat and user.is_admin %}
<button class="btn btn--icon" type="button" aria-label="Inspect this chat" <button class="btn btn--icon" type="button" aria-label="Inspect this chat"
title="Inspect this chat" aria-expanded="false" data-toggle="#inspector"> title="Inspect this chat" aria-expanded="false" data-toggle="#inspector">
+372
View File
@@ -0,0 +1,372 @@
"""Compaction: what is summarised, what is sent, and when it happens by itself."""
from __future__ import annotations
import json
from datetime import UTC, datetime, timedelta
import httpx
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import Chat, Connection, Message, Model
from lembas.services import chat as chat_service
from lembas.services import compaction as compaction_service
from lembas.services import prompts, settings_store
from lembas.services.crypto import encrypt
@pytest.fixture
def chat(db, registered, make_chat) -> Chat:
connection = Connection(
name="Test", base_url="http://x.test", api_key_encrypted=encrypt("")
)
db.add(connection)
db.commit()
db.add(
Model(connection_id=connection.id, model_id="test-model", context_length=1000)
)
db.commit()
return db.get(Chat, make_chat())
def _exchange(db, chat: Chat, *, question: str, answer: str, minutes: int = 0) -> Message:
"""One user turn and its reply, backdated so ordering is deterministic."""
when = datetime.now(UTC) - timedelta(minutes=minutes)
db.add(Message(chat_id=chat.id, role="user", content=question, created_at=when))
reply = Message(
chat_id=chat.id,
role="assistant",
content=answer,
created_at=when + timedelta(seconds=1),
)
db.add(reply)
db.commit()
return reply
def _usage(reply: Message, *, context_tokens: int, limit: int = 1000) -> None:
reply.usage_json = {
"prompt_tokens": context_tokens,
"completion_tokens": 0,
"total_tokens": context_tokens,
"context_tokens": context_tokens,
"context_limit": limit,
"estimated": False,
"elapsed_ms": 10,
"rounds": 1,
}
# --- The boundary -------------------------------------------------------------
def test_an_uncompacted_chat_splits_into_nothing_and_everything(db, chat):
_exchange(db, chat, question="one", answer="two")
messages = list(db.scalars(select(Message).order_by(Message.created_at)))
assert compaction_service.split(db, chat, messages) == ([], messages)
def test_a_dangling_cutoff_reads_as_uncompacted(db, chat):
"""There is no foreign key to null it out on an upgraded database, so the
guard is load-bearing rather than defensive."""
reply = _exchange(db, chat, question="one", answer="two", minutes=10)
compaction_service.apply(chat, summary="a summary", upto=reply)
db.commit()
db.delete(reply)
db.commit()
assert compaction_service.cutoff_message(db, chat) is None
assert compaction_service.split(db, chat, [])[0] == []
def test_the_cutoff_lands_on_a_finished_reply(db, chat):
"""So the kept history starts on a user turn, which is what every chat
template expects."""
_exchange(db, chat, question="one", answer="two", minutes=10)
db.add(Message(chat_id=chat.id, role="user", content="three"))
db.commit()
assert compaction_service.last_complete(db, chat).content == "two"
# --- What gets sent -----------------------------------------------------------
def test_the_summary_replaces_the_compacted_turns(db, chat):
old = _exchange(db, chat, question="what is lembas?", answer="Waybread.", minutes=10)
_exchange(db, chat, question="how much?", answer="A bite.", minutes=1)
compaction_service.apply(chat, summary="They asked about lembas.", upto=old)
db.commit()
messages = chat_service.build_messages(db, chat)
contents = [m["content"] for m in messages]
assert "what is lembas?" not in contents
assert "how much?" in contents
assert any("They asked about lembas." in c for c in contents)
def test_the_summary_is_carried_by_a_user_and_an_assistant_turn(db, chat):
"""A leading assistant turn breaks templates requiring the first non-system
message to be user; a lone leading user turn produces user, user whenever the
kept history starts on a user turn -- which it always does."""
old = _exchange(db, chat, question="one", answer="two", minutes=10)
_exchange(db, chat, question="three", answer="four", minutes=1)
compaction_service.apply(chat, summary="Summary.", upto=old)
db.commit()
roles = [m["role"] for m in chat_service.build_messages(db, chat)]
assert roles[:2] == ["user", "assistant"]
# And it still alternates from there.
assert roles == ["user", "assistant", "user", "assistant"]
def test_there_is_still_exactly_one_system_message(db, chat):
settings_store.update(db, {"system_prompt": "Speak as Gandalf."})
old = _exchange(db, chat, question="one", answer="two", minutes=10)
compaction_service.apply(chat, summary="Summary.", upto=old)
db.commit()
roles = [m["role"] for m in chat_service.build_messages(db, chat, system_prompt="S")]
assert roles.count("system") == 1
assert roles[0] == "system"
def test_clearing_the_lead_fragment_still_sends_the_summary(db, chat):
prompts.save(db, {"task.compact_lead": "", "task.compact_ack": ""})
old = _exchange(db, chat, question="one", answer="two", minutes=10)
compaction_service.apply(chat, summary="Summary.", upto=old)
db.commit()
messages = chat_service.build_messages(db, chat)
assert messages[0] == {"role": "user", "content": "Summary."}
# --- The transcript -----------------------------------------------------------
def test_only_the_delta_is_summarised_the_second_time(db, chat):
"""Re-summarising the whole chat grows quadratically and eventually exceeds
the very window it is protecting."""
first = _exchange(db, chat, question="the old part", answer="ok", minutes=20)
compaction_service.apply(chat, summary="Earlier summary.", upto=first)
db.commit()
second = _exchange(db, chat, question="the new part", answer="ok", minutes=5)
transcript = compaction_service.transcript(db, chat, upto=second)
assert "the new part" in transcript
assert "the old part" not in transcript
assert "Earlier summary." in compaction_service.previous_summary_block(chat)
def test_a_first_compaction_has_no_previous_summary(db, chat):
assert compaction_service.previous_summary_block(chat) == ""
def test_a_huge_transcript_is_trimmed_from_the_front(db, chat):
reply = _exchange(db, chat, question="x" * 40_000, answer="ok", minutes=5)
transcript = compaction_service.transcript(db, chat, upto=reply)
assert len(transcript) < 40_000
assert transcript.startswith("[earlier turns omitted]")
# --- The button ---------------------------------------------------------------
def _summariser(text: str = "## What we are doing\n\nAsking about lembas."):
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"choices": [{"message": {"content": text}}]})
return handler
async def test_compacting_summarises_and_hides_the_earlier_turns(
client: TestClient, db, chat, mock_http
):
_exchange(db, chat, question="what is lembas?", answer="Waybread.", minutes=10)
mock_http(_summariser())
response = client.post(f"/api/chats/{chat.id}/compact")
assert response.status_code == 200
db.refresh(chat)
assert "Asking about lembas." in chat.compact_summary
assert chat.compacted_through_id
# The messages are still there, behind the divider.
assert "earlier messages, summarised" in response.text
assert db.scalar(select(Message).where(Message.content == "what is lembas?")) is not None
async def test_compacting_while_a_reply_is_being_written_is_refused(
client: TestClient, db, chat, mock_http
):
_exchange(db, chat, question="one", answer="two", minutes=10)
db.add(Message(chat_id=chat.id, role="assistant", content="", complete=False))
db.commit()
response = client.post(f"/api/chats/{chat.id}/compact")
assert response.status_code == 409
async def test_compaction_can_be_turned_off_by_clearing_its_prompt(
client: TestClient, db, chat, mock_http
):
_exchange(db, chat, question="one", answer="two", minutes=10)
prompts.save(db, {"task.compact": ""})
response = client.post(f"/api/chats/{chat.id}/compact")
assert response.status_code == 409
assert "turned off" in response.json()["detail"]
async def test_compacting_an_empty_chat_is_refused(client: TestClient, db, chat, mock_http):
assert client.post(f"/api/chats/{chat.id}/compact").status_code == 409
# --- Rewinding across the boundary --------------------------------------------
def test_editing_at_or_before_the_cutoff_clears_the_compaction(
client: TestClient, db, chat, mock_http
):
"""A rewind deletes everything after the edited message, so a boundary at or
behind it no longer describes anything that exists."""
first_reply = _exchange(db, chat, question="one", answer="two", minutes=20)
_exchange(db, chat, question="three", answer="four", minutes=10)
compaction_service.apply(chat, summary="Summary.", upto=first_reply)
db.commit()
first_user = db.scalar(select(Message).where(Message.content == "one"))
mock_http(_summariser())
client.post(
f"/api/chats/{chat.id}/messages/{first_user.id}/edit", data={"content": "one again"}
)
db.refresh(chat)
assert chat.compact_summary == ""
assert chat.compacted_through_id is None
# --- Automatic ----------------------------------------------------------------
def test_it_fires_when_the_window_is_nearly_full(db, chat):
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
_usage(reply, context_tokens=960)
db.commit()
assert compaction_service.should_compact(db, chat) is True
def test_it_does_not_fire_with_room_to_spare(db, chat):
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
_usage(reply, context_tokens=400)
db.commit()
assert compaction_service.should_compact(db, chat) is False
def test_a_large_pending_turn_is_counted(db, chat):
"""The recorded figure is one turn stale. Fifty thousand characters pasted
into the composer overflow a window that measured 90% last time."""
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
_usage(reply, context_tokens=900)
db.commit()
assert compaction_service.should_compact(db, chat) is False
assert compaction_service.should_compact(db, chat, pending="x" * 400) is True
def test_it_never_fires_without_a_context_length(db, chat):
"""Acting on a number nobody supplied is exactly what 0-means-unknown is
there to prevent."""
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
_usage(reply, context_tokens=99_000, limit=0)
db.commit()
assert compaction_service.should_compact(db, chat) is False
def test_a_threshold_of_zero_turns_it_off(db, chat):
settings_store.update(db, {"compact_threshold": 0})
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
_usage(reply, context_tokens=999)
db.commit()
assert compaction_service.should_compact(db, chat) is False
def test_it_does_not_fire_on_the_first_turn(db, chat):
assert compaction_service.should_compact(db, chat) is False
def test_it_acts_on_estimated_counts_too(db, chat):
"""A premature compaction costs one turn of answer quality, not data -- the
messages are still there. That is what makes acting on an estimate safe."""
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
_usage(reply, context_tokens=960)
reply.usage_json = {**reply.usage_json, "estimated": True}
db.commit()
assert compaction_service.should_compact(db, chat) is True
async def test_a_generation_compacts_before_it_asks(db, chat, mock_http):
"""At the top of _run, so build_request is called once and what goes out is
the compacted conversation."""
from lembas.services import generation as generation_service
reply = _exchange(db, chat, question="what is lembas?", answer="Waybread.", minutes=10)
_usage(reply, context_tokens=980)
db.commit()
pending = Message(chat_id=chat.id, role="user", content="more?")
db.add(pending)
db.commit()
placeholder = Message(chat_id=chat.id, role="assistant", content="", complete=False)
db.add(placeholder)
db.commit()
sent: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
sent.append(body)
if body.get("stream"):
return httpx.Response(200, text="data: [DONE]\n\n")
return httpx.Response(200, json={"choices": [{"message": {"content": "A summary."}}]})
mock_http(handler)
generation = generation_service.Generation(chat_id=chat.id, message_id=placeholder.id)
generation_service._RUNNING[placeholder.id] = generation
await generation_service._run(generation)
generation_service._RUNNING.clear()
db.expire_all()
assert db.get(Chat, chat.id).compact_summary == "A summary."
# The streamed request went out after compaction, carrying the summary.
streamed = next(b for b in sent if b.get("stream"))
assert any("A summary." in str(m.get("content")) for m in streamed["messages"])
assert not any(m.get("content") == "what is lembas?" for m in streamed["messages"])
async def test_a_failed_compaction_still_sends_the_reply(db, chat, mock_http):
"""Refusing to answer because the summariser was unavailable is a worse
trade than sending the uncompacted request."""
from lembas.services import generation as generation_service
reply = _exchange(db, chat, question="one", answer="two", minutes=10)
_usage(reply, context_tokens=980)
db.commit()
placeholder = Message(chat_id=chat.id, role="assistant", content="", complete=False)
db.add(placeholder)
db.commit()
def handler(request: httpx.Request) -> httpx.Response:
if json.loads(request.content).get("stream"):
return httpx.Response(
200, text='data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n'
)
return httpx.Response(500, json={"error": {"message": "no"}})
mock_http(handler)
generation = generation_service.Generation(chat_id=chat.id, message_id=placeholder.id)
generation_service._RUNNING[placeholder.id] = generation
await generation_service._run(generation)
generation_service._RUNNING.clear()
assert generation.text == "hi"
assert not generation.error