A reply can stop and ask you something

Three features turn out to be one mechanism: a command waiting to be
approved, a question the model wants answered, and "this reply is waiting
for you" are all — stop the generation, put an interactive block in the
bubble, wait for a POST, carry on. So there is one primitive, and the only
thing using it so far is `ask_user`: a model can offer you a few answers
and a box to write your own.

The shell executor is not here yet. This lands first on purpose, because
it is the riskiest machinery in the feature and it is worth having working
before any subprocess exists to complicate it.

Two things about where the pause sits. It pauses a round, not a call: a
round's calls run together under a semaphore, and parking four coroutines
on four separate answers inside that gather would queue them behind each
other invisibly. And Stop had to be taught about it — `cancel` is read
between streamed chunks and there are no chunks while paused, so the
button did nothing at all until `request_stop` learned to resolve the
pause itself.

Also here: a risk class on every tool (read, write, execute), which is
what the four permission modes will be a table over, and the systemd unit
loses ProtectKernelTunables. That last one is not tidying — it
bind-mounts /proc/sys read-only, which stops bubblewrap mounting /proc at
all, and the obvious workaround would expose this process's environment
and with it the encryption key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-01 19:30:44 +02:00
parent ecb52e9978
commit b39e4eac88
19 changed files with 1662 additions and 17 deletions
+50
View File
@@ -413,6 +413,22 @@ def _tool_activity(events: list[dict], *, live: bool = True) -> str:
)
def _ask_html(chat_id: str, pending) -> str:
"""The card asking the reader something, or nothing at all.
Returns "" when there is nothing pending, and the frame is sent
unconditionally, because this is one of the few blocks that has to be able
to *clear* itself: the card must vanish the moment it is answered.
`reasoning`, `tools` and `render` are the opposite -- guarded by truthiness
so a frame can never blank them.
"""
if pending is None:
return ""
return templates.get_template("chat/_interaction.html").render(
{"ask": pending, "chat_id": chat_id}
)
async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
"""Stream a generation that is running independently of this request.
@@ -441,6 +457,7 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
yield sse.event("render", render_markdown(generation.text))
yield sse.event("metrics", _metrics_html(generation))
yield sse.event("status", escape_text(generation.status))
yield sse.event("ask", _ask_html(chat_id, generation.pending))
last_frame = time.monotonic()
if generation.done:
@@ -656,6 +673,39 @@ async def stop_message(db: Db, user: RequiredUser, chat_id: str, message_id: str
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/{chat_id}/interaction/{interaction_id}")
async def answer_interaction(
db: Db,
user: RequiredUser,
chat_id: str,
interaction_id: str,
choice: str = Form(""),
text: str = Form(""),
) -> Response:
"""Answer a question, or allow something, that a reply is waiting on.
`_owned_chat` is the authorisation and it is not decoration: without it any
signed-in account that guessed an id would be answering -- and later,
approving a command in -- somebody else's conversation.
An id matching nothing (already answered, timed out, or the server was
restarted) is a 204 with a toast rather than a 404. The card is gone either
way, and an error page swapped into the middle of a chat is worse than
being told plainly.
"""
chat = _owned_chat(db, chat_id, user.id)
answered = generation_service.answer(
chat.id, interaction_id, choice=choice.strip(), text=text.strip()
)
response = Response(status_code=status.HTTP_204_NO_CONTENT)
if not answered:
response.headers["HX-Trigger"] = json.dumps(
{"lembas:notify": {"message": "That question is no longer waiting for an answer."}}
)
return response
@router.patch("/{chat_id}")
async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str) -> Response:
"""Partially update a chat.