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
+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,