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:
@@ -129,6 +129,13 @@ class Generation:
|
||||
# the reply and is written onto the message, so the Execute button sends
|
||||
# exactly what was proposed rather than something parsed back out of prose.
|
||||
plan: dict | None = None
|
||||
# The queue, seen from the reply's side. `drained` says this reply's ending
|
||||
# handed the next waiting prompt to a fresh one; `injected_ids` names the
|
||||
# prompts taken into *this* reply between two rounds of tool calls. Both are
|
||||
# read only by `_follow`, which turns them into bubbles on the `done` frame
|
||||
# -- the one frame that reaches a browser after a reply is over.
|
||||
drained: bool = False
|
||||
injected_ids: list[str] = field(default_factory=list)
|
||||
|
||||
def touch(self) -> None:
|
||||
self.version += 1
|
||||
@@ -187,6 +194,22 @@ def answer(
|
||||
return False
|
||||
|
||||
|
||||
def running_for(chat_id: str) -> Generation | None:
|
||||
"""The reply being written in this chat, if there is one.
|
||||
|
||||
A linear scan for the reason `answer` gives above: one entry per reply in
|
||||
flight, consulted at human speed. `_prune` first, because a finished
|
||||
generation lingers `KEEP_FINISHED` so that late followers still get the
|
||||
final frames -- and without the sweep those five minutes would look like a
|
||||
chat that is permanently busy, and queue everything typed into it.
|
||||
"""
|
||||
_prune()
|
||||
for generation in _RUNNING.values():
|
||||
if generation.chat_id == chat_id and not generation.done:
|
||||
return generation
|
||||
return None
|
||||
|
||||
|
||||
_VERDICTS = (interaction.ALLOW, interaction.ALLOW_ALWAYS, interaction.DENY)
|
||||
|
||||
|
||||
@@ -328,6 +351,12 @@ async def _run(generation: Generation) -> None:
|
||||
|
||||
model = chat_service.model_for(db, chat)
|
||||
generation.context_limit = model.context_length if model is not None else 0
|
||||
# Kept for `_inject`, which builds a user turn after this session
|
||||
# has closed. A turn taken in mid-reply has to be shaped exactly as
|
||||
# the same words typed a moment later would have been -- images to a
|
||||
# vision model, a plain string to anything else, or the endpoint
|
||||
# rejects the whole request.
|
||||
vision = chat_service.model_supports(db, chat, "vision")
|
||||
|
||||
generation.prompt_estimate = tokens.estimate_request(payload)
|
||||
|
||||
@@ -406,10 +435,17 @@ async def _run(generation: Generation) -> None:
|
||||
if generation.stopped or not calls:
|
||||
break
|
||||
|
||||
if round_number == tools_service.MAX_ROUNDS:
|
||||
if round_number == budget:
|
||||
# Out of rounds with the model still asking for tools. Recorded
|
||||
# rather than silently dropped: an answer that stops here needs
|
||||
# to be explicable.
|
||||
#
|
||||
# `budget`, not `MAX_ROUNDS`. The loop is sized by the budget on
|
||||
# the line above and the message below has always reported it,
|
||||
# but the comparison was against the global 3 -- so an agent
|
||||
# chat allowed forty steps stopped after three and said it had
|
||||
# taken forty. Two numbers, one of them wrong, in code whose
|
||||
# whole job is to say what happened.
|
||||
generation.tool_events.append(
|
||||
{
|
||||
"name": calls[0]["name"],
|
||||
@@ -454,6 +490,22 @@ async def _run(generation: Generation) -> None:
|
||||
generation.plan = outcome.event["plan"]
|
||||
generation.touch()
|
||||
|
||||
# Something typed while this reply was working. Taken in here, at a
|
||||
# round boundary, rather than made to wait for the whole reply: an
|
||||
# agent that has just finished one loop and is about to start
|
||||
# another is exactly when "actually, do it the other way" is worth
|
||||
# having.
|
||||
#
|
||||
# Only while there is a round left to answer in. Injecting into the
|
||||
# last one would deliver the prompt into a reply that then runs out
|
||||
# of budget without addressing it -- and it is marked delivered, so
|
||||
# nothing would ever send it again. Below that line it waits for
|
||||
# `_drain`, which always gives it a reply of its own.
|
||||
if round_number + 1 < budget and (
|
||||
added := _inject(generation, generation.chat_id, vision)
|
||||
):
|
||||
messages.append(added)
|
||||
|
||||
payload = {**payload, "messages": messages}
|
||||
|
||||
# A plan ends the turn. One more request so the model can say what
|
||||
@@ -525,6 +577,11 @@ async def _run(generation: Generation) -> None:
|
||||
# the row. The other order left a window in which the finished frame
|
||||
# showed the previous turn's stored values.
|
||||
_persist(generation, title, time.monotonic() - started)
|
||||
# After the row is authoritative and before `done`, for the same reason
|
||||
# `_persist` is: `_follow` breaks the instant it sees that flag, and the
|
||||
# frame it then sends is the one that has to carry the next turn's
|
||||
# bubbles. There is no push channel that outlives a single reply.
|
||||
_drain(generation)
|
||||
generation.done = True
|
||||
generation.finished_at = datetime.now(UTC)
|
||||
generation.touch()
|
||||
@@ -1006,6 +1063,117 @@ def _question_from(payload: dict) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _next_waiting(db, chat_id: str) -> Message | None:
|
||||
"""The oldest prompt in this chat that has not been sent."""
|
||||
return db.scalars(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.chat_id == chat_id,
|
||||
Message.role == ROLE_USER,
|
||||
Message.queued.is_(True),
|
||||
)
|
||||
.order_by(Message.created_at)
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
|
||||
def _drain(generation: Generation) -> None:
|
||||
"""Hand the next waiting prompt to a reply of its own, if there is one.
|
||||
|
||||
Exactly one, not all of them. Draining the lot would put two consecutive
|
||||
user turns into the next request, which several local chat templates refuse
|
||||
outright -- `build_messages` already goes to some trouble over that around
|
||||
the compaction lead. "One after another" is also what was asked for: the
|
||||
second waiting prompt is drained by the reply the first one starts, and so
|
||||
on down the chain.
|
||||
|
||||
Three refusals, and none of them is a special case:
|
||||
|
||||
- **Superseded.** The same test `_persist` makes, for the same reason: a
|
||||
regeneration cancels its predecessor and the predecessor's `finally:`
|
||||
still runs. Without this, regenerating would drain the queue *and* leave
|
||||
a third generation running.
|
||||
- **Stopped.** Stop means stop, and the queue stays visible and
|
||||
undelivered with Send now beside it. This is also what makes shutdown
|
||||
safe -- cancellation sets `stopped`, so a restart never fires off a reply
|
||||
with nobody watching.
|
||||
- **Errored.** The endpoint has just failed. Feeding the next prompt into it
|
||||
produces a second failure and spends somebody's words to do it.
|
||||
"""
|
||||
owner = _RUNNING.get(generation.message_id)
|
||||
if owner is not None and owner is not generation:
|
||||
return
|
||||
if generation.stopped or generation.error:
|
||||
return
|
||||
|
||||
try:
|
||||
with session_scope() as db:
|
||||
chat = db.get(Chat, generation.chat_id)
|
||||
if chat is None:
|
||||
return
|
||||
waiting = _next_waiting(db, chat.id)
|
||||
if waiting is None:
|
||||
return
|
||||
|
||||
waiting.queued = False
|
||||
assistant = chat_service.create_message(
|
||||
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
|
||||
)
|
||||
chat_id, assistant_id = chat.id, assistant.id
|
||||
except Exception: # noqa: BLE001 - the reply is over either way
|
||||
log.exception("could not drain the queue for chat %s", generation.chat_id)
|
||||
return
|
||||
|
||||
# Outside the session: this starts a task, and a task is not something to
|
||||
# hold a database session open across.
|
||||
ensure(chat_id, assistant_id)
|
||||
generation.drained = True
|
||||
|
||||
|
||||
def _inject(generation: Generation, chat_id: str, vision: bool) -> dict | None:
|
||||
"""Take the oldest waiting prompt into this reply, between two rounds.
|
||||
|
||||
Marked delivered and committed *before* the request goes out, so this is
|
||||
at-most-once. A crash in between loses the turn, which is recoverable --
|
||||
the words are still in the transcript with Send now beside them. The other
|
||||
way round would ask the same question twice and let an agent act on it
|
||||
twice, which is not.
|
||||
|
||||
Sent verbatim, in the user role, with no framing. Everything else this
|
||||
codebase injects is quoted and attributed because it came out of a file, a
|
||||
page or a machine; this one genuinely *is* the person at the keyboard,
|
||||
authenticated by the session cookie and stored as a `Message` whose role
|
||||
says so. Wrapping it would teach a model that a user turn can be a
|
||||
quotation, which is the exact distinction the other two rely on. What the
|
||||
model needs -- that this can happen at all -- is one sentence in the
|
||||
harness, where authored wording lives.
|
||||
"""
|
||||
try:
|
||||
with session_scope() as db:
|
||||
waiting = _next_waiting(db, chat_id)
|
||||
if waiting is None:
|
||||
return None
|
||||
|
||||
waiting.queued = False
|
||||
entry = chat_service.message_payload(waiting, vision=vision)
|
||||
# The reply that answers it must sort *before* it, or the next
|
||||
# turn's transcript reads "answer, then the question it answered"
|
||||
# and a small model dutifully answers again. Moving the placeholder
|
||||
# rather than the prompt keeps several interjections in the order
|
||||
# they were typed.
|
||||
placeholder = db.get(Message, generation.message_id)
|
||||
if placeholder is not None:
|
||||
placeholder.created_at = datetime.now(UTC)
|
||||
generation.injected_ids.append(waiting.id)
|
||||
except Exception: # noqa: BLE001 - a lost interjection is not a failed reply
|
||||
log.exception("could not take a queued prompt into chat %s", chat_id)
|
||||
return None
|
||||
|
||||
generation.status = "Taking in what you just added…"
|
||||
generation.touch()
|
||||
return entry
|
||||
|
||||
|
||||
def _persist(generation: Generation, title: str, elapsed: float) -> None:
|
||||
"""Write the finished reply, name the chat, and set the unread flag.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user