ADR 0139 — A coding-tool refusal is something a model can act on
Context
Every refusal the coding tools made was a plain Error. toolResultFromError
scrubs anything that is not an AppError down to a bare
INTERNAL_SERVER_ERROR — correct, because an internal cause must not leave the
process — so a missing file, an ambiguous snippet, a file that already exists
and a path outside the root all reached the model as the same empty failure.
Observed, not theorised: in a run of nine models on one task, a model met two of these, concluded that writing files was unavailable to it, began writing everything into the workspace root, and finally stopped writing code at all in favour of prose about what it would have done.
The tools were unusable in a second way. apply_patch required a baseSha256
carried from the last read and defaulted to dryRun: true, so one edit was two
calls whose protocol the schema never showed. Half the models never managed an
edit; not finding a way to change a file, they rewrote whole files with
write_file and met its refusals too.
Decision
An ordinary outcome is a typed refusal; a host-level cause stays internal.
The boundary is the layer, not the errno. A model is an operator inside the
workspace: a refusal phrased from a relative path and the facts of its own
request tells it nothing it could not read with read_file. So typed refusals
are constructed in the tool layer, from context that layer knows.
contained-files.ts — shared with harness resource discovery — stays
AppError-free, and everything it throws keeps being scrubbed. Default-deny:
a cause with no case in the tool layer is internal.
ENOENT on a path the model named is NOT_FOUND; ENOENT from a root that
vanished is not, and reaches nobody. EACCES and ENOSPC have no case and stay
generic. The artifact store returning an inconsistent totalBytes is a host
breaking its contract, not an outcome — also generic.
The message travels inside the details it belongs to. toolResultFromError
renders details: appErr.details ?? { message }, so structured details displace
the message entirely: the natural way to write an informative refusal — a count
plus a sentence — would have arrived as {"occurrences":3} with no sentence.
codingRefusal copies the message into the details, which makes that
unrepresentable rather than merely discouraged, and puts the instruction in
hint, where the Agent envelope carries it as _hint.
edit_file replaces apply_patch. One clean path, not two forms of one
mutation. The digest becomes optional — oldText is itself a freshness guard
for the region being changed — and dryRun defaults to off, so an edit is one
call. A caller wanting the whole-file guarantee still passes the digest
read_file returns, and edit_file returns the new one so the next edit chains
without re-reading.
Dropping the mandatory digest removed the check that made a stale computation
visible, so the window it guarded is closed rather than narrowed: the read, the
occurrence count and the construction of the new content all happen inside
withCodingPathLock. Without that, two concurrent edits of different snippets
in one file would each build a whole file from the same base and the second
write would erase the first. The lock is process-local — nine agents in one
process are covered; two processes over one workspace are not.
write_file creates missing parents, and says what it created. Every
mainstream tool set does, and the observed failure was that models do not read
refusals — they conclude. The walk that finds the missing directories runs
before authorization and reports them in the authorization payload, because a
host cannot authorize a mutation it has not been told about; the creation
happens after. Containment is unchanged: mkdirat has no O_NOFOLLOW, so the
guarantee comes from the openDirectoryAt that follows each create and refuses
a symlink. Auto-creation introduces one new risk — a typo becomes a successful
write into a tree nobody meant to make — so createdDirectories names what
appeared, which is the only signal by which a model catches its own packags/.
Consequences
- The authorization union gains
edit,listandgloband losespatch. The dangerous case is not the exhaustive matcher the compiler catches: a default-allow matcher silently authorizes operations the host never approved, and a default-deny one silently killsedit_file. The migration says so. packages/core/tests/coding-tool-refusals.test.tsenumerates the tools fromAGENT_CODING_TOOL_NAMESand refuses one with no registered refusal, in the shapeoption-effectsalready uses. It asserts the serialized envelope, not the code alone — a gate checking only the code is green on exactly the message-displacement defect described above.- The Darwin backend gains
createDirectoryAt; it is the one part of this that cannot be proven on Linux, and the real-Darwin lane (→ ADR 0135) is where it is proven.