A tool parameter's JSON Schema declares {"type": "integer"} (or "boolean") and stops there, so its domain lives only in the code that consumes the number — and every consumer read an out-of-domain value as a different value rather than as a caller error. Five carriers, all measured 2026-10-08 on master (bb69103a), each with its own probe:
read
| call |
reading |
line_limit=3 (baseline) |
lines 1-3 of a 10-line file |
line_limit=-3 |
lines 1-7 — all_lines[start:start-3] slices from the end — and the continuation hint printed truncated at start_line=-2, a line number that cannot be asked for |
line_limit=0 |
all 10 lines: the value is falsy, so an or chain read it as absent and returned the default 1000-line window |
start_line=0 / -5 |
line 1: clamped by max(1, ...), silently |
start_line_byte_offset=-5 |
the whole line: clamped by max(0, ...) to 0, which is byte-identical to asking for no offset |
grep (one-match fixture)
| call |
reading |
context_before=1 (baseline) |
the block shows the match and the line before it |
context_before=-2 |
five.txt:3: with no line under it at all — max(0, i - context_before) starts after the match, so the matching line itself was dropped — and the summary read "the search stopped at its result budget (max_results=200) … so this count is a floor": a cut attributed to a cause that was not the cause, with the remedy ("raise max_results") aimed at it. The budget formula max_results * (2 + cb + ca) collapses to zero at cb = -2. |
max_results=0 |
the default 200 (falsy again) |
max_results=-1 |
one match, with the same budget sentence naming max_results=-1 |
bash / pwsh — echo HELLO under each value
timeout |
reading |
| absent |
HELLO |
5 |
HELLO |
0 |
(no output) / [timed out after 0ms] / [killed by signal: 9] — the command was killed with the value the caller sent, and the reading calls it a timeout |
-5 |
the same, with [timed out after -5000ms] |
"abc" |
HELLO, silently under the 30s default |
The daemon's watchdog already reads this field the other way: scheduler._tool_silence_seconds treats a non-positive value as unusable and bounds the call by the default, with a test stating why ("clamping it to the grace alone would fire the watchdog instantly on every such call"). Two readers of one datum with two answers is the defect.
edit — the carrier where the mistake writes
replace_all="false" (a string) is truthy, so content.replace(old, new) ran without the count: a two-occurrence old_string was replaced in both places and the call answered Made 2 replacements — a write where the caller had explicitly excluded it. Measured on a two-line fixture, before and after.
What the fix must do
A parameter's declared domain is enforced at the tool boundary, and an out-of-domain value is refused, naming the parameter, the value and the domain — never silently replaced by another value. Refusing is what every other caller error in this layer already does (an invalid regex, a missing path), and it is the difference between "the call did not run" and "the call ran and gave you a reading of a range nobody asked for".
- the rule lives in one place (
emrg/tools/base.py), so a new count parameter cannot be added without it;
- every carrier above refuses, and the refusal names the spelling the caller used (the aliases
offset/limit included);
- the schema says the domain before the call, so a model is not left to learn it from a refusal;
- tests in both directions per carrier: the out-of-domain value is refused and the in-domain value still returns exactly what it did before (a refusal that also swallows legal calls is not a fix).
Acceptance
read.line_limit < 1, read.start_line < 1, read.start_line_byte_offset < 0 → refused, nothing read.
grep.context_before/context_after < 0, grep.max_results < 1 → refused, and no search runs, so no budget sentence can be printed for a search that did not happen.
bash.timeout/pwsh.timeout ≤ 0 or unreadable → refused before any spawn; the command does not run and no timeout is reported.
edit.replace_all that is not true/false → refused; the file is untouched.
- Each of the above states its domain in the schema description.
- Every in-domain value keeps its current behaviour (pinned by tests).
Measured boundary, not fixed here
emrg/server/daemon.py::_handle_read_file — the GUI workspace panel's preview — reads the same line_limit with min(limit, self._MAX_READ_LINES) and no domain check, so a negative value there returns empty content with truncated: true. It is a client-protocol path rather than the tool layer (registered at daemon.py:2881, GUI caller at gui/main.js:697), so it is named here rather than changed in the same PR. No caller in the tree sends a non-positive value today.
The read.start_line_byte_offset schema sentence is blocked behind the open PR #1929, which rewrites that description block: adding it here would put both branches on the same lines. The behaviour is refused either way (pinned by tests/test_read_tool.py::test_read_refuses_a_negative_byte_offset); the sentence lands with that rewrite.
A tool parameter's JSON Schema declares
{"type": "integer"}(or"boolean") and stops there, so its domain lives only in the code that consumes the number — and every consumer read an out-of-domain value as a different value rather than as a caller error. Five carriers, all measured 2026-10-08 onmaster(bb69103a), each with its own probe:readline_limit=3(baseline)line_limit=-3all_lines[start:start-3]slices from the end — and the continuation hint printedtruncated at start_line=-2, a line number that cannot be asked forline_limit=0orchain read it as absent and returned the default 1000-line windowstart_line=0/-5max(1, ...), silentlystart_line_byte_offset=-5max(0, ...)to 0, which is byte-identical to asking for no offsetgrep(one-match fixture)context_before=1(baseline)context_before=-2five.txt:3:with no line under it at all —max(0, i - context_before)starts after the match, so the matching line itself was dropped — and the summary read "the search stopped at its result budget (max_results=200) … so this count is a floor": a cut attributed to a cause that was not the cause, with the remedy ("raise max_results") aimed at it. The budget formulamax_results * (2 + cb + ca)collapses to zero atcb = -2.max_results=0max_results=-1max_results=-1bash/pwsh—echo HELLOunder each valuetimeoutHELLO5HELLO0(no output)/[timed out after 0ms]/[killed by signal: 9]— the command was killed with the value the caller sent, and the reading calls it a timeout-5[timed out after -5000ms]"abc"HELLO, silently under the 30s defaultThe daemon's watchdog already reads this field the other way:
scheduler._tool_silence_secondstreats a non-positive value as unusable and bounds the call by the default, with a test stating why ("clamping it to the grace alone would fire the watchdog instantly on every such call"). Two readers of one datum with two answers is the defect.edit— the carrier where the mistake writesreplace_all="false"(a string) is truthy, socontent.replace(old, new)ran without the count: a two-occurrenceold_stringwas replaced in both places and the call answeredMade 2 replacements— a write where the caller had explicitly excluded it. Measured on a two-line fixture, before and after.What the fix must do
A parameter's declared domain is enforced at the tool boundary, and an out-of-domain value is refused, naming the parameter, the value and the domain — never silently replaced by another value. Refusing is what every other caller error in this layer already does (an invalid regex, a missing path), and it is the difference between "the call did not run" and "the call ran and gave you a reading of a range nobody asked for".
emrg/tools/base.py), so a new count parameter cannot be added without it;offset/limitincluded);Acceptance
read.line_limit< 1,read.start_line< 1,read.start_line_byte_offset< 0 → refused, nothing read.grep.context_before/context_after< 0,grep.max_results< 1 → refused, and no search runs, so no budget sentence can be printed for a search that did not happen.bash.timeout/pwsh.timeout≤ 0 or unreadable → refused before any spawn; the command does not run and no timeout is reported.edit.replace_allthat is nottrue/false→ refused; the file is untouched.Measured boundary, not fixed here
emrg/server/daemon.py::_handle_read_file— the GUI workspace panel's preview — reads the sameline_limitwithmin(limit, self._MAX_READ_LINES)and no domain check, so a negative value there returns empty content withtruncated: true. It is a client-protocol path rather than the tool layer (registered atdaemon.py:2881, GUI caller atgui/main.js:697), so it is named here rather than changed in the same PR. No caller in the tree sends a non-positive value today.The
read.start_line_byte_offsetschema sentence is blocked behind the open PR #1929, which rewrites that description block: adding it here would put both branches on the same lines. The behaviour is refused either way (pinned bytests/test_read_tool.py::test_read_refuses_a_negative_byte_offset); the sentence lands with that rewrite.