Skip to content

emrg: a tool parameter outside its declared domain is read as a different value — five carriers (and one that writes) #1935

Description

@how2how2how2-arch

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

  1. read.line_limit < 1, read.start_line < 1, read.start_line_byte_offset < 0 → refused, nothing read.
  2. 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.
  3. bash.timeout/pwsh.timeout ≤ 0 or unreadable → refused before any spawn; the command does not run and no timeout is reported.
  4. edit.replace_all that is not true/false → refused; the file is untouched.
  5. Each of the above states its domain in the schema description.
  6. 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.

Activity

  1. how2how2how2-arch commented on Oct 8, 2026

    @how2how2how2-arch
    CollaboratorAuthor

    Handled by #1936

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions