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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module Rubino
|
|
4
6
|
module Agent
|
|
5
7
|
# Executes tool calls with approval checks and result formatting.
|
|
@@ -9,16 +11,29 @@ module Rubino
|
|
|
9
11
|
# Loop#handle_tool_result.
|
|
10
12
|
attr_writer :on_result
|
|
11
13
|
|
|
14
|
+
# True once any tool was BLOCKED for approval in a non-interactive session
|
|
15
|
+
# (#260): a write/edit/shell that needed a prompt no one could answer. The
|
|
16
|
+
# one-shot CLI reads this after the run to exit NON-ZERO so CI/automation
|
|
17
|
+
# fails loudly instead of treating a silently-skipped action as success.
|
|
18
|
+
def blocked_for_approval?
|
|
19
|
+
@blocked_for_approval == true
|
|
20
|
+
end
|
|
21
|
+
|
|
12
22
|
def initialize(registry:, approval_policy:, ui:, config:,
|
|
13
23
|
tool_call_repository: Tools::ToolCallRepository.new,
|
|
14
24
|
cancel_token: nil, read_tracker: nil, event_bus: nil,
|
|
15
|
-
on_result: nil)
|
|
25
|
+
on_result: nil, session_id: nil)
|
|
16
26
|
@registry = registry
|
|
17
27
|
@approval_policy = approval_policy
|
|
18
28
|
@ui = ui
|
|
19
29
|
@config = config
|
|
20
30
|
@tool_call_repository = tool_call_repository
|
|
21
31
|
@cancel_token = cancel_token
|
|
32
|
+
# Session the audit row is attributed to. The tool_calls table requires
|
|
33
|
+
# a non-null session_id FK, so without this every audit insert violated
|
|
34
|
+
# the constraint and was swallowed by the repository's rescue — leaving
|
|
35
|
+
# the table empty on every execution, streaming or not (#262).
|
|
36
|
+
@session_id = session_id
|
|
22
37
|
# Optional sink the Loop registers so a tool that runs on the STREAMING
|
|
23
38
|
# path (ruby_llm dispatches it mid-stream via ToolBridge → straight into
|
|
24
39
|
# #execute, never returning through Loop#execute_tool_calls) is still
|
|
@@ -44,6 +59,17 @@ module Rubino
|
|
|
44
59
|
|
|
45
60
|
# Executes a single tool call, returns a Tools::Result.
|
|
46
61
|
def execute(name:, arguments:, call_id:)
|
|
62
|
+
# Cancellation checkpoint BEFORE the tool runs (#335b). On the streaming
|
|
63
|
+
# path ruby_llm dispatches tool calls mid-stream through ToolBridge into
|
|
64
|
+
# here, and the loop's per-iteration #check! is far above us — so without
|
|
65
|
+
# this a cancel that arrived while a PREVIOUS tool was running (or during
|
|
66
|
+
# the thinking phase) wouldn't be observed until the model resumed
|
|
67
|
+
# streaming, letting the next tool fire after the user already hit
|
|
68
|
+
# interrupt. Raising here halts the in-flight turn at the next tool
|
|
69
|
+
# boundary, the soonest safe checkpoint, so "esc to interrupt" actually
|
|
70
|
+
# stops the agent instead of letting it run one more tool.
|
|
71
|
+
@cancel_token&.check!
|
|
72
|
+
|
|
47
73
|
tool = @registry.find(name)
|
|
48
74
|
raise ToolError, "Unknown tool: #{name}" unless tool
|
|
49
75
|
|
|
@@ -58,6 +84,29 @@ module Rubino
|
|
|
58
84
|
result: denied, reason: "policy-denied")
|
|
59
85
|
return finish(name, arguments, call_id, denied)
|
|
60
86
|
when :ask
|
|
87
|
+
# Headless FAIL-CLOSED floor (#260). A tool the policy wants to ASK
|
|
88
|
+
# about — a write/edit, or a shell command not covered by the
|
|
89
|
+
# permissions allowlist / read-only auto-allow — cannot be approved
|
|
90
|
+
# when there is no interactive session (one-shot `rubino prompt`/`-q`,
|
|
91
|
+
# a pipe, a gate-less embed). Auto-running it (the old UI::Null#confirm
|
|
92
|
+
# → true bug) is the prompt-injection→RCE foot-gun; hanging on a prompt
|
|
93
|
+
# no one can answer is the opencode bug. So DENY with a clear,
|
|
94
|
+
# single-line block message and record the block so the run can exit
|
|
95
|
+
# non-zero. Anything the user already allowlisted resolved to :allow
|
|
96
|
+
# before reaching here, so this never regresses a configured command.
|
|
97
|
+
unless @ui.interactive?
|
|
98
|
+
@blocked_for_approval = true
|
|
99
|
+
message = approval_block_message(tool, arguments)
|
|
100
|
+
@ui.warning(message) if @ui.respond_to?(:warning)
|
|
101
|
+
# Let the headless adapter latch the block so the one-shot CLI can
|
|
102
|
+
# exit non-zero (#260) without threading a flag up through the loop.
|
|
103
|
+
@ui.tool_blocked(message) if @ui.respond_to?(:tool_blocked)
|
|
104
|
+
blocked = Tools::Result.denied(name: name, call_id: call_id, reason: :noninteractive)
|
|
105
|
+
record_denied(name: name, call_id: call_id, arguments: arguments,
|
|
106
|
+
result: blocked, reason: "noninteractive-blocked")
|
|
107
|
+
return finish(name, arguments, call_id, blocked)
|
|
108
|
+
end
|
|
109
|
+
|
|
61
110
|
unless request_approval(tool, arguments)
|
|
62
111
|
denied = Tools::Result.denied(name: name, call_id: call_id, reason: :user)
|
|
63
112
|
record_denied(name: name, call_id: call_id, arguments: arguments,
|
|
@@ -66,6 +115,18 @@ module Rubino
|
|
|
66
115
|
end
|
|
67
116
|
end
|
|
68
117
|
|
|
118
|
+
# Warn-not-block doom-loop guard (#414): when the detector tripped but
|
|
119
|
+
# hard_stop is off (the default), the call is ALLOWED — surface a
|
|
120
|
+
# one-time warning so a stuck autopilot is visible without hard-denying a
|
|
121
|
+
# legitimate repeated/idempotent call.
|
|
122
|
+
if @approval_policy.respond_to?(:doom_loop_warning) &&
|
|
123
|
+
@approval_policy.doom_loop_warning && @ui.respond_to?(:warning)
|
|
124
|
+
@ui.warning(
|
|
125
|
+
"doom-loop guard: '#{name}' called with identical arguments repeatedly — " \
|
|
126
|
+
"proceeding (set doom_loop.hard_stop:true to block)"
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
69
130
|
notify_yolo_if_applicable(tool, arguments)
|
|
70
131
|
emit_started(name, arguments)
|
|
71
132
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
@@ -105,7 +166,11 @@ module Rubino
|
|
|
105
166
|
if tool.respond_to?(:stream_chunk=) && (@ui.respond_to?(:tool_chunk) || @event_bus)
|
|
106
167
|
tool.stream_chunk = lambda do |chunk|
|
|
107
168
|
streamed = true
|
|
108
|
-
|
|
169
|
+
# Read stream_kind LAZILY: the tool only knows its output kind
|
|
170
|
+
# (e.g. :diff for `git diff`) once #call has inspected the command,
|
|
171
|
+
# which happens AFTER this lambda is installed.
|
|
172
|
+
kind = tool.respond_to?(:stream_kind) ? (tool.stream_kind || :plain) : :plain
|
|
173
|
+
@ui.tool_chunk(name, chunk, kind: kind) if @ui.respond_to?(:tool_chunk)
|
|
109
174
|
# Mirror the chunk onto the bus so the API/SSE stream isn't silent
|
|
110
175
|
# during a long tool call: the Recorder maps TOOL_PROGRESS to a
|
|
111
176
|
# `tool.progress` event, which resets the idle watchdog. Without
|
|
@@ -155,13 +220,13 @@ module Rubino
|
|
|
155
220
|
error_code: error_code&.to_sym,
|
|
156
221
|
artifact: artifact
|
|
157
222
|
)
|
|
158
|
-
|
|
159
|
-
|
|
223
|
+
record_audit(name: name, call_id: call_id, arguments: arguments,
|
|
224
|
+
result: result, status: "completed")
|
|
160
225
|
result
|
|
161
226
|
rescue StandardError => e
|
|
162
227
|
result = Tools::Result.error(name: name, call_id: call_id, error: e.message)
|
|
163
|
-
|
|
164
|
-
|
|
228
|
+
record_audit(name: name, call_id: call_id, arguments: arguments,
|
|
229
|
+
result: result, status: "failed", error: e.message)
|
|
165
230
|
result
|
|
166
231
|
ensure
|
|
167
232
|
tool.cancel_token = nil if tool.respond_to?(:cancel_token=)
|
|
@@ -243,9 +308,12 @@ module Rubino
|
|
|
243
308
|
masked = Util::SecretsMask.mask_value(value, key: key)
|
|
244
309
|
memo[key.to_s] = truncate_for_event(masked.to_s)
|
|
245
310
|
end
|
|
246
|
-
rescue StandardError
|
|
311
|
+
rescue StandardError => e
|
|
247
312
|
# Never block the run because of a serialisation hiccup — drop the
|
|
248
|
-
# arguments rather than crash the tool emission path.
|
|
313
|
+
# arguments rather than crash the tool emission path. Log it so a coding
|
|
314
|
+
# bug here doesn't silently blank every tool event's arguments.
|
|
315
|
+
Rubino.logger&.warn(event: "tool_executor.sanitize_arguments_failed",
|
|
316
|
+
error: e.message, error_class: e.class.name)
|
|
249
317
|
nil
|
|
250
318
|
end
|
|
251
319
|
|
|
@@ -265,7 +333,7 @@ module Rubino
|
|
|
265
333
|
end
|
|
266
334
|
|
|
267
335
|
def record_denied(name:, call_id:, arguments:, result:, reason:)
|
|
268
|
-
|
|
336
|
+
record_audit(
|
|
269
337
|
name: name,
|
|
270
338
|
call_id: call_id,
|
|
271
339
|
arguments: arguments,
|
|
@@ -273,8 +341,21 @@ module Rubino
|
|
|
273
341
|
status: "denied",
|
|
274
342
|
error: reason
|
|
275
343
|
)
|
|
276
|
-
rescue StandardError
|
|
277
|
-
# Don't fail the user's request just because the audit write failed
|
|
344
|
+
rescue StandardError => e
|
|
345
|
+
# Don't fail the user's request just because the audit write failed —
|
|
346
|
+
# but log it, so a silently dropped denial-audit row is traceable.
|
|
347
|
+
Rubino.logger&.warn(event: "tool_executor.record_denied_failed",
|
|
348
|
+
error: e.message, error_class: e.class.name)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Stamps the executor's session id onto the Result (built deep in the tool
|
|
352
|
+
# pipeline with no session context) before the audit write, so the
|
|
353
|
+
# NOT-NULL session_id FK on tool_calls is satisfied (#262). Single
|
|
354
|
+
# chokepoint for every record call — success, failure, and denial.
|
|
355
|
+
def record_audit(name:, call_id:, arguments:, result:, status:, error: nil)
|
|
356
|
+
result.session_id = @session_id if result.respond_to?(:session_id=)
|
|
357
|
+
@tool_call_repository.record(name: name, call_id: call_id, arguments: arguments,
|
|
358
|
+
result: result, status: status, error: error)
|
|
278
359
|
end
|
|
279
360
|
|
|
280
361
|
# The reason behind the policy's :deny, when the policy exposes one
|
|
@@ -285,6 +366,19 @@ module Rubino
|
|
|
285
366
|
@approval_policy.last_deny_reason || :policy
|
|
286
367
|
end
|
|
287
368
|
|
|
369
|
+
# The single-line "blocked" notice surfaced to stderr (via @ui.warning)
|
|
370
|
+
# when a tool needs approval but there is no interactive session (#260).
|
|
371
|
+
# Names the tool and the actionable escape hatches so a scripted run shows
|
|
372
|
+
# WHY nothing happened instead of failing silently.
|
|
373
|
+
def approval_block_message(tool, arguments)
|
|
374
|
+
cmd = Security::ApprovalPolicy.command_string(tool, arguments).to_s
|
|
375
|
+
cmd = cmd.lines.first.to_s.rstrip
|
|
376
|
+
cmd = "#{cmd[0, 57]}…" if cmd.length > 60
|
|
377
|
+
suffix = cmd.empty? ? "" : " (#{cmd})"
|
|
378
|
+
"blocked: #{tool.name}#{suffix} needs approval but no interactive session " \
|
|
379
|
+
"(use --yolo to allow, or allowlist it)"
|
|
380
|
+
end
|
|
381
|
+
|
|
288
382
|
def request_approval(tool, arguments)
|
|
289
383
|
command = Security::ApprovalPolicy.command_string(tool, arguments)
|
|
290
384
|
_hit, pattern_key, description = Security::DangerousPatterns.detect(command)
|
|
@@ -347,13 +441,21 @@ module Rubino
|
|
|
347
441
|
# header followed by nothing reads as a truncated/broken card (#109).
|
|
348
442
|
return "#{tool.name} wants to run" if pairs.empty?
|
|
349
443
|
|
|
444
|
+
# multi_edit carries an `edits` ARRAY whose generic .to_s render is an
|
|
445
|
+
# unreadable escaped Ruby hash (literal \n, truncated). Lay it out as
|
|
446
|
+
# clean per-edit `- old` / `+ new` blocks, matching how the single
|
|
447
|
+
# `edit` tool already previews — so the user can see what will change.
|
|
448
|
+
if (edits_preview = multi_edit_preview(tool, arguments))
|
|
449
|
+
return edits_preview
|
|
450
|
+
end
|
|
451
|
+
|
|
350
452
|
# The common case — ONE short single-line argument (a shell command, a
|
|
351
|
-
# file path) — inlines onto the header: `shell wants:
|
|
453
|
+
# file path) — inlines onto the header: `shell wants: touch hello.txt`
|
|
352
454
|
# (P7). Multi-arg / multi-line calls keep the per-key layout below.
|
|
353
455
|
if pairs.size == 1
|
|
354
456
|
key, value = pairs.first
|
|
355
457
|
text = Util::SecretsMask.mask_value(value, key: key).to_s
|
|
356
|
-
return "#{tool.name} wants:
|
|
458
|
+
return "#{tool.name} wants: #{text}" if !text.include?("\n") && text.length <= 120
|
|
357
459
|
end
|
|
358
460
|
|
|
359
461
|
lines = ["#{tool.name} wants:"]
|
|
@@ -378,6 +480,42 @@ module Rubino
|
|
|
378
480
|
end
|
|
379
481
|
end
|
|
380
482
|
|
|
483
|
+
# Clean per-edit preview for multi_edit: a header with the file path then,
|
|
484
|
+
# for each edit, its `- old` / `+ new` lines (edits blank-line separated),
|
|
485
|
+
# trimmed to a sane line budget. nil for any other tool / shape so the
|
|
486
|
+
# generic per-key formatter handles it. Mirrors EditTool's diff preview.
|
|
487
|
+
MULTI_EDIT_PREVIEW_LINES = 16
|
|
488
|
+
def multi_edit_preview(tool, arguments)
|
|
489
|
+
return nil unless tool.name == "multi_edit"
|
|
490
|
+
|
|
491
|
+
edits = arguments["edits"] || arguments[:edits]
|
|
492
|
+
return nil unless edits.is_a?(Array) && !edits.empty?
|
|
493
|
+
|
|
494
|
+
path = arguments["file_path"] || arguments[:file_path]
|
|
495
|
+
lines = ["multi_edit wants: #{path} (#{edits.size} edit#{"s" if edits.size != 1})"]
|
|
496
|
+
body = []
|
|
497
|
+
edits.each_with_index do |edit, idx|
|
|
498
|
+
old_s = edit["old_string"] || edit[:old_string]
|
|
499
|
+
new_s = edit["new_string"] || edit[:new_string]
|
|
500
|
+
body << "" unless idx.zero?
|
|
501
|
+
body.concat(Util::SecretsMask.mask_value(old_s, key: "old_string").to_s.lines.map { |l| " - #{l.chomp}" })
|
|
502
|
+
body.concat(Util::SecretsMask.mask_value(new_s, key: "new_string").to_s.lines.map { |l| " + #{l.chomp}" })
|
|
503
|
+
end
|
|
504
|
+
if body.size > MULTI_EDIT_PREVIEW_LINES
|
|
505
|
+
dropped = body.size - MULTI_EDIT_PREVIEW_LINES
|
|
506
|
+
body = body.first(MULTI_EDIT_PREVIEW_LINES)
|
|
507
|
+
body << " [… #{dropped} more line(s)]"
|
|
508
|
+
end
|
|
509
|
+
(lines + body).join("\n")
|
|
510
|
+
rescue StandardError => e
|
|
511
|
+
# A preview is cosmetic — fall back to the generic per-key formatter
|
|
512
|
+
# rather than crash the approval prompt. Log it so a malformed-shape
|
|
513
|
+
# coding bug here doesn't silently disable the multi_edit diff preview.
|
|
514
|
+
Rubino.logger&.warn(event: "tool_executor.multi_edit_preview_failed",
|
|
515
|
+
error: e.message, error_class: e.class.name)
|
|
516
|
+
nil
|
|
517
|
+
end
|
|
518
|
+
|
|
381
519
|
# Persists the complete (pre-truncation) output to a per-call file under
|
|
382
520
|
# the rubino home so the model can read back whatever the inline
|
|
383
521
|
# head+tail elided (the spill seam Util::Output.truncate calls back into
|
|
@@ -391,7 +529,21 @@ module Rubino
|
|
|
391
529
|
dir = File.join(Rubino.home_path, "tool-results")
|
|
392
530
|
FileUtils.mkdir_p(dir)
|
|
393
531
|
path = File.join(dir, "#{id}.txt")
|
|
394
|
-
File.write
|
|
532
|
+
# Write ATOMICALLY (temp + rename): a plain File.write can be cut MID-
|
|
533
|
+
# WRITE by an Interrupt (Ctrl+C) — which is NOT a StandardError, so the
|
|
534
|
+
# rescue below never catches it — leaving a TRUNCATED recovery file the
|
|
535
|
+
# marker still points the model at, so it reads back a silently partial
|
|
536
|
+
# output. rename(2) on the same filesystem is atomic, so a reader sees
|
|
537
|
+
# either the old file or the complete new one, never a torn one; the temp
|
|
538
|
+
# is cleaned up if the interrupt lands before the rename.
|
|
539
|
+
tmp = "#{path}.#{Process.pid}.#{SecureRandom.hex(4)}.tmp"
|
|
540
|
+
begin
|
|
541
|
+
File.write(tmp, text)
|
|
542
|
+
File.rename(tmp, path)
|
|
543
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
544
|
+
FileUtils.rm_f(tmp)
|
|
545
|
+
raise
|
|
546
|
+
end
|
|
395
547
|
path
|
|
396
548
|
rescue StandardError => e
|
|
397
549
|
Rubino.logger&.warn(event: "tool_output.spill_failed", error: e.message)
|
|
@@ -103,7 +103,10 @@ module Rubino
|
|
|
103
103
|
thinking: request.thinking,
|
|
104
104
|
prefill: request.prefill,
|
|
105
105
|
image_paths: request.image_paths,
|
|
106
|
-
stream: request.stream
|
|
106
|
+
stream: request.stream?,
|
|
107
|
+
on_intermediate_message: request.on_intermediate_message,
|
|
108
|
+
on_round_trip: request.on_round_trip,
|
|
109
|
+
budget_exhausted: request.budget_exhausted
|
|
107
110
|
)
|
|
108
111
|
end
|
|
109
112
|
|
data/lib/rubino/api/server.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "rack"
|
|
4
4
|
require "uri"
|
|
5
|
+
require "json"
|
|
5
6
|
require "puma"
|
|
6
7
|
require "puma/configuration"
|
|
7
8
|
require "puma/launcher"
|
|
@@ -62,10 +63,28 @@ module Rubino
|
|
|
62
63
|
c.bind(bind_url)
|
|
63
64
|
c.app(app)
|
|
64
65
|
c.quiet
|
|
66
|
+
# Errors raised below the Rack stack (e.g. Puma's HTTP parser rejecting
|
|
67
|
+
# an oversized QUERY_STRING) bypass ErrorHandler and would otherwise
|
|
68
|
+
# render Puma's verbose default page — leaking the Puma version and
|
|
69
|
+
# gem file paths/line numbers (S5-1). Render the same clean envelope
|
|
70
|
+
# with no internals instead.
|
|
71
|
+
c.lowlevel_error_handler(Server.lowlevel_error_handler)
|
|
65
72
|
end
|
|
66
73
|
Puma::Launcher.new(config).run
|
|
67
74
|
end
|
|
68
75
|
|
|
76
|
+
# A Puma lowlevel_error_handler that mirrors ErrorHandler's
|
|
77
|
+
# {error:{code,message}} JSON envelope and never exposes the exception
|
|
78
|
+
# class, message, backtrace, Puma version, or file paths.
|
|
79
|
+
#
|
|
80
|
+
# @return [Proc] callable Puma invokes as (error, env=nil, status=nil)
|
|
81
|
+
def self.lowlevel_error_handler
|
|
82
|
+
lambda do |_error, _env = nil, _status = nil|
|
|
83
|
+
body = JSON.generate(error: { code: "bad_request", message: "bad request" })
|
|
84
|
+
[400, { "content-type" => "application/json" }, [body]]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
69
88
|
# Composes the Rack middleware stack around the router. Order matters:
|
|
70
89
|
# Observability is outermost (sees every status, including 500s from
|
|
71
90
|
# ErrorHandler), then ErrorHandler, then RateLimit (so /v1/health and
|
|
@@ -35,19 +35,35 @@ module Rubino
|
|
|
35
35
|
].freeze
|
|
36
36
|
IMAGE_EXTS = %w[.png .jpg .jpeg .gif .webp .bmp .tiff .tif].freeze
|
|
37
37
|
|
|
38
|
-
# Leading magic bytes per recognised image MIME (WebP is
|
|
39
|
-
# RIFF container + WEBP tag). Marcel lets the file NAME
|
|
40
|
-
# when the content sniff only yields a generic type
|
|
41
|
-
# octet-stream), so a text file renamed fake.png came back
|
|
42
|
-
# was shipped to the provider (#158)
|
|
43
|
-
#
|
|
44
|
-
|
|
38
|
+
# Leading magic bytes per recognised image/document MIME (WebP is
|
|
39
|
+
# special-cased: RIFF container + WEBP tag). Marcel lets the file NAME
|
|
40
|
+
# break the tie when the content sniff only yields a generic type
|
|
41
|
+
# (text/plain, octet-stream), so a text file renamed fake.png came back
|
|
42
|
+
# image/png and was shipped to the provider (#158) — and a text file
|
|
43
|
+
# renamed report.docx came back as :document and got a shell-hint
|
|
44
|
+
# instead of reading inline (#239). An image or document verdict must
|
|
45
|
+
# therefore be backed by the actual signature.
|
|
46
|
+
OLE2 = "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1".b
|
|
47
|
+
SIGNATURES = {
|
|
45
48
|
"image/png" => ["\x89PNG\r\n\x1a\n".b],
|
|
46
49
|
"image/jpeg" => ["\xFF\xD8\xFF".b],
|
|
47
50
|
"image/gif" => ["GIF87a".b, "GIF89a".b],
|
|
48
51
|
"image/bmp" => ["BM".b],
|
|
49
52
|
"image/x-ms-bmp" => ["BM".b],
|
|
50
|
-
"image/tiff" => ["II*\x00".b, "MM\x00*".b]
|
|
53
|
+
"image/tiff" => ["II*\x00".b, "MM\x00*".b],
|
|
54
|
+
"application/pdf" => ["%PDF".b],
|
|
55
|
+
# OOXML and ODF are ZIP containers.
|
|
56
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => ["PK".b],
|
|
57
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => ["PK".b],
|
|
58
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation" => ["PK".b],
|
|
59
|
+
"application/vnd.oasis.opendocument.text" => ["PK".b],
|
|
60
|
+
"application/vnd.oasis.opendocument.spreadsheet" => ["PK".b],
|
|
61
|
+
# Legacy Office is an OLE2 compound file.
|
|
62
|
+
"application/msword" => [OLE2],
|
|
63
|
+
"application/vnd.ms-excel" => [OLE2],
|
|
64
|
+
"application/vnd.ms-powerpoint" => [OLE2],
|
|
65
|
+
"application/rtf" => ["{\\rtf".b],
|
|
66
|
+
"text/rtf" => ["{\\rtf".b]
|
|
51
67
|
}.freeze
|
|
52
68
|
|
|
53
69
|
module_function
|
|
@@ -97,12 +113,14 @@ module Rubino
|
|
|
97
113
|
basename = File.basename(real)
|
|
98
114
|
mime = Marcel::MimeType.for(Pathname(real), name: basename).to_s
|
|
99
115
|
|
|
100
|
-
# Extension-spoof gate (#158): an image verdict that
|
|
101
|
-
# don't back up came from the extension, not the
|
|
102
|
-
# from content alone (no name:); when that is
|
|
103
|
-
# binary sniff names the honest type — so
|
|
104
|
-
# rejected at the staging gate as text/plain
|
|
105
|
-
|
|
116
|
+
# Extension-spoof gate (#158, #239): an image or document verdict that
|
|
117
|
+
# the magic bytes don't back up came from the extension, not the
|
|
118
|
+
# content. Re-resolve from content alone (no name:); when that is
|
|
119
|
+
# generic too, the text/binary sniff names the honest type — so
|
|
120
|
+
# fake.png full of text is rejected at the staging gate as text/plain
|
|
121
|
+
# before any network call, and report.docx full of text reads inline
|
|
122
|
+
# as text instead of bouncing off the document converter.
|
|
123
|
+
if (IMAGE_MIMES.include?(mime) || DOCUMENT_MIMES.include?(mime)) && !signature?(real, mime)
|
|
106
124
|
mime = Marcel::MimeType.for(Pathname(real)).to_s
|
|
107
125
|
if mime.empty? || mime == "application/octet-stream"
|
|
108
126
|
return base_helper.send(:binary?, real) ? [:binary, "application/octet-stream"] : [:text, "text/plain"]
|
|
@@ -136,12 +154,12 @@ module Rubino
|
|
|
136
154
|
end
|
|
137
155
|
|
|
138
156
|
# True when the file's leading bytes carry the signature +mime+ claims.
|
|
139
|
-
#
|
|
140
|
-
def
|
|
157
|
+
# MIMEs without a known signature fail closed (not verified).
|
|
158
|
+
def signature?(real, mime)
|
|
141
159
|
head = File.binread(real, 16).to_s.b
|
|
142
160
|
return head.start_with?("RIFF") && head[8, 4] == "WEBP" if mime == "image/webp"
|
|
143
161
|
|
|
144
|
-
Array(
|
|
162
|
+
Array(SIGNATURES[mime]).any? { |sig| head.start_with?(sig) }
|
|
145
163
|
end
|
|
146
164
|
|
|
147
165
|
# JSON/XML/YAML/JS and friends arrive as application/* but are text.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Boot
|
|
5
|
+
# Loads configuration at process startup, turning a malformed/corrupt
|
|
6
|
+
# config.yml into a clean, actionable boot abort instead of a raw Ruby +
|
|
7
|
+
# Psych double backtrace (CFG-1).
|
|
8
|
+
#
|
|
9
|
+
# The entrypoint (`exe/rubino`) calls {Config::Loader#load} for EVERY
|
|
10
|
+
# command, before Thor dispatch. Any {Config::ConfigError} (or a
|
|
11
|
+
# {Psych::SyntaxError} that escapes the loader) used to propagate all the
|
|
12
|
+
# way out of `exe/rubino:16`, so a single typo in config.yml killed the
|
|
13
|
+
# process with a stack trace — even `rubino doctor`, whose graceful
|
|
14
|
+
# corruption handler (#259) was never reached because boot died first.
|
|
15
|
+
#
|
|
16
|
+
# {.load!} runs the load behind a rescue that writes a single-line
|
|
17
|
+
# diagnostic (what's wrong + the config path + how to fix it) to $stderr
|
|
18
|
+
# and exits non-zero — boot abort, not exception, mirroring
|
|
19
|
+
# {EncryptionKey.validate!}. doctor's own handling still works: doctor
|
|
20
|
+
# re-loads via the Loader and reports corruption itself, so a clean boot
|
|
21
|
+
# here does not mask it.
|
|
22
|
+
module ConfigGuard
|
|
23
|
+
# The Loader normalizes every malformed config shape into a
|
|
24
|
+
# {Config::ConfigError} at the source. The remaining classes here are a
|
|
25
|
+
# defensive backstop: should any raw Psych/IO failure ever slip past the
|
|
26
|
+
# loader (a new shape, a refactor), it still becomes a clean boot abort
|
|
27
|
+
# rather than a double backtrace on every command (CFG-R2).
|
|
28
|
+
def self.load!(loader: Config::Loader.new, stderr: $stderr, argv: [])
|
|
29
|
+
loader.load
|
|
30
|
+
# LOAD-time schema validation (F8): a HAND-EDITED config.yml with an
|
|
31
|
+
# unknown key or a wrong-typed value used to load SILENTLY (the validator
|
|
32
|
+
# only ran at `config set` time) and only blow up later. Surface those as
|
|
33
|
+
# a clear, NON-FATAL warning here — the boot chokepoint every command
|
|
34
|
+
# already passes through — so the user is told at startup instead of
|
|
35
|
+
# discovering it as a runtime crash / provider 4xx. Never fatal: a
|
|
36
|
+
# warning must not block a usable config, and a probe hiccup is ignored.
|
|
37
|
+
# Pure-meta commands (version/help) never need a configured model, so the
|
|
38
|
+
# config-issue warning is noise on `rubino --version`/`--help` — skip it.
|
|
39
|
+
warn_config_issues(loader, stderr) unless meta_command?(argv)
|
|
40
|
+
nil
|
|
41
|
+
rescue Config::ConfigError, Psych::Exception, SystemCallError, IOError => e
|
|
42
|
+
stderr.puts "rubino: config error — #{e.message}"
|
|
43
|
+
stderr.puts "rubino: fix #{loader.config_path}, restore a backup, or re-run 'rubino setup'."
|
|
44
|
+
exit 1
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# `--version`/`-v`/`version` and `--help`/`-h`/`help` are pure-meta: they
|
|
48
|
+
# print static text and exit, so a config-issue warning on them is pure
|
|
49
|
+
# noise. True when the invocation is one of those (the meta flag/word is the
|
|
50
|
+
# FIRST token, matching how Commands.start dispatches them).
|
|
51
|
+
def self.meta_command?(argv)
|
|
52
|
+
first = Array(argv).first.to_s
|
|
53
|
+
%w[--version -v version --help -h help].include?(first)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Emits a one-line-per-issue config WARNING to stderr (F8), or nothing when
|
|
57
|
+
# the config is clean. Best-effort — any failure here is swallowed so a
|
|
58
|
+
# validation hiccup can never break boot.
|
|
59
|
+
def self.warn_config_issues(loader, stderr)
|
|
60
|
+
issues = Config::Validator.warnings(loader.raw_config)
|
|
61
|
+
return if issues.empty?
|
|
62
|
+
|
|
63
|
+
stderr.puts "rubino: warning: #{loader.config_path} has #{issues.size} " \
|
|
64
|
+
"config issue#{"s" if issues.size != 1} (run `rubino doctor` for details):"
|
|
65
|
+
issues.first(5).each { |msg| stderr.puts "rubino: - #{msg}" }
|
|
66
|
+
rescue StandardError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -52,7 +52,7 @@ module Rubino
|
|
|
52
52
|
rescue StandardError
|
|
53
53
|
[]
|
|
54
54
|
end
|
|
55
|
-
names = (::Rubino::Commands::BuiltIns::NAMES + custom).uniq
|
|
55
|
+
names = (::Rubino::Commands::BuiltIns::NAMES + agent_command_names + custom).uniq
|
|
56
56
|
files = -> { Rubino::Workspace.primary_root }
|
|
57
57
|
# ARGUMENT sources: the dropdown completes the argument of these commands
|
|
58
58
|
# the same way it completes `/command` and `@file`.
|
|
@@ -79,10 +79,22 @@ module Rubino
|
|
|
79
79
|
# verbs + the known config keys flattened from the defaults tree.
|
|
80
80
|
# * /skills — the `✗ none` clear entry + the enable/disable verbs +
|
|
81
81
|
# the skill names (#188); after a toggle verb, the names again.
|
|
82
|
-
|
|
82
|
+
Rubino::UI::CompletionSource.new(commands: names, files: files,
|
|
83
|
+
arg_sources: arg_sources,
|
|
84
|
+
descriptions: completion_descriptions)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# The per-command ARGUMENT completion sources (#39): the dropdown
|
|
90
|
+
# completes the argument of these commands the same way it completes
|
|
91
|
+
# `/command` and `@file`. See the per-entry notes inline.
|
|
92
|
+
def arg_sources
|
|
93
|
+
{
|
|
83
94
|
"skills" => ->(args) { skills_arg_candidates(args) },
|
|
84
95
|
"agents" => ->(args) { agents_arg_candidates(args) },
|
|
85
96
|
"tasks" => ->(args) { agents_arg_candidates(args) },
|
|
97
|
+
"agent" => ->(args) { args.empty? ? primary_agent_names : [] },
|
|
86
98
|
"reply" => ->(args) { args.empty? ? blocked_subagent_ids : [] },
|
|
87
99
|
"mcp" => ->(args) { mcp_arg_candidates(args) },
|
|
88
100
|
"mode" => ->(args) { args.empty? ? Rubino::Modes::ALL.map(&:to_s) : [] },
|
|
@@ -97,12 +109,35 @@ module Rubino
|
|
|
97
109
|
"jobs" => ->(args) { args.empty? ? recent_job_ids : [] },
|
|
98
110
|
"config" => ->(args) { config_arg_candidates(args) }
|
|
99
111
|
}
|
|
100
|
-
Rubino::UI::CompletionSource.new(commands: names, files: files,
|
|
101
|
-
arg_sources: arg_sources,
|
|
102
|
-
descriptions: completion_descriptions)
|
|
103
112
|
end
|
|
104
113
|
|
|
105
|
-
|
|
114
|
+
# Agent slash commands (#320): every visible agent is reachable as a
|
|
115
|
+
# `/<name>` (a bare `/<primary>` switches, `/<name> <msg>` routes one
|
|
116
|
+
# turn). Surfaced in the dropdown alongside the built-ins so they're
|
|
117
|
+
# discoverable; resolved lazily so a freshly registered agent appears.
|
|
118
|
+
def agent_command_names
|
|
119
|
+
::Rubino.agent_registry.all.reject(&:hidden?).map { |a| "/#{a.name}" }
|
|
120
|
+
rescue StandardError
|
|
121
|
+
[]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The switchable primary-agent names, for the `/agent <name>` argument.
|
|
125
|
+
def primary_agent_names
|
|
126
|
+
::Rubino.agent_registry.primary_agents.map(&:name)
|
|
127
|
+
rescue StandardError
|
|
128
|
+
[]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Describe each `/<name>` agent command so the dropdown explains what
|
|
132
|
+
# switching/routing to it does — primaries switch, subagents run one-shot.
|
|
133
|
+
def merge_agent_descriptions!(descriptions)
|
|
134
|
+
::Rubino.agent_registry.all.reject(&:hidden?).each do |a|
|
|
135
|
+
verb = a.primary? ? "switch to" : "run one turn as"
|
|
136
|
+
descriptions["/#{a.name}"] = "#{verb} the #{a.name} agent — #{a.description}"
|
|
137
|
+
end
|
|
138
|
+
rescue StandardError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
106
141
|
|
|
107
142
|
# Argument candidates per /agents position: ids → subcommands → nothing.
|
|
108
143
|
def agents_arg_candidates(args)
|
|
@@ -247,6 +282,7 @@ module Rubino
|
|
|
247
282
|
rescue StandardError
|
|
248
283
|
nil
|
|
249
284
|
end
|
|
285
|
+
merge_agent_descriptions!(descriptions)
|
|
250
286
|
descriptions.merge(
|
|
251
287
|
"steer" => "park a note the subagent folds in at its next turn",
|
|
252
288
|
"probe" => "ask the subagent an ephemeral question (not saved)",
|
|
@@ -53,7 +53,13 @@ module Rubino
|
|
|
53
53
|
paint
|
|
54
54
|
break unless children_live?
|
|
55
55
|
end
|
|
56
|
-
rescue StandardError
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
# The ticker exits on any error so a hiccup never crashes the REPL,
|
|
58
|
+
# but a swallowed coding bug would silently kill the live-card refresh
|
|
59
|
+
# for the rest of the session with no trace. Log it once (this rescue
|
|
60
|
+
# only ever fires once per ticker — the loop is already dead here).
|
|
61
|
+
Rubino.logger.warn(event: "cli.idle_card_ticker.crashed",
|
|
62
|
+
error: e.message, error_class: e.class.name)
|
|
57
63
|
nil
|
|
58
64
|
end
|
|
59
65
|
end
|