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
@@ -110,10 +110,13 @@ module Rubino
110
110
  return override if override
111
111
 
112
112
  path = File.join(PROMPTS_DIR, "#{name}.txt")
113
- File.exist?(path) ? File.read(path).strip : ""
113
+ # Read as UTF-8 explicitly: the built-in prompts carry non-ASCII glyphs
114
+ # (em-dashes), so relying on the locale default_external crashes under a
115
+ # bare C/POSIX locale (#273; consistent with #250/#251).
116
+ File.exist?(path) ? File.read(path, encoding: "UTF-8").strip : ""
114
117
  rescue StandardError
115
118
  path = File.join(PROMPTS_DIR, "#{name}.txt")
116
- File.exist?(path) ? File.read(path).strip : ""
119
+ File.exist?(path) ? File.read(path, encoding: "UTF-8").strip : ""
117
120
  end
118
121
  end
119
122
  end
@@ -9,8 +9,6 @@ module Rubino
9
9
  :permissions, :tools, :hidden
10
10
 
11
11
  # Types: :primary (user-switchable), :subagent (invokable), :utility (hidden)
12
- TYPES = %i[primary subagent utility].freeze
13
-
14
12
  def initialize(attrs = {})
15
13
  @name = attrs[:name]
16
14
  @type = attrs[:type] || :primary
@@ -32,10 +30,6 @@ module Rubino
32
30
  @type == :subagent
33
31
  end
34
32
 
35
- def utility?
36
- @type == :utility
37
- end
38
-
39
33
  def hidden?
40
34
  @hidden
41
35
  end
@@ -72,9 +66,7 @@ module Rubino
72
66
  # its companions `task_result`/`task_stop`) so it can spawn its own
73
67
  # subagents. Runaway recursion / fan-out is no longer prevented by hiding
74
68
  # the tool here — it is bounded in ONE place, Tools::BackgroundTasks#reserve,
75
- # by the depth / per-owner / global caps. (DELEGATION_TOOLS is kept as a
76
- # named set for any reader that still wants to reason about the group.)
77
- DELEGATION_TOOLS = %w[task task_result task_stop].freeze
69
+ # by the depth / per-owner / global caps.
78
70
 
79
71
  # Tools that ONLY make sense for a subagent and must be hidden from a
80
72
  # primary/top-level agent. ask_parent escalates a question to the PARENT — a
@@ -68,12 +68,6 @@ module Rubino
68
68
  @active
69
69
  end
70
70
 
71
- # True once a fallback has been activated this turn — lets callers emit the
72
- # "switched to fallback" status only when something actually changed.
73
- def active?
74
- @index.positive?
75
- end
76
-
77
71
  # Advance to the next usable, non-duplicate fallback and rebuild the
78
72
  # adapter. Returns true if it actually switched, false when the chain is
79
73
  # exhausted (or empty). Mirrors try_activate_fallback (helpers.py:1020):
@@ -4,14 +4,30 @@ module Rubino
4
4
  module Agent
5
5
  # Manages turn and iteration budgets to prevent runaway loops.
6
6
  class IterationBudget
7
+ # Upper bound on a turn/iteration cap (F2). A 0/negative cap is rejected as
8
+ # "would never run"; a value this large is just as nonsensical the other way
9
+ # — `--max-turns 99999999999999` was silently accepted, defeating the whole
10
+ # point of a runaway guard. Reject anything above this sane ceiling with the
11
+ # same clear message rather than letting an effectively-unbounded cap
12
+ # through. 10_000 is far past any legitimate agentic turn yet small enough
13
+ # to keep the comparison meaningful.
14
+ MAX_CAP = 10_000
15
+
7
16
  def initialize(config: nil, max_tool_iterations: nil)
8
17
  @config = config || Rubino.configuration
9
- @max_turns = @config.agent_max_turns
18
+ # A 0/negative cap is nonsense (the turn could never run a single
19
+ # iteration), so REJECT it with a clear message at both entry points —
20
+ # the configured `agent.max_turns` and the CLI `--max-turns N` override —
21
+ # rather than silently coercing it to "unbounded" / the default and
22
+ # surprising the user. nil/absent stays meaningful (unbounded rail /
23
+ # config default).
24
+ @max_turns = require_positive_cap!(@config.agent_max_turns, "agent.max_turns")
10
25
  # An explicit override (the CLI `--max-turns N` flag, threaded through
11
26
  # Runner → Lifecycle) wins over the config default so the documented
12
27
  # control knob actually caps tool iterations (#141). A nil/blank
13
28
  # override falls back to the configured budget, unchanged.
14
- @max_tool_iterations = positive_int(max_tool_iterations) || @config.agent_max_tool_iterations
29
+ override = require_positive_cap!(max_tool_iterations, "--max-turns")
30
+ @max_tool_iterations = override || @config.agent_max_tool_iterations
15
31
  @max_turn_seconds = @config.agent_max_turn_seconds
16
32
  @turn_started_at = Time.now
17
33
  end
@@ -21,6 +37,34 @@ module Rubino
21
37
  within_iteration_limit?(iteration) && within_time_limit?
22
38
  end
23
39
 
40
+ # True ONLY when offering the interactive Continue extension would actually
41
+ # help: the SOFT iteration ceiling (@max_tool_iterations) is what's
42
+ # exhausted, and neither non-extendable rail is the blocker (#403).
43
+ # extend! raises only the soft ceiling, so it is impotent against the TIME
44
+ # limit AND the max_turns OUTER rail. When either of those is what's spent,
45
+ # extending is a no-op and re-prompting would loop forever — callers must
46
+ # force-summarize instead. Hence extendable? is FALSE when the time limit
47
+ # OR the max_turns outer rail is the blocker, and only TRUE when the soft
48
+ # iteration ceiling is what's exhausted. Also false on an unbounded soft
49
+ # cap (nothing to extend).
50
+ def extendable?(iteration)
51
+ within_time_limit? && within_turns_rail?(iteration) && !within_soft_iteration_limit?(iteration)
52
+ end
53
+
54
+ # Grants `by` more tool iterations so a turn that hit the cap can resume
55
+ # the SAME turn with full context (#399, the Cline/Roo "reset the counter,
56
+ # keep context" pattern). Only the soft iteration ceiling moves — the
57
+ # max_turns OUTER rail and the max_turn_seconds safety-net are untouched,
58
+ # so repeated extensions can never bypass the max_turns/clock ceiling (a
59
+ # runaway still stops at max_turns). No-op on an unbounded (nil) cap.
60
+ # Returns the new ceiling.
61
+ def extend!(by)
62
+ amount = positive_int(by)
63
+ return @max_tool_iterations if amount.nil? || @max_tool_iterations.nil?
64
+
65
+ @max_tool_iterations += amount
66
+ end
67
+
24
68
  private
25
69
 
26
70
  # Coerce an override to a positive Integer, or nil if it's absent/garbage
@@ -33,9 +77,71 @@ module Rubino
33
77
  n if n && n.positive?
34
78
  end
35
79
 
80
+ # A turn/iteration cap must be a POSITIVE integer when a NUMBER is given.
81
+ # nil/absent or non-numeric garbage (e.g. an empty Thor option) is treated
82
+ # as "unset" → use the default / unbounded rail, preserving the lenient
83
+ # prior behaviour. A genuinely-numeric 0 or NEGATIVE value, however, is an
84
+ # unambiguous misconfiguration (the turn could never run a single
85
+ # iteration) → reject it with a clear, actionable error naming the knob,
86
+ # instead of silently degrading to "unbounded" / the default.
87
+ def require_positive_cap!(value, label)
88
+ n = numeric_cap(value)
89
+ return positive_int(value) if n.nil? # nil/non-numeric ⇒ unset
90
+
91
+ # A 0/negative cap (the turn could never run a single iteration).
92
+ unless n.positive?
93
+ raise Rubino::ConfigurationError,
94
+ "invalid #{label}: #{value.inspect} — must be a positive integer " \
95
+ "(a 0 or negative cap would never let the turn run). " \
96
+ "Set it to 1 or more, or leave it unset for the default."
97
+ end
98
+
99
+ # An absurdly large cap (F2) is as nonsensical the OTHER way — e.g.
100
+ # `--max-turns 99999999999999` was silently accepted, making the runaway
101
+ # guard a no-op. Reject anything above the sane ceiling with an equally
102
+ # clear, actionable message naming the knob and the accepted range.
103
+ if n > MAX_CAP
104
+ raise Rubino::ConfigurationError,
105
+ "invalid #{label}: #{value.inspect} — must be a positive integer no greater than " \
106
+ "#{MAX_CAP} (a larger cap effectively removes the runaway guard). " \
107
+ "Leave it unset for the default."
108
+ end
109
+
110
+ positive_int(value)
111
+ end
112
+
113
+ # The value as a number iff it IS one (Integer/Float, or a numeric string
114
+ # like "0"/"-5"); nil for nil or non-numeric garbage. Used to tell a
115
+ # genuine 0/negative cap (reject) from "unset" (lenient default).
116
+ def numeric_cap(value)
117
+ return value if value.is_a?(Numeric)
118
+ return nil if value.nil?
119
+
120
+ s = value.to_s.strip
121
+ return nil if s.empty?
122
+
123
+ Integer(s, exception: false) || Float(s, exception: false)
124
+ end
125
+
36
126
  # A nil cap means "unbounded": never stop on that dimension rather than
37
- # crashing the turn comparing a number with nil (#139).
127
+ # crashing the turn comparing a number with nil (#139). The full iteration
128
+ # limit is the conjunction of the OUTER max_turns rail (#414) and the SOFT
129
+ # @max_tool_iterations ceiling — even after extend! lifts the soft ceiling,
130
+ # the iteration count may never exceed max_turns, so a runaway that keeps
131
+ # extending still terminates at the hard outer bound.
38
132
  def within_iteration_limit?(iteration)
133
+ within_turns_rail?(iteration) && within_soft_iteration_limit?(iteration)
134
+ end
135
+
136
+ # The OUTER max_turns rail (#414): a hard ceiling extend! cannot move. A
137
+ # nil max_turns means this rail is unbounded.
138
+ def within_turns_rail?(iteration)
139
+ @max_turns.nil? || iteration <= @max_turns
140
+ end
141
+
142
+ # The SOFT @max_tool_iterations ceiling: the only dimension extend! can
143
+ # lift. A nil ceiling means it is unbounded (nothing to extend).
144
+ def within_soft_iteration_limit?(iteration)
39
145
  @max_tool_iterations.nil? || iteration <= @max_tool_iterations
40
146
  end
41
147