The four kinds and the words each produces, at the registry level and through
the four policy gates: a timeout names its deadline, a decline carries the
comment, and neither is reported as the other. The existing denial assertions
now expect the specific clause.
The prompts knew the difference already — one branch printed "timed out",
another fell through to a plain no — and then threw it away by returning
False/None.
Each returns a `Denial` now: the timed policies name their deadline, a cancel
is its own answer, and a non-interactive run says nobody could be asked. The
batch decision and the skill write decision carry it too.
The four policy gates phrased their refusal themselves, and all four said
"declined or timed out". They read the `Denial` their prompt returned instead:
the command denylist, the private-host fetch policy, the skill write and the
directory-access request each name the answer they got.
`get_fetch_policy_confirm_hook` no longer coerces the answer to `bool`, which
would have reported a timeout as an allow.
A refused tool call read "denied by user confirm", whether the human had said
no or the prompt had timed out — the model was told a decision it never got.
`Denial` (`agent/tools.py`) carries the kind — declined / timeout / cancelled /
no prompt — plus the deadline and the human's comment, and `Denial.clause()`
spells it once for every message that needs it. The soft gate and the batch
decision pass it through, so a timeout says its deadline and a missing hook
says nobody was asked.
The access hook short-circuited on YOLO mode and answered its own question
with a process-only grant, so `--yes` (and `/yolo on`) reached any path outside
the workspace without asking. That grant is a filesystem capability, not one
risky call: the command denylist and a skill write are asked either way, and so
is this one now.
`yolo_allow` stays as the process-scoped store a host fills when it approves
without persisting; nothing auto-approves it any more. A non-interactive run
denies, like the other policy prompts.
Registry cases (none / once / round / grant / YOLO / denial / error / handler
record / closest answer wins), the wire cap keeping the line, the three policy
paths that record their answer, and a pretty detail that ignores the header.
The existing confirm tests now assert the line they gain.
The line is for the model, and the human already saw the prompt. A detail that
parses the result (write_file's char count) must not see a header in front of
it; the verbose `[tool ok]` view keeps the whole text.
The command denylist, the private-host fetch policy and the skill write grant
asked the human and then said nothing about it in the result.
Each now records the answer for the running call: `user (process)` for this
call's override and `grant (process)` when an earlier one already covers the
program or host, `user (once|session)` / `grant (session)` / `config (policy)`
for a skill write. `request_directory_access` is unchanged: its own text already
names the level and scope it granted.
A tool result said nothing about the answer that let the call run: the human
approved a delete, the model saw only "deleted x", and a resumed transcript
could not tell a YOLO auto-approval from a prompt.
The soft gate now returns an `Approval` (`user` / `grant` / `yolo` / `config`
with the scope that answer reaches) and `execute_output` leads the result with
`[approval] user (once)`. A handler that prompts on its own records its answer
through `tools/context.py` (`collect_approvals` around one call), and the one
line printed is the answer closest to a human. A call that needed no
confirmation, a denial and an `error:` result carry no line.
The comments claimed a result above the ceiling has no document, which is not
what the code does: it has one, and the dropped tail is what that document
cannot serve.
The storage ceiling's notice trailed the head it was cut from, and the wire
cap sends the head: the only way to learn that a result had been cut was to
fetch a 2 MB body back through its document, which reads to the very end.
The notice now leads, says the tail is not readable, and `cap_tool_message`
names the same fact in the document marker, so neither the head nor the marker
presents a cut body as the whole output.
AGENTS.md, README, and /status now describe the request-prep cap and the
document tools (`read_document` / `search_document`). /status also shows the
storage ceiling.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Tool results no longer use a truncate token, so the shared `truncate_generic`
wrapper goes. `read_file` keeps its file token, and a user `@path` mention
keeps the memory token (its inlined text has no re-readable source).
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
run_argv, run_argv_batch, fetch, fetch_local, and the vcs tools now return
their output whole, so the request-prep cap trims it on the wire and names
its document. A fetched page or a long log reads on from the tool message,
with no re-fetch and no re-run.
The run_argv_batch run budget stays as an execution guard: it stops a
runaway batch from spawning further huge-output steps.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
A tool result now stays whole in its message (up to a storage ceiling) and
is trimmed on the request copy by the request-prep pass, where soft compact
already runs. The trimmed copy names the document that holds the rest, so
every surface (chat-completions, Responses, Messages) shares one rule.
New [agent] hard_max_tool_result_chars bounds the stored body; the wire cap
stays [agent] max_tool_result_chars.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
A tool result stays whole in its own tool message; a request-prep marker
names a document_id (the message's tool_call_id). read_document,
search_document, document_info, and list_documents resolve that id back to
the message body, so reading and searching a capped result needs no re-run.
The store binds on the host instance and reads the agent's live history,
which carries whole results and re-reads whatever a resume reloaded.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Once the limit prompt offered its three answers as notification buttons, the
read took the button path and skipped the prompt line the terminal used to
print: the human at the terminal saw the reason and then an indefinite wait,
with nothing saying what to type. It writes the line again, as every other
prompt that reads both surfaces already does.
A path outside the workspace is usually rejected while the model only wants a
scratch file: the refusal named the grant tool, so the model had to ask a human
for access it never needed. A write now also names `new_temporary_workspace`,
whose path needs no grant. Reads and commands keep the grant hint alone.
The answer tables lose the third button (two answers, or the prompt's own),
the popup's close and a body click both withdraw it, and the desktop section
says why every popup rings the terminal: the human is at another window by
construction — the announcement waits out the grace period — and no desktop
lets a program raise one.
A confirmation offers Allow once / Deny and a prompt's own ``choices`` are
the whole button set; a popup that goes out rings the bell once, and
withdrawing it afterwards — by click or close — adds nothing and never
answers the prompt. A reply field's text is an answer whatever it spells.
"Go to terminal" promised something no desktop can do — raise another
application's window — and the popup's own close button does as much, so it
was a third button that answered nothing. `REVIEW` leaves the answer
vocabulary; a click on the body (the platform's default action) or a close
still withdraws the notification and leaves the prompt waiting.
The bell now rings as the notification is shown. A prompt is only announced
after `grace_seconds` of nobody answering it, so the human is at some other
window — possibly another virtual desktop, where a popup is easy to miss — and
the terminal's bell is the only portable cue that says where to look. It rings
once per announcement; withdrawing the popup afterwards stays silent.
The body table gains the workspace (last line, `~` for home, following a
rebind), the desktop section says which popups expire and why, and "Go to
terminal" is described as what it does: withdraw, ring, leave the prompt
waiting — no window is raised, because no desktop allows it.
That a home path under `~` names the chat and follows a rebind, that a
timed prompt hands its deadline to the popup while an untimed one keeps it,
and that going to the terminal — by button or by clicking the body —
withdraws the notification, rings the bell and leaves the prompt waiting.
A notification said which prompt was waiting but not which chat, and two
windows look alike. The body's last line is now the workspace the chat runs
in, with the home directory shown as `~`, and it follows a session resumed
into — or rebound to — another directory: `ReplState` retargets the installed
notifier instead of freezing the directory the process started in.
A timed prompt has a deadline, the popup did not, so it could stay on screen
announcing an answer window that had already closed. The deadline is now the
popup's own timeout (rounded up to whole seconds); a prompt without one keeps
the old "until dismissed" behaviour.
"Go to terminal" (and a click on the body) withdrew the notification and left
the prompt waiting, but nothing showed the human *where*. No desktop lets a
program raise another window — Wayland has no such call and the X11 ones need
a helper per WM — so the terminal rings its bell instead.
The body is no longer "the reason or nothing": it is the question, the
reason behind `include_reason`, the object of the prompt and the deadline,
with the desktop keeping the lines apart and the terminal flattening them.
The tables gain the answers a question offers (a reply field and the way
back), the channels' click-to-terminal behaviour, and the secret prompt that
is announced nowhere at all.
What a prompt declares (question, object, deadline), how the two channels
render it (lines apart on the desktop, one line in a terminal), that a long
reason goes before the deadline does, that a question keeps its reply field
*and* the way back to the terminal, that a body click leaves the prompt
waiting, that a reply spelling "review" is still an answer, and that a secret
is never announced.
A tool confirm's notification said which tool asked and nothing else, and
the three timed prompts (denylist, directory access, skill write) hid the
clock: each of them denies on timeout, so a human reading the notification
late was answering a prompt that had already given up.
`_tool_facts` lifts the object out of the call — the program's name, or the
file's — and never an argument, a URL or a piece of code: those stay in the
reason, which only travels with `include_reason`. The timed prompts pass
their timeout as `expires_in`, and a batch names the tools it asks about the
same way instead of keeping them behind the reason.
The body was the reason line or nothing, so with the default
`include_reason = false` every notification was a bare title. It is now the
question, then the reason when the config allows it, then what the prompt is
about, and last the deadline — filled in that order until the budget is gone,
so a long reason drops out before "no answer in 30s = deny" does. A desktop
popup wraps and keeps the lines apart; a terminal escape sequence carries them
on one line.
Two answers were missing from the surface: a reply prompt had no button at all
(a question could not be sent back to the terminal), and the notification body
was not clickable. Clicking it is the freedesktop default action, so it now
withdraws the notification and leaves the prompt waiting — the same as the
"Go to terminal" button. A typed reply is recorded by `reply()`, which keeps a
reply that spells "review" an answer; a secret is never announced at all.