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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user