Class: Pikuri::Workspace::Read
- Inherits:
-
Tool
- Object
- Tool
- Pikuri::Workspace::Read
- Defined in:
- lib/pikuri/workspace/read.rb
Overview
The read tool as a Tool subclass: Read.new(workspace: ws)
produces a tool whose Tool#to_ruby_llm_tool wiring is identical
to any bundled tool's. Workspace is captured by the execute closure.
Output format
cat-n: each line "%6d\t%s" (6-col line number, tab, content). Chosen for
training-data familiarity — cat -n is everywhere, so even small local
models recognize it; opencode's "<n>: " saves tokens but trades
familiarity, pi omits numbers (cheapest, but no range citation or Edit
boundaries).
The numbering and DESCRIPTION's read-a-larger-window nudge were
confirmed as a pair by an outside-model review (Grok 4.6, 2026-08) —
don't trade either back for tokens.
Truncation rules
Line/byte windowing is delegated to FileType.read_as_text_paged,
which returns a Extractor::Page this tool renders (same windower
backs VectorDb::Tools::Read). Two limits, first to fire wins: a line
limit (DEFAULT_LIMIT, overridable via limit) and a byte cap
(MAX_BYTES, not a parameter, bypassable by paging via offset). Lines
over MAX_LINE_LENGTH are truncated with LINE_TRUNCATION_MARKER. (The
constants alias the PAGE_* ones on Extractor.)
PDF and other extracted formats
Which formats read as text is the Extractor registry's business:
with pikuri-pdf registered, PDFs are claimed by %PDF- ahead of the
binary refusal and extracted with one "--- Page N ---" header per page,
lazily where the format allows (a 500-page PDF's first window parses only
its pages). PDF line numbers are for citation only — PDFs aren't editable.
No extractable text (scanned/empty) → an LLM-actionable hint;
encrypted/malformed → "Error: ...". No OCR.
Image attachments
PNG/JPEG/GIF/WebP (magic-byte detected) route to Read.format_image ahead of
the binary sniff, returning a RubyLLM::Content: a metadata note ("Read
image: …") the model cites, plus the file as an attachment the model
looks at. +offset+/+limit+ are ignored (no line paging). Over
MAX_IMAGE_BYTES → "Error: image too large…" rather than a payload the
provider rejects; no auto-resize (see ideas/coding-agent.md). Vision
capability is not checked — a non-vision model's provider error is
something the LLM reacts to, cheaper than tracking model metadata.
Refusals (all as "Error: ...")
Path outside the workspace (Filesystem::Error), not found, EACCES, a directory (refused, but the refusal carries the directory's own entries — see Read.directory_refusal), image over MAX_IMAGE_BYTES, binary content (nothing in the registry claims it — the FileType.binary? heuristic catches archives/artifacts with no extension list), offset past EOF.
Sharing: P_one_agent — every successful read marks the path on its
Workspace, which is the read-before-edit gate's state, so a shared
instance would let one agent's read unlock another's Edit. with_workspace
rebinds a copy when a sub-agent needs a different tree.
Constant Summary collapse
- DEFAULT_LIMIT =
Returns default value of the
limitparameter (lines per call). Pikuri::Extractor::PAGE_DEFAULT_LIMIT
- MAX_LINE_LENGTH =
Returns per-line character cap; longer lines are truncated with LINE_TRUNCATION_MARKER.
Pikuri::Extractor::PAGE_MAX_LINE_LENGTH
- LINE_TRUNCATION_MARKER =
Returns suffix appended to lines truncated by MAX_LINE_LENGTH.
Pikuri::Extractor::PAGE_LINE_TRUNCATION_MARKER
- MAX_BYTES =
Returns hard byte cap on input content per call (line bytes + the joining newline; rendered output is larger due to the
"%6d\t"prefix). Pikuri::Extractor::PAGE_MAX_BYTES
- MAX_BYTES_LABEL =
Returns human-readable MAX_BYTES for the continuation marker.
"#{MAX_BYTES / 1024} KB"- MAX_IMAGE_BYTES =
Returns hard size cap on inline-attached images (Anthropic's per-image limit; same order on OpenAI/Gemini). Above it we refuse rather than encode a payload the provider would reject.
5 * 1024 * 1024
- MAX_IMAGE_BYTES_LABEL =
Returns human-readable MAX_IMAGE_BYTES for refusal messages.
"#{MAX_IMAGE_BYTES / (1024 * 1024)} MB"- DESCRIPTION =
Description shown to the LLM (opencode-shape). Per-parameter constraints live in the parameter descriptions.
<<~DESC Read a file from the workspace and return its contents with line numbers. Usage: - Output is line-numbered in `cat -n` style so subsequent edits can reference exact line numbers. - Use `offset` and `limit` to page through large files; when the response ends in `Use offset=N to continue`, call again with that offset. - Lines longer than #{MAX_LINE_LENGTH} chars are truncated with a marker — use `grep` for content inside such files. - PDFs are text-extracted page-by-page with `--- Page N ---` markers in the output. Cite pages back to the user from those markers. PDFs cannot be modified with `edit`. - PNG / JPEG / GIF / WebP files are attached as images you can see directly, alongside a short text note with the path and size. Requires a vision-capable model; on a text-only model the provider will reject the call. Images cannot be modified with `edit`. - Other binary files (archives, compiled artifacts) are refused; this tool reads text otherwise. - If unsure of the path, use `glob` first to look up filenames. - Avoid tiny repeated slices — if you need more context, read a larger window. DESC
Class Method Summary collapse
-
.read(workspace:, path:, offset:, limit:) ⇒ String, RubyLLM::Content
Resolve
pathagainstworkspace, refuse directories/binaries/missing files, return the cat-n slice or an"Error: ..."observation.
Instance Method Summary collapse
- #initialize(workspace:) ⇒ Read constructor
-
#with_workspace(workspace) ⇒ Read
A new Read bound to
workspace.
Constructor Details
#initialize(workspace:) ⇒ Read
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/pikuri/workspace/read.rb', line 118 def initialize(workspace:) super( name: 'read', description: DESCRIPTION, parameters: Parameters.build { |p| p.required_string :path, 'Path to the file to read. Relative paths ' \ 'resolve against the workspace root, e.g. ' \ '"lib/foo.rb" or "/abs/path/to/file.txt".' p.optional_integer :offset, 'Line number to start reading from (1-indexed). ' \ "Defaults to 1, e.g. 200." p.optional_integer :limit, 'Maximum number of lines to read. Defaults to ' \ "#{DEFAULT_LIMIT}, e.g. 500." }, execute: ->(path:, offset: 1, limit: DEFAULT_LIMIT) { Read.read(workspace: workspace, path: path, offset: offset, limit: limit) }, trifecta_legs: Tool::TrifectaLegs.new( private: workspace.filesystem.private?, untrusted: workspace.filesystem.trusted? ? :none : :hard, egress_payload_review: :no_egress ) ) end |
Class Method Details
.read(workspace:, path:, offset:, limit:) ⇒ String, RubyLLM::Content
Resolve path against workspace, refuse directories/binaries/missing
files, return the cat-n slice or an "Error: ..." observation.
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 |
# File 'lib/pikuri/workspace/read.rb', line 166 def self.read(workspace:, path:, offset:, limit:) return "Error: offset must be >= 1, got #{offset}" if offset < 1 return "Error: limit must be >= 1, got #{limit}" if limit < 1 resolved = workspace.resolve_for_read(path) return "Error: file not found: #{path}" unless resolved.exist? return directory_refusal(workspace: workspace, path: path, resolved: resolved) if resolved.directory? mime = Pikuri::FileType.detect_mime(resolved) return format_image(path: path, resolved: resolved, mime: mime) if mime&.start_with?('image/') page = Pikuri::FileType.read_as_text_paged( resolved, offset: offset, limit: limit, max_bytes: MAX_BYTES, max_line_length: MAX_LINE_LENGTH ) # Record the read so a later edit/overwrite clears the read-before-edit # gate. Any successful text/PDF read counts (even a partial window) — # the exact-bytes guard is Edit's +old_string+ match; the ledger only # attests "engaged via read". workspace.mark_read(resolved) render_page(page) rescue Filesystem::Error => e "Error: #{e.}" rescue Errno::EACCES => e "Error: cannot read #{path}: #{e.}" rescue ArgumentError # Nothing in the Extractor registry claimed the content — # read_as_text_paged's binary refusal (directories and images # were already handled above). "Error: cannot read binary file: #{path}" rescue RuntimeError => e # Extraction failure (malformed / unsupported PDF, ...) # surfaced by read_as_text_paged. "Error: #{e.}" end |
Instance Method Details
#with_workspace(workspace) ⇒ Read
A new Pikuri::Workspace::Read bound to workspace. Used by
SubAgent::SubAgentTool when a persona needs a fresh (temp)
workspace, so paths resolve against the right root and reads land in
that workspace's ledger.
152 153 154 |
# File 'lib/pikuri/workspace/read.rb', line 152 def with_workspace(workspace) self.class.new(workspace: workspace) end |