Commit Graph
3 Commits
Author SHA1 Message Date
Kaede AkinoandClaude Opus 5 da3082aff8 Ask a group of decisions at once, not one at a time
`request_user_input` took one question. A turn that needed three raised them
one after another, each hidden until the one before it was answered — and a
second question you cannot see is a second question that cannot inform the
first. It now takes up to three, drawn as one form with one Continue.

The earlier message on `2ec0e12` gave the wrong reason for not doing this.
It said several questions per call "would need a queue and a
multi-question card", as though the runtime's single `pendingUserInput`
forbade it. It never did: a call carrying three questions is still one
blocking request and one continuation, which is exactly what a queue is
not. That was a preference dressed as a constraint. The real cost is the
two things below — per-question drafts, and a form that can outgrow its
strip — and neither was a reason to refuse.

The queue that *did* arrive in `22258e3` does not cover it either, for a
reason worth writing down. `request_user_input` is `.sequential`, and a
batch runs in parallel only when every call in it is, so two questions in
one turn execute one after the other and `pendingUserInputs` never holds
more than one. The queue and its `1 of N` are live for approvals and
purely defensive for questions. Sequential is right for this tool —
racing a question against other calls means output arriving under
something still being read — so batching inside one call is the only way
several questions reach a person together. The queue stays as it is.

A question may be left unanswered, and the tool reports that as a null
rather than an error. That needs saying in the tool's own description or
a model reads the null as a failure and asks the same thing again, which
puts the user in a loop they answered their way out of once already. So
the description now says plainly: a null is the user declining to decide
that one — choose, say which assumption you used, and do not ask again.
Continue needs one answer, not all of them; it needs at least one because
answering nothing is Cancel, and reporting that as success would hand the
model a result whose only content is that it was not refused.

Drafts become per question. Sharing one text field and one selection set
across a form meant typing into the third question cleared the first, and
the mutual exclusion between an option and a typed answer — which is
per-question by definition — reached across all of them. `AgentUserQuestion`
carries the id the model chose, so answers travel under the model's own
names rather than an index we assign, and every question is reported back
whether answered or not: an absent key would read as a bug where a null
reads as a decision.

Two things fell out of rendering it. A bare `ScrollView` capped at the
form's maximum height is greedy, so a one-question card was handed 320pt
of box for 120pt of question; `ViewThatFits` prefers the natural stack and
falls back to scrolling only when the questions genuinely overflow. And
`reported` is not private, because that shape is the contract with the
model and a test should be able to hold it still — including that a
secret, which `request_user_input` never asks for, is reported as skipped
rather than echoed if one ever arrives there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:52:21 +08:00
Kaede AkinoandClaude Opus 5 22258e3f2f Run a turn's tools at once, not one after another
`AgentRuntime` was one actor doing four jobs: holding the run's state,
driving the loop, executing tools, and publishing events. Adding an
audit, a rewrite, or a new tool source meant editing it. The loop now
keeps only the state and the control flow, and each phase is a value it
drives — `AgentContextPipeline` at the model boundary, `AgentTurnDriver`
for one assistant turn, `AgentToolScheduler` for the batch,
`AgentToolExecutor` for a single call, `AgentToolRegistry` for where
tools come from. Each is testable without a run around it. Four seams are
protocols with real conformers: `AgentToolProviding`, `AgentLoopHook`,
`AgentContextTransforming`, `AgentModelStreaming`. Authorization is
deliberately not among them — `AgentApprovalHandling` stays a structural
stage of the executor, because a permission gate you can remove by
leaving an element out of an array is not a gate.

A turn's calls run concurrently only when every one of them is marked
parallel; a single sequential call takes the whole turn serial rather
than being fenced into its own segment. Segmenting would buy back only
the cross-host case and cost semantics nobody can hold in their head
while reading an incident transcript. Concurrency is declared per tool
and may be overridden per call by preflight, which is how `ssh_exec`
decides: parallel exactly when `CommandRiskClassifier` proves the command
safe, the same locally-proven evidence that already decides its safety.
MCP tools are parallel by default, and a server's destructive hint or Ask
First makes them sequential — the hints can only tighten concurrency,
never grant it, so remote annotations remain risk hints rather than proof
of safety. The ceiling is worth stating plainly: `SSHExecSession` and
`SFTPClient` are actors, so calls aimed at one host still queue inside
their session. The win is several hosts at once, and host work
overlapping `fetch`, `web_search`, and MCP.

Whatever order they finish in, results reach the transcript in the order
the model asked. `toolFinished` still follows completion, because that is
when a card should stop spinning, so the two orders now disagree by
design — and `AgentToolResultOrdering` repairs it at the model boundary,
where both the run's snapshot and the conversation's own copy pass
through one implementation.

Three defects that serial execution was hiding, in rising order of harm.
A denied call was relabelled `Failed` the instant it was refused, because
the denial is an error to the model and the card followed the result;
`Denied` therefore never reached the screen. A run that gave up halfway —
cancelled, out of tool budget, or handed a response truncated
mid-arguments — left an assistant turn whose tool calls nothing answered,
and every provider rejects that outright on the *next* request, so an
interrupted run became a permanently unusable conversation. Every call
now gets a card and a result on every path, refusals included. And
`pendingApproval` was a single slot: two calls needing approval in one
turn overwrote each other, the first continuation was never resumed, and
the run stopped with no error and nothing on screen to answer. Approvals
and agent questions are queues now, presented one at a time with a
`1 of N` count, and run state is derived from what is outstanding rather
than asserted by whichever call moved last.

Events travel one ordered path. The runtime and the approval broker used
to publish independently, each wrapping its durable write in its own
unstructured `Task`, so a review's events had no defined order against
the tool events they belong between. `AgentEventChannel` fans out to the
UI and to a single serial recorder. `messageStarted` now carries its
message rather than just an id, so the visible turn is stamped with the
run's clock instead of whenever the UI got around to the event — a card
proposed later could otherwise carry an earlier timestamp than the turn
that asked for it, and sort above it.

`AgentContextPipeline` is also where trimming finally has somewhere to
live. `ssh_exec` returns up to 256 KB and `sftp_read` up to 128 KB, and
each is replayed on every later turn; two log reads were enough to crowd
out the conversation they were meant to explain. At most one turn's tool
output is now replayed in full, and older results keep their head and
their tail, because a truncated command output missing its ending has
lost the error message. It shapes only the request: snapshots and stored
history keep everything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:28:07 +08:00
Kaede AkinoandClaude Opus 5 2ec0e123a3 Let the user answer something the model did not offer
`request_user_input` took a prompt and an array of bare strings and drew a
`Picker`. When none of the choices fit — which is exactly when asking was
worth doing — Cancel was the only move available, and a cancelled run
tells the agent nothing about why. So a free-form field now sits in the
list alongside the options rather than under them as an afterthought, on
by default, and `allow_custom` has to be set false to take it away.

Options become objects. A bare string left the model nowhere to put the
tradeoff, so it either crammed the reasoning into the label or dropped
it, and the reader chose between four words with no way to tell them
apart. Each option now carries an id, a label, a sentence on what it
costs, and a `recommended` flag — a flag rather than a `(Recommended)`
suffix, so the convention lives in the schema instead of in prose the
model has to remember, and the label keeps its whole budget. The ceiling
drops from twenty choices to four; twenty was never a decision anyone was
being asked to make.

Nothing is preselected, the recommendation included. A recommendation is
a label, not a default, and a preselected answer is one the user can
submit without reading — so Continue stays disabled until they actually
answer. Where one answer is wanted the two routes are exclusive and each
clears the other, because a half-typed "something else" left sitting
under a chosen radio is a second answer contradicting the first. Where
several are wanted they are additive, since "these two, plus this other
thing" is a real reply.

Selected ids come back in the order the request listed them rather than
the order they were clicked: the model wrote the list and reads the
answer against it. Alongside them the tool returns `custom_text` and a
canonical `answer`, so a model that reads only one field still reads the
right one. Malformed and duplicated options are rejected rather than
dropped — a menu quietly missing the option the user wanted is a worse
failure than an error the model can see and retry.

`request_user_secret` shares the interaction handler, so it now refuses
`.choice` explicitly. A secret has to come back through `SecureField` and
the broker; anything else means it travelled the ordinary-answer path,
where it would have been echoed on screen and left in plain view state.

None of this is persisted, so there is nothing to migrate. Two things
were deliberately left out. Several questions per call does not fit a
tool that blocks mid-run on a single `pendingUserInput` — it would need a
queue and a multi-question card, and one question per call is right for
an agent that is already holding the user up. Per-option previews need a
two-pane layout, and this card is a narrow strip above the composer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 08:37:09 +08:00