Skip to content

Add the read_document tool; send PDF attachments as text and page images - #55

Merged
nicklongmore86 merged 9 commits into
mainfrom
feat/read-document-tool
Oct 24, 2026
Merged

nicklongmore86 merged 9 commits into
mainfrom
feat/read-document-tool

Conversation

@nicklongmore86

Copy link
Copy Markdown
Owner

Design PR 3: the read_document agent tool, built on the sandboxed DocumentWorkerHost from #52.

What changed

Shared contract (src/main/tools/types.ts): ToolImage and ToolResult.images, the same edit as feat/provider-images. This PR only produces images. Provider code is untouched.

read_document (src/main/tools/read-document.ts, registered for every connection)

  • Input: path, plus one of pages ("3", "2-5", "10-"), start (continue from a page) or region ({page,x,y,width,height}, zoom up to 8x). Optional include_location.
  • Uses the same guard as read_file (granted folders, realpath, secret/protected blocklist) and openVerified (no links, single hard link, handle re-checked). The worker gets a copy of the bytes, never the path.
  • content: a header (path, type, size, pages, range shown, title/author), then the text page by page, then notes. The last line gives either a start: N hint or "End of document". images: rendered drawing/scan pages (label: "page 3"), the zoomed region, or the photo (upright, scaled, EXIF/GPS stripped). Capture time and camera come back as text. Location only with include_location.
  • No approval needed, but every call is audited. The entry records includeLocation when it was asked for and locationRead: true when a location was actually returned, plus the kind and the image count (new RunResult.audit, merged by defineTool).
  • HEIC, AVIF and Office files get a clear "not supported yet" (unsupported). Text files are pointed to read_file. Reader refusals and worker failures (timeout, crash, memory, a misbehaving worker) become clean error results with an audit category (unreadable, out-of-range, timeout, worker-failed).
  • Per-call limits come from Sandboxed document reader: sniffing, PDF (PDFium WASM) and images #52 (50 pages, 20 images, 100 MB, text budget). The tool also refuses files over 100 MB before reading them.

read_file: the binary refusal now says X is a PDF, not text; use read_document to read it. for PDFs and images.

Attachments: when a chat is sent, a PDF attachment is read by the reader:

  • its text goes in page by page, in a fenced block marked user-provided and untrusted;
  • drawn or scanned pages go as image blocks on the Claude API, with a note in their place on other connections (sendsImages);
  • results are cached per attachment id (16 PDFs), so a re-send doesn't parse again;
  • a PDF that can't be read becomes a placeholder that says why;
  • PDF chips are no longer greyed (canReceive).

Image attachments are left for the other PR.

Lifecycle: one DocumentWorkerHost per app (src/main/index.ts), shared by the tool and attachments and disposed on will-quit.

Verification

  • pnpm typecheck, pnpm lint and pnpm format:check: clean.
  • pnpm test (Vitest, whole repo): 125 files passed, 2 skipped. 2673 test cases passed, 34 skipped.
    • New: src/main/tools/read-document.test.ts uses a fake host. It covers output format, ranges/start/regions, input validation, the photo GPS gate and its audit fields, unsupported types, error mapping, and the guards (outside the grant, .env, protected userData, symlink out of the grant, hard link, folder, missing file, over 100 MB). It also covers the new read_file messages.
    • New: the PDF cases in src/main/chat/attachments.test.ts: fenced untrusted text, images vs notes, caching, failure placeholders, and session wiring.
  • pnpm build && xvfb-run -a pnpm test:e2e: 129 passed. That includes the new e2e/read-document.spec.ts (6 tests), which runs the real tools (out/main/tools.js, a new build entry) over the real worker:
    • a PDF: text, the drawn page as a decodable PNG, the start hint, a zoomed region;
    • a rotated JPEG with EXIF: comes back upright, with EXIF stripped, GPS only when asked, and locationRead audited;
    • guard refusals;
    • a password-protected PDF;
    • timeout and hostile-worker failures as clean tool errors, with main still responsive.

Notes for merging

  • src/main/tools/types.ts matches the contract exactly. feat/provider-images may also touch canReceive in src/renderer/src/attachments/model.ts (for images), which could give a one-line conflict there.
  • Until providers send tool images, images reach no model. Each content is written to make sense on its own.

🤖 Generated with Claude Code

…images

read_document reads PDFs and images inside the granted folders through the
sandboxed DocumentWorkerHost: text page by page with a continuation hint,
drawn/scanned pages and photos as ToolResult.images, page ranges, `start`,
zoomed regions and GPS only with include_location (audited as
includeLocation/locationRead). Same guard and verified open as read_file;
read-only and audited. HEIC/AVIF/Office answer "not supported yet"; reader
refusals and worker failures become clean tool errors.

Adds ToolImage / ToolResult.images (shared contract with feat/provider-images);
defineTool passes images through and merges RunResult.audit into the entry.
read_file's binary refusal points PDFs and images to read_document. PDF
attachments are converted when sending (fenced, untrusted), with drawn pages
as image blocks on the Claude API and a note elsewhere; results are cached per
attachment id and PDF chips are no longer greyed. One host per app, disposed
on quit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Co-authored-by: omnigent <noreply@omnigent.ai>
@nicklongmore86 nicklongmore86 added the windows-ci Run Windows CI on this PR label Oct 24, 2026
nicklongmore86 and others added 8 commits October 24, 2026 15:51
…eep e2e bundle out of the installer

- Tools' audit facts now go under args.extra and only allowlisted small scalars
  (kind, imageCount, locationRead) survive; a tool can no longer overwrite its
  summarized args or log arbitrary values.
- Test the 8x region zoom cap through the real PDF reader (exactly 8x, and a
  region needing 16x capped at 8x) instead of a junk input field.
- Exclude out/main/tools.js (the e2e-only entry) from app.asar; afterPack now
  fails the build if a test-only bundle is packaged.
- e2e: use the native realpath for the sandbox so Windows 8.3 temp names
  (RUNNER~1) match the guard's view.

Co-authored-by: omnigent <noreply@omnigent.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the e2e tools entry, the job launcher (and the pin compiled into it)
moves into out/main/chunks, which the afterPack check did not search.

Co-authored-by: omnigent <noreply@omnigent.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
afterPack accepted the pin if any .js under out/main held it, so a stray or
decoy file could satisfy the check. Now it starts from package.json `main`,
follows the static requires/imports of local chunks (resolved inside out/main,
symlinks included), refuses test-only entries, and requires exactly one
JOB_RUN_SHA256 definition there, equal to the packaged job-run.exe's hash.
It fails on zero or several pins and on imports it cannot resolve.

Co-authored-by: omnigent <noreply@omnigent.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The main build now writes out/main/.build-manifest.json (each chunk's static and
dynamic chunk imports, straight from Rollup) and compiles the pin in as the string
`job-run.exe-sha256:<digest>`. bundledPin walks only that manifest from the
production entry, fails closed on a missing manifest or entry, a listed chunk that
is missing or outside out/main, a reachable test-only entry, or a computed
require()/import(); and requires the marker exactly once in the graph and nowhere
else under out/main. Text in comments or strings no longer adds edges, and the
constant's (renamed) identifier no longer matters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Co-authored-by: omnigent <noreply@omnigent.ai>
Drop the computed-load regex and its method exemption: loader patterns
(globalThis.require, module.require, (0, require), createRequire aliases)
no longer matter. bundledPin() now fails on any symlink under out/main,
scans every file there for `job-run.exe-sha256:`, requires exactly one
occurrence with a valid 64-hex digest, and requires that file to be a
chunk reachable from the production entry in the Rollup manifest graph.
Any decoy anywhere, however it is loaded, is a second marker.

Leave out/main/.build-manifest.json out of the package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: omnigent <noreply@omnigent.ai>
Resolve the conflicts with #56 and send PDF page images through its vision path:

- canReceive(kind, vision): PDFs always (as text), images only with vision.
- toModelConversation takes #56's `vision` plus `readPdf`; a PDF's drawn pages
  go as image blocks unless vision is false, checked with isSendableImage and
  built with imageBlock, then budgeted and encoded by each client like any
  other image. The Claude-API-only sendsImages flag is gone.
- Tests: PDF pages reach a vision OpenAI-compatible connection as image_url
  and a non-vision one as a note; read_document's images reach the Claude API
  tool_result and the OpenAI-compatible user message against fake servers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Co-authored-by: omnigent <noreply@omnigent.ai>
… can use

- cachedPdfReading keeps at most 16 views and 64 MB in all (least recently
  used out first); a view larger than the cap is used once and not kept.
- A connection known not to see images reads its PDFs as text only (no
  page renders). A cached text-only view is read again with its pages when
  a later send can use them.
- toModelConversation reads image and PDF attachments newest first, as
  withImageBudget orders them, and asks each PDF for no more page images
  than the request's budget (20) has left, 8 at most; older PDFs get text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Co-authored-by: omnigent <noreply@omnigent.ai>
@nicklongmore86
nicklongmore86 merged commit 4139c94 into main Oct 24, 2026
4 checks passed
@nicklongmore86
nicklongmore86 deleted the feat/read-document-tool branch October 24, 2026 17:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

windows-ci Run Windows CI on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant