Skip to content

chore: release v0.8.0 — ranged file reads and an honest truncation marker (#31) - #32

Merged
mabry1985 merged 1 commit into
mainfrom
feat/ranged-file-reads
Sep 14, 2026
Merged

mabry1985 merged 1 commit into
mainfrom
feat/ranged-file-reads

Conversation

@mabry1985

Copy link
Copy Markdown
Member

Why

github_read_file and github_read_pr_file cut every read at 20,000 chars (out[:20000] + "\n… (truncated at 20000 chars)") and took no offset, so the rest of a large file couldn't be read through them. On the QA review panel this became the main limit on large PRs (#31):

  • A lane runs out of turns working around the cut. On fix(snapshot): export and import agents in the provider-registry shape (#3128) protoAgent#3521, find_removed_behavior ran 974 s and ended mid-investigation ("The PR head file is truncated at 20000 chars. Let me read the base version to see the unchanged tail…"). It never produced a findings array or a FINDER_STATUS line. graph/snapshot_op.py (870 lines) and graph/snapshot_import.py are both over the cap.
  • The cut gets reported as a broken file. Three rounds running, the structural lane posted a blocker: "graph/snapshot_op.py is truncated mid-statement at line 539 → SyntaxError". The file is intact and parses. The old cut landed mid-line, and the marker said nothing about how much was left or how to continue.

What changed

Ranged reads. Both tools take start_line: int = 1 and end_line: int = 0: 1-based and inclusive, with 0 meaning "to the end of the file" (still subject to the cap). The contents API always returns the whole file, so the range is sliced locally. The request is still --method GET with -f ref=… (#29), and the range never reaches GitHub.

One footer line that says how to continue (read_tools._render_file). It always names the file's total line count:

Case Footer
over the cap (cut at a line boundary, never mid-line) … [truncated: showed lines 1-487 of 870 (20000-char cap); continue with start_line=488]
…cut inside a caller range that stops before EOF …; continue with start_line=488, end_line=800]
a range returned in full, file continues … [showed lines 100-200 of 870; continue with start_line=201]
a range returned in full, to EOF … [showed lines 541-870 of 870; end of file]
the one mid-line exception: a single line longer than the whole cap (minified code) … [truncated mid-line: line 1 of 3 alone is 25000 chars (20000-char cap), showed its first 20000; continue with start_line=2]

The footer is always the last line, on its own line. The shown text, not counting the newline before the footer, stays within the cap. That is the same rule as the unchanged whole-file read, so a file of exactly 20000 chars is still returned untouched.

Small files are byte-identical. The default read (no range) of a file that fits comes back exactly as before, with no header and no footer. An explicit range always gets a footer.

Bad input is an Error: string, never a raise. start_line < 1, end_line < 0, and end_line < start_line are rejected before any gh call. A start_line past EOF says so and gives the line count, e.g. Error: start_line=871 is past the end of big.py — it has 870 line(s). github_read_pr_file names the PR head in that message and puts no content header over it.

Tool descriptions (what the model sees) state the 20000-char cap, that a cut lands on a line boundary with a marker (with the marker shown), and to page with start_line/end_line rather than guessing or reading the base version. A test pins the docstring's plain-literal 20000 to _MAX_FILE_CHARS. The paging contract is documented in PROTO.md §5 and summarized in the README.

The finder-prompt note from the issue's item 3 is deliberately not in this PR. The reviewer runs an operator override of code-review-structural.yaml, so the paging guidance lives in these tool descriptions, which every lane already sees.

Tests

tests/test_read_tools.py, with +35 tests against an 870-line synthetic file where 487 whole lines fit the cap:

  • Byte-identical small reads: "", one char, CRLF, blank lines, unicode, exactly 20000 chars, a 487-line file. Checked through both tools, and read_pr_file keeps its header.
  • Line-boundary cut: exact marker and numbers, body = lines 1-487 with line 487 whole and nothing of line 488, one char over the cap vs exactly at it.
  • Paging: following the marker reconstructs the whole file in two calls.
  • Ranged slices: first, middle, last, a single line, end_line past EOF (clamps), end_line=0. Checked through both tools.
  • Cut inside a requested range: the marker carries the caller's end_line.
  • Past EOF: start_line past EOF (both tools) and a ranged read of an empty file.
  • Bad ranges: four bad-range messages × both tools, with run_gh never called.
  • Mid-line exception: the oversize single line, and paging past it.
  • Descriptions: the cap, the marker, and start_line/end_line with defaults 1/0 in the args schema.
  • Randomized invariants (seeded, 3000 cases, including CRLF, empty lines, lines over the cap, random caps and ranges): the footer's numbers always describe the output exactly.
  • GET regression (fix(read): contents reads must be GETs — -f ref= made gh send POST, a 404 #29) extended: a ranged read on both tools is still a GET carrying the ref, and no range parameter appears in the gh argv.
  • Updated test: test_long_content_is_truncated now asserts the new mid-line marker.

Teeth check: I reverted the body to the pre-#31 behavior, a raw char cut ("".join(lines[a-1:last])[:cap]), and 5 tests failed: the line-boundary marker test, the at-vs-over-the-cap test, the paging test, the cut-inside-a-range test, and the randomized invariants.

Gates: these match CI.

  • uv run --no-project --python 3.12 --with-requirements requirements-dev.txt pytest -q --basetemp=…: 313 passed (278 on main before this change).
  • uvx ruff@0.15.10 check .: clean.
  • uvx ruff@0.15.10 format --check .: clean.

Release

This is a v0.8.0 minor release, because the tools gain new parameters. protoagent.plugin.yaml and pyproject.toml are bumped together (tests/test_version.py). In uv.lock only the project's own version line was edited; I did not re-lock.

Fixes #31

🤖 Generated with Claude Code

https://claude.ai/code/session_01LSRkzcPyaDrKSY7geFuGr3

…o continue (#31)

github_read_file and github_read_pr_file take start_line (1-based, default 1)
and end_line (inclusive, 0 = to the end), sliced locally from the whole file
the contents API returns (still --method GET with the ref as a query param).

The 20000-char cap now cuts at a LINE boundary, never mid-line, and ends with
one footer line naming the file's total line count and the next start_line:
  … [truncated: showed lines 1-487 of 870 (20000-char cap); continue with start_line=488]
A range returned in full ends with "… [showed lines A-B of N; continue with
start_line=B+1]" or "…; end of file]". A single line longer than the whole cap
is the one mid-line cut and says so ("truncated mid-line: line A of N alone is
L chars …"). The default read of a file that fits is byte-identical, as before.

Bad ranges and a start_line past EOF are Error strings, checked before any gh
call where possible. Tool descriptions state the cap, the marker, and to page
with start_line/end_line instead of guessing or reading the base version.

Release v0.8.0 (new tool parameters): manifest, pyproject, uv.lock project line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSRkzcPyaDrKSY7geFuGr3
@mabry1985

Copy link
Copy Markdown
Member Author

Live check against real GitHub (branch head 6563b613, loaded as the plugin package, github_read_file on protoLabsAI/protoAgent:graph/snapshot_op.py@main, 903 lines / 43,323 chars):

page 1: 20013 chars | … [truncated: showed lines 1-406 of 903 (20000-char cap); continue with start_line=407]
page 2: 20055 chars | … [truncated: showed lines 407-837 of 903 (20000-char cap); continue with start_line=838]
page 3: 3475 chars  | … [showed lines 838-903 of 903; end of file]
pages rebuild the file exactly: True

Following the continuation markers until end of file, then joining the pages without their marker lines, reproduces the raw gh api content byte for byte. Each cut falls on a line boundary, so no line is split between pages.

@protoreview protoreview Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA panel review — PASS

code-review-structural · head 6563b6132047 · formal

The PR adds line-range reads with an honest truncation marker to the GitHub read tools; all four LLM finders came back clean on the diff itself. The single surviving finding is a pre-existing nit in api.py (the diff does not touch that file) where fetch_issues inherits a state validator that accepts "merged" — a PR-only state — and forwards it to gh issue list rather than rejecting it with the documented message. Verification confirmed the finding at head SHA with no severity change. No panel disagreement; no coverage gaps.

Findings

Severity Location Finding Verified
nit api.py:40 fetch_issues accepts state="merged" via the shared _norm_state validator, which is valid for PRs but not for issues; the value passes validation and is forward… confirmed
findings JSON (machine-readable)
[
  {
    "file": "api.py",
    "line": 40,
    "severity": "nit",
    "category": "api-contract",
    "claim": "fetch_issues accepts state=\"merged\" via the shared _norm_state validator, which is valid for PRs but not for issues; the value passes validation and is forwarded to `gh issue list --state merged`, producing a generic gh stderr error instead of the documented validation message. (protopatch)",
    "evidence": "_norm_state (line 40-43) accepts (\"open\", \"closed\", \"merged\", \"all\"). fetch_issues (line 52-56) uses it as its sole state validator, so state=\"merged\" passes the check and reaches `gh issue list --state merged` \u2014 a command gh does not support (issues have no \"merged\" state). The resulting error is a generic gh stderr message classified by check_gh_error, not the clear \"state must be one of \u2026\" message the docstring promises. Fix: replace the _norm_state call in fetch_issues with an inline check against (\"open\", \"closed\", \"all\"), or parameterize _norm_state to accept the valid set. fetch_prs can keep using the four-state set.",
    "source": "protopatch",
    "verdict": "confirmed",
    "note": "Re-read api.py at head SHA: _norm_state returns s if s in (\"open\",\"closed\",\"merged\",\"all\"); fetch_issues calls it and only errors when it returns None, so state=\"merged\" passes and reaches `gh issue list --state merged`. Pre-existing (PR diff does not touch api.py); nit severity is appropriate."
  }
]

1 finding(s) excluded from the verdict by in-diff confinement (file not among this PR's changed paths):

  • api.py (nit) — fetch_issues accepts state="merged" via the shared _norm_state validator, which is valid for PRs but not for issues; the value passes validation and is forwarde

@mabry1985
mabry1985 merged commit b21520a into main Sep 14, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Large files are unreadable past 20,000 chars — add ranged reads and a truncation marker that says how to continue

1 participant