Two selects that never wrote anything, and a queue

The approval card in Auto mode and the missing /effort were one bug. Both
selects hung their hx-patch on an empty sibling form reached by form="…",
and htmx binds a trigger to the annotated element: change fires on the
select and bubbles to its ancestors, which a sibling is not. The live rows
read agent_mode=manual and params_json={} while the browser showed Auto and
Effort: high. policy.py was never involved.

The verb moves onto the control; the empty form stays as value scoping,
which is the half of the CLAUDE.md note that was right. conftest gains
control_named so a test asserts the element carrying the name carries the
verb, rather than asserting the markup that was there throughout.

The composer's highlight was a third instance of the same carelessness in
CSS: .tok-mention is written for the transcript and scoped to nothing, so
the mirror painted its token in accent-coloured monospace over the
textarea's own text. Scoped under .msg; the mirror restates transparency
and font rather than inheriting them, and bleeds by box-shadow.

/effort is now offered before the first prompt and _new_chat reads it.
/index re-walks the project directory on demand, file_write drops the
listing it just invalidated, and the index ladder falls through to SFTP on
a host that refuses exec instead of returning nothing.

A second message during a reply is queued rather than starting a second
concurrent generation: a real Message row with queued set, so it survives a
restart and can be withdrawn. _drain hands one on at the end of a reply,
_inject takes one in at a tool-round boundary so an agent can be steered
mid-task. Stop leaves the queue undelivered. The terminal's Auto toggle
becomes off/copy/send, and send posts straight to the chat without touching
the composer.

@ now offers notes, skills, this chat's attachments and a URL to fetch; a
knowledge base attaches as a reference rather than a copy. copy_document
carries provenance, which was the one attach path that dropped it.

Also fixes an unrelated live bug: the round loop compared against the
global MAX_ROUNDS of 3 while sizing itself from the agent budget of 40, so
agent replies stopped after three rounds and reported forty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-02 19:49:34 +02:00
parent 0bee366488
commit 8a3a225fea
31 changed files with 2131 additions and 81 deletions
+332 -5
View File
@@ -10,8 +10,8 @@ from collections.abc import AsyncIterator
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, Response, StreamingResponse
from sqlalchemy import select
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import Db, RequiredUser, require_permission
@@ -49,6 +49,12 @@ router = APIRouter(prefix="/api/chats", tags=["chats"])
# Well under nginx's 60s default; see services/sse.py:KEEPALIVE.
KEEPALIVE_AFTER = 15.0
# How many prompts may wait behind a reply at once. The terminal panel's Auto
# send is what this exists for: a `for` loop in a shell can produce commands
# faster than any model answers them, and a bound with a sentence attached is
# better than four hundred rows nobody meant to write.
MAX_QUEUED = 10
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
chat = db.get(Chat, chat_id)
@@ -70,6 +76,7 @@ def _new_chat(
ssh_profile_id: str = "",
project_dir: str = "",
agent_mode: str = "",
reasoning_effort: str = "",
) -> Chat:
"""Create a chat row, resolving which model it should use.
@@ -81,7 +88,8 @@ def _new_chat(
before the first word. Without it, reaching Plan mode meant starting a chat
in Manual, sending something to make the chat exist, and only then being
offered the control -- by which point the model had already answered under
the wrong rules.
the wrong rules. The reasoning effort is accepted for the same reason, and
wins over the model's default: an explicit choice beats an inherited one.
"""
chosen = None
if model_id:
@@ -126,6 +134,12 @@ def _new_chat(
# Left alone entirely on a plain chat, where it means nothing.
if profile is not None and agent_mode.strip() in agent_policy.MODES:
chat.agent_mode = agent_mode.strip()
# After the model's defaults, so choosing one on the new-chat screen wins
# over the administrator's. Empty means "whatever the model said", not
# "none" -- clearing it is what the blank option on an existing chat does.
wanted_effort = reasoning_effort.strip().lower()
if wanted_effort in chat_service.EFFORTS:
chat.params_json = {**chat.params_json, "reasoning_effort": wanted_effort}
db.add(chat)
db.commit()
return chat
@@ -144,6 +158,7 @@ async def start_chat(
ssh_profile_id: str = Form(""),
project_dir: str = Form(""),
agent_mode: str = Form(""),
reasoning_effort: str = Form(""),
) -> Response:
"""Create a chat from its first message.
@@ -166,6 +181,7 @@ async def start_chat(
ssh_profile_id=ssh_profile_id,
project_dir=project_dir,
agent_mode=agent_mode,
reasoning_effort=reasoning_effort,
)
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
@@ -400,6 +416,108 @@ async def compact_chat(request: Request, db: Db, user: RequiredUser, chat_id: st
)
@router.post("/{chat_id}/index")
async def reindex_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
"""Walk the project directory again, now.
The listing is cached for five minutes and only ever built when a reply
starts, so a tree that has just changed under somebody's hands -- a checkout,
a build, anything done in the terminal panel rather than through
`file_write` -- stays wrong until the next reply after the TTL lapses. This
is the "look again" that was missing.
Read-only, and therefore outside `agent/policy.py` for the reason the
directory browser is: it is LLeMbas acting on a person's instruction, not a
model choosing to look, and a listing that asked permission would be
useless. The gate is ownership of the connection, checked here rather than
trusted from the chat.
"""
from lembas.db.models import SshProfile
from lembas.services.agent import index as index_service
from lembas.services.agent import ssh as ssh_service
chat = _owned_chat(db, chat_id, user.id)
if chat.kind != KIND_AGENT or not chat.ssh_profile_id:
raise HTTPException(status.HTTP_409_CONFLICT, "This chat has no project directory.")
if not permissions.has(db, user, "tools.agent"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not use agent connections.")
profile = db.get(SshProfile, chat.ssh_profile_id)
if profile is None or profile.owner_id != user.id or not profile.enabled:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection is not available.")
if not profile.host_key:
raise HTTPException(
status.HTTP_409_CONFLICT,
"This connection's host key has not been accepted yet.",
)
project_dir = chat.project_dir or profile.default_dir or ""
try:
found = await index_service.ensure(
ssh_service.SshExecutor(ssh_service.spec_from(profile), project_dir),
profile.id,
project_dir,
refresh=True,
)
except Exception as exc: # noqa: BLE001 - surfaced to the reader, not swallowed
log.warning("could not index %s for chat %s: %s", project_dir, chat.id, exc)
raise HTTPException(
status.HTTP_502_BAD_GATEWAY, "Could not read the project directory."
) from exc
listed = len(found.paths)
return JSONResponse(
{
"ok": True,
"files": found.total,
"listed": listed,
"truncated": found.truncated,
"message": (
f"{found.total} files under {project_dir or '~'}"
+ (f", {listed} listed." if listed != found.total else ".")
),
}
)
@router.post("/{chat_id}/bases")
async def attach_base(
request: Request, db: Db, user: RequiredUser, chat_id: str, base_id: str = Form("")
) -> Response:
"""Scope this chat to a knowledge base, from the `@` menu.
A base is a *reference*, not an attachment: `Chat.knowledge_bases` already
narrows `knowledge_search`, and the harness already names the attached bases
so the model can tell "there is nothing about this" from "I can only see
this folder". Copying a folder of documents into the window instead would
cost the context on every request forever to answer one question.
Additive, and idempotent -- choosing the same base twice is not an error.
Removing one is a checkbox in the chat's settings, where the whole set is
visible at once.
"""
from lembas.db.models import KnowledgeBase
from lembas.services.library import documents as documents_service
chat = _owned_chat(db, chat_id, user.id)
if not permissions.has(db, user, "library.use"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not use the library.")
base = db.scalar(
documents_service.visible_bases(db, user).where(KnowledgeBase.id == base_id)
)
if base is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That knowledge base is not available.")
if base.id not in {b.id for b in chat.knowledge_bases}:
chat.knowledge_bases = [*chat.knowledge_bases, base]
db.commit()
return templates.TemplateResponse(
request, "chat/_base_chip.html", {"request": request, "base": base}
)
@router.post("/{chat_id}/keep")
async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
"""Stop a temporary chat being temporary.
@@ -489,6 +607,22 @@ async def post_message(
return _send(request, db, chat, user, content, file_ids=file_ids)
def _reply_in_flight(db: DBSession, chat: Chat) -> bool:
"""Whether this chat already has a reply being written.
The row is the authority, not the registry: a restart leaves an incomplete
assistant message behind with no `Generation` anywhere, and that row is what
starts the reply again on the next page load. The registry is consulted too,
for the sliver in which a generation is still running and its row has
already been written -- `_persist` sets `complete` before `_run` sets
`done`.
"""
unfinished = db.scalar(
select(Message).where(Message.chat_id == chat.id, Message.complete.is_(False))
)
return unfinished is not None or generation_service.running_for(chat.id) is not None
def _note_rewind(chat: Chat) -> None:
"""Record that an agent chat's transcript went back and the machine did not.
@@ -516,11 +650,53 @@ def _send(
Shared by the composer and by anything else that puts words into a
conversation on somebody's behalf -- carrying out a plan, for one. One path
rather than two, so a second way of sending cannot drift from the first.
If a reply is already being written, the turn is *queued* instead: written,
shown, and not sent. Starting a second reply here is what used to happen,
and it produced two generations answering the same chat from two different
prefixes of it, with Stop pointing at whichever bubble came first in the
document.
"""
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
if queued := _reply_in_flight(db, chat):
waiting = db.scalar(
select(func.count())
.select_from(Message)
.where(Message.chat_id == chat.id, Message.queued.is_(True))
)
if waiting >= MAX_QUEUED:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"There are already {MAX_QUEUED} messages waiting to be sent.",
)
user_message = chat_service.create_message(db, chat, ROLE_USER, content, queued=queued)
if file_ids:
files_service.claim(db, ids=file_ids, user_id=user.id, message_id=user_message.id)
db.refresh(user_message)
if queued:
# One bubble and no assistant placeholder. The streaming shell is the
# only thing that starts a generation, so a placeholder here would be a
# second concurrent reply -- exactly what the queue exists to prevent.
if (live := generation_service.running_for(chat.id)) is not None:
# So a reply between two rounds of tool calls notices it, and so
# anybody following sees the status change.
live.touch()
return templates.TemplateResponse(
request,
"chat/_message.html",
{
"request": request,
"message": user_message,
"chat": chat,
"user": user,
"models_by_id": {
m.model_id: m for m in chat_service.available_models(db, user)
},
**audio_service.template_flags(db, user),
},
)
assistant_message = chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
)
@@ -680,10 +856,90 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
)
title_html = templates.get_template("chat/_title_oob.html").render({"chat": chat})
yield sse.event("done", final_html + title_html)
# What the queue did while this reply was running. There is no push
# channel that outlives one message's stream, and this is the last frame
# that reaches the browser -- so it carries the rest out of band, the
# way the chat title already does.
moved_html, queue_html = _queue_frames(db, chat, owner, generation)
yield sse.event("done", moved_html + final_html + queue_html + title_html)
yield sse.event("close", "")
def _render_bubble(db: DBSession, chat: Chat, owner: User | None, message: Message) -> str:
"""One finished bubble, rendered the way the `done` frame renders its own."""
return templates.get_template("chat/_message.html").render(
{
"message": message,
"body_html": (
render_markdown(message.content) if message.role == ROLE_ASSISTANT else ""
),
"chat": chat,
"user": owner,
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, None)},
**audio_service.template_flags(db, owner),
}
)
def _queue_frames(
db: DBSession, chat: Chat, owner: User | None, generation
) -> tuple[str, str]:
"""The bubbles the queue produced during this reply, as out-of-band HTML.
Two pieces, because they swap differently. Anything taken *into* this reply
mid-round now sorts before it, so it is rendered ahead of the finished
bubble in the same `outerHTML` swap and its stale node is deleted out of
band -- one frame, and the DOM ends up in the order the database is in.
Anything drained *after* the reply is a new pair appended to the thread.
"""
moved: list[str] = []
out_of_band: list[str] = []
for injected_id in generation.injected_ids:
row = db.get(Message, injected_id)
if row is None:
continue
moved.append(_render_bubble(db, chat, owner, row))
# Removed where it was; it is about to reappear above the reply.
out_of_band.append(f'<article id="msg-{row.id}" hx-swap-oob="delete"></article>')
if generation.drained:
fresh = list(
db.scalars(
select(Message)
.where(Message.chat_id == chat.id, Message.complete.is_(False))
.order_by(Message.created_at)
)
)
for assistant in fresh:
# The user turn that was waiting has just lost its Send now and
# Discard, so it is re-rendered in place.
delivered = db.scalars(
select(Message)
.where(
Message.chat_id == chat.id,
Message.role == ROLE_USER,
Message.created_at <= assistant.created_at,
)
.order_by(Message.created_at.desc())
.limit(1)
).first()
if delivered is not None:
out_of_band.append(
f'<div hx-swap-oob="outerHTML:#msg-{delivered.id}">'
+ _render_bubble(db, chat, owner, delivered)
+ "</div>"
)
out_of_band.append(
'<div hx-swap-oob="beforeend:#thread">'
+ _render_bubble(db, chat, owner, assistant)
+ "</div>"
)
return "".join(moved), "".join(out_of_band)
def _metrics_html(generation) -> str:
"""The metric chips for a reply still being written.
@@ -799,6 +1055,16 @@ async def edit_message(
if not content and not message.attachments:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "A message cannot be empty.")
# Editing rewinds and then starts a reply, unconditionally. Doing that while
# one is already being written is a second concurrent generation -- the
# thing the queue exists to prevent -- reachable here by a button that is on
# screen throughout. It was reachable before the queue too; nothing made it
# obvious.
if _reply_in_flight(db, chat):
raise HTTPException(
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it."
)
message.content = content
# Attachments cascade with their message, so the files go too.
@@ -884,6 +1150,67 @@ async def stop_message(db: Db, user: RequiredUser, chat_id: str, message_id: str
return Response(status_code=status.HTTP_204_NO_CONTENT)
def _waiting_message(db: DBSession, chat: Chat, message_id: str) -> Message:
message = db.get(Message, message_id)
if message is None or message.chat_id != chat.id or not message.queued:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "That message is not waiting to be sent."
)
return message
@router.post("/{chat_id}/messages/{message_id}/discard")
async def discard_queued(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> Response:
"""Withdraw a prompt that has not been sent.
Deleted outright rather than marked: it never reached a model, nothing in
the transcript refers to it, and a conversation full of tombstones for
things nobody said is worse than the row being gone. Attachments cascade.
An empty body rather than a 204, because htmx does not swap on a 204 and the
bubble has to disappear.
"""
chat = _owned_chat(db, chat_id, user.id)
message = _waiting_message(db, chat, message_id)
db.delete(message)
db.commit()
return HTMLResponse("")
@router.post("/{chat_id}/messages/{message_id}/send-now")
async def send_queued_now(
request: Request, db: Db, user: RequiredUser, chat_id: str, message_id: str
) -> Response:
"""Deliver a waiting prompt at once.
Refused while a reply is being written rather than allowed to jump ahead of
it: that is what the queue *is*, and starting a second generation here is
the thing this whole mechanism exists to stop. Stop the reply first.
The whole thread comes back, which is the rewind and compaction idiom, and
is safe only because of the refusal above -- there is no live bubble to
destroy.
"""
chat = _owned_chat(db, chat_id, user.id)
message = _waiting_message(db, chat, message_id)
if _reply_in_flight(db, chat):
raise HTTPException(
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it."
)
message.queued = False
db.commit()
assistant = chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
)
generation_service.ensure(chat.id, assistant.id)
return templates.TemplateResponse(
request, "chat/_thread.html", {"request": request, **_thread_context(db, chat, user)}
)
@router.post("/{chat_id}/interaction/{interaction_id}")
async def answer_interaction(
request: Request,
+152 -1
View File
@@ -16,14 +16,17 @@ from fastapi import (
status,
)
from fastapi.responses import FileResponse
from sqlalchemy import select
from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.db.models import Attachment, Document
from lembas.db.models import Attachment, Document, KnowledgeBase, Note
from lembas.security import permissions
from lembas.services import files as files_service
from lembas.services import settings_store
from lembas.services.fetch import FetchError, fetch
from lembas.services.library import documents as documents_service
from lembas.services.library import notes as notes_service
from lembas.services.library import skills as skills_service
from lembas.web.templating import templates
log = logging.getLogger(__name__)
@@ -144,6 +147,102 @@ async def attach_from_knowledge(
)
def _chip(request: Request, attachment: Attachment) -> Response:
return templates.TemplateResponse(
request, "chat/_attachment_chip.html", {"request": request, "attachment": attachment}
)
def _not_available(request: Request, what: str) -> Response:
return templates.TemplateResponse(
request,
"chat/_attachment_error.html",
{"request": request, "filename": what, "error": f"That {what} is not available."},
)
@router.post("/from-note", dependencies=[Depends(require_permission("files.upload"))])
async def attach_from_note(
request: Request, db: Db, user: RequiredUser, note_id: str = Form(""), chat_id: str = Form("")
) -> Response:
"""Attach a note the model wrote earlier.
A copy, like every other attach path: a note is edited far more often than a
document, and a transcript that changes underneath itself because somebody
tidied a note later is the thing all of this is arranged to prevent.
"""
note = notes_service.get(db, note_id, user)
if note is None:
return _not_available(request, "note")
return _chip(
request,
files_service.store_text(
db,
user_id=user.id,
chat_id=chat_id or None,
filename=f"{note.title or 'note'}.txt",
text=note.body,
source_path=note.title or "",
source_label="Note",
),
)
@router.post("/from-skill", dependencies=[Depends(require_permission("files.upload"))])
async def attach_from_skill(
request: Request, db: Db, user: RequiredUser, skill_id: str = Form(""), chat_id: str = Form("")
) -> Response:
"""Hand a skill over directly, rather than hoping the model fetches it.
The index of enabled skills is already in the harness and `skill_get` pulls
a body on demand -- but only if the model decides to. `@` is the reader
saying "use this one", which is a different act and deserves a way to say it.
"""
skill = skills_service.get(db, skill_id, user)
if skill is None:
return _not_available(request, "skill")
return _chip(
request,
files_service.store_text(
db,
user_id=user.id,
chat_id=chat_id or None,
filename=f"{skill.name}.md",
text=skill.body,
source_path=skill.name,
source_label="Skill",
),
)
@router.post("/from-attachment", dependencies=[Depends(require_permission("files.upload"))])
async def attach_from_attachment(
request: Request,
db: Db,
user: RequiredUser,
attachment_id: str = Form(""),
chat_id: str = Form(""),
) -> Response:
"""Point at something already in this conversation, without uploading again.
Copied rather than referenced, like everything else here -- an attachment
belongs to the message it was sent with, and two messages sharing one row
would make deleting either of them a question rather than an answer.
"""
original = db.get(Attachment, attachment_id)
if original is None or original.user_id != user.id:
return _not_available(request, "attachment")
return _chip(
request,
files_service.copy_attachment(
db, user_id=user.id, chat_id=chat_id or None, attachment=original
),
)
@router.get("/knowledge-picker", dependencies=[Depends(require_permission("files.upload"))])
async def knowledge_picker(
request: Request, db: Db, user: RequiredUser, q: str = "", chat_id: str = ""
@@ -210,9 +309,14 @@ async def mention_picker(
][:20]
documents: list = []
notes: list = []
skills: list = []
bases: list = []
if permissions.has(db, user, "library.use"):
if needle:
documents = documents_service.search(db, user, q, limit=10)
notes = notes_service.search(db, user, q, limit=5)
skills = skills_service.search(db, user, q, limit=5)
else:
documents = list(
db.scalars(
@@ -221,6 +325,48 @@ async def mention_picker(
.limit(10)
)
)
notes = list(
db.scalars(
notes_service.visible(db, user).order_by(Note.updated_at.desc()).limit(5)
)
)
skills = list(db.scalars(skills_service.visible(db, user).limit(5)))
# A whole base is a *reference*, not a copy: attaching one scopes the
# chat to it and the model searches inside it. Dumping the contents of
# a folder of contracts into the window would be the wrong shape
# entirely, and `Chat.knowledge_bases` already means exactly this.
# Only in an existing chat, because there is nothing to attach it to
# before one exists -- the same reason project files are absent there.
if chat_id:
bases = [
base
for base in db.scalars(
documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)
)
if not needle or needle in base.name.lower()
][:5]
# A URL typed after `@` is a page to read, not a name to look up. The
# fetcher, its SSRF guard and its HTML-to-text already live behind
# `/api/files/link`; this only offers it.
website = q.strip() if q.strip().lower().startswith(("http://", "https://")) else ""
attachments: list = []
if chat_id and needle:
attachments = list(
db.scalars(
select(Attachment)
.where(
Attachment.user_id == user.id,
Attachment.chat_id == chat_id,
Attachment.message_id.is_not(None),
)
.order_by(Attachment.created_at.desc())
.limit(20)
)
)
attachments = [a for a in attachments if needle in a.filename.lower()][:5]
return templates.TemplateResponse(
request,
@@ -230,6 +376,11 @@ async def mention_picker(
"user": user,
"files": files,
"documents": documents,
"notes": notes,
"skills": skills,
"bases": bases,
"attachments": attachments,
"website": website,
"q": q,
"chat_id": chat_id,
"profile_id": profile_id,