Module: Rubino::Util::Output
- Defined in:
- lib/rubino/util/output.rb
Overview
Smart truncation of long tool output for the scrollback preview.
Rule shape (5 head + 10 tail + marker, threshold 30) follows the pattern that emerged from surveying Codex, Gemini CLI, Roo, and Aider: tail bias because errors, exit codes, and command summaries live at the end. A head-heavy split (which would be intuitive for “show me the start”) consistently hides the part the user actually needs when something failed.
The FULL output still goes to the model and the session DB — this is only what the user sees in the live scroll. The marker tells them so they don’t think they’re missing something irrecoverable.
Constant Summary collapse
- DEFAULT_MAX =
30- DEFAULT_HEAD =
5- DEFAULT_TAIL =
10
Class Method Summary collapse
-
.elide(text, max) ⇒ String
Single-line elision to
maxcharacters with a trailing ellipsis. -
.first_line(text, max) ⇒ Object
First NON-BLANK line, elided to
maxchars (max-1 + “…”). -
.first_nonblank_line(text) ⇒ Object
First NON-BLANK line of
text, stripped (or “” when all-blank). -
.preview(text, max: DEFAULT_MAX, head: DEFAULT_HEAD, tail: DEFAULT_TAIL) ⇒ String
Returns either the full text (when total lines <= max) or a head + marker + tail preview.
- .tail_bias_bytes(text, max_bytes, spill_path = nil) ⇒ Object
- .tail_bias_lines(text, max_lines, spill_path = nil) ⇒ Object
-
.truncate(text, max_bytes:, max_lines:, spill: nil) ⇒ Object
Truncates long tool output to stay within byte/line limits, with tail-bias because the part the agent (and a human reading the log) actually need is at the end: exit-code suffix, error message, backtrace, “X failures” line.
Class Method Details
.elide(text, max) ⇒ String
Single-line elision to max characters with a trailing ellipsis. Shared by the parent-note tools (AnswerChild/Task/Steer) that all carried a byte-identical private ‘truncate`. Pure function.
52 53 54 55 |
# File 'lib/rubino/util/output.rb', line 52 def self.elide(text, max) s = text.to_s s.length > max ? "#{s[0, max]}…" : s end |
.first_line(text, max) ⇒ Object
First NON-BLANK line, elided to max chars (max-1 + “…”). The single source for the subagent card and view rows, which carried a byte-identical private copy. Distinct from #elide (which keeps max chars before the ellipsis) — this row shape budgets the ellipsis IN.
70 71 72 73 |
# File 'lib/rubino/util/output.rb', line 70 def self.first_line(text, max) first = first_nonblank_line(text) first.length > max ? "#{first[0, max - 1]}…" : first end |
.first_nonblank_line(text) ⇒ Object
First NON-BLANK line of text, stripped (or “” when all-blank). A multi-line ruby/shell command often starts with a blank line, so a naive ‘.lines.first` rendered an empty approval/activity hint (#141). Pure function shared by the subagent card / view rows and the task tool’s approval preview, which each carried this extraction inline.
62 63 64 |
# File 'lib/rubino/util/output.rb', line 62 def self.first_nonblank_line(text) text.to_s.each_line.map(&:strip).find { |l| !l.empty? }.to_s end |
.preview(text, max: DEFAULT_MAX, head: DEFAULT_HEAD, tail: DEFAULT_TAIL) ⇒ String
Returns either the full text (when total lines <= max) or a head + marker + tail preview. Pure function — no side effects, no IO. Caller decides where to render the result.
31 32 33 34 35 36 37 38 39 40 41 42 43 |
# File 'lib/rubino/util/output.rb', line 31 def self.preview(text, max: DEFAULT_MAX, head: DEFAULT_HEAD, tail: DEFAULT_TAIL) return "" if text.nil? || text.to_s.empty? lines = text.to_s.lines.map(&:chomp) return lines.join("\n") if lines.size <= max omitted = lines.size - head - tail head_pt = lines.first(head) tail_pt = lines.last(tail) marker = "… [#{omitted} more lines · full in DB] …" (head_pt + [marker] + tail_pt).join("\n") end |
.tail_bias_bytes(text, max_bytes, spill_path = nil) ⇒ Object
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
# File 'lib/rubino/util/output.rb', line 102 def self.tail_bias_bytes(text, max_bytes, spill_path = nil) encoding = text.encoding recover = spill_path ? " · full output saved to #{spill_path} — read it with offset/limit" : "" marker_template = "\n... [%d bytes elided#{recover} · use grep/head to narrow] ...\n" marker_max = (marker_template % 999_999_999).bytesize head_budget = (max_bytes * 0.1).to_i tail_budget = max_bytes - head_budget - marker_max # Below ~200 bytes the marker eats the entire budget, so fall back # to a simple head truncation (old behavior). Realistic caps go # through the head+tail path. if tail_budget <= 0 truncated = text.byteslice(0, max_bytes).to_s.force_encoding(encoding).scrub("") tail_note = spill_path ? " · full output: #{spill_path}" : "" return "#{truncated}\n... [truncated at #{max_bytes} bytes#{tail_note}]" end head = text.byteslice(0, head_budget).to_s.force_encoding(encoding).scrub("") tail = text.byteslice(-tail_budget, tail_budget).to_s.force_encoding(encoding).scrub("") elided = text.bytesize - head.bytesize - tail.bytesize "#{head}#{format(marker_template, elided)}#{tail}" end |
.tail_bias_lines(text, max_lines, spill_path = nil) ⇒ Object
125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 |
# File 'lib/rubino/util/output.rb', line 125 def self.tail_bias_lines(text, max_lines, spill_path = nil) lines = text.lines return text if lines.size <= max_lines recover = spill_path ? " · full output saved to #{spill_path} — read it with offset/limit" : "" head_count = [max_lines / 10, 5].max tail_count = max_lines - head_count - 1 # Vanishing budget falls back to head-only truncation. if tail_count <= 0 tail_note = spill_path ? " · full output: #{spill_path}" : "" return "#{lines.first(max_lines).join}\n... [truncated at #{max_lines} lines#{tail_note}]" end elided = lines.size - head_count - tail_count head = lines.first(head_count).join tail = lines.last(tail_count).join "#{head}... [#{elided} lines elided#{recover} · use grep/head to narrow] ...\n#{tail}" end |
.truncate(text, max_bytes:, max_lines:, spill: nil) ⇒ Object
Truncates long tool output to stay within byte/line limits, with tail-bias because the part the agent (and a human reading the log) actually need is at the end: exit-code suffix, error message, backtrace, “X failures” line. Head-only truncation drops exactly the bytes that matter when something blows up at byte 49,999.
Shape: keep ~10% head + bulk of the budget in the tail + a marker in the middle saying how many bytes/lines were elided. Mirrors the pattern #preview already uses for the scrollback body.
When spill is supplied it is called with the full pre-truncation text and must return a path (or nil); the marker then points the model at it, so the elided middle isn’t lost — the model can ‘read` the file with offset/limit to recover any part. (Claude-Code-style spill.) Pure aside from that injected callback.
90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/rubino/util/output.rb', line 90 def self.truncate(text, max_bytes:, max_lines:, spill: nil) text = text.to_s over_bytes = text.bytesize > max_bytes over_lines = text.lines.size > max_lines return text unless over_bytes || over_lines spill_path = spill&.call(text) text = tail_bias_bytes(text, max_bytes, spill_path) if over_bytes text = tail_bias_lines(text, max_lines, spill_path) if text.lines.size > max_lines text end |