rubino-agent 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +11 -2
  3. data/AGENTS.md +1 -1
  4. data/CHANGELOG.md +172 -5
  5. data/CONTRIBUTING.md +10 -1
  6. data/README.md +14 -5
  7. data/Rakefile +31 -0
  8. data/docs/agents.md +42 -23
  9. data/docs/architecture.md +2 -2
  10. data/docs/commands.md +35 -3
  11. data/docs/configuration.md +20 -23
  12. data/docs/getting-started.md +5 -3
  13. data/docs/security.md +16 -5
  14. data/docs/skills.md +31 -0
  15. data/docs/troubleshooting.md +1 -1
  16. data/exe/rubino +16 -2
  17. data/install.sh +721 -59
  18. data/lib/rubino/active_agent.rb +73 -0
  19. data/lib/rubino/agent/action_claim_guard.rb +881 -0
  20. data/lib/rubino/agent/agent_registry.rb +5 -2
  21. data/lib/rubino/agent/definition.rb +1 -9
  22. data/lib/rubino/agent/fallback_chain.rb +0 -6
  23. data/lib/rubino/agent/iteration_budget.rb +109 -3
  24. data/lib/rubino/agent/loop.rb +476 -20
  25. data/lib/rubino/agent/model_call_runner.rb +81 -3
  26. data/lib/rubino/agent/prompts/build.txt +22 -5
  27. data/lib/rubino/agent/response_validator.rb +8 -0
  28. data/lib/rubino/agent/runner.rb +133 -8
  29. data/lib/rubino/agent/tool_executor.rb +166 -14
  30. data/lib/rubino/agent/truncation_continuation.rb +4 -1
  31. data/lib/rubino/api/server.rb +19 -0
  32. data/lib/rubino/attachments/classify.rb +35 -17
  33. data/lib/rubino/boot/config_guard.rb +71 -0
  34. data/lib/rubino/cli/chat/completion_builder.rb +42 -6
  35. data/lib/rubino/cli/chat/idle_card_host.rb +7 -1
  36. data/lib/rubino/cli/chat/session_resolver.rb +87 -21
  37. data/lib/rubino/cli/chat_command.rb +1189 -50
  38. data/lib/rubino/cli/commands.rb +282 -2
  39. data/lib/rubino/cli/config_command.rb +68 -8
  40. data/lib/rubino/cli/doctor_command.rb +204 -12
  41. data/lib/rubino/cli/jobs_command.rb +12 -0
  42. data/lib/rubino/cli/memory_command.rb +53 -20
  43. data/lib/rubino/cli/onboarding_wizard.rb +79 -6
  44. data/lib/rubino/cli/session_command.rb +172 -18
  45. data/lib/rubino/cli/setup_command.rb +131 -8
  46. data/lib/rubino/cli/skills_command.rb +183 -9
  47. data/lib/rubino/cli/trust_gate.rb +16 -7
  48. data/lib/rubino/commands/built_ins.rb +2 -0
  49. data/lib/rubino/commands/command.rb +12 -2
  50. data/lib/rubino/commands/executor.rb +149 -12
  51. data/lib/rubino/commands/handlers/agent_switch.rb +100 -0
  52. data/lib/rubino/commands/handlers/agents.rb +156 -41
  53. data/lib/rubino/commands/handlers/config.rb +4 -1
  54. data/lib/rubino/commands/handlers/help.rb +113 -14
  55. data/lib/rubino/commands/handlers/memory.rb +15 -5
  56. data/lib/rubino/commands/handlers/sessions.rb +26 -3
  57. data/lib/rubino/commands/handlers/status.rb +9 -4
  58. data/lib/rubino/commands/loader.rb +12 -0
  59. data/lib/rubino/config/configuration.rb +86 -24
  60. data/lib/rubino/config/defaults.rb +140 -33
  61. data/lib/rubino/config/loader.rb +62 -12
  62. data/lib/rubino/config/validator.rb +341 -0
  63. data/lib/rubino/config/writer.rb +123 -31
  64. data/lib/rubino/context/compressor.rb +184 -22
  65. data/lib/rubino/context/environment_inspector.rb +2 -2
  66. data/lib/rubino/context/file_discovery.rb +2 -2
  67. data/lib/rubino/context/message_boundary.rb +27 -1
  68. data/lib/rubino/context/project_languages.rb +90 -0
  69. data/lib/rubino/context/prompt_assembler.rb +105 -22
  70. data/lib/rubino/context/summary_builder.rb +45 -4
  71. data/lib/rubino/context/token_budget.rb +36 -11
  72. data/lib/rubino/context/token_estimate.rb +45 -0
  73. data/lib/rubino/context/tool_result_pruner.rb +81 -0
  74. data/lib/rubino/database/connection.rb +154 -3
  75. data/lib/rubino/database/migrations/001_create_initial_schema.rb +314 -40
  76. data/lib/rubino/database/migrator.rb +98 -5
  77. data/lib/rubino/documents/cap_exceeded.rb +13 -0
  78. data/lib/rubino/documents/converters/csv.rb +4 -3
  79. data/lib/rubino/documents/converters/docx.rb +29 -5
  80. data/lib/rubino/documents/converters/html.rb +5 -1
  81. data/lib/rubino/documents/converters/json.rb +2 -1
  82. data/lib/rubino/documents/converters/pdf.rb +11 -2
  83. data/lib/rubino/documents/converters/plain.rb +2 -1
  84. data/lib/rubino/documents/converters/pptx.rb +11 -2
  85. data/lib/rubino/documents/converters/xlsx.rb +35 -4
  86. data/lib/rubino/documents/converters/xml.rb +2 -1
  87. data/lib/rubino/documents/limits.rb +210 -0
  88. data/lib/rubino/documents.rb +10 -3
  89. data/lib/rubino/errors.rb +36 -5
  90. data/lib/rubino/interaction/cancel_token.rb +19 -3
  91. data/lib/rubino/interaction/events.rb +13 -0
  92. data/lib/rubino/interaction/lifecycle.rb +99 -13
  93. data/lib/rubino/interaction/polishing.rb +176 -0
  94. data/lib/rubino/jobs/cron_job_repository.rb +5 -8
  95. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +11 -0
  96. data/lib/rubino/jobs/handlers/distill_skill_job.rb +65 -9
  97. data/lib/rubino/jobs/queue.rb +63 -8
  98. data/lib/rubino/jobs/runner.rb +24 -6
  99. data/lib/rubino/jobs/worker.rb +0 -4
  100. data/lib/rubino/llm/adapter_response.rb +47 -4
  101. data/lib/rubino/llm/credential_check.rb +15 -16
  102. data/lib/rubino/llm/error_classifier.rb +89 -1
  103. data/lib/rubino/llm/inline_think_filter.rb +69 -12
  104. data/lib/rubino/llm/request.rb +30 -3
  105. data/lib/rubino/llm/ruby_llm_adapter.rb +394 -46
  106. data/lib/rubino/llm/tool_bridge.rb +113 -9
  107. data/lib/rubino/mcp/manager.rb +18 -1
  108. data/lib/rubino/mcp/mcp_tool_wrapper.rb +14 -3
  109. data/lib/rubino/memory/aux_retry.rb +107 -0
  110. data/lib/rubino/memory/backends/sqlite.rb +73 -44
  111. data/lib/rubino/memory/backends.rb +23 -7
  112. data/lib/rubino/memory/salience_gate.rb +103 -0
  113. data/lib/rubino/memory/sqlite_extraction.rb +70 -0
  114. data/lib/rubino/memory/sqlite_extraction_prompt.rb +11 -0
  115. data/lib/rubino/memory/store.rb +33 -5
  116. data/lib/rubino/memory/threat_scanner.rb +52 -0
  117. data/lib/rubino/output/cost.rb +52 -0
  118. data/lib/rubino/output/headless_block_latch.rb +53 -0
  119. data/lib/rubino/output/result_serializer.rb +222 -0
  120. data/lib/rubino/output/turn_recorder.rb +77 -0
  121. data/lib/rubino/security/approval_policy.rb +227 -32
  122. data/lib/rubino/security/command_allowlist.rb +79 -4
  123. data/lib/rubino/security/doom_loop_detector.rb +21 -2
  124. data/lib/rubino/security/hardline_guard.rb +189 -16
  125. data/lib/rubino/security/pattern_matcher.rb +28 -5
  126. data/lib/rubino/security/prefix_deriver.rb +25 -6
  127. data/lib/rubino/security/readonly_commands.rb +145 -5
  128. data/lib/rubino/security/secret_path.rb +134 -0
  129. data/lib/rubino/security/url_safety.rb +255 -0
  130. data/lib/rubino/session/repository.rb +212 -11
  131. data/lib/rubino/session/store.rb +139 -14
  132. data/lib/rubino/skills/installer.rb +230 -0
  133. data/lib/rubino/skills/prompt_index.rb +2 -2
  134. data/lib/rubino/skills/registry.rb +52 -1
  135. data/lib/rubino/skills/skill.rb +64 -3
  136. data/lib/rubino/skills/skill_tool.rb +16 -5
  137. data/lib/rubino/tools/background_tasks.rb +157 -13
  138. data/lib/rubino/tools/base.rb +204 -3
  139. data/lib/rubino/tools/edit_tool.rb +73 -18
  140. data/lib/rubino/tools/glob_tool.rb +48 -9
  141. data/lib/rubino/tools/grep_tool.rb +103 -9
  142. data/lib/rubino/tools/multi_edit_tool.rb +64 -9
  143. data/lib/rubino/tools/patch_tool.rb +5 -0
  144. data/lib/rubino/tools/read_attachment_tool.rb +3 -1
  145. data/lib/rubino/tools/read_tool.rb +33 -15
  146. data/lib/rubino/tools/read_tracker.rb +153 -35
  147. data/lib/rubino/tools/registry.rb +113 -12
  148. data/lib/rubino/tools/result.rb +9 -1
  149. data/lib/rubino/tools/ruby_tool.rb +0 -0
  150. data/lib/rubino/tools/shell_registry.rb +70 -0
  151. data/lib/rubino/tools/shell_tool.rb +40 -1
  152. data/lib/rubino/tools/summarize_file_tool.rb +6 -0
  153. data/lib/rubino/tools/task_stop_tool.rb +10 -16
  154. data/lib/rubino/tools/task_tool.rb +36 -8
  155. data/lib/rubino/tools/vision_tool.rb +5 -0
  156. data/lib/rubino/tools/webfetch_tool.rb +39 -7
  157. data/lib/rubino/tools/websearch_tool.rb +92 -30
  158. data/lib/rubino/tools/write_tool.rb +23 -4
  159. data/lib/rubino/ui/api.rb +10 -1
  160. data/lib/rubino/ui/base.rb +11 -0
  161. data/lib/rubino/ui/bottom_composer.rb +382 -74
  162. data/lib/rubino/ui/cli.rb +515 -83
  163. data/lib/rubino/ui/completion_menu.rb +11 -7
  164. data/lib/rubino/ui/headless_trace.rb +63 -0
  165. data/lib/rubino/ui/live_region.rb +70 -7
  166. data/lib/rubino/ui/markdown_renderer.rb +142 -7
  167. data/lib/rubino/ui/notifier.rb +0 -2
  168. data/lib/rubino/ui/null.rb +52 -5
  169. data/lib/rubino/ui/paste_store.rb +16 -2
  170. data/lib/rubino/ui/queued_indicators.rb +6 -1
  171. data/lib/rubino/ui/status_bar.rb +61 -7
  172. data/lib/rubino/ui/streaming_markdown.rb +59 -6
  173. data/lib/rubino/ui/subagent_view.rb +29 -4
  174. data/lib/rubino/ui/tool_label.rb +52 -0
  175. data/lib/rubino/update_check.rb +39 -4
  176. data/lib/rubino/util/atomic_file.rb +117 -0
  177. data/lib/rubino/util/ignore_rules.rb +120 -0
  178. data/lib/rubino/util/output.rb +229 -12
  179. data/lib/rubino/util/secrets_mask.rb +70 -7
  180. data/lib/rubino/util/spill_store.rb +153 -0
  181. data/lib/rubino/version.rb +1 -1
  182. data/lib/rubino/workspace.rb +9 -1
  183. data/lib/rubino.rb +191 -7
  184. data/rubino-agent.gemspec +1 -0
  185. data/skills/ruby-expert/SKILL.md +1 -0
  186. metadata +42 -12
  187. data/lib/rubino/agent/router.rb +0 -65
  188. data/lib/rubino/database/migrations/002_create_runs.rb +0 -45
  189. data/lib/rubino/database/migrations/003_create_skill_states.rb +0 -15
  190. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +0 -36
  191. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +0 -27
  192. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +0 -34
  193. data/lib/rubino/database/migrations/007_create_messages_fts.rb +0 -59
  194. data/lib/rubino/database/migrations/008_create_memory_facts.rb +0 -75
  195. data/lib/rubino/database/migrations/009_create_memory_graph.rb +0 -55
  196. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +0 -20
@@ -29,14 +29,23 @@ module Rubino
29
29
  File.extname(path.to_s).downcase == ".pdf"
30
30
  end
31
31
 
32
- def convert(path)
32
+ def convert(path, budget = Limits.null_budget)
33
33
  require "pdf/reader"
34
34
  reader = PDF::Reader.new(path)
35
- pages = reader.pages.map { |page| page_text(page) }
35
+ pages = []
36
+ # budget.tick per page bails a page bomb DURING extraction, and the
37
+ # accumulated-bytes cap bounds a single pathologically dense page.
38
+ reader.pages.each do |page|
39
+ txt = page_text(page)
40
+ budget.tick(bytes: txt.bytesize)
41
+ pages << txt
42
+ end
36
43
  text = pages.reject(&:empty?).join("\n\n")
37
44
  return scanned_note if text.strip.empty?
38
45
 
39
46
  text
47
+ rescue Rubino::Interrupted, CapExceeded
48
+ raise
40
49
  rescue PDF::Reader::MalformedPDFError, PDF::Reader::UnsupportedFeatureError
41
50
  scanned_note
42
51
  end
@@ -42,8 +42,9 @@ module Rubino
42
42
  MARKDOWN_EXTS.include?(ext) || LANGS.key?(ext)
43
43
  end
44
44
 
45
- def convert(path)
45
+ def convert(path, budget = Limits.null_budget)
46
46
  raw = File.binread(path).to_s.dup.force_encoding("UTF-8")
47
+ budget.add_bytes(raw.bytesize)
47
48
  raw = raw.scrub("�") unless raw.valid_encoding?
48
49
  ext = File.extname(path.to_s).downcase
49
50
 
@@ -26,10 +26,19 @@ module Rubino
26
26
  File.extname(path.to_s).downcase == ".pptx"
27
27
  end
28
28
 
29
- def convert(path)
29
+ def convert(path, budget = Limits.null_budget)
30
30
  require "ruby_powerpoint"
31
+ # PRE-OPEN guard against a slide/text zip-expand bomb (see Docx). Sum
32
+ # EVERY entry under ppt/ -- including a bomb hidden at a nested/non-
33
+ # standard path behind a .rels Target. `ppt/**` matches across `/`
34
+ # (guard_zip! globs without FNM_PATHNAME) so a deep bomb is caught (#337).
35
+ Limits.guard_zip!(path, budget, ["ppt/**"])
31
36
  ppt = RubyPowerpoint::Presentation.new(path)
32
- parts = ppt.slides.each_with_index.map { |slide, i| slide_markdown(slide, i + 1) }
37
+ parts = ppt.slides.each_with_index.map do |slide, i|
38
+ md = slide_markdown(slide, i + 1)
39
+ budget.tick(bytes: md.to_s.bytesize)
40
+ md
41
+ end
33
42
  parts.compact.join("\n\n")
34
43
  end
35
44
 
@@ -30,10 +30,26 @@ module Rubino
30
30
  EXTS.include?(File.extname(path.to_s).downcase)
31
31
  end
32
32
 
33
- def convert(path)
33
+ # OpenDocument (ODS) body globs: roo reads `content.xml` at the archive
34
+ # ROOT (and may touch other root *.xml like styles.xml/meta.xml) -- NOT
35
+ # under xl/. Scoping the pre-open guard to xl/** alone let an ODS bomb sum
36
+ # to zero and slip to inflate (#350); we add the root XML read paths.
37
+ ODS_GLOBS = ["content.xml", "*.xml"].freeze
38
+ # OOXML (xlsx) body parts live under xl/ (across `/`, no FNM_PATHNAME).
39
+ XLSX_GLOBS = ["xl/**"].freeze
40
+
41
+ def convert(path, budget = Limits.null_budget)
34
42
  require "roo"
43
+ # PRE-OPEN guard: a 400k-row spreadsheet expands its sheet/content XML
44
+ # far past the on-disk cap. Sum the uncompressed sizes of the body
45
+ # entries (and any nested/non-standard part a bomb could hide behind a
46
+ # .rels Target) from the central directory and bail before roo inflates
47
+ # them. Globs match across `/` (guard_zip! omits FNM_PATHNAME) so a deep
48
+ # bomb is summed too (#337); the glob set is chosen per format so an ODS
49
+ # bomb rooted at content.xml is also caught (#350).
50
+ Limits.guard_zip!(path, budget, zip_globs(path))
35
51
  book = Roo::Spreadsheet.open(path)
36
- parts = book.sheets.map { |name| sheet_markdown(book, name) }.compact
52
+ parts = book.sheets.map { |name| sheet_markdown(book, name, budget) }.compact
37
53
  parts.join("\n\n")
38
54
  ensure
39
55
  book&.close if defined?(book) && book.respond_to?(:close)
@@ -41,18 +57,33 @@ module Rubino
41
57
 
42
58
  private
43
59
 
44
- def sheet_markdown(book, name)
60
+ # Read-path globs for the pre-open zip-bomb guard, by format. ODS keeps
61
+ # its body at the archive root (content.xml + sibling *.xml), so the
62
+ # xl/** OOXML scope would miss its bomb (#350). The whole-archive backstop
63
+ # in guard_zip! bounds anything these globs don't, but scoping correctly
64
+ # keeps the tight body cap doing the real work.
65
+ def zip_globs(path)
66
+ File.extname(path.to_s).downcase == ".ods" ? ODS_GLOBS : XLSX_GLOBS
67
+ end
68
+
69
+ def sheet_markdown(book, name, budget = Limits.null_budget)
45
70
  sheet = book.sheet(name)
46
71
  rows = []
47
72
  if sheet.first_row && sheet.last_row
73
+ # budget.tick per row bails a 400k-row bomb DURING extraction --
74
+ # before roo materialises every cell into memory.
48
75
  (sheet.first_row..sheet.last_row).each do |r|
49
- rows << (sheet.first_column..sheet.last_column).map { |c| sheet.cell(r, c) }
76
+ cells = (sheet.first_column..sheet.last_column).map { |c| sheet.cell(r, c) }
77
+ budget.tick(bytes: cells.sum { |c| c.to_s.bytesize })
78
+ rows << cells
50
79
  end
51
80
  end
52
81
  table = Table.emit(rows)
53
82
  return nil if table.empty?
54
83
 
55
84
  "## #{name}\n\n#{table}"
85
+ rescue Rubino::Interrupted, CapExceeded
86
+ raise
56
87
  rescue StandardError
57
88
  nil
58
89
  end
@@ -20,8 +20,9 @@ module Rubino
20
20
  File.extname(path.to_s).downcase == ".xml"
21
21
  end
22
22
 
23
- def convert(path)
23
+ def convert(path, budget = Limits.null_budget)
24
24
  raw = File.read(path, encoding: "bom|utf-8")
25
+ budget.add_bytes(raw.bytesize)
25
26
  pretty = pretty_print(raw) || raw.strip
26
27
  "```xml\n#{pretty}\n```\n"
27
28
  end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Documents
5
+ # Shared decompression-bomb / runaway-conversion guard for the in-process
6
+ # converters (#S4-1). The 25 MB on-disk `max_file_bytes` is trivially
7
+ # defeated by zip compression: a 100 KB .docx expands to 34 MB of XML and
8
+ # ~1M paragraphs, driving rubino to ~1.4 GB RSS / ~100 s of uninterruptible
9
+ # CPU before the output cap (applied only AFTER full conversion) throws the
10
+ # result away. The fix caps BEFORE/DURING conversion.
11
+ #
12
+ # A Budget is created once per conversion and threaded into the converter's
13
+ # per-element loop. Each iteration calls #tick(elements:, bytes:), which:
14
+ # - honors the cancel_token (raises Rubino::Interrupted so the turn is
15
+ # interruptible mid-conversion, not just at chunk boundaries);
16
+ # - enforces an element/page/row count ceiling (paragraphs, rows, pages,
17
+ # slides) so a structural bomb stops after N units;
18
+ # - enforces a decompressed-bytes ceiling (accumulated extracted/parsed
19
+ # text) so an expand bomb stops once it has produced a few x the output
20
+ # cap of text;
21
+ # - enforces a wall-clock budget so any pathological slow path (a single
22
+ # huge element, a quadratic gem call) still bails in bounded time.
23
+ # On any ceiling, it raises CapExceeded -> shell-hint. All caps are
24
+ # generous relative to a real document but tiny relative to a bomb.
25
+ module Limits
26
+ module_function
27
+
28
+ # Defaults. Overridable via config (attachments.policy.convert_*), so an
29
+ # operator can loosen them, but the secure defaults bound a bomb hard.
30
+ # - MAX_ELEMENTS: paragraphs/rows/pages/slides processed before bail.
31
+ # - MAX_DECOMPRESSED_BYTES: accumulated extracted text bytes; ~5 MB is
32
+ # ~50 x the 100 KB inline budget and far below the 34 MB an expand
33
+ # bomb produces.
34
+ # - WALL_CLOCK_SECONDS: total conversion budget.
35
+ # - TICK_INTERVAL: how often (in elements) to read the clock, so the
36
+ # time check itself is cheap in the hot loop.
37
+ DEFAULT_MAX_ELEMENTS = 50_000
38
+ DEFAULT_MAX_DECOMPRESSED = 5_000_000 # ~5 MB of extracted text
39
+ DEFAULT_WALL_CLOCK_SECONDS = 15.0
40
+ TICK_INTERVAL = 256
41
+
42
+ # PRE-OPEN zip-bomb guard for the OOXML converters (docx/xlsx/pptx). The
43
+ # decisive cost of a zip-expand bomb is paid the instant the gem opens the
44
+ # file: it reads the (e.g. 34 MB) decompressed XML entry into a String and
45
+ # builds the full Nokogiri DOM (~1.4 GB RSS) BEFORE yielding a single
46
+ # paragraph -- so per-element ticking alone is too late. The central
47
+ # directory carries each entry's UNCOMPRESSED size, readable without
48
+ # decompressing, so we sum the relevant XML entries first and bail to the
49
+ # shell-hint before the gem inflates anything.
50
+ #
51
+ # The sum runs WITHOUT File::FNM_PATHNAME so `*` crosses `/` -- a bomb
52
+ # planted at a nested, non-standard path (e.g. xl/worksheets/deep/sheet.xml,
53
+ # reachable via the workbook .rels Target, or ppt/slides/extra/s.xml) is
54
+ # caught just like one at the canonical depth. The pre-fix glob used
55
+ # FNM_PATHNAME, so `*` stopped at `/` and a deep bomb summed to zero and
56
+ # slipped through to roo's inflate (#337). Globs still scope the sum to the
57
+ # body parts (word/document*.xml, xl/**, ppt/**) so a large thumbnail/media
58
+ # blob doesn't false-positive. Raises CapExceeded over cap.
59
+ #
60
+ # #350: scoping to the OOXML body globs alone missed formats whose read
61
+ # paths live OUTSIDE that prefix -- notably an ODS, whose `content.xml`
62
+ # sits at the archive ROOT (not under xl/) yet is routed through the same
63
+ # roo/xlsx converter. Such a bomb summed to ZERO under `xl/**` and slipped
64
+ # to roo's inflate. The converter now passes the ACTUAL read-path globs per
65
+ # format (ODS adds `content.xml`/root `*.xml`). As a backstop we ALSO sum
66
+ # the WHOLE archive's uncompressed bytes against a (looser) total cap, so a
67
+ # bomb at any unforeseen path is still bounded even if no body glob matches
68
+ # it. The two caps are independent: the per-glob sum keeps the body tight,
69
+ # the whole-archive backstop guarantees no out-of-glob path is unbounded.
70
+ def guard_zip!(path, budget, globs)
71
+ require "zip"
72
+ scoped = 0
73
+ archive = 0
74
+ archive_cap = total_archive_cap(budget)
75
+ Zip::File.open(path) do |zip|
76
+ zip.each do |entry|
77
+ size = entry.size.to_i
78
+ archive += size
79
+ if archive > archive_cap
80
+ raise CapExceeded, "decompressed zip size cap (whole-archive #{archive_cap} bytes) exceeded"
81
+ end
82
+
83
+ # No FNM_PATHNAME: `*` matches across `/` so nested-path bombs sum.
84
+ next unless globs.any? { |g| File.fnmatch?(g, entry.name) }
85
+
86
+ scoped += size
87
+ if scoped > budget.max_decompressed_bytes
88
+ raise CapExceeded, "decompressed zip size cap (#{budget.max_decompressed_bytes} bytes) exceeded"
89
+ end
90
+ end
91
+ end
92
+ rescue CapExceeded
93
+ raise
94
+ rescue StandardError
95
+ # A malformed/unreadable zip is not our concern here -- let the gem-level
96
+ # converter handle it (it degrades to nil/shell-hint). Don't block a
97
+ # valid file because the pre-check tripped on an exotic zip layout.
98
+ nil
99
+ end
100
+
101
+ # Whole-archive backstop cap (#350). Looser than the per-glob body cap so a
102
+ # legit doc with large media/thumbnails the converter never reads doesn't
103
+ # false-positive, but still finite so an out-of-glob bomb can't be
104
+ # unbounded. Defaults to ARCHIVE_CAP_MULTIPLIER x the body cap (∞ stays ∞).
105
+ ARCHIVE_CAP_MULTIPLIER = 20
106
+
107
+ def total_archive_cap(budget)
108
+ body = budget.max_decompressed_bytes
109
+ return body if body == Float::INFINITY
110
+
111
+ body * ARCHIVE_CAP_MULTIPLIER
112
+ end
113
+
114
+ # A no-op budget for direct converter calls / tests that don't thread a
115
+ # real budget. Caps are effectively unbounded but cancellation still
116
+ # works if a token is supplied.
117
+ def null_budget
118
+ Budget.new(
119
+ max_elements: Float::INFINITY,
120
+ max_decompressed_bytes: Float::INFINITY,
121
+ wall_clock_seconds: Float::INFINITY
122
+ )
123
+ end
124
+
125
+ # Builds a Budget from config, falling back to the secure defaults.
126
+ def budget(cancel_token: nil)
127
+ cfg = policy_config
128
+ Budget.new(
129
+ max_elements: int(cfg["convert_max_elements"], DEFAULT_MAX_ELEMENTS),
130
+ max_decompressed_bytes: int(cfg["convert_max_decompressed_bytes"], DEFAULT_MAX_DECOMPRESSED),
131
+ wall_clock_seconds: flt(cfg["convert_wall_clock_seconds"], DEFAULT_WALL_CLOCK_SECONDS),
132
+ cancel_token: cancel_token
133
+ )
134
+ end
135
+
136
+ def policy_config
137
+ Rubino.configuration.dig("attachments", "policy") || {}
138
+ rescue StandardError
139
+ {}
140
+ end
141
+
142
+ def int(value, default)
143
+ value.nil? ? default : Integer(value)
144
+ rescue ArgumentError, TypeError
145
+ default
146
+ end
147
+
148
+ def flt(value, default)
149
+ value.nil? ? default : Float(value)
150
+ rescue ArgumentError, TypeError
151
+ default
152
+ end
153
+
154
+ # Per-conversion resource counter. Not thread-safe by design: a single
155
+ # conversion runs on one thread; the cancel_token IS the cross-thread
156
+ # signal and is itself lock-free/monotonic.
157
+ class Budget
158
+ attr_reader :elements, :bytes, :max_decompressed_bytes
159
+
160
+ def initialize(max_elements:, max_decompressed_bytes:, wall_clock_seconds:, cancel_token: nil)
161
+ @max_elements = max_elements
162
+ @max_decompressed_bytes = max_decompressed_bytes
163
+ @wall_clock = wall_clock_seconds
164
+ @deadline = monotonic + wall_clock_seconds
165
+ @cancel_token = cancel_token
166
+ @elements = 0
167
+ @bytes = 0
168
+ @since_clock = 0
169
+ end
170
+
171
+ # Account for one (or more) processed units and `bytes` of extracted
172
+ # text, then enforce every cap. Call once per element in the converter's
173
+ # hot loop. Raises Rubino::Interrupted on cancel, CapExceeded on any cap.
174
+ def tick(elements: 1, bytes: 0)
175
+ @elements += elements
176
+ @bytes += bytes
177
+ @since_clock += elements
178
+
179
+ # Cancellation first: a cancelled turn must abort even mid-bomb.
180
+ raise Rubino::Interrupted if @cancel_token&.cancelled?
181
+
182
+ raise CapExceeded, "element count cap (#{@max_elements}) exceeded" if @elements > @max_elements
183
+ if @bytes > @max_decompressed_bytes
184
+ raise CapExceeded, "decompressed size cap (#{@max_decompressed_bytes} bytes) exceeded"
185
+ end
186
+
187
+ # Reading a clock per element is measurable in a tight 1M-iteration
188
+ # loop; sample it every TICK_INTERVAL elements instead.
189
+ return unless @since_clock >= TICK_INTERVAL
190
+
191
+ @since_clock = 0
192
+ raise CapExceeded, "wall-clock budget (#{format("%.0f", @wall_clock)}s) exceeded" if monotonic > @deadline
193
+ end
194
+
195
+ # Account for `count` extracted bytes WITHOUT advancing the element count
196
+ # (e.g. a raw whole-file read in html/csv/json/xml/plain). Still checks
197
+ # the byte ceiling, the clock, and cancellation.
198
+ def add_bytes(count)
199
+ tick(elements: 0, bytes: count)
200
+ end
201
+
202
+ private
203
+
204
+ def monotonic
205
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
@@ -29,14 +29,21 @@ module Rubino
29
29
  # the file (unknown format, or the format's optional gem isn't installed, or
30
30
  # extraction produced nothing). Never raises -- a converter failure degrades
31
31
  # to nil so the caller emits the actionable shell-hint.
32
- def to_markdown(path, mime: nil)
32
+ def to_markdown(path, mime: nil, cancel_token: nil)
33
33
  converter = Registry.for(mime: mime, path: path)
34
34
  return nil unless converter
35
35
 
36
- out = converter.convert(path)
36
+ budget = Limits.budget(cancel_token: cancel_token)
37
+ out = converter.convert(path, budget)
37
38
  out = out.to_s
38
39
  out.strip.empty? ? nil : out
39
- rescue LoadError, StandardError
40
+ rescue Rubino::Interrupted
41
+ # A cancelled turn must propagate so the run aborts cleanly; do NOT
42
+ # swallow it into the nil/shell-hint degrade path.
43
+ raise
44
+ rescue CapExceeded, LoadError, StandardError
45
+ # Decompression bomb / runaway / missing gem / extraction failure all
46
+ # degrade to nil so the caller emits the actionable shell-hint.
40
47
  nil
41
48
  end
42
49
 
data/lib/rubino/errors.rb CHANGED
@@ -19,6 +19,24 @@ module Rubino
19
19
  # Domain errors (ConfigurationError, DatabaseError, SessionError, ToolError,
20
20
  # CompactionError, JobError) also subclass Error and live in lib/rubino.rb.
21
21
 
22
+ module Database
23
+ # Raised when a DB connection can't be established because a peer held the
24
+ # write lock past the bounded retry budget — a SUSTAINED concurrent-migration
25
+ # contention (#333/#359). Reopened with its full rationale in
26
+ # database/connection.rb, but DEFINED here, in the always-`require`d
27
+ # errors.rb, on purpose: `CLI::Commands.start` rescues this constant as its
28
+ # final boot-lock backstop (#445), and database/connection.rb is LAZILY
29
+ # autoloaded (only once `Rubino.database` is first touched). For any error
30
+ # that reaches that rescue BEFORE the DB is opened — an unknown subcommand, a
31
+ # bad flag, an empty prompt, a config-set validation error — Ruby would
32
+ # otherwise evaluate the rescue's class expression against an UNLOADED
33
+ # constant and raise `NameError: uninitialized constant
34
+ # Rubino::Database::BusyError`, masking the real (often clean Thor) error
35
+ # with a ~60-line backtrace. Defining it eagerly here keeps the rescue inert
36
+ # for non-DB errors while still catching a genuine concurrent-boot BusyError.
37
+ class BusyError < StandardError; end
38
+ end
39
+
22
40
  # Resource not found. Maps to 404.
23
41
  #
24
42
  # @param resource [String, Symbol] resource type (e.g. "Session", :run)
@@ -60,12 +78,25 @@ module Rubino
60
78
  end
61
79
  end
62
80
 
63
- # User interrupted an in-progress LLM turn (Esc / Ctrl+C in the chat TUI).
64
- # Caught by the Loop/Lifecycle so partial content can still be persisted
65
- # and the UI can return to a ready state cleanly.
81
+ # An in-progress LLM turn was aborted. +reason+ distinguishes a deliberate
82
+ # user interrupt (Esc / Ctrl+C in the chat TUI :user) from an EXTERNAL
83
+ # teardown (SIGTERM/SIGHUP from systemd, a terminal close, or a supervisor
84
+ # kill — :external). Both are caught by the Loop/Lifecycle so partial content
85
+ # can still be persisted and the UI returns to a ready state cleanly, but the
86
+ # result LABEL must not claim "interrupted by user" when no user interrupted
87
+ # (#361b). Default stays :user with the historical message for compatibility.
66
88
  class Interrupted < Error
67
- def initialize(message = "interrupted by user")
68
- super
89
+ attr_reader :reason
90
+
91
+ def initialize(message = nil, reason: :user)
92
+ @reason = reason
93
+ super(message || default_message(reason))
94
+ end
95
+
96
+ private
97
+
98
+ def default_message(reason)
99
+ reason == :external ? "interrupted by external signal" : "interrupted by user"
69
100
  end
70
101
  end
71
102
 
@@ -20,11 +20,24 @@ module Rubino
20
20
  # made the chat trap raise ThreadError, the flag never flipped, and the
21
21
  # turn ran on. Keep this lock-free and trap-safe.
22
22
  class CancelToken
23
+ # Why the turn was cancelled — distinguishes a deliberate user interrupt
24
+ # (Esc / Ctrl+C) from an EXTERNAL teardown (SIGTERM/SIGHUP from systemd,
25
+ # a terminal close, or a supervisor kill). Both unwind the turn the same
26
+ # way, but the result LABEL must not claim "interrupted by user" when no
27
+ # user interrupted (#361b). Defaults to :user — the overwhelmingly common
28
+ # case and the one the historical message described.
29
+ attr_reader :reason
30
+
23
31
  def initialize
24
32
  @cancelled = false
33
+ @reason = :user
25
34
  end
26
35
 
27
- def cancel!
36
+ # +reason+ records WHY: :user (Esc/Ctrl+C, default) or :external
37
+ # (SIGTERM/SIGHUP teardown). One-shot like @cancelled — the first reason
38
+ # wins, so a later cancel! can't relabel a genuine user interrupt.
39
+ def cancel!(reason: :user)
40
+ @reason = reason unless @cancelled
28
41
  @cancelled = true
29
42
  end
30
43
 
@@ -34,9 +47,12 @@ module Rubino
34
47
 
35
48
  # Raises Interrupted if the token has been cancelled. Used as a poll
36
49
  # point inside hot loops (per-chunk in streams, per-iteration in the
37
- # agent loop).
50
+ # agent loop). The Interrupted carries a reason-appropriate message so an
51
+ # external-signal teardown is not mislabeled as a user interrupt (#361b).
38
52
  def check!
39
- raise Rubino::Interrupted if cancelled?
53
+ return unless cancelled?
54
+
55
+ raise Rubino::Interrupted.new(reason: @reason)
40
56
  end
41
57
  end
42
58
  end
@@ -31,6 +31,11 @@ module Rubino
31
31
  # Context events
32
32
  PROMPT_ASSEMBLED = :prompt_assembled
33
33
  CONTEXT_BUDGET_CHECKED = :context_budget_checked
34
+ # The per-turn tool-iteration budget was extended at the cap because the
35
+ # user chose "Continue (+N)" at the interactive budget-extension prompt
36
+ # (#399). The turn resumes with full context. Payload:
37
+ # { iteration:, granted:, new_cap: }.
38
+ BUDGET_EXTENDED = :budget_extended
34
39
 
35
40
  # Compression events
36
41
  COMPRESSION_STARTED = :compression_started
@@ -96,6 +101,14 @@ module Rubino
96
101
  # (currently AttachFileTool). Payload: { path:, filename:,
97
102
  # content_type:, byte_size: }.
98
103
  ARTIFACT_CREATED = :artifact_created
104
+
105
+ # Harness diagnostic, not model answer (#418). Fired when the #381
106
+ # pessimistic-summary reconciliation detects the model claimed it did
107
+ # nothing while the tool-call ledger shows work ran. Routed here (and to
108
+ # stderr) — never into the text answer — so JSON/SSE consumers can carry
109
+ # it as metadata without polluting `--output-format text` stdout.
110
+ # Payload: { note: }.
111
+ HARNESS_NOTE = :harness_note
99
112
  end
100
113
  end
101
114
  end
@@ -5,10 +5,20 @@ module Rubino
5
5
  # Orchestrates the full lifecycle of a single user interaction.
6
6
  # Coordinates all phases from input to final response and post-turn jobs.
7
7
  class Lifecycle
8
+ # The session this lifecycle is currently bound to. Starts as the session
9
+ # passed in, but an automatic budget-triggered compaction swaps it to the
10
+ # compaction child (see #check_and_compact). The owning Runner reads this
11
+ # back after #execute so the NEXT turn runs on the (small) child rather
12
+ # than re-compacting the dead parent every turn (P3 F1). Defined as a
13
+ # method (not attr_reader) because @session is REASSIGNED on compaction.
14
+ def active_session
15
+ @session
16
+ end
17
+
8
18
  def initialize(session:, event_bus:, ui:, config:, ignore_rules: false,
9
19
  agent_definition: nil, cancel_token: nil,
10
20
  model_override: nil, provider_override: nil,
11
- max_tool_iterations: nil)
21
+ max_tool_iterations: nil, polishing: nil)
12
22
  @session = session
13
23
  @event_bus = event_bus
14
24
  @ui = ui
@@ -18,6 +28,12 @@ module Rubino
18
28
  @cancel_token = cancel_token
19
29
  @model_override = model_override
20
30
  @provider_override = provider_override
31
+ # The Runner-owned detached post-turn polishing worker (#319). When
32
+ # given, the post-turn jobs are handed to it to drain OFF the live
33
+ # turn's critical path so the next prompt is never gated. Nil on the
34
+ # API/server path and nested subagent runs, which keep the original
35
+ # synchronous inline drain (no interactive prompt to free up).
36
+ @polishing = polishing
21
37
  # Explicit per-run cap from `--max-turns` (Runner → here → IterationBudget).
22
38
  # nil ⇒ use the configured agent_max_tool_iterations (#141).
23
39
  @max_tool_iterations = max_tool_iterations
@@ -173,17 +189,43 @@ module Rubino
173
189
  )
174
190
 
175
191
  if budget.needs_compaction?(messages)
192
+ compressor = Context::Compressor.new(session_id: @session[:id])
193
+
194
+ # Anti-thrash back-off (#415a): if the last two compactions in this
195
+ # lineage each saved <10%, skip the paid summary call this turn —
196
+ # the session is hovering at the threshold and re-compacting would
197
+ # only shave a message or two. The user can still force /compact.
198
+ return messages if compressor.thrashing?
199
+
176
200
  @state.transition_to!(:compressing_context, event_bus: @event_bus)
177
201
  @ui.compression_started
178
202
  @event_bus.emit(Events::COMPRESSION_STARTED, session_id: @session[:id])
179
203
 
180
- compressor = Context::Compressor.new(session_id: @session[:id])
181
204
  result = compressor.compact!
182
205
 
183
206
  @event_bus.emit(Events::COMPRESSION_FINISHED, **result)
184
207
  @ui.compression_finished(result)
185
208
 
186
- # Reload messages after compaction
209
+ # Swap the active session to the compaction child (F1). compact!
210
+ # wrote head+summary+tail into a fresh child and marked THIS parent
211
+ # status="compacted" — exactly the swap the manual /compact path
212
+ # performs (chat_command.rb: result[:compact_into] → build_runner on
213
+ # the child). The automatic path used to skip this, so the turn's
214
+ # response, update_session_state, and the post-turn jobs all stayed
215
+ # bound to the now-dead parent: it never shrank, needs_compaction?
216
+ # stayed permanently true, and the gem re-compacted EVERY subsequent
217
+ # turn (superlinear DB/context bloat + ~2.9x slowdown). Reassigning
218
+ # @session to the child means subsequent turns persist to the small
219
+ # child and compaction fires only once per genuine threshold-cross.
220
+ # Guard against a no-op compaction (too few messages / empty middle),
221
+ # which creates no child and returns no target — keep the parent then.
222
+ child_id = result[:target_session_id]
223
+ if child_id
224
+ child = @session_repo.find(child_id)
225
+ @session = child if child
226
+ end
227
+
228
+ # Reload messages after compaction (from the now-active session)
187
229
  assembler = Context::PromptAssembler.new(
188
230
  session: @session,
189
231
  memory_context: {},
@@ -210,7 +252,10 @@ module Rubino
210
252
  # force a redundant re-read + a second approval round-trip. The
211
253
  # gate itself still re-prompts on any on-disk change.
212
254
  read_tracker: Tools::ReadTracker.for_session(@session[:id]),
213
- event_bus: @event_bus
255
+ event_bus: @event_bus,
256
+ # Attributes audit rows to this session so the tool_calls FK is
257
+ # satisfied and the table actually fills (#262).
258
+ session_id: @session[:id]
214
259
  )
215
260
 
216
261
  # Dispatch through AdapterFactory so a "fake/..." model id (or an
@@ -267,10 +312,26 @@ module Rubino
267
312
 
268
313
  def enqueue_post_turn_jobs
269
314
  queue = Jobs::Queue.new
270
-
271
- # Extract memory if enabled
272
- if @config.memory_auto_extract?
273
- queue.enqueue("ExtractMemoryJob", { session_id: @session[:id] })
315
+ # When a detached polishing worker is wired (interactive CLI), only
316
+ # PERSIST the rows here and let that worker drain them off the live
317
+ # turn's critical path (#319). Without one (API/server, subagent) keep
318
+ # the original behaviour: in inline mode #enqueue drains synchronously.
319
+ drain_inline = @polishing.nil?
320
+
321
+ # Turn index for the throttle gates below: message_count grows by a
322
+ # fixed 2 per completed turn (persist_user_message + update_session_state),
323
+ # so this is a deterministic, monotonic per-session turn counter — no new
324
+ # column needed (#412/#414).
325
+ turn_no = current_turn_index
326
+
327
+ # Extract memory if enabled — THROTTLED to ~every N turns (Hermes'
328
+ # nudge_interval) instead of every turn (#412). `drain_inline` is already
329
+ # false on the interactive CLI (a polishing worker drains it OFF the live
330
+ # turn's critical path); it is only true in API/server/subagent contexts
331
+ # that have no background drainer, so the throttle keeps the aux-LLM
332
+ # extract off the interactive path AND cuts its cadence ~10x.
333
+ if @config.memory_auto_extract? && interval_due?(turn_no, @config.memory_auto_extract_interval)
334
+ queue.enqueue("ExtractMemoryJob", { session_id: @session[:id] }, drain_inline: drain_inline)
274
335
  @event_bus.emit(Events::JOB_ENQUEUED, type: "ExtractMemoryJob")
275
336
  end
276
337
 
@@ -282,17 +343,42 @@ module Rubino
282
343
  # already covered) before spending one aux-model call. Handler lookup
283
344
  # is load-order independent: Jobs::Registry resolves the class from
284
345
  # the Handlers namespace on demand (#81).
285
- if @config.skills_auto_distill?
286
- queue.enqueue("DistillSkillJob", { session_id: @session[:id] })
346
+ if @config.skills_auto_distill? && interval_due?(turn_no, @config.skills_auto_distill_interval)
347
+ queue.enqueue("DistillSkillJob", { session_id: @session[:id] }, drain_inline: drain_inline)
287
348
  @event_bus.emit(Events::JOB_ENQUEUED, type: "DistillSkillJob")
288
349
  end
289
350
 
290
351
  # Summarize if session is getting long
291
352
  message_count = @message_store.count(@session[:id])
292
- return unless message_count > 20
353
+ if message_count > 20
354
+ queue.enqueue("SummarizeSessionJob", { session_id: @session[:id] }, drain_inline: drain_inline)
355
+ @event_bus.emit(Events::JOB_ENQUEUED, type: "SummarizeSessionJob")
356
+ end
357
+
358
+ # Detach: kick the polishing worker so it drains the rows just enqueued
359
+ # off this thread. Returns immediately — the next prompt is never gated.
360
+ @polishing&.start(ui: @ui, event_bus: @event_bus)
361
+ end
362
+
363
+ # Deterministic per-session turn counter for the throttle gates (#412/#414).
364
+ # sessions.message_count grows by a fixed 2 per completed turn
365
+ # (persist_user_message + update_session_state), so dividing by 2 yields the
366
+ # turn number. Reads the persisted row (not @session, which is reassigned on
367
+ # compaction). Falls back to 1 (always-due) if the row can't be read.
368
+ def current_turn_index
369
+ row = @session_repo.find(@session[:id])
370
+ count = row && (row[:message_count] || row["message_count"])
371
+ count ? [count.to_i / 2, 1].max : 1
372
+ rescue StandardError
373
+ 1
374
+ end
375
+
376
+ # True when a turn-throttled job is due: every turn for interval <= 1, else
377
+ # on turns that land on the interval boundary. turn_no is always >= 1.
378
+ def interval_due?(turn_no, interval)
379
+ return true if interval.nil? || interval <= 1
293
380
 
294
- queue.enqueue("SummarizeSessionJob", { session_id: @session[:id] })
295
- @event_bus.emit(Events::JOB_ENQUEUED, type: "SummarizeSessionJob")
381
+ (turn_no % interval).zero?
296
382
  end
297
383
  end
298
384
  end