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
+156 -5
View File
@@ -17,8 +17,10 @@ from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import time
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
@@ -28,9 +30,9 @@ from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message, User
from lembas.db.session import session_scope
from lembas.services import chat as chat_service
from lembas.services import compaction as compaction_service
from lembas.services import interaction, tokens
from lembas.services import metrics as metrics_service
from lembas.services import prompts as prompts_service
from lembas.services import tokens
from lembas.services import tools as tools_service
from lembas.services.llm.openai_client import (
LLMError,
@@ -41,6 +43,7 @@ from lembas.services.llm.openai_client import (
stream_chat,
)
from lembas.services.reasoning import REASONING, ReasoningSplitter
from lembas.services.tools import ToolOutcome
log = logging.getLogger(__name__)
@@ -108,6 +111,16 @@ class Generation:
finished_at: datetime | None = None
cancel: bool = False
# Set while the reply is stopped waiting for a person -- an approval, or a
# question the model asked. None at every other moment. Read by `_follow`,
# which sends the card, and by `request_stop`, which resolves it: `cancel`
# is otherwise only ever read between streamed chunks, and there are no
# chunks while this is set.
pending: interaction.Interruption | None = None
# Seconds spent waiting for a person, cumulative. Taken off the wall-clock
# budget so that thinking time is the model's and not the reader's.
waited: float = 0.0
def touch(self) -> None:
self.version += 1
@@ -134,12 +147,47 @@ def request_stop(message_id: str) -> bool:
if generation is None or generation.done:
return False
generation.cancel = True
# A paused reply produces no chunks, and the chunk loop is the only place
# `cancel` is ever read -- so without this, Stop does nothing at all while
# an approval card is on screen. Resolving the pause is the wakeup; `_run`
# then takes its ordinary stopped path rather than needing a second branch.
if generation.pending is not None:
generation.pending.resolve(interaction.CANCELLED)
return True
def answer(chat_id: str, interaction_id: str, *, choice: str, text: str) -> bool:
"""Resolve whichever running reply is parked on this interruption.
A linear scan of the registry: it holds one entry per reply in flight, and
this runs at human speed. Scoped to the chat because the caller has already
checked that this reader owns *that* chat, and an id alone would not.
"""
for generation in _RUNNING.values():
pending = generation.pending
if generation.chat_id != chat_id or pending is None or pending.id != interaction_id:
continue
outcome = choice if choice in _ANSWERS else interaction.ANSWER
return pending.resolve(outcome, text=text or choice)
return False
_ANSWERS = (interaction.ALLOW, interaction.ALLOW_ALWAYS, interaction.DENY)
def _prune() -> None:
cutoff = datetime.now(UTC) - KEEP_FINISHED
now = time.monotonic()
for message_id, generation in list(_RUNNING.items()):
# A paused reply is deliberately not `done` -- a page reload has to be
# able to reattach to it. Its timeout is what stops it lingering, and
# this is the belt to that pair of braces: a deadline long past means
# the timeout did not fire, and a task parked forever is worse than one
# that gives up.
pending = generation.pending
if pending is not None and now > pending.expires_at + KEEP_FINISHED.total_seconds():
log.warning("resolving a stuck interaction on message %s", message_id)
pending.resolve(interaction.EXPIRED)
if generation.done and generation.finished_at and generation.finished_at < cutoff:
_RUNNING.pop(message_id, None)
_TASKS.pop(message_id, None)
@@ -339,10 +387,18 @@ async def _run(generation: Generation) -> None:
tools_service.assistant_turn(calls, "".join(round_text)),
]
# Decided before anything runs, never during. A round's calls run
# together under a semaphore, and four people-shaped pauses inside
# that gather would queue behind each other invisibly -- see
# services/interaction.py.
decided = await _authorise(generation, tool_context, calls)
if generation.stopped:
break
generation.status = _tool_status(calls)
generation.touch()
try:
outcomes = await _run_calls(tool_context, calls)
outcomes = await _run_calls(tool_context, calls, decided=decided)
finally:
generation.status = ""
generation.touch()
@@ -488,7 +544,98 @@ def _tool_status(calls: list[dict]) -> str:
return f"Running {len(calls)} tools…"
async def _run_calls(context, calls: list[dict]) -> list:
def _ask_items(context, calls: list[dict]) -> list[interaction.Item]:
"""Which of this round's calls need a person, and what to show about each.
Looked up through `context.tools`, the map of what was actually offered --
the same authority `run_tool` uses. A name that is not in it is left alone
here and refused there, so an unknown tool cannot smuggle itself past by
being unclassifiable.
"""
book = context.tools if context.tools is not None else tools_service.REGISTRY
items: list[interaction.Item] = []
for index, call in enumerate(calls):
tool = book.get(call["name"])
if tool is None or tool.risk != tools_service.RISK_ASK:
continue
try:
args = json.loads(call["arguments"] or "{}")
except json.JSONDecodeError:
args = {}
if not isinstance(args, dict):
args = {}
options = [str(o).strip() for o in (args.get("options") or []) if str(o).strip()]
items.append(
interaction.Item(
index=index,
kind=interaction.KIND_QUESTION,
tool_name=call["name"],
title=str(args.get("question") or "").strip() or "A question for you",
options=tuple(options[: interaction.MAX_OPTIONS]),
)
)
return items
async def _authorise(generation, context, calls: list[dict]) -> dict[int, ToolOutcome]:
"""Which of this round's calls may run, and what the others answer instead.
Returns outcomes keyed by the call's index. Every index the caller does not
find here is cleared to run; every index it does find is answered without
the runner being reached at all. That is what keeps
`zip(calls, outcomes, strict=True)` aligned -- an endpoint matching on
`tool_call_id` pairs the wrong content with the right id otherwise.
"""
items = _ask_items(context, calls)
if not items:
return {}
timeout = float(context.interaction_timeout or 900)
pause = interaction.build(uuid.uuid4().hex, items, timeout=timeout)
generation.status = interaction.summarise(pause.items)
reply = await interaction.wait_for(generation, pause, timeout=timeout)
generation.status = ""
if reply.ended:
generation.stopped = True
return {}
return {item.index: _answered(item, reply) for item in items}
def _answered(item: interaction.Item, reply: interaction.Reply) -> ToolOutcome:
"""One question's answer, as the model will read it back."""
event = {
"name": item.tool_name,
"kind": "ask",
"label": "Asked you",
"query": item.title,
"results": [],
}
if reply.outcome == interaction.EXPIRED:
return ToolOutcome(
"They did not answer. Carry on as best you can without it, or say "
"what you still need.",
{**event, "status": "error", "error": "No answer.", "text": ""},
)
answer_text = reply.text.strip()
if not answer_text:
return ToolOutcome(
"They closed the question without answering.",
{**event, "status": "error", "error": "No answer.", "text": ""},
)
return ToolOutcome(
f"They answered: {answer_text}",
{**event, "status": "ok", "text": answer_text},
)
async def _run_calls(
context, calls: list[dict], *, decided: dict[int, ToolOutcome] | None = None
) -> list:
"""Run one round's calls together, results in call order.
Sequential was right when every tool was a local database read. A remote one
@@ -507,11 +654,15 @@ async def _run_calls(context, calls: list[dict]) -> list:
"""
limit = asyncio.Semaphore(MAX_PARALLEL_TOOLS)
async def one(call: dict):
async def one(index: int, call: dict):
# Already answered by a person, or refused before it got here. It still
# occupies its index, because the tool turns have to line up.
if decided and index in decided:
return decided[index]
async with limit:
return await tools_service.run_tool(context, call["name"], call["arguments"])
return list(await asyncio.gather(*(one(call) for call in calls)))
return list(await asyncio.gather(*(one(i, c) for i, c in enumerate(calls))))
def _pending_text(db, message: Message) -> str: