rubino-agent 0.3.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +115 -0
- data/.rubocop_todo.yml +955 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +97 -0
- data/CHANGELOG.md +344 -0
- data/CONTRIBUTING.md +69 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/Rakefile +8 -0
- data/docs/agents.md +190 -0
- data/docs/api/v1.md +414 -0
- data/docs/architecture.md +177 -0
- data/docs/commands.md +375 -0
- data/docs/configuration.md +590 -0
- data/docs/getting-started.md +143 -0
- data/docs/jobs.md +332 -0
- data/docs/mcp.md +128 -0
- data/docs/memory.md +98 -0
- data/docs/models-and-keys.md +173 -0
- data/docs/oauth-providers.md +145 -0
- data/docs/plugins.md +195 -0
- data/docs/security.md +145 -0
- data/docs/skills.md +322 -0
- data/docs/tools.md +395 -0
- data/docs/troubleshooting.md +73 -0
- data/exe/rubino +9 -0
- data/install.sh +275 -0
- data/lib/rubino/active_skill.rb +50 -0
- data/lib/rubino/agent/agent_registry.rb +120 -0
- data/lib/rubino/agent/backoff_policy.rb +116 -0
- data/lib/rubino/agent/definition.rb +128 -0
- data/lib/rubino/agent/degenerate_recovery.rb +271 -0
- data/lib/rubino/agent/fallback_chain.rb +194 -0
- data/lib/rubino/agent/iteration_budget.rb +50 -0
- data/lib/rubino/agent/loop.rb +617 -0
- data/lib/rubino/agent/model_call_runner.rb +383 -0
- data/lib/rubino/agent/prompts/build.txt +69 -0
- data/lib/rubino/agent/prompts/compaction.txt +20 -0
- data/lib/rubino/agent/prompts/explore.txt +19 -0
- data/lib/rubino/agent/prompts/general.txt +20 -0
- data/lib/rubino/agent/prompts/plan.txt +31 -0
- data/lib/rubino/agent/response_validator.rb +70 -0
- data/lib/rubino/agent/router.rb +65 -0
- data/lib/rubino/agent/runner.rb +195 -0
- data/lib/rubino/agent/tool_executor.rb +402 -0
- data/lib/rubino/agent/truncation_continuation.rb +137 -0
- data/lib/rubino/api/middleware/auth.rb +43 -0
- data/lib/rubino/api/middleware/error_handler.rb +65 -0
- data/lib/rubino/api/middleware/json_parser.rb +100 -0
- data/lib/rubino/api/middleware/observability.rb +59 -0
- data/lib/rubino/api/middleware/rate_limit.rb +136 -0
- data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
- data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
- data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
- data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
- data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
- data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
- data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
- data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
- data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
- data/lib/rubino/api/operations/files/read_operation.rb +40 -0
- data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
- data/lib/rubino/api/operations/health_operation.rb +46 -0
- data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
- data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
- data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
- data/lib/rubino/api/operations/metrics_operation.rb +18 -0
- data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
- data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
- data/lib/rubino/api/operations/models/list_operation.rb +45 -0
- data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
- data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
- data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
- data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
- data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
- data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
- data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
- data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
- data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
- data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
- data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
- data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
- data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
- data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
- data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
- data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
- data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
- data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
- data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
- data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
- data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
- data/lib/rubino/api/request.rb +54 -0
- data/lib/rubino/api/responses.rb +64 -0
- data/lib/rubino/api/router.rb +72 -0
- data/lib/rubino/api/schemas.rb +103 -0
- data/lib/rubino/api/server.rb +102 -0
- data/lib/rubino/api/tls.rb +108 -0
- data/lib/rubino/attachments/classification.rb +16 -0
- data/lib/rubino/attachments/classify.rb +171 -0
- data/lib/rubino/attachments/defang.rb +47 -0
- data/lib/rubino/attachments/policy.rb +36 -0
- data/lib/rubino/attachments/preamble.rb +120 -0
- data/lib/rubino/boot/encryption_key.rb +32 -0
- data/lib/rubino/cli/chat/bang_shell.rb +257 -0
- data/lib/rubino/cli/chat/completion_builder.rb +290 -0
- data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
- data/lib/rubino/cli/chat/image_inbox.rb +168 -0
- data/lib/rubino/cli/chat/session_resolver.rb +176 -0
- data/lib/rubino/cli/chat_command.rb +1674 -0
- data/lib/rubino/cli/commands.rb +250 -0
- data/lib/rubino/cli/config_command.rb +96 -0
- data/lib/rubino/cli/doctor_command.rb +251 -0
- data/lib/rubino/cli/jobs_command.rb +60 -0
- data/lib/rubino/cli/memory_command.rb +135 -0
- data/lib/rubino/cli/onboarding_wizard.rb +207 -0
- data/lib/rubino/cli/server_command.rb +139 -0
- data/lib/rubino/cli/session_command.rb +125 -0
- data/lib/rubino/cli/setup_command.rb +107 -0
- data/lib/rubino/cli/skills_command.rb +85 -0
- data/lib/rubino/cli/tools_command.rb +81 -0
- data/lib/rubino/cli/trust_gate.rb +71 -0
- data/lib/rubino/commands/built_ins.rb +46 -0
- data/lib/rubino/commands/command.rb +116 -0
- data/lib/rubino/commands/executor.rb +550 -0
- data/lib/rubino/commands/handlers/agents.rb +510 -0
- data/lib/rubino/commands/handlers/config.rb +88 -0
- data/lib/rubino/commands/handlers/help.rb +148 -0
- data/lib/rubino/commands/handlers/jobs.rb +71 -0
- data/lib/rubino/commands/handlers/mcp.rb +229 -0
- data/lib/rubino/commands/handlers/memory.rb +200 -0
- data/lib/rubino/commands/handlers/sessions.rb +207 -0
- data/lib/rubino/commands/handlers/skills.rb +195 -0
- data/lib/rubino/commands/handlers/status.rb +211 -0
- data/lib/rubino/commands/loader.rb +90 -0
- data/lib/rubino/config/configuration.rb +455 -0
- data/lib/rubino/config/defaults.rb +569 -0
- data/lib/rubino/config/loader.rb +115 -0
- data/lib/rubino/config/reasoning_prefs.rb +67 -0
- data/lib/rubino/config/writer.rb +72 -0
- data/lib/rubino/context/compressor.rb +149 -0
- data/lib/rubino/context/environment_inspector.rb +176 -0
- data/lib/rubino/context/file_discovery.rb +45 -0
- data/lib/rubino/context/message_boundary.rb +39 -0
- data/lib/rubino/context/prompt_assembler.rb +382 -0
- data/lib/rubino/context/summary_builder.rb +159 -0
- data/lib/rubino/context/token_budget.rb +68 -0
- data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
- data/lib/rubino/database/connection.rb +77 -0
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
- data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
- data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
- data/lib/rubino/database/migrator.rb +48 -0
- data/lib/rubino/documents/converters/csv.rb +79 -0
- data/lib/rubino/documents/converters/docx.rb +129 -0
- data/lib/rubino/documents/converters/html.rb +28 -0
- data/lib/rubino/documents/converters/json.rb +35 -0
- data/lib/rubino/documents/converters/pdf.rb +59 -0
- data/lib/rubino/documents/converters/plain.rb +68 -0
- data/lib/rubino/documents/converters/pptx.rb +64 -0
- data/lib/rubino/documents/converters/xlsx.rb +62 -0
- data/lib/rubino/documents/converters/xml.rb +45 -0
- data/lib/rubino/documents/html.rb +71 -0
- data/lib/rubino/documents/registry.rb +68 -0
- data/lib/rubino/documents/table.rb +63 -0
- data/lib/rubino/documents.rb +50 -0
- data/lib/rubino/errors.rb +119 -0
- data/lib/rubino/files/workspace.rb +93 -0
- data/lib/rubino/interaction/cancel_token.rb +43 -0
- data/lib/rubino/interaction/clipboard_image.rb +84 -0
- data/lib/rubino/interaction/event_bus.rb +48 -0
- data/lib/rubino/interaction/events.rb +101 -0
- data/lib/rubino/interaction/image_input.rb +127 -0
- data/lib/rubino/interaction/input_queue.rb +117 -0
- data/lib/rubino/interaction/lifecycle.rb +299 -0
- data/lib/rubino/interaction/probe.rb +65 -0
- data/lib/rubino/interaction/state.rb +56 -0
- data/lib/rubino/jobs/cron_job_repository.rb +75 -0
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
- data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
- data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
- data/lib/rubino/jobs/queue.rb +184 -0
- data/lib/rubino/jobs/registry.rb +45 -0
- data/lib/rubino/jobs/runner.rb +79 -0
- data/lib/rubino/jobs/scheduler.rb +138 -0
- data/lib/rubino/jobs/webhook_delivery.rb +225 -0
- data/lib/rubino/jobs/worker.rb +59 -0
- data/lib/rubino/llm/adapter_factory.rb +47 -0
- data/lib/rubino/llm/adapter_response.rb +65 -0
- data/lib/rubino/llm/auxiliary_client.rb +61 -0
- data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
- data/lib/rubino/llm/content_builder.rb +55 -0
- data/lib/rubino/llm/credential_check.rb +93 -0
- data/lib/rubino/llm/error_classifier.rb +364 -0
- data/lib/rubino/llm/fake_provider.rb +292 -0
- data/lib/rubino/llm/inline_think_filter.rb +58 -0
- data/lib/rubino/llm/model_catalog.rb +29 -0
- data/lib/rubino/llm/provider_resolver.rb +48 -0
- data/lib/rubino/llm/reasoning_manager.rb +100 -0
- data/lib/rubino/llm/request.rb +56 -0
- data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
- data/lib/rubino/llm/scenario_loader.rb +68 -0
- data/lib/rubino/llm/scenario_selector.rb +80 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
- data/lib/rubino/llm/scenarios/analysis.yml +501 -0
- data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
- data/lib/rubino/llm/scenarios/failure.yml +65 -0
- data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
- data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
- data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
- data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
- data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
- data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
- data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
- data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
- data/lib/rubino/llm/thinking_support.rb +84 -0
- data/lib/rubino/llm/tool_bridge.rb +89 -0
- data/lib/rubino/logger.rb +99 -0
- data/lib/rubino/mcp/manager.rb +180 -0
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
- data/lib/rubino/mcp.rb +57 -0
- data/lib/rubino/memory/backend.rb +104 -0
- data/lib/rubino/memory/backends/default.rb +101 -0
- data/lib/rubino/memory/backends/sqlite.rb +653 -0
- data/lib/rubino/memory/backends.rb +53 -0
- data/lib/rubino/memory/deduplicator.rb +74 -0
- data/lib/rubino/memory/extractor.rb +85 -0
- data/lib/rubino/memory/flusher.rb +31 -0
- data/lib/rubino/memory/retriever.rb +50 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
- data/lib/rubino/memory/sqlite_graph.rb +154 -0
- data/lib/rubino/memory/store.rb +228 -0
- data/lib/rubino/memory/threat_scanner.rb +68 -0
- data/lib/rubino/metrics.rb +175 -0
- data/lib/rubino/modes.rb +93 -0
- data/lib/rubino/oauth/connection_repository.rb +95 -0
- data/lib/rubino/oauth/provider/github.rb +75 -0
- data/lib/rubino/oauth/provider/google.rb +59 -0
- data/lib/rubino/oauth/provider.rb +149 -0
- data/lib/rubino/oauth/registry.rb +86 -0
- data/lib/rubino/oauth/token_encryptor.rb +87 -0
- data/lib/rubino/plugins/registry.rb +75 -0
- data/lib/rubino/plugins.rb +86 -0
- data/lib/rubino/run/approval_gate.rb +243 -0
- data/lib/rubino/run/attachment_downloader.rb +166 -0
- data/lib/rubino/run/event_store.rb +74 -0
- data/lib/rubino/run/executor.rb +383 -0
- data/lib/rubino/run/gate_registry.rb +39 -0
- data/lib/rubino/run/recorder.rb +69 -0
- data/lib/rubino/run/repository.rb +118 -0
- data/lib/rubino/run/session_approval_cache.rb +118 -0
- data/lib/rubino/security/allowlist_persister.rb +55 -0
- data/lib/rubino/security/approval_policy.rb +227 -0
- data/lib/rubino/security/command_allowlist.rb +24 -0
- data/lib/rubino/security/dangerous_patterns.rb +118 -0
- data/lib/rubino/security/deny_persister.rb +73 -0
- data/lib/rubino/security/doom_loop_detector.rb +43 -0
- data/lib/rubino/security/hardline_guard.rb +105 -0
- data/lib/rubino/security/pattern_matcher.rb +62 -0
- data/lib/rubino/security/prefix_deriver.rb +124 -0
- data/lib/rubino/security/readonly_commands.rb +211 -0
- data/lib/rubino/session/exporter.rb +101 -0
- data/lib/rubino/session/message.rb +77 -0
- data/lib/rubino/session/repository.rb +295 -0
- data/lib/rubino/session/store.rb +198 -0
- data/lib/rubino/session/summary_store.rb +65 -0
- data/lib/rubino/skills/prompt_index.rb +85 -0
- data/lib/rubino/skills/registry.rb +208 -0
- data/lib/rubino/skills/skill.rb +176 -0
- data/lib/rubino/skills/skill_tool.rb +215 -0
- data/lib/rubino/skills/state_repository.rb +37 -0
- data/lib/rubino/skills/toggle.rb +26 -0
- data/lib/rubino/tools/answer_child_tool.rb +83 -0
- data/lib/rubino/tools/ask_parent_tool.rb +232 -0
- data/lib/rubino/tools/attach_file_tool.rb +120 -0
- data/lib/rubino/tools/background_tasks.rb +520 -0
- data/lib/rubino/tools/base.rb +222 -0
- data/lib/rubino/tools/custom_tool_loader.rb +119 -0
- data/lib/rubino/tools/edit_tool.rb +122 -0
- data/lib/rubino/tools/git_tool.rb +71 -0
- data/lib/rubino/tools/github_tool.rb +233 -0
- data/lib/rubino/tools/glob_tool.rb +69 -0
- data/lib/rubino/tools/grep_tool.rb +206 -0
- data/lib/rubino/tools/memory_tool.rb +184 -0
- data/lib/rubino/tools/multi_edit_tool.rb +110 -0
- data/lib/rubino/tools/patch_tool.rb +260 -0
- data/lib/rubino/tools/probe_tool.rb +175 -0
- data/lib/rubino/tools/question_tool.rb +128 -0
- data/lib/rubino/tools/read_attachment_tool.rb +180 -0
- data/lib/rubino/tools/read_tool.rb +212 -0
- data/lib/rubino/tools/read_tracker.rb +98 -0
- data/lib/rubino/tools/registry.rb +166 -0
- data/lib/rubino/tools/result.rb +113 -0
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/session_search_tool.rb +103 -0
- data/lib/rubino/tools/shell_input_tool.rb +96 -0
- data/lib/rubino/tools/shell_kill_tool.rb +76 -0
- data/lib/rubino/tools/shell_output_tool.rb +72 -0
- data/lib/rubino/tools/shell_registry.rb +158 -0
- data/lib/rubino/tools/shell_tail_tool.rb +118 -0
- data/lib/rubino/tools/shell_tool.rb +330 -0
- data/lib/rubino/tools/steer_tool.rb +118 -0
- data/lib/rubino/tools/subagent_probe.rb +89 -0
- data/lib/rubino/tools/summarize_file_tool.rb +182 -0
- data/lib/rubino/tools/task_result_tool.rb +90 -0
- data/lib/rubino/tools/task_stop_tool.rb +80 -0
- data/lib/rubino/tools/task_tool.rb +622 -0
- data/lib/rubino/tools/test_tool.rb +454 -0
- data/lib/rubino/tools/todo_tool.rb +93 -0
- data/lib/rubino/tools/tool_call_repository.rb +33 -0
- data/lib/rubino/tools/vision_tool.rb +85 -0
- data/lib/rubino/tools/webfetch_tool.rb +153 -0
- data/lib/rubino/tools/websearch_tool.rb +179 -0
- data/lib/rubino/tools/write_tool.rb +61 -0
- data/lib/rubino/trust.rb +88 -0
- data/lib/rubino/ui/api.rb +296 -0
- data/lib/rubino/ui/base.rb +252 -0
- data/lib/rubino/ui/bottom_composer.rb +1599 -0
- data/lib/rubino/ui/cli.rb +1987 -0
- data/lib/rubino/ui/completion_menu.rb +321 -0
- data/lib/rubino/ui/completion_source.rb +284 -0
- data/lib/rubino/ui/escape_reader.rb +169 -0
- data/lib/rubino/ui/indented_io.rb +88 -0
- data/lib/rubino/ui/input_history.rb +108 -0
- data/lib/rubino/ui/live_region.rb +183 -0
- data/lib/rubino/ui/markdown_renderer.rb +506 -0
- data/lib/rubino/ui/notifier.rb +163 -0
- data/lib/rubino/ui/null.rb +195 -0
- data/lib/rubino/ui/paste_store.rb +176 -0
- data/lib/rubino/ui/printer_base.rb +79 -0
- data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
- data/lib/rubino/ui/queued_indicators.rb +66 -0
- data/lib/rubino/ui/status_bar.rb +100 -0
- data/lib/rubino/ui/stdout_proxy.rb +161 -0
- data/lib/rubino/ui/streaming_markdown.rb +186 -0
- data/lib/rubino/ui/subagent_cards.rb +134 -0
- data/lib/rubino/ui/subagent_view.rb +255 -0
- data/lib/rubino/ui.rb +21 -0
- data/lib/rubino/update_check.rb +187 -0
- data/lib/rubino/util/duration.rb +23 -0
- data/lib/rubino/util/hyperlink.rb +105 -0
- data/lib/rubino/util/output.rb +145 -0
- data/lib/rubino/util/secrets_mask.rb +83 -0
- data/lib/rubino/version.rb +5 -0
- data/lib/rubino/workspace.rb +85 -0
- data/lib/rubino-agent.rb +5 -0
- data/lib/rubino.rb +318 -0
- data/mise.toml +2 -0
- data/rubino-agent.gemspec +103 -0
- data/skills/ruby-expert/SKILL.md +67 -0
- data/skills/ruby-expert/references/concurrency.md +357 -0
- data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
- data/skills/ruby-expert/references/errors-and-types.md +460 -0
- data/skills/ruby-expert/references/gem-authoring.md +459 -0
- data/skills/ruby-expert/references/language-idioms.md +465 -0
- data/skills/ruby-expert/references/metaprogramming.md +339 -0
- data/skills/ruby-expert/references/oo-design.md +553 -0
- data/skills/ruby-expert/references/performance.md +383 -0
- data/skills/ruby-expert/references/rails.md +424 -0
- data/skills/ruby-expert/references/security.md +404 -0
- data/skills/ruby-expert/references/testing.md +473 -0
- data/skills/ruby-expert/references/tooling.md +466 -0
- metadata +856 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Util
|
|
5
|
+
# Smart truncation of long tool output for the scrollback preview.
|
|
6
|
+
#
|
|
7
|
+
# Rule shape (5 head + 10 tail + marker, threshold 30) follows the
|
|
8
|
+
# pattern that emerged from surveying Codex, Gemini CLI, Roo, and
|
|
9
|
+
# Aider: tail bias because errors, exit codes, and command summaries
|
|
10
|
+
# live at the end. A head-heavy split (which would be intuitive for
|
|
11
|
+
# "show me the start") consistently hides the part the user actually
|
|
12
|
+
# needs when something failed.
|
|
13
|
+
#
|
|
14
|
+
# The FULL output still goes to the model and the session DB — this
|
|
15
|
+
# is only what the user sees in the live scroll. The marker tells
|
|
16
|
+
# them so they don't think they're missing something irrecoverable.
|
|
17
|
+
module Output
|
|
18
|
+
DEFAULT_MAX = 30
|
|
19
|
+
DEFAULT_HEAD = 5
|
|
20
|
+
DEFAULT_TAIL = 10
|
|
21
|
+
|
|
22
|
+
# Returns either the full text (when total lines <= max) or a
|
|
23
|
+
# head + marker + tail preview. Pure function — no side effects,
|
|
24
|
+
# no IO. Caller decides where to render the result.
|
|
25
|
+
#
|
|
26
|
+
# @param text [String] the raw output
|
|
27
|
+
# @param max [Integer] line count above which we trim
|
|
28
|
+
# @param head [Integer] lines to keep from the top
|
|
29
|
+
# @param tail [Integer] lines to keep from the bottom
|
|
30
|
+
# @return [String] the preview (always a String, never nil)
|
|
31
|
+
def self.preview(text, max: DEFAULT_MAX, head: DEFAULT_HEAD, tail: DEFAULT_TAIL)
|
|
32
|
+
return "" if text.nil? || text.to_s.empty?
|
|
33
|
+
|
|
34
|
+
lines = text.to_s.lines.map(&:chomp)
|
|
35
|
+
return lines.join("\n") if lines.size <= max
|
|
36
|
+
|
|
37
|
+
omitted = lines.size - head - tail
|
|
38
|
+
head_pt = lines.first(head)
|
|
39
|
+
tail_pt = lines.last(tail)
|
|
40
|
+
marker = "… [#{omitted} more lines · full in DB] …"
|
|
41
|
+
|
|
42
|
+
(head_pt + [marker] + tail_pt).join("\n")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Single-line elision to +max+ characters with a trailing ellipsis.
|
|
46
|
+
# Shared by the parent-note tools (AnswerChild/Task/Steer) that all
|
|
47
|
+
# carried a byte-identical private `truncate`. Pure function.
|
|
48
|
+
#
|
|
49
|
+
# @param text [#to_s] the raw text (nil becomes "")
|
|
50
|
+
# @param max [Integer] character budget before eliding
|
|
51
|
+
# @return [String] the text, or its first +max+ chars + "…"
|
|
52
|
+
def self.elide(text, max)
|
|
53
|
+
s = text.to_s
|
|
54
|
+
s.length > max ? "#{s[0, max]}…" : s
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# First NON-BLANK line of +text+, stripped (or "" when all-blank). A
|
|
58
|
+
# multi-line ruby/shell command often starts with a blank line, so a
|
|
59
|
+
# naive `.lines.first` rendered an empty approval/activity hint (#141).
|
|
60
|
+
# Pure function shared by the subagent card / view rows and the task
|
|
61
|
+
# tool's approval preview, which each carried this extraction inline.
|
|
62
|
+
def self.first_nonblank_line(text)
|
|
63
|
+
text.to_s.each_line.map(&:strip).find { |l| !l.empty? }.to_s
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# First NON-BLANK line, elided to +max+ chars (max-1 + "…"). The single
|
|
67
|
+
# source for the subagent card and view rows, which carried a
|
|
68
|
+
# byte-identical private copy. Distinct from #elide (which keeps +max+
|
|
69
|
+
# chars before the ellipsis) — this row shape budgets the ellipsis IN.
|
|
70
|
+
def self.first_line(text, max)
|
|
71
|
+
first = first_nonblank_line(text)
|
|
72
|
+
first.length > max ? "#{first[0, max - 1]}…" : first
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Truncates long tool output to stay within byte/line limits, with
|
|
76
|
+
# tail-bias because the part the agent (and a human reading the log)
|
|
77
|
+
# actually need is at the end: exit-code suffix, error message,
|
|
78
|
+
# backtrace, "X failures" line. Head-only truncation drops exactly
|
|
79
|
+
# the bytes that matter when something blows up at byte 49,999.
|
|
80
|
+
#
|
|
81
|
+
# Shape: keep ~10% head + bulk of the budget in the tail + a marker
|
|
82
|
+
# in the middle saying how many bytes/lines were elided. Mirrors the
|
|
83
|
+
# pattern #preview already uses for the scrollback body.
|
|
84
|
+
#
|
|
85
|
+
# When +spill+ is supplied it is called with the full pre-truncation
|
|
86
|
+
# text and must return a path (or nil); the marker then points the
|
|
87
|
+
# model at it, so the elided middle isn't lost — the model can `read`
|
|
88
|
+
# the file with offset/limit to recover any part. (Claude-Code-style
|
|
89
|
+
# spill.) Pure aside from that injected callback.
|
|
90
|
+
def self.truncate(text, max_bytes:, max_lines:, spill: nil)
|
|
91
|
+
text = text.to_s
|
|
92
|
+
over_bytes = text.bytesize > max_bytes
|
|
93
|
+
over_lines = text.lines.size > max_lines
|
|
94
|
+
return text unless over_bytes || over_lines
|
|
95
|
+
|
|
96
|
+
spill_path = spill&.call(text)
|
|
97
|
+
text = tail_bias_bytes(text, max_bytes, spill_path) if over_bytes
|
|
98
|
+
text = tail_bias_lines(text, max_lines, spill_path) if text.lines.size > max_lines
|
|
99
|
+
text
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.tail_bias_bytes(text, max_bytes, spill_path = nil)
|
|
103
|
+
encoding = text.encoding
|
|
104
|
+
recover = spill_path ? " · full output saved to #{spill_path} — read it with offset/limit" : ""
|
|
105
|
+
marker_template = "\n... [%d bytes elided#{recover} · use grep/head to narrow] ...\n"
|
|
106
|
+
marker_max = (marker_template % 999_999_999).bytesize
|
|
107
|
+
head_budget = (max_bytes * 0.1).to_i
|
|
108
|
+
tail_budget = max_bytes - head_budget - marker_max
|
|
109
|
+
|
|
110
|
+
# Below ~200 bytes the marker eats the entire budget, so fall back
|
|
111
|
+
# to a simple head truncation (old behavior). Realistic caps go
|
|
112
|
+
# through the head+tail path.
|
|
113
|
+
if tail_budget <= 0
|
|
114
|
+
truncated = text.byteslice(0, max_bytes).to_s.force_encoding(encoding).scrub("")
|
|
115
|
+
tail_note = spill_path ? " · full output: #{spill_path}" : ""
|
|
116
|
+
return "#{truncated}\n... [truncated at #{max_bytes} bytes#{tail_note}]"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
head = text.byteslice(0, head_budget).to_s.force_encoding(encoding).scrub("")
|
|
120
|
+
tail = text.byteslice(-tail_budget, tail_budget).to_s.force_encoding(encoding).scrub("")
|
|
121
|
+
elided = text.bytesize - head.bytesize - tail.bytesize
|
|
122
|
+
"#{head}#{format(marker_template, elided)}#{tail}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.tail_bias_lines(text, max_lines, spill_path = nil)
|
|
126
|
+
lines = text.lines
|
|
127
|
+
return text if lines.size <= max_lines
|
|
128
|
+
|
|
129
|
+
recover = spill_path ? " · full output saved to #{spill_path} — read it with offset/limit" : ""
|
|
130
|
+
head_count = [max_lines / 10, 5].max
|
|
131
|
+
tail_count = max_lines - head_count - 1
|
|
132
|
+
# Vanishing budget falls back to head-only truncation.
|
|
133
|
+
if tail_count <= 0
|
|
134
|
+
tail_note = spill_path ? " · full output: #{spill_path}" : ""
|
|
135
|
+
return "#{lines.first(max_lines).join}\n... [truncated at #{max_lines} lines#{tail_note}]"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
elided = lines.size - head_count - tail_count
|
|
139
|
+
head = lines.first(head_count).join
|
|
140
|
+
tail = lines.last(tail_count).join
|
|
141
|
+
"#{head}... [#{elided} lines elided#{recover} · use grep/head to narrow] ...\n#{tail}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Util
|
|
5
|
+
# Heuristic masking for credentials in tool arguments. The model often
|
|
6
|
+
# passes secrets through cleanly (env vars, config files), but a stray
|
|
7
|
+
# `command: "curl -H 'Authorization: Bearer sk_live_…'"` showing up in
|
|
8
|
+
# an approval prompt — or, worse, in the persistent scrollback — is a
|
|
9
|
+
# leak waiting to happen. Mask aggressively on display; the underlying
|
|
10
|
+
# tool still receives the real value.
|
|
11
|
+
module SecretsMask
|
|
12
|
+
SECRET_KEY_TOKENS = %w[
|
|
13
|
+
password passwd
|
|
14
|
+
secret
|
|
15
|
+
token bearer
|
|
16
|
+
api_key apikey api-key
|
|
17
|
+
access_key accesskey access-key
|
|
18
|
+
private_key privatekey private-key
|
|
19
|
+
auth authorization
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
# Pattern that matches `key=value`, `key: value`, `key value` for the
|
|
23
|
+
# secret-named keys, inside a free-text string (shell command, URL
|
|
24
|
+
# query). The trailing value is grabbed up to whitespace or a known
|
|
25
|
+
# delimiter; quoted values are grabbed whole. `Bearer <token>` is
|
|
26
|
+
# treated as a single value so `Authorization: Bearer XYZ` masks
|
|
27
|
+
# the whole token instead of leaving XYZ exposed.
|
|
28
|
+
INLINE_RE = /
|
|
29
|
+
(?<key>password|passwd|secret|token|
|
|
30
|
+
api[_-]?key|access[_-]?key|private[_-]?key|
|
|
31
|
+
authorization|auth|bearer)
|
|
32
|
+
(?<sep>\s*[:=]\s*|\s+)
|
|
33
|
+
(?<val>"[^"]+"|'[^']+'|(?:Bearer\s+)?[^"'\s]+)
|
|
34
|
+
/xi
|
|
35
|
+
|
|
36
|
+
MASK = "***"
|
|
37
|
+
|
|
38
|
+
# True if the given key looks sensitive on its own (used when the
|
|
39
|
+
# caller already has key/value pairs, e.g. a Hash of arguments).
|
|
40
|
+
def self.sensitive_key?(key)
|
|
41
|
+
k = key.to_s.downcase.tr("-", "_")
|
|
42
|
+
SECRET_KEY_TOKENS.any? { |t| k == t.tr("-", "_") || k.include?(t.tr("-", "_")) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Mask a single value, given the key it belongs to. Returns MASK if
|
|
46
|
+
# the key is sensitive; otherwise scans the value for inline secrets.
|
|
47
|
+
def self.mask_value(value, key: nil)
|
|
48
|
+
return value if value.nil?
|
|
49
|
+
return MASK if key && sensitive_key?(key)
|
|
50
|
+
|
|
51
|
+
mask_inline(value.to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Mask inline patterns like `Authorization: Bearer XYZ` in any string,
|
|
55
|
+
# whether or not the caller knows the surrounding context. Quoted
|
|
56
|
+
# values keep their quotes around the mask so the surrounding
|
|
57
|
+
# structure (`-H "Authorization: ***"`) stays balanced — otherwise
|
|
58
|
+
# the mask would eat a quote and the rest of the string would look
|
|
59
|
+
# like one long open string.
|
|
60
|
+
def self.mask_inline(text)
|
|
61
|
+
text.to_s.gsub(INLINE_RE) do
|
|
62
|
+
m = Regexp.last_match
|
|
63
|
+
val = m[:val]
|
|
64
|
+
masked = case val[0]
|
|
65
|
+
when '"' then %("#{MASK}")
|
|
66
|
+
when "'" then "'#{MASK}'"
|
|
67
|
+
else MASK
|
|
68
|
+
end
|
|
69
|
+
"#{m[:key]}#{m[:sep]}#{masked}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Convenience for Hash arguments: returns a new Hash with sensitive
|
|
74
|
+
# values masked, leaving the original untouched (the real value still
|
|
75
|
+
# has to reach the tool).
|
|
76
|
+
def self.mask_hash(hash)
|
|
77
|
+
return hash unless hash.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
hash.each_with_object({}) { |(k, v), out| out[k] = mask_value(v, key: k) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
# The set of directory roots the agent is allowed to work in.
|
|
5
|
+
#
|
|
6
|
+
# Historically rubino had exactly ONE root, resolved at launch from
|
|
7
|
+
# terminal.cwd or Dir.pwd, and every tool re-derived it. This module turns
|
|
8
|
+
# that single root into an ordered SET of roots: the primary (launch) root
|
|
9
|
+
# plus any directories added via `--add-dir` / `/add-dir`. The default — no
|
|
10
|
+
# extra dirs — is byte-identical to the old single-root behaviour.
|
|
11
|
+
#
|
|
12
|
+
# Modelled on Claude Code's `--add-dir`: extra roots widen the write/edit
|
|
13
|
+
# sandbox (see Tools::Base#within_workspace?) so the agent can touch files
|
|
14
|
+
# under any allowed root, e.g. a service and its client library at once.
|
|
15
|
+
module Workspace
|
|
16
|
+
@added = []
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# The primary root: terminal.cwd when set, else the process cwd. This is
|
|
21
|
+
# the same rule Tools::Base#workspace_root has always used, kept as the
|
|
22
|
+
# single source of truth so the @-picker, shell/test cwd, file API and
|
|
23
|
+
# attachment downloader all agree on "the" root.
|
|
24
|
+
def primary_root
|
|
25
|
+
Rubino.configuration&.dig("terminal", "cwd") || Dir.pwd
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Every allowed root: the primary first, then each added dir, de-duped on
|
|
29
|
+
# canonical path so re-adding the launch dir (or the same dir twice) is a
|
|
30
|
+
# no-op. Returns plain strings.
|
|
31
|
+
def roots
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
ordered = [primary_root, *@added]
|
|
34
|
+
seen = Set.new
|
|
35
|
+
ordered.filter_map do |dir|
|
|
36
|
+
real = canonical(dir)
|
|
37
|
+
next unless real
|
|
38
|
+
next if seen.include?(real)
|
|
39
|
+
|
|
40
|
+
seen << real
|
|
41
|
+
dir
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Canonical (realpath, symlinks resolved) form of every root — what the
|
|
47
|
+
# sandbox compares against.
|
|
48
|
+
def canonical_roots
|
|
49
|
+
roots.filter_map { |dir| canonical(dir) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Adds an extra allowed root. Returns the canonical path on success, or
|
|
53
|
+
# raises ArgumentError with a human-readable reason when the dir doesn't
|
|
54
|
+
# exist / isn't a readable directory. realpath-resolves so a symlinked
|
|
55
|
+
# add-dir lands on its true destination (and matches the sandbox check).
|
|
56
|
+
def add(dir)
|
|
57
|
+
expanded = File.expand_path(dir.to_s)
|
|
58
|
+
raise ArgumentError, "no such directory: #{dir}" unless File.directory?(expanded)
|
|
59
|
+
raise ArgumentError, "not readable: #{dir}" unless File.readable?(expanded)
|
|
60
|
+
|
|
61
|
+
real = File.realpath(expanded)
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
@added << real unless @added.include?(real) || canonical(primary_root) == real
|
|
64
|
+
end
|
|
65
|
+
real
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Test/teardown hook: drop all added roots (the primary is always derived
|
|
69
|
+
# live from config/cwd, so it can't be reset here).
|
|
70
|
+
def reset!
|
|
71
|
+
@mutex.synchronize { @added = [] }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def canonical(path)
|
|
77
|
+
return nil if path.nil? || path.to_s.empty?
|
|
78
|
+
|
|
79
|
+
File.realpath(File.expand_path(path.to_s))
|
|
80
|
+
rescue StandardError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/rubino-agent.rb
ADDED
data/lib/rubino.rb
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zeitwerk"
|
|
4
|
+
require "dry-configurable"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
# Main module for the Rubino gem.
|
|
8
|
+
# Provides an agentic framework with persistent memory, sessions,
|
|
9
|
+
# context compaction, and extensible tool system built on ruby_llm.
|
|
10
|
+
module Rubino
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
class ConfigurationError < Error; end
|
|
13
|
+
class DatabaseError < Error; end
|
|
14
|
+
class SessionError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised when --resume <query> matches more than one session by id-prefix
|
|
17
|
+
# or title-substring. Carries the matches so the CLI can list them and
|
|
18
|
+
# ask the user to disambiguate, instead of silently picking the first.
|
|
19
|
+
class AmbiguousSessionError < SessionError
|
|
20
|
+
attr_reader :query, :matches
|
|
21
|
+
|
|
22
|
+
def initialize(query, matches)
|
|
23
|
+
@query = query
|
|
24
|
+
@matches = matches
|
|
25
|
+
super(build_message)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_message
|
|
31
|
+
lines = ["Ambiguous --resume '#{@query}': #{@matches.size} sessions match."]
|
|
32
|
+
@matches.first(10).each do |s|
|
|
33
|
+
lines << " #{s[:id][0, 8]} #{s[:title] || "(no title)"} [#{s[:status]}]"
|
|
34
|
+
end
|
|
35
|
+
lines << "Use --resume <full-id> (8+ chars) to pick one."
|
|
36
|
+
lines.join("\n")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class ToolError < Error; end
|
|
41
|
+
class CompactionError < Error; end
|
|
42
|
+
class JobError < Error; end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
require_relative "rubino/errors"
|
|
46
|
+
|
|
47
|
+
module Rubino
|
|
48
|
+
class << self
|
|
49
|
+
# Returns the Zeitwerk loader for autoloading
|
|
50
|
+
def loader
|
|
51
|
+
@loader ||= begin
|
|
52
|
+
loader = Zeitwerk::Loader.for_gem
|
|
53
|
+
loader.inflector.inflect(
|
|
54
|
+
# Acronym modules
|
|
55
|
+
"cli" => "CLI",
|
|
56
|
+
"llm" => "LLM",
|
|
57
|
+
"ui" => "UI",
|
|
58
|
+
"api" => "API",
|
|
59
|
+
"tls" => "TLS",
|
|
60
|
+
"mcp" => "MCP",
|
|
61
|
+
"oauth" => "OAuth",
|
|
62
|
+
# Files with compound names that need exact mapping
|
|
63
|
+
"ruby_llm_adapter" => "RubyLLMAdapter",
|
|
64
|
+
"mcp_tool_wrapper" => "MCPToolWrapper",
|
|
65
|
+
"bedrock_bearer_client" => "BedrockBearerClient",
|
|
66
|
+
"adapter_response" => "AdapterResponse",
|
|
67
|
+
"indented_io" => "IndentedIO",
|
|
68
|
+
"webfetch_tool" => "WebFetchTool",
|
|
69
|
+
"websearch_tool" => "WebSearchTool",
|
|
70
|
+
"github_tool" => "GitHubTool",
|
|
71
|
+
"skill_tool" => "SkillTool",
|
|
72
|
+
"custom_tool_loader" => "CustomToolLoader",
|
|
73
|
+
"custom_tool_builder" => "CustomToolBuilder",
|
|
74
|
+
"tool_pair_sanitizer" => "ToolPairSanitizer",
|
|
75
|
+
"degenerate_recovery" => "DegenerateResponseRecovery"
|
|
76
|
+
)
|
|
77
|
+
# Migrations are plain SQL files, not Ruby constants
|
|
78
|
+
loader.ignore(
|
|
79
|
+
File.expand_path("rubino/database/migrations", __dir__)
|
|
80
|
+
)
|
|
81
|
+
# errors.rb defines multiple constants in Rubino (NotFoundError, ...),
|
|
82
|
+
# not a single Rubino::Errors module — loaded manually via require_relative.
|
|
83
|
+
loader.ignore(File.expand_path("rubino/errors.rb", __dir__))
|
|
84
|
+
# rubino-agent.rb is a require shim matching the gem name; it maps to no
|
|
85
|
+
# Rubino constant (and "Rubino-agent" isn't a valid cname). Zeitwerk must
|
|
86
|
+
# not try to manage it.
|
|
87
|
+
loader.ignore(File.expand_path("rubino-agent.rb", __dir__))
|
|
88
|
+
loader
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns the current configuration instance
|
|
93
|
+
def configuration
|
|
94
|
+
@configuration ||= Config::Configuration.new
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Yields the configuration for block-style setup
|
|
98
|
+
def configure
|
|
99
|
+
yield(configuration) if block_given?
|
|
100
|
+
configuration
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Drops the memoized configuration so the next #configuration reload reads
|
|
104
|
+
# config.yml / .env fresh. Used after the first-run onboarding wizard writes
|
|
105
|
+
# them mid-process so the just-saved key is visible without a restart.
|
|
106
|
+
def reload_configuration!
|
|
107
|
+
@configuration = nil
|
|
108
|
+
configuration
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Returns the current UI adapter instance.
|
|
112
|
+
#
|
|
113
|
+
# A thread-local override (set via #with_ui) wins over the process-global
|
|
114
|
+
# adapter. This is what lets the API server run many runs concurrently:
|
|
115
|
+
# each run executes in its own thread (Run::Executor#start) with its own
|
|
116
|
+
# gated UI::API, and tools that reach for the global adapter
|
|
117
|
+
# (QuestionTool#ask, TaskTool) resolve to THAT run's UI — not a shared,
|
|
118
|
+
# gate-less global that would silently drop interactive prompts (the
|
|
119
|
+
# clarify/`question` flow) and could cross-talk between runs.
|
|
120
|
+
def ui
|
|
121
|
+
Thread.current[:rubino_ui] || (@ui ||= UI.build(configuration.ui_adapter))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Sets the process-global UI adapter (CLI boot, tests).
|
|
125
|
+
attr_writer :ui
|
|
126
|
+
|
|
127
|
+
# Runs the block with +adapter+ as the thread-scoped UI, restoring the
|
|
128
|
+
# previous value afterwards (nested-safe). Used by Run::Executor to bind
|
|
129
|
+
# the run's gated UI::API for the duration of the worker thread so global
|
|
130
|
+
# `Rubino.ui` lookups inside tools hit the right, gated instance.
|
|
131
|
+
def with_ui(adapter)
|
|
132
|
+
prev = Thread.current[:rubino_ui]
|
|
133
|
+
Thread.current[:rubino_ui] = adapter
|
|
134
|
+
yield
|
|
135
|
+
ensure
|
|
136
|
+
Thread.current[:rubino_ui] = prev
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The EventBus of the CURRENTLY-RUNNING parent turn. The API/server path
|
|
140
|
+
# injects a fresh per-run bus (Run::Executor) that its Recorder is attached
|
|
141
|
+
# to; the CLI path uses the process-global bus. A backgrounded `task`
|
|
142
|
+
# subagent emits its SPAWNED/COMPLETED/FAILED lifecycle events here so they
|
|
143
|
+
# reach THAT run's recorder (and SSE stream) rather than a detached global
|
|
144
|
+
# bus. Falls back to the global bus when no turn-scoped bus is bound.
|
|
145
|
+
def active_event_bus
|
|
146
|
+
Thread.current[:rubino_event_bus] || event_bus
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Binds +bus+ as the turn-scoped event bus for the duration of the block
|
|
150
|
+
# (set by Interaction::Lifecycle around the loop run, like #with_ui binds
|
|
151
|
+
# the UI). Thread-local so a tool reaches it with no signature churn.
|
|
152
|
+
def with_event_bus(bus)
|
|
153
|
+
prev = Thread.current[:rubino_event_bus]
|
|
154
|
+
Thread.current[:rubino_event_bus] = bus
|
|
155
|
+
yield
|
|
156
|
+
ensure
|
|
157
|
+
Thread.current[:rubino_event_bus] = prev
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The InputQueue of the CURRENTLY-RUNNING parent turn, if any. A background
|
|
161
|
+
# subagent (TaskTool) reads this to deliver its completion notification back
|
|
162
|
+
# into the parent's live loop — the parent picks it up at its next iteration
|
|
163
|
+
# boundary via Loop#inject_steered_input, so the notice lands as a user
|
|
164
|
+
# message between turns, NEVER between an assistant tool_use and its results.
|
|
165
|
+
# Nil on the API/server path (no steering queue) — there the result is still
|
|
166
|
+
# reachable via the BackgroundTasks registry / `task_result`.
|
|
167
|
+
def background_sink
|
|
168
|
+
Thread.current[:rubino_background_sink]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Binds +queue+ as the background-subagent notification sink for the
|
|
172
|
+
# duration of the block (set by Interaction::Lifecycle around the turn,
|
|
173
|
+
# exactly like #with_ui binds the run's UI). Thread-local so a tool can
|
|
174
|
+
# reach it with zero signature churn through the loop/executor.
|
|
175
|
+
def with_background_sink(queue)
|
|
176
|
+
prev = Thread.current[:rubino_background_sink]
|
|
177
|
+
Thread.current[:rubino_background_sink] = queue
|
|
178
|
+
yield
|
|
179
|
+
ensure
|
|
180
|
+
Thread.current[:rubino_background_sink] = prev
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The BackgroundTasks entry id of the subagent run executing on THIS thread,
|
|
184
|
+
# if any. Set by TaskTool#run_child_thread around the child Runner#run! so a
|
|
185
|
+
# tool the child invokes (today: ask_parent) can find its own registry entry
|
|
186
|
+
# — the card it surfaces on, the steer queue it receives answers through —
|
|
187
|
+
# without threading the id through the loop/executor/tool signatures. Nil on
|
|
188
|
+
# the parent thread and on any non-delegated (top-level) run, which is the
|
|
189
|
+
# signal ask_parent uses to refuse (a top-level agent has no parent to ask).
|
|
190
|
+
def current_subagent_id
|
|
191
|
+
Thread.current[:rubino_current_subagent_id]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Binds +id+ as the current subagent id for the duration of the block
|
|
195
|
+
# (set by TaskTool around the child run, exactly like #with_ui / the
|
|
196
|
+
# background sink). Thread-local so the child's tools reach it with zero
|
|
197
|
+
# signature churn.
|
|
198
|
+
def with_current_subagent_id(id)
|
|
199
|
+
prev = Thread.current[:rubino_current_subagent_id]
|
|
200
|
+
Thread.current[:rubino_current_subagent_id] = id
|
|
201
|
+
yield
|
|
202
|
+
ensure
|
|
203
|
+
Thread.current[:rubino_current_subagent_id] = prev
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Returns the current structured logger.
|
|
207
|
+
def logger
|
|
208
|
+
@logger ||= Logger.new
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Sets the logger (useful for testing).
|
|
212
|
+
attr_writer :logger
|
|
213
|
+
|
|
214
|
+
# Returns the database connection
|
|
215
|
+
def database
|
|
216
|
+
@database ||= Database::Connection.new(configuration.database_path)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# First-run guard for any DB-touching entry point. A brand-new RUBINO_HOME
|
|
220
|
+
# has no schema yet (setup/chat hasn't migrated it), so a read path like
|
|
221
|
+
# `rubino sessions list` would otherwise hit a raw
|
|
222
|
+
# `SQLite3::SQLException: no such table` backtrace (#35). `healthy?` only
|
|
223
|
+
# runs `SELECT 1`, which passes the moment SQLite lazily creates the empty
|
|
224
|
+
# file — the tables are still missing — so we also check migrator.pending?.
|
|
225
|
+
# Migrations are idempotent, so this is safe to call on every command. This
|
|
226
|
+
# is the same logic the interactive `chat` command already used; promoted
|
|
227
|
+
# here so the read CLIs (sessions/memory/jobs) share one implementation.
|
|
228
|
+
# Returns true when the schema is ready, false when initialization failed
|
|
229
|
+
# (callers decide whether that's fatal or degrades to an empty state).
|
|
230
|
+
def ensure_database_ready!
|
|
231
|
+
connection = database
|
|
232
|
+
migrator = Database::Migrator.new(connection)
|
|
233
|
+
return true unless connection.healthy? == false || migrator.pending?
|
|
234
|
+
|
|
235
|
+
ensure_directories!
|
|
236
|
+
migrator.migrate!
|
|
237
|
+
true
|
|
238
|
+
rescue StandardError => e
|
|
239
|
+
logger.debug(event: "ensure_database_ready_failed", error: "#{e.class}: #{e.message}")
|
|
240
|
+
false
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Returns the event bus instance
|
|
244
|
+
def event_bus
|
|
245
|
+
@event_bus ||= Interaction::EventBus.new
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Returns the shared agent registry (primary/subagent/utility definitions).
|
|
249
|
+
# Memoized process-wide so the `task` tool can resolve a subagent by name
|
|
250
|
+
# at call time without each boot path having to thread an instance through
|
|
251
|
+
# the tool executor. Both entry points (CLI ChatCommand, API ServerCommand)
|
|
252
|
+
# touch this at boot so delegation works identically over /v1 and in chat;
|
|
253
|
+
# the tool also reads it lazily here, so a stripped boot still resolves.
|
|
254
|
+
def agent_registry
|
|
255
|
+
@agent_registry ||= Agent::AgentRegistry.new
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Sets the agent registry (useful for testing / custom boots).
|
|
259
|
+
attr_writer :agent_registry
|
|
260
|
+
|
|
261
|
+
# Returns the plugin registry
|
|
262
|
+
def plugin_registry
|
|
263
|
+
Plugins.registry
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# DSL for defining plugins
|
|
267
|
+
def plugin(&)
|
|
268
|
+
Plugins.registry.instance_eval(&)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Resets all memoized state (useful for testing)
|
|
272
|
+
def reset!
|
|
273
|
+
@configuration = nil
|
|
274
|
+
@ui = nil
|
|
275
|
+
@database = nil
|
|
276
|
+
@event_bus = nil
|
|
277
|
+
@agent_registry = nil
|
|
278
|
+
Plugins.reset!
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Returns the home directory path. Delegates to the SAME resolver the
|
|
282
|
+
# config Loader uses (RUBINO_HOME → else ~/.rubino) so the server
|
|
283
|
+
# (which loads config.yml through the Loader) and the CLI (config/setup/
|
|
284
|
+
# doctor + ensure_directories!) never disagree about where state lives.
|
|
285
|
+
# Previously this read the YAML `paths.home` default (~/.rubino) and
|
|
286
|
+
# ignored $RUBINO_HOME, splitting the brain at first boot / for .env.
|
|
287
|
+
def home_path
|
|
288
|
+
Rubino::Config::Loader.default_home_path
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Ensures the home directory and subdirectories exist. The home holds
|
|
292
|
+
# secrets (.env) and the database, so it is forced to 0700 here — the
|
|
293
|
+
# single code path every entry point (setup/chat/prompt/doctor) goes
|
|
294
|
+
# through to materialize the home — not just when `setup` ran first
|
|
295
|
+
# (#65): an auto-created home used to be left at the umask's 0755.
|
|
296
|
+
def ensure_directories!
|
|
297
|
+
home = home_path
|
|
298
|
+
FileUtils.mkdir_p(home)
|
|
299
|
+
File.chmod(0o700, home)
|
|
300
|
+
%w[memories sessions logs skills commands tools plugins].each do |subdir|
|
|
301
|
+
dir = File.join(home, subdir)
|
|
302
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Setup autoloading
|
|
309
|
+
Rubino.loader.setup
|
|
310
|
+
|
|
311
|
+
# Register the built-in memory backends. The default backend wraps the
|
|
312
|
+
# existing Store/Retriever/Extractor, so an unset `memory.backend` is
|
|
313
|
+
# byte-identical to the pre-pluggable behavior.
|
|
314
|
+
Rubino::Memory::Backends.register(Rubino::Memory::Backends::Default)
|
|
315
|
+
# The "tiny-Zep" SQLite backend: LLM-extracted atomic facts, bi-temporal
|
|
316
|
+
# supersession, and hybrid FTS5 + recency recall. Switch with
|
|
317
|
+
# `rubino memory backend sqlite`.
|
|
318
|
+
Rubino::Memory::Backends.register(Rubino::Memory::Backends::Sqlite)
|
data/mise.toml
ADDED