ADR 0029: Optional tool parameters
- Status: accepted
- Date: 2026-09-28
Context
Every parameter of a primitive was required, so a tool could not grow a
refinement without breaking its callers: fs-read could not take a line
range, index-grep a file glob, fs-edit a replace-all switch. Models
worked around the gaps through the shell (on the SWE-bench runs, about
1,100 sed -n, head/tail/cat and python -c reads against 586
fs-read calls), where the reads escape the file tools' guard rules and
their results the structure the window relies on.
Decision
A Go primitive may declare trailing parameters Optional.
- Optional parameters come last;
Registerrefuses a required one after an optional one, and optional parameters on a variadic primitive. - The tool schema leaves them out of
required. - A call may stop before any of them.
ArgsFromJSONleaves missing ornulloptional arguments at the end off: handlers see fewer arguments and branch onlen(c.Args); Scheme callers write(fs-read path)or(fs-read path 120 50). - An optional argument skipped before a later one that is given gets its
type's empty value (
"",0,#f,()), so handlers must treat the empty value as "not given" (fs-read's start 0 is line 1, index-grep's glob "" is every file). Amended 2026-09-28: the first version refused such calls ("argument context needs glob too"), and on the first SWE-bench dev tasks with these tools that was 10 of the 17 failed file tool calls: models pass options by name, not position. - Arity accepts any count from the required ones to the total.
- Guard checkers and previews already read arguments by position with a length check; that is now a rule for new ones too.
register-tooldoes not declare optional parameters yet: Scheme tools are all-required until a use needs more.
Consequences
Tools can gain options without new names or breaking project code. Replay is unaffected: requests carry the tool schemas recorded in the log.
Amendment 2026-10-06: arguments the model wrapped in a JSON string
Of 12,109 tool results in the 161 local session logs of 2026-09-21 to 10-05,
23 were refused for the shape of their arguments, and 12 of those were a
model wrapping JSON in a string: the whole object sent as a JSON string (7,
glm: six shell-exec and an fs-edit), and args sent as a JSON string
holding the list (5: qwen three times, glm, qwen3.8-max). Each cost a round
to be sent again. The other 11 were names the tool does not have (limit,
offset, timeout, commentary, parameters) and a missing path or
cmd.
scheme.Prim.TidyArgs unwraps the first two. When a reply arrives
(Loop.tidyCalls, before the log, the strategy and the tool read it), a
call's arguments that are a JSON string holding an object, and a list or
object parameter that is a JSON string holding a list or an object, are
replaced by what the string holds, and the log gets a note saying which call
and what was read that way. A string that is not exactly that JSON is left as
it is, and ArgsFromJSON still refuses it. The guard is unchanged and judges
what it always judged: the values ArgsFromJSON makes of the arguments, which
are the same values the plain call would give. Strategies that parse a call's
args (the test and check detection, shell.scm, the read tracking) see the
unwrapped text, so a wrapped go test is still counted as a test run.
Names the tool does not have stay errors. Ignoring a limit on fs-read
would tell the model a bound applied that did not, and ADR 38 already refuses
an unknown field for the same reason.
Amendment 2026-10-16: an MCP tool tells "left out" from false and 0
An optional parameter the model leaves out before one it gives arrives as
its type's empty value, and an MCP tool's handler left out of the call
every optional argument that was empty: "", 0 or #f. A false or a 0 the
model gave on purpose was therefore never sent, and the server applied its
default (a delete asked to be non-recursive went out recursive). An MCP
primitive now takes a parameter left out as void (scheme.Prim.SkipsAsVoid),
the handler and the confirm's preview leave out only void, and false, 0
and "" go to the server as given. Host primitives keep the typed empty
values. The regression is TestMCPSendsFalseAndZeroAsGiven.