Editing a config on a host cost two full copies of it in tokens. `sftp_read`
returns the file inline, and `sftp_write` takes the replacement back as a
`content` string the model has to re-emit in full — so the ordinary "pull it,
change three lines, push it back" job paid for the whole file twice, and
anything over the 128 KB inline cap, or anything that was not UTF-8, was out
of reach entirely. There was also nowhere to keep a draft between turns: a
script the agent was composing lived only in the transcript, which the context
pipeline trims head-and-tail as the run grows.
`AgentScratchWorkspace` is a per-conversation staging directory under
Application Support, inside the sandbox container, deleted with the
conversation that owns it. Application Support rather than Caches because the
system may evict a Caches directory mid-run, and a staging area that can vanish
between two tool calls is worse than none. Paths are relative; `..` is refused
rather than resolved, which is the inverse of `normalizeRemotePath` and the
right inversion — a relative path with `..` in it is never something the agent
needed. Symlinks are refused even when they point back inside, because an alias
within the workspace makes two paths the same file and quietly breaks both the
quota arithmetic and the atomicity `apply(_:to:)` claims. Every resolved path
is then proven to still be under the scope. Writes are bounded per file, per
conversation, and by entry count, and hidden files are counted — a quota you
can evade by naming a file `.big` is not a quota.
It is an actor, and that is load-bearing rather than incidental. Pi serializes
file mutations through an explicit per-path queue; here the actor already
serializes every operation against every other, so a read-modify-write is
atomic for free. That is what lets every local scratch tool stay `.parallel`:
a batch containing one does not have to drag its whole turn serial to be safe.
`scratch_edit` follows Pi's `edit` and is what makes the rest pay off. Every
`old_text` is matched against the file as it was before the call, not against
the result of the preceding edit, so the model never simulates its own changes
to write the second one. Matches must be unique and non-overlapping; anything
ambiguous fails the whole call rather than applying part of it, because a
half-applied edit is the one outcome nobody can reason about. Matching is exact
first, then one line-aligned retry that ignores smart quotes, unicode dashes
and trailing whitespace — without it an edit fails whenever the model retypes a
block with tidied punctuation, which is often. And it returns a unified diff
rather than the new file, which is the entire point: an 8 MB file can now be
changed for the price of the lines that changed.
`scratch_pull` and `scratch_push` move the bytes, and are separate tools rather
than options on `sftp_read` / `sftp_write`. A tool that means two different
things depending on which optional argument is present is harder for a model to
choose correctly and harder to audit, and it would have put two safety levels
inside one descriptor. The SFTP tools are untouched.
`Safety` gains `locallyContained`. The existing two cases state *why* a call may
skip the gate, and "read-only" is the wrong reason for a scratch write — it
changes something, it just cannot change anything outside storage the app owns.
It is auto-allowed in every mode including Ask for approval, because a staging
area you must approve into is not a staging area, and six dialogs for one edit
loop teach the reader to click through the seventh, which is the one that
reaches a host. `isAutoAllowed` is a property of the case rather than an
`== .requiresAuthorization` at the call site, so a fourth case has to make a
decision here instead of defaulting to allowed. Nothing switches exhaustively
over `Safety` and it is not persisted, so there is no migration.
`scratch_push` is the only gated tool here, and it has a problem the SFTP tools
do not: the approval card would show a filename where it used to show the bytes,
leaving the reviewer — human or model — agreeing to something neither had seen.
Its preflight therefore reads the staged file and puts the size, digest and a
redacted twenty-line head into `localReasons`, which both the card and Security
Review's `Local signals:` line already render. It also carries the digest in
`executionMetadata` and rechecks it at execute time: approval suspends the turn,
and what comes back from the workspace afterwards is only the same file if
nothing rewrote it in between.
The schema helpers move to an `AgentToolSchemaBuilding` protocol with defaults
rather than a namespace enum, so a conforming type still calls `descriptor(…)`
and `string(max:)` unqualified and a second tool file costs a conformance
instead of a copy of forty lines.
`UnifiedDiff` is a formatter over `CollectionDifference` — Swift's
`difference(from:)` is already a Myers diff, so no dependency. It trims the
common prefix and suffix before running, because Myers is fast on similar
inputs and quadratic on unrelated ones, and refuses anything past a line limit
in what is left.
Its output was checked against the real tool before any expectation was written
down: for eight cases it is byte-identical to GNU `diff -u`, and `patch -p1`
applies each one and reproduces the target file. That found three bugs reading
the code had not. `firstChangedLine` was nil for every replacement, because the
first changed row of one is the removal and a removal has no line number on the
new side. A trailing-newline-only difference produced an empty diff with
`identical: false` — true and useless. And `LineEnding.detected` reported `.lf`
for every CRLF file, because Swift treats "\r\n" as a single Character and
`firstIndex(of: "\n")` finds nothing in a Windows file; editing one line would
have rewritten every line ending in it. A fourth came from the BOM test:
`String(data:encoding:.utf8)` strips a UTF-8 BOM while decoding, so checking the
decoded String never fired and writing it back dropped those three bytes. It
now reads the raw Data, and the test asserts on bytes for the same reason —
decoding would have reported a pass either way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Typing at a running agent put a bubble on screen and, more often than
not, did nothing else. The loop returned `complete` the moment an
assistant turn came back with no tool calls, and the steering queue was
only drained at the top of the *next* iteration — so anything typed while
the final reply was being written was dropped on the floor, sitting in the
transcript as a turn the model was never shown. Pi does not have this bug
because its inner loop runs `while (hasMoreToolCalls || pendingMessages.length > 0)`:
a queued message keeps the loop alive even when the model asked for
nothing. That is the rule adopted here, and it is the whole fix — the
guard now completes only when the queue is empty and otherwise continues,
which buys the message a turn of its own.
The second defect was quieter and worse. The bubble was appended to both
`messages` and `transcript` at the keystroke, so the conversation ordered
it *before* the assistant text that actually preceded it while the run's
snapshot ordered it after. `transcript` is what `pendingPriorMessages`
replays as the next run's history, so a steered conversation would later
show the model a conversation that never happened. Nothing is written at
the keystroke now. The queued message enters both lists on
`steeringDelivered`, which the runtime emits at the moment it injects it,
and its `createdAt` is restamped there — a message stamped while the
previous turn was still streaming sorts above the reply the user was
reading when they typed it, which is not where the model saw it.
A queue is a promise, so it can be broken and the words have to survive
that. `steer` returns a Bool rather than swallowing the message: `false`
means there is no run left to deliver it and the caller still owns the
text. The gate is `acceptsSteering` rather than `runTask != nil`, because
`complete` awaits its final persist before the run ends and an actor is
reentrant at every await — a message accepted in that window would land
in a queue nobody drains again. It is cleared as the first statement of
every terminating path, which makes the window empty rather than narrow,
and set in `start` rather than inside the run task, because the first
drain happens ahead of the first provider request and something typed
that early should still be delivered. Whatever is undelivered when a run
ends — stopped, failed, or refused in that race — goes back into the
composer with its attachments, joined above whatever has been typed
since. Returning the whole queue at once rather than one entry per
refused hop is also what keeps the words in the order they were written.
What is queued is drawn as a queue, not as a turn. A strip above the
composer lists each pending message with an ✕ that hands it back, and it
empties itself as each row enters the transcript proper. The caption
changes to "held until you answer above" while an approval or a question
card is holding the run, because in that state nothing is working towards
the next step and promising one would be a lie. Rows show what was typed,
not the composed payload, which carries the attachment blocks folded in
for the model. Its height is measured from its rows and bounded, because
a bare `ScrollView` is greedy and would stand at its 88pt ceiling with
one line in it — the same problem, and the same answer, as the draft
editor's height.
Stop and Send no longer stand side by side. Drawn together they read as a
pair of equals and the primary action moved under the pointer the moment
the draft gained a character; they share one slot now, Stop when there is
nothing to send and Send otherwise. That leaves Stop without a target
while typing, so `⌘.` reaches it from the draft field as well as from the
button. And each region above the composer now rules itself off from what
is above it instead of below it: ruling below meant any visible card was
followed by two adjacent hairlines, its own and the composer's.
Two things from Pi are deliberately left out. There is no follow-up queue
— the second queue, delivered only once the agent would otherwise stop —
because Enter while running means steering, as it does in Pi's own TUI.
And there is no `QueueMode`: Pi drains one message per boundary by
default, where draining the whole queue costs one round trip instead of
several for the common case of two hurried messages. The delivered bubble
is also left unmarked, since once injected it is an ordinary user turn,
and `ChatMessage` is rebuilt from stored transcript rows on relaunch, so
any flag would quietly disappear on restart.
The regression test was checked against the unfixed loop rather than
merely observed to pass: reverting the guard makes it fail with one
provider request instead of two, and with the queued message absent from
both the second request and the saved snapshot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`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>
Force quitting during a tool batch produced a conversation that could not
be continued at all, only deleted: `failed to stream request: error: No
tool output found for function call call_…`. The assistant turn is
persisted as soon as it arrives, its results only when the run next saves
a snapshot, so a process that dies in between restores a turn whose calls
nothing answers — and every provider rejects that outright rather than
degrading. It is the exact mirror of the orphaned result this codebase
already repaired, and the same class of fatal.
Recorded results come back first. `toolFinished` is durable and lands as
each tool finishes, so in the common case the output is sitting in the
database while the transcript claims to know nothing about it;
`loadConversations` now materializes any result whose transcript row was
never written. In a batch of three killed during the third, the first two
come back with the output they actually produced.
`AgentUnansweredToolCallRepair` answers whatever storage cannot, at the
model boundary and at the snapshot seed — the same two places the orphan
repair runs, and for the same reason: repairing at the seed writes the
fix forward instead of re-deriving it on every future turn. What it says
matters as much as that it says something. It does not claim the tool did
not run. The process died after the command was dispatched, so whether
the remote side executed it is genuinely unknown, and a model told "this
never ran" will cheerfully run it again — which is a data-loss bug the
first time the call was `rm`. It says the outcome is unknown and to check
before repeating.
A card caught mid-flight also came back spinning. Every unfinished run is
marked interrupted at launch, so `running` is not a state a restored card
can legitimately be in, and nothing was ever going to stop that spinner.
Worth noting against the previous commit: batching the snapshot save to
once per turn rather than once per tool widened this window, and the
recovery above is what closes it — results are still recorded one at a
time, so nothing a tool produced is lost, only deferred.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`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>
`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>
Replies rendered their newlines away and their tables into mush. The
parser was never the problem. `AttributedString(markdown:)` is cmark-gfm
underneath and records everything — probed against a three-column table,
it returns the four characters `ab12` with every cell carrying
`[tableCell 0, tableHeaderRow, table [.left, .center, .right]]`. Column
count, alignments, header row and cell indices are all there. SwiftUI's
`Text` reads inline attributes only and ignores `presentationIntent`
entirely, so the structure was parsed and then discarded at draw time.
`a\n\nb` has the same shape: two paragraph intents, and a character
buffer that literally reads `ab`, because emitting a newline is the
renderer's job and there was no renderer.
So no new dependency. `apple/swift-markdown` would have bought a ready
tree and task-list checkboxes, at the price of a second Markdown parser
and a C target in a build that already carries three vendored ones — and
the actual labour, the SwiftUI block views, is identical either way.
What it costs instead is one pass that rebuilds nesting from the flat run
list: walk the runs, keep a stack of open containers, compare the
`identity` ints Foundation already hands out. `components` is ordered
innermost-first, which the grouper relies on and a test now pins, because
that is an observed fact about the framework rather than a contract.
Soft breaks are the one place we leave the spec. CommonMark renders a
lone newline as a space and Foundation obeys, emitting an untagged `" "`
run that is indistinguishable from a typed space — so the choice cannot
be made after parsing. It is made on the source, by appending a
backslash hard break the way GitHub's comment renderer does, because a
model that wrote a newline meant one. Every guard in `MarkdownSoftBreaks`
marks somewhere that edit would corrupt rather than break: fenced and
indented code, table rows, headings, thematic breaks, setext underlines,
list markers. One of them is not cosmetic. `composed(_:)` folds
attachments into the message as `<output label="…">…</output>` and
promises the bubble shows exactly what was uploaded; Foundation returns
that untouched as one HTML block, down to the `*` that emphasis would
otherwise eat, so editing a byte inside one would break that promise.
Two hazards found while wiring the shell fence card. `SSHSessionModel
.insert` writes raw bytes with no bracketed-paste wrapper, so an embedded
newline reaches the PTY as Return — "Insert" on a three-line script would
execute the first two while presenting itself as a preview. Multi-line
fences therefore get the risk badge and a copy button and nothing that
touches a session. And `selectedTab` is nil whenever the pinned Assistant
tab is showing, which is exactly where the full-width transcript lives:
`CommandCardView`'s Insert and Run have been dead there all along. The
new card disables them instead of looking live and doing nothing.
Rendering model output is a trust surface, and tool results are fed back
into it. Only `http`, `https` and `mailto` become clickable; a refused
scheme keeps its label as prose and names its destination on hover, which
is what stops `[click here](file:///…)` from reading as ordinary text.
Raw HTML is never interpreted. Images needed nothing — Foundation drops
the URL and keeps the alt text, closing the channel where a model-chosen
image address discloses the reader's IP and reading time the moment a
message renders.
The transcript is rebuilt around it. User turns are trailing bubbles that
size to their contents, assistant replies have no container at all, and
the rules between turns are gone because alignment and fill already say
who is speaking. Tool calls collapse to one line that opens itself while
the call is in flight or after it failed. Attachments fold to a labelled
row, unaltered and one click from open, rather than printing two hundred
lines of log into a right-aligned bubble. `ConversationView` and its
cards move out of `ContextPanelView`, where they had nothing to do with
the context panel.
Two fixes fall out of measuring the result. `TextEditor` has no
content-based ideal height — given `minHeight: 46, maxHeight: 120` it
takes 120, so an empty composer stood 185pt tall with the placeholder
marooned at the top; its height now comes from a hidden twin of the draft
and starts at 40. And the table rendered blank: a horizontal `ScrollView`
proposes unbounded width, the header `Divider` is greedy and took it, and
every centred or trailing column was aligned to an edge no one can reach.
Cells wrap instead, which also reads better at 260pt than a scrolling
region nested inside a scrolling one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Whether an endpoint really runs web search on its own servers is inferred
from the model id, and a gateway can make that inference wrong in either
direction. Both directions turned up within a day of shipping it: an
OpenAI-shaped `/responses` proxy in front of Claude got a server tool it
forwards as an ordinary function, and DeepSeek's Anthropic-shaped
endpoint — which does implement the tool — got refused for not being
Claude. A guess that fails both ways needs a switch, so Web Search grows
one. On by default; off sends every model through the backend, which
behaves the same on every endpoint.
The switch made it worth separating the two questions that were tangled
together. Whether the protocol has anywhere to *put* a server tool is a
hard fact about the request body — Chat Completions does not, and no
setting overrides that, because overriding it yields a request with no
search tool of any kind. Whether the endpoint runs search *for this
model* is the guess. They now have separate names.
DeepSeek is also a first party on the Anthropic shape. Its `/anthropic`
guide lists `server_tool_use` and `web_search_tool_result` as supported
content blocks while marking `code_execution_tool_result` and
`mcp_tool_use` unsupported in the same table, so that list is real rather
than copied from Anthropic's. The family rule was reading "not
Anthropic's own model" as "no native search", which refused a pairing
that works. It is about implementation, not branding. The tool
*declaration* name is the one thing that page does not document; sending
Anthropic's `web_search_20250305` is an assumption, and an unverified
one.
`DuckDuckGo` gains the `(Local)` suffix `Bing` already had. Both are the
same thing — a browser-shaped request whose answer is a page Termind
parses itself — so marking one and not the other was distinguishing
nothing. Applied to both it separates the two backends that have no key
and no contract from the three that do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The agent had thirteen tools and no way to search. `fetch` reads a URL
it is handed; nothing produced one, so looking up a CVE, a changelog or
an error string meant the model guessing an address. Provider-native
search was not wired either: every kind sent only local function
declarations, and the parsers knew `text`, `tool_use` and
`function_call` and nothing else.
Two paths now, and exactly one is live per turn. A model with the
`webSearch` ability on an endpoint that actually implements the server
tool gets the provider's own search; everything else gets a
`web_search` tool backed by a backend configured once in Settings.
Offering both would put two tools in front of the model for one job and
let its guess decide whether the search appears as a card beforehand or
a record afterwards.
Chat Completions is excluded. `web_search_options` exists there on two
`*-search-preview` SKUs, while `.openAI` is also every compatible
gateway this app talks to, and an unknown tool type is a 400 on every
message rather than a missing feature.
The wire protocol alone turned out not to be enough. A server tool
exists where a vendor's API and that vendor's model meet, and a gateway
breaks the pairing: an OpenAI-shaped `/responses` endpoint pointed at
Claude accepts `{"type": "web_search"}` and forwards it as an ordinary
function declaration. The model calls it, native mode has deliberately
withheld the local tool, and the run reports `Unknown tool: web_search`.
So the model family has to agree with the endpoint before native is
chosen.
A native search never reaches the tool pipeline, so nothing would be
approved, recorded or shown, and the assistant would cite pages the
transcript never mentions reaching. `recordNativeWebSearch` raises an
ordinary card for it after the fact — but that card is a record, not a
turn. Appending it to the model-facing transcript replays a
`function_call_output` whose call id matches nothing, which OpenAI
answers with `No tool call found for function call output`, and only on
the *second* request, because the first is what plants the orphan.
`priorMessages` is persisted before the request goes out, so an affected
conversation keeps the orphan across restarts; incoming history is now
filtered for tool results that answer no call, which repairs it.
Anthropic's `server_tool_use` streams `input_json_delta` exactly like
`tool_use`. Read as a tool call it produces one with an empty name, and
`AgentRuntime` rejects that — so a single native search failed the whole
assistant turn. `AnthropicStreamState` exists for this.
Bing is the keyless default, and reachability is the reason: it answers
from mainland China and DuckDuckGo does not. Its relevance is the
weaker half of that trade. Probed live, `mkt` is not optional — without
it Bing returns a well-formed page, correctly titled with the query, of
results for something else entirely, which is the worst failure
available here because the parse succeeds and the model is handed
confident nonsense. Even with it, an anonymous request degrades to
roughly first-token matching: "CVE-2024-3094 xz backdoor" returns
cve.org's landing page. Browser headers, a cookie jar, `form=` and
`setlang`/`cc` moved none of it. DuckDuckGo returns the NVD record, the
CVE record and the Wikipedia article for the same query and is offered
for anyone who can reach it.
Snippets only, from every backend. Each can return page text, and taking
it would make search a second way to read the web — one whose
destination is picked by the engine rather than classified by us.
Leaving it out keeps `fetch` the single door, with its address
classification, redirect budget and private-network escalation still on
the path. `web_search` is `.locallyReadOnly` for a public backend to
match what `fetch` became, and escalates for exactly the destinations
`fetch` escalates for.
DeepSeek v4 needed no code: its `/responses` implements the identical
tool type and returns identical `web_search_call` items, so it only had
to be recognised. Recognition is a regex over the model id, applied
outside the reported-abilities guard on purpose — no `/models` payload
carries server-side search and Pi's catalog has no field for it, so a
source listing abilities never means it ruled search out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ssh_exec` and every SFTP tool take `host_id` as a required UUID, and
`list_hosts` was the model's only source of one. So acting on the pane
the user is looking at cost a round trip and then a match of "here"
against `display_name` — and two hosts called `web-01` in different
groups is an ordinary Vault. A wrong guess there runs a command on the
wrong server, which makes this a correctness fix rather than a
shortcut.
The session context now carries the focused pane's id. It is not a pill:
an opaque UUID tells the user nothing the host name does not, and the
host pill already discloses which machine is being described.
`host` becomes `host_name` so the two read as the pair they are. The
block instructs nothing, so a key spelled exactly like the argument it
belongs in is doing work a sentence would otherwise have to do.
A quick-connect target has an id but no Vault name, so an id alone now
counts as context worth sending — that is precisely the case where a
display name could not have identified the host at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ssh_exec` built and tore down a complete SSH connection every time it
ran. Its `defer` freed the session and closed the transport before
returning, so each command paid a TCP connect, a handshake, a host-key
check, and a public-key or Secure Enclave signature. With a jump host
the whole route was rebuilt per hop, per command: an agent turn running
five commands behind one jump performed ten handshakes to do work that
needs one connection and five channels.
SFTP was pooled per `(runID, hostID)`, which is barely a pool — a run is
short — and it had a reentrancy hole. Two tool calls that both reached
the `await` on `connectSFTP` before either stored its client each built
a connection, and one was never disconnected.
The pool is now keyed by host alone and outlives runs and conversations,
which is what makes an idle timeout meaningful: run-scoped connections
are already bounded, so a sweep would rarely have fired. A follow-up
message reuses the connection; three idle minutes gives it back. Exec
and SFTP stay on separate sessions so a long command cannot block a
directory listing — at most two connections per host, against one per
command before.
Two boundaries make reuse safe rather than merely cheap.
A retry only happens while the command is provably not running.
`channelUnavailable` is thrown for a dead session, a channel that would
not open, or a `process_startup` that failed; everything after startup
succeeds is rethrown unchanged. A socket that drops mid-read must not
silently run half an `rm` a second time.
A command that timed out or was cancelled leaves the channel mid-stream
with the remote possibly still working. Closing it courteously means a
blocking `libssh2_channel_close` on the queue every later command for
that host has to wait behind, so the session is abandoned instead and
`libssh2_session_free` reclaims the channel with it.
Reuse also has to keep answering the same question. Each entry carries a
route fingerprint — host, port, username, known host keys, proxy, jump
chain — built only from records the store already holds. No Keychain
read: this runs before every command, and a rotated credential on an
unchanged identity is not a mismatch, because the live session is still
the same box and the same login. Editing a host's address retires the
connection bound to the old one rather than running the next command on
a machine the user no longer means.
No connect or disconnect tool. The model does not manage this, so
putting four more definitions in every request would buy nothing.
`AgentRuntime.runCleanup` goes with it: closing pooled connections was
its only caller, and leaving it wired to a no-op invites someone to hang
per-run teardown off a pool that is no longer per-run.
The unit tests run against stubs and prove the pool's behaviour, not
libssh2's. That one connection now carries five commands still has to be
confirmed against a real host, in its `auth.log`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every cache these providers keep is a prefix cache, and the working
directory of a terminal is the most volatile value the app has. Putting
one inside the other meant a single `cd` between turns changed byte zero
of the request and missed the entire replayed conversation — on a BYOK
key, in an app whose whole purpose is moving around a filesystem.
Nothing sets `cache_control` yet, so Anthropic was unaffected, but
OpenAI and Google cache prefixes automatically: this was already
costing money, and it was a trap laid for the day explicit caching lands.
The context now rides the tail of the message list, immediately before
the turn it describes. History the model has already seen is immutable,
so `[system + history]` keeps hitting and the divergence is the newest
turn, which was never cacheable anyway. The system prompt goes back to
being byte-identical across every run, conversation, and user.
It is sent but not kept. `snapshot.messages` is the durable transcript,
and a block added there would be persisted, replayed, and — since
restore projects user rows into the visible list — eventually shown in
the user's own chat bubble. Leaving it out costs the next run one
uncached message at the tail and nothing before it.
Two consecutive user messages are already routine here: `.tool` rows
become `user` rows on all three adapters, so any parallel tool call
produces them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`clamp` walks upward first, so `clamp(.low)` on a model whose levels are
`[off, high]` — several Kimi builds — returns high. Across the bundled
catalog that is 70 models spending full reasoning effort to write six
words, with `off` sitting right there unused.
The upward bias is correct where `SecurityReviewClient` uses it: a review
that does not reason is worse than a slow one, so failing upward fails
safe. Titling is the opposite. It needs no reasoning, and a rejected
title just falls back to the opening line.
`clamp(.off)` is the one request the clamp cannot answer by walking up,
so it takes off where it exists and the lowest available level where it
does not: 847 models now send no reasoning field at all, and the models
left reasoning are the ten with no lower rung to stand on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Context was decoration. Three defects, all reported from use.
Session context followed the conversation. `ConversationModel` owned the
chips and the remote system info, and `refreshAssistantContext` stamped
the focused pane onto whichever chat the sidebar was showing — so sending
from the sidebar carried a host and directory into the Assistant tab,
which has no session to describe. It now lives on `WindowSidebarState`
as a `SessionContext`, and `ComposerView` takes it as a parameter
defaulting to `.none`. The Assistant tab passes nothing, so it cannot
show or send a session it does not have. Two bugs fell out with it:
leaving a Workspace clears the context instead of freezing the last
value, and opening a chat in the sidebar no longer marks it.
Nothing was sent. Only `RemoteSystemInfo` reached the model; host and
cwd reached nothing, and `ContextChip` had no payload at all — the
SSH-failure attachment was a label for a log pasted into the draft.
`SessionContext.modelContext` now renders one fenced data-only block
into the run's system prompt, chips carry their content, and
`appendUserTurn` folds attachments into the turn it returns. What was
uploaded is literally what the bubble shows.
Titles were the first message. `ConversationTitleService` mirrors
`CommandComposerService` — tool-free, strict JSON, validated — and
resolves `.chat` rather than adding a Titler role, per the argument
already written into `AIRole`. It runs once after the first reply and
persists to the `title` column that existed but was never read back, so
no migration. It reads `transcript`, not `messages`: a failed run writes
its reason to the visible list alone, and that is not a name.
`Pane.remoteSystemInfo` already withholds the probe when Shell
Integration is off, so the second gate in `AppModel` is gone — the value
sent is the value the chips were drawn from, and the two cannot
disagree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects in how a conversation drives and shows a run.
Stop never reached the runtime. `Task { await runtime?.cancel() }`
resolves the property when the task runs, which is after `stop()` has
already set it to nil, so the run carried on executing tools with nobody
listening — secrets undiscarded, SSH and SFTP connections uncleaned.
`messages` was replayed as the next run's prior context, but it is a
display projection: no tool calls, and no tool results at all, because
those are drawn as cards. The model lost everything it had already done,
and a tool-only turn came back as an assistant message with empty
content, which Anthropic and Google both answer with a 400. The
model-facing transcript is now kept beside the visible one and rebuilt
from the same events.
The runtime held the conversation through both its approval broker and
its interaction handler while the conversation held the runtime, so every
chat that had ever run an agent stayed alive for the session. All three
edges are weak now, and a conversation that is gone denies and cancels.
Alongside: messages and tool cards are merged into one ordered timeline
rather than drawn as two lists, a tool-only turn no longer leaves an
empty bubble, the transcript follows streaming text instead of only new
rows, and conversations live in one app-wide store so two windows share
instances rather than each building their own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`bytes.lines` discards empty lines, and a blank line is the only thing
that dispatches an SSE event. Every streamed chat response therefore
arrived as one undelimited blob: `parse` failed to decode it, the turn
produced no text and no tool calls, and the agent loop ended after a
single request. That is both "the assistant never says anything" and
"tool calls never continue".
Line splitting now lives beside the field accumulator, so the streaming
and buffered entry points frame identically — they did not before, which
is why the bug hid on the side without tests. CRLF, LF and bare CR all
terminate a line; a body that ends without a trailing blank line still
delivers its last event; and a single line is bounded so an endless one
cannot exhaust memory.
The legacy MCP HTTP+SSE transport reads through the same parser. Its
stream stays open, so the accumulator never flushed and the endpoint
handshake waited until it timed out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tool the runtime does not have produced an error result and no events
at all, so the transcript showed an empty assistant turn and nothing
else — "the model said nothing" rather than "the model called a tool we
do not have". It gets a card now.
Registering tools trapped on a duplicate name, and names partly come from
remote MCP servers: one server exposing both `list-hosts` and
`list.hosts` sanitizes to a collision and took the app down. First
registration wins, so a remote name cannot shadow a built-in, and MCP
names are disambiguated where they are minted.
Every event was handed to the repository, which meant an unstructured
task and a `ModelContext.save()` per streamed token, all on the main
actor — while recording nothing, because the store ignores message
events. Only run-scoped events reach storage now, and the store no longer
saves when it has written nothing.
Run history also matched stored messages by run id, so a conversation's
earlier turns — replayed into every later run — were re-inserted on each
save, colliding with the unique constraint. Matching by message id
instead leaves the rows that are already correct alone. Restoring now
orders by run before time, so two runs with close timestamps cannot
interleave.
Also: one shared URLSession rather than one per run that was never
invalidated, a bounded read of a failed response body, the write-only
`sawOutput` plumbing removed, an orphaned event stream finished rather
than left suspended, and `AgentRunPersisting` no longer ships default
implementations that could silently become the witness.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>