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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +11 -2
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +172 -5
- data/CONTRIBUTING.md +10 -1
- data/README.md +14 -5
- data/Rakefile +31 -0
- data/docs/agents.md +42 -23
- data/docs/architecture.md +2 -2
- data/docs/commands.md +35 -3
- data/docs/configuration.md +20 -23
- data/docs/getting-started.md +5 -3
- data/docs/security.md +16 -5
- data/docs/skills.md +31 -0
- data/docs/troubleshooting.md +1 -1
- data/exe/rubino +16 -2
- data/install.sh +721 -59
- data/lib/rubino/active_agent.rb +73 -0
- data/lib/rubino/agent/action_claim_guard.rb +881 -0
- data/lib/rubino/agent/agent_registry.rb +5 -2
- data/lib/rubino/agent/definition.rb +1 -9
- data/lib/rubino/agent/fallback_chain.rb +0 -6
- data/lib/rubino/agent/iteration_budget.rb +109 -3
- data/lib/rubino/agent/loop.rb +476 -20
- data/lib/rubino/agent/model_call_runner.rb +81 -3
- data/lib/rubino/agent/prompts/build.txt +22 -5
- data/lib/rubino/agent/response_validator.rb +8 -0
- data/lib/rubino/agent/runner.rb +133 -8
- data/lib/rubino/agent/tool_executor.rb +166 -14
- data/lib/rubino/agent/truncation_continuation.rb +4 -1
- data/lib/rubino/api/server.rb +19 -0
- data/lib/rubino/attachments/classify.rb +35 -17
- data/lib/rubino/boot/config_guard.rb +71 -0
- data/lib/rubino/cli/chat/completion_builder.rb +42 -6
- data/lib/rubino/cli/chat/idle_card_host.rb +7 -1
- data/lib/rubino/cli/chat/session_resolver.rb +87 -21
- data/lib/rubino/cli/chat_command.rb +1189 -50
- data/lib/rubino/cli/commands.rb +282 -2
- data/lib/rubino/cli/config_command.rb +68 -8
- data/lib/rubino/cli/doctor_command.rb +204 -12
- data/lib/rubino/cli/jobs_command.rb +12 -0
- data/lib/rubino/cli/memory_command.rb +53 -20
- data/lib/rubino/cli/onboarding_wizard.rb +79 -6
- data/lib/rubino/cli/session_command.rb +172 -18
- data/lib/rubino/cli/setup_command.rb +131 -8
- data/lib/rubino/cli/skills_command.rb +183 -9
- data/lib/rubino/cli/trust_gate.rb +16 -7
- data/lib/rubino/commands/built_ins.rb +2 -0
- data/lib/rubino/commands/command.rb +12 -2
- data/lib/rubino/commands/executor.rb +149 -12
- data/lib/rubino/commands/handlers/agent_switch.rb +100 -0
- data/lib/rubino/commands/handlers/agents.rb +156 -41
- data/lib/rubino/commands/handlers/config.rb +4 -1
- data/lib/rubino/commands/handlers/help.rb +113 -14
- data/lib/rubino/commands/handlers/memory.rb +15 -5
- data/lib/rubino/commands/handlers/sessions.rb +26 -3
- data/lib/rubino/commands/handlers/status.rb +9 -4
- data/lib/rubino/commands/loader.rb +12 -0
- data/lib/rubino/config/configuration.rb +86 -24
- data/lib/rubino/config/defaults.rb +140 -33
- data/lib/rubino/config/loader.rb +62 -12
- data/lib/rubino/config/validator.rb +341 -0
- data/lib/rubino/config/writer.rb +123 -31
- data/lib/rubino/context/compressor.rb +184 -22
- data/lib/rubino/context/environment_inspector.rb +2 -2
- data/lib/rubino/context/file_discovery.rb +2 -2
- data/lib/rubino/context/message_boundary.rb +27 -1
- data/lib/rubino/context/project_languages.rb +90 -0
- data/lib/rubino/context/prompt_assembler.rb +105 -22
- data/lib/rubino/context/summary_builder.rb +45 -4
- data/lib/rubino/context/token_budget.rb +36 -11
- data/lib/rubino/context/token_estimate.rb +45 -0
- data/lib/rubino/context/tool_result_pruner.rb +81 -0
- data/lib/rubino/database/connection.rb +154 -3
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +314 -40
- data/lib/rubino/database/migrator.rb +98 -5
- data/lib/rubino/documents/cap_exceeded.rb +13 -0
- data/lib/rubino/documents/converters/csv.rb +4 -3
- data/lib/rubino/documents/converters/docx.rb +29 -5
- data/lib/rubino/documents/converters/html.rb +5 -1
- data/lib/rubino/documents/converters/json.rb +2 -1
- data/lib/rubino/documents/converters/pdf.rb +11 -2
- data/lib/rubino/documents/converters/plain.rb +2 -1
- data/lib/rubino/documents/converters/pptx.rb +11 -2
- data/lib/rubino/documents/converters/xlsx.rb +35 -4
- data/lib/rubino/documents/converters/xml.rb +2 -1
- data/lib/rubino/documents/limits.rb +210 -0
- data/lib/rubino/documents.rb +10 -3
- data/lib/rubino/errors.rb +36 -5
- data/lib/rubino/interaction/cancel_token.rb +19 -3
- data/lib/rubino/interaction/events.rb +13 -0
- data/lib/rubino/interaction/lifecycle.rb +99 -13
- data/lib/rubino/interaction/polishing.rb +176 -0
- data/lib/rubino/jobs/cron_job_repository.rb +5 -8
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +11 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +65 -9
- data/lib/rubino/jobs/queue.rb +63 -8
- data/lib/rubino/jobs/runner.rb +24 -6
- data/lib/rubino/jobs/worker.rb +0 -4
- data/lib/rubino/llm/adapter_response.rb +47 -4
- data/lib/rubino/llm/credential_check.rb +15 -16
- data/lib/rubino/llm/error_classifier.rb +89 -1
- data/lib/rubino/llm/inline_think_filter.rb +69 -12
- data/lib/rubino/llm/request.rb +30 -3
- data/lib/rubino/llm/ruby_llm_adapter.rb +394 -46
- data/lib/rubino/llm/tool_bridge.rb +113 -9
- data/lib/rubino/mcp/manager.rb +18 -1
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +14 -3
- data/lib/rubino/memory/aux_retry.rb +107 -0
- data/lib/rubino/memory/backends/sqlite.rb +73 -44
- data/lib/rubino/memory/backends.rb +23 -7
- data/lib/rubino/memory/salience_gate.rb +103 -0
- data/lib/rubino/memory/sqlite_extraction.rb +70 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +11 -0
- data/lib/rubino/memory/store.rb +33 -5
- data/lib/rubino/memory/threat_scanner.rb +52 -0
- data/lib/rubino/output/cost.rb +52 -0
- data/lib/rubino/output/headless_block_latch.rb +53 -0
- data/lib/rubino/output/result_serializer.rb +222 -0
- data/lib/rubino/output/turn_recorder.rb +77 -0
- data/lib/rubino/security/approval_policy.rb +227 -32
- data/lib/rubino/security/command_allowlist.rb +79 -4
- data/lib/rubino/security/doom_loop_detector.rb +21 -2
- data/lib/rubino/security/hardline_guard.rb +189 -16
- data/lib/rubino/security/pattern_matcher.rb +28 -5
- data/lib/rubino/security/prefix_deriver.rb +25 -6
- data/lib/rubino/security/readonly_commands.rb +145 -5
- data/lib/rubino/security/secret_path.rb +134 -0
- data/lib/rubino/security/url_safety.rb +255 -0
- data/lib/rubino/session/repository.rb +212 -11
- data/lib/rubino/session/store.rb +139 -14
- data/lib/rubino/skills/installer.rb +230 -0
- data/lib/rubino/skills/prompt_index.rb +2 -2
- data/lib/rubino/skills/registry.rb +52 -1
- data/lib/rubino/skills/skill.rb +64 -3
- data/lib/rubino/skills/skill_tool.rb +16 -5
- data/lib/rubino/tools/background_tasks.rb +157 -13
- data/lib/rubino/tools/base.rb +204 -3
- data/lib/rubino/tools/edit_tool.rb +73 -18
- data/lib/rubino/tools/glob_tool.rb +48 -9
- data/lib/rubino/tools/grep_tool.rb +103 -9
- data/lib/rubino/tools/multi_edit_tool.rb +64 -9
- data/lib/rubino/tools/patch_tool.rb +5 -0
- data/lib/rubino/tools/read_attachment_tool.rb +3 -1
- data/lib/rubino/tools/read_tool.rb +33 -15
- data/lib/rubino/tools/read_tracker.rb +153 -35
- data/lib/rubino/tools/registry.rb +113 -12
- data/lib/rubino/tools/result.rb +9 -1
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/shell_registry.rb +70 -0
- data/lib/rubino/tools/shell_tool.rb +40 -1
- data/lib/rubino/tools/summarize_file_tool.rb +6 -0
- data/lib/rubino/tools/task_stop_tool.rb +10 -16
- data/lib/rubino/tools/task_tool.rb +36 -8
- data/lib/rubino/tools/vision_tool.rb +5 -0
- data/lib/rubino/tools/webfetch_tool.rb +39 -7
- data/lib/rubino/tools/websearch_tool.rb +92 -30
- data/lib/rubino/tools/write_tool.rb +23 -4
- data/lib/rubino/ui/api.rb +10 -1
- data/lib/rubino/ui/base.rb +11 -0
- data/lib/rubino/ui/bottom_composer.rb +382 -74
- data/lib/rubino/ui/cli.rb +515 -83
- data/lib/rubino/ui/completion_menu.rb +11 -7
- data/lib/rubino/ui/headless_trace.rb +63 -0
- data/lib/rubino/ui/live_region.rb +70 -7
- data/lib/rubino/ui/markdown_renderer.rb +142 -7
- data/lib/rubino/ui/notifier.rb +0 -2
- data/lib/rubino/ui/null.rb +52 -5
- data/lib/rubino/ui/paste_store.rb +16 -2
- data/lib/rubino/ui/queued_indicators.rb +6 -1
- data/lib/rubino/ui/status_bar.rb +61 -7
- data/lib/rubino/ui/streaming_markdown.rb +59 -6
- data/lib/rubino/ui/subagent_view.rb +29 -4
- data/lib/rubino/ui/tool_label.rb +52 -0
- data/lib/rubino/update_check.rb +39 -4
- data/lib/rubino/util/atomic_file.rb +117 -0
- data/lib/rubino/util/ignore_rules.rb +120 -0
- data/lib/rubino/util/output.rb +229 -12
- data/lib/rubino/util/secrets_mask.rb +70 -7
- data/lib/rubino/util/spill_store.rb +153 -0
- data/lib/rubino/version.rb +1 -1
- data/lib/rubino/workspace.rb +9 -1
- data/lib/rubino.rb +191 -7
- data/rubino-agent.gemspec +1 -0
- data/skills/ruby-expert/SKILL.md +1 -0
- metadata +42 -12
- data/lib/rubino/agent/router.rb +0 -65
- data/lib/rubino/database/migrations/002_create_runs.rb +0 -45
- data/lib/rubino/database/migrations/003_create_skill_states.rb +0 -15
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +0 -36
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +0 -27
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +0 -34
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +0 -59
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +0 -75
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +0 -55
- 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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/rubino/documents.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
@event_bus.emit(Events::JOB_ENQUEUED, type: "SummarizeSessionJob")
|
|
381
|
+
(turn_no % interval).zero?
|
|
296
382
|
end
|
|
297
383
|
end
|
|
298
384
|
end
|