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:
co-authored by
Claude Opus 5
parent
ecadb66414
commit
1c659a5640
@@ -0,0 +1,189 @@
|
||||
"""Pausing a reply to ask the person reading it something.
|
||||
|
||||
Three features turn out to be one mechanism. A command that needs approving, 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 approval is a shape of question rather
|
||||
than a separate machine.
|
||||
|
||||
Two things about where it sits matter.
|
||||
|
||||
**It pauses a round, not a call.** A round's tool 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 the reader would get
|
||||
four cards, answerable in any order, for commands whose order matters. So one
|
||||
card describes everything in the round that needs a decision, and the calls that
|
||||
survive it run concurrently exactly as they did before.
|
||||
|
||||
**Stop has to keep working.** `generation.cancel` is read in one place, between
|
||||
streamed chunks, and there are no chunks while paused. Rather than a second
|
||||
poller, `generation.request_stop` resolves the pause directly; see the comment
|
||||
there. Nothing in this module reaches back into `services.generation`, which is
|
||||
what keeps it testable on its own.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - annotation only
|
||||
from lembas.services.generation import Generation
|
||||
|
||||
KIND_APPROVAL = "approval"
|
||||
KIND_QUESTION = "question"
|
||||
|
||||
# How a pause ended.
|
||||
ALLOW = "allow"
|
||||
ALLOW_ALWAYS = "allow_always"
|
||||
DENY = "deny"
|
||||
ANSWER = "answer"
|
||||
CANCELLED = "cancelled" # Stop was pressed while the card was showing
|
||||
EXPIRED = "expired" # nobody answered in time
|
||||
|
||||
# Outcomes that mean "go ahead".
|
||||
PERMITTED = (ALLOW, ALLOW_ALWAYS)
|
||||
|
||||
# A card offering more than this many buttons is a card nobody reads.
|
||||
MAX_OPTIONS = 6
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Item:
|
||||
"""One thing being asked about.
|
||||
|
||||
`index` is the position of the call in its round, so a decision can be
|
||||
matched back to the call it was about -- the tool turns have to line up with
|
||||
the assistant turn's `tool_calls` or an endpoint pairs the wrong result with
|
||||
the right id.
|
||||
"""
|
||||
|
||||
index: int
|
||||
kind: str
|
||||
tool_name: str
|
||||
title: str
|
||||
detail: str = ""
|
||||
reason: str = ""
|
||||
options: tuple[str, ...] = ()
|
||||
allow_free_text: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class Interruption:
|
||||
"""A reply, stopped, waiting for one answer to cover every item."""
|
||||
|
||||
id: str
|
||||
items: tuple[Item, ...]
|
||||
expires_at: float = 0.0
|
||||
_future: asyncio.Future | None = field(default=None, repr=False, compare=False)
|
||||
|
||||
@property
|
||||
def kind(self) -> str:
|
||||
return KIND_QUESTION if any(i.kind == KIND_QUESTION for i in self.items) else KIND_APPROVAL
|
||||
|
||||
@property
|
||||
def options(self) -> tuple[str, ...]:
|
||||
for item in self.items:
|
||||
if item.options:
|
||||
return item.options
|
||||
return ()
|
||||
|
||||
@property
|
||||
def allow_free_text(self) -> bool:
|
||||
return any(item.allow_free_text for item in self.items)
|
||||
|
||||
def resolve(self, outcome: str, *, text: str = "") -> bool:
|
||||
"""Complete this pause. Idempotent -- a second answer is ignored.
|
||||
|
||||
Returns whether this call was the one that answered it, which is what
|
||||
the endpoint reports back: a card answered twice (two tabs, a double
|
||||
click) should say so rather than pretend.
|
||||
"""
|
||||
if self._future is None or self._future.done():
|
||||
return False
|
||||
self._future.set_result(Reply(outcome=outcome, text=text))
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Reply:
|
||||
outcome: str
|
||||
text: str = ""
|
||||
|
||||
@property
|
||||
def permitted(self) -> bool:
|
||||
return self.outcome in PERMITTED
|
||||
|
||||
@property
|
||||
def ended(self) -> bool:
|
||||
"""Whether this outcome means the whole reply should stop."""
|
||||
return self.outcome == CANCELLED
|
||||
|
||||
|
||||
def build(
|
||||
interaction_id: str, items: list[Item] | tuple[Item, ...], *, timeout: float
|
||||
) -> Interruption:
|
||||
"""An interruption with its future attached, ready to be waited on."""
|
||||
return Interruption(
|
||||
id=interaction_id,
|
||||
items=tuple(items),
|
||||
expires_at=time.monotonic() + timeout,
|
||||
_future=asyncio.get_running_loop().create_future(),
|
||||
)
|
||||
|
||||
|
||||
async def wait_for(
|
||||
generation: Generation, interruption: Interruption, *, timeout: float
|
||||
) -> Reply:
|
||||
"""Park the generation on this interruption until somebody answers.
|
||||
|
||||
Sets `generation.pending` and touches, so the follower sends the card on its
|
||||
next frame; clears both in `finally`, so answering makes it disappear. The
|
||||
time spent here accumulates on `generation.waited` and is taken off the
|
||||
reply's wall-clock budget -- a reader who thinks for ten minutes about one
|
||||
command should not thereby spend the whole allowance.
|
||||
"""
|
||||
generation.pending = interruption
|
||||
generation.touch()
|
||||
started = time.monotonic()
|
||||
try:
|
||||
return await asyncio.wait_for(asyncio.shield(interruption._future), timeout)
|
||||
except TimeoutError:
|
||||
return Reply(outcome=EXPIRED)
|
||||
finally:
|
||||
generation.waited += time.monotonic() - started
|
||||
generation.pending = None
|
||||
generation.touch()
|
||||
|
||||
|
||||
def summarise(items: tuple[Item, ...]) -> str:
|
||||
"""What to show in the status line while the card is up."""
|
||||
if not items:
|
||||
return ""
|
||||
if items[0].kind == KIND_QUESTION:
|
||||
return "Waiting for your answer…"
|
||||
if len(items) == 1:
|
||||
return f"Waiting for you to allow {items[0].tool_name}…"
|
||||
return f"Waiting for you to allow {len(items)} actions…"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ALLOW",
|
||||
"ALLOW_ALWAYS",
|
||||
"ANSWER",
|
||||
"CANCELLED",
|
||||
"DENY",
|
||||
"EXPIRED",
|
||||
"KIND_APPROVAL",
|
||||
"KIND_QUESTION",
|
||||
"MAX_OPTIONS",
|
||||
"PERMITTED",
|
||||
"Interruption",
|
||||
"Item",
|
||||
"Reply",
|
||||
"build",
|
||||
"summarise",
|
||||
"wait_for",
|
||||
]
|
||||
Reference in New Issue
Block a user