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,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module LLM
|
|
7
|
+
# Wraps a Rubino::Tools::Base instance into a RubyLLM::Tool subclass
|
|
8
|
+
# so that ruby_llm can register it, serialize its schema to the LLM, and
|
|
9
|
+
# dispatch tool calls through our full execution pipeline.
|
|
10
|
+
#
|
|
11
|
+
# When a ToolExecutor is provided (always the case in production), tool
|
|
12
|
+
# execution goes through:
|
|
13
|
+
# ApprovalPolicy → tool.call() → truncation → ToolCallRepository.record
|
|
14
|
+
#
|
|
15
|
+
# This ensures identical behavior regardless of LLM provider — there is
|
|
16
|
+
# now a single tool-execution path in the entire application.
|
|
17
|
+
module ToolBridge
|
|
18
|
+
# Returns a RubyLLM::Tool instance wrapping agent_tool.
|
|
19
|
+
def self.for(agent_tool, ui: nil, event_bus: nil, tool_executor: nil)
|
|
20
|
+
klass = bridge_class_for(agent_tool.name)
|
|
21
|
+
klass.new(agent_tool,
|
|
22
|
+
ui: ui || Rubino.ui,
|
|
23
|
+
event_bus: event_bus || Rubino.event_bus,
|
|
24
|
+
tool_executor: tool_executor)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.bridge_class_for(tool_name)
|
|
28
|
+
@cache ||= {}
|
|
29
|
+
@cache[tool_name] ||= build_class(tool_name)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.build_class(tool_name)
|
|
33
|
+
klass = Class.new(::RubyLLM::Tool) do
|
|
34
|
+
define_method(:name) { tool_name }
|
|
35
|
+
|
|
36
|
+
define_method(:initialize) do |agent_tool, ui:, event_bus:, tool_executor:|
|
|
37
|
+
@agent_tool = agent_tool
|
|
38
|
+
@ui = ui
|
|
39
|
+
@event_bus = event_bus
|
|
40
|
+
@tool_executor = tool_executor
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
define_method(:description) { @agent_tool.description }
|
|
44
|
+
define_method(:params_schema) { @agent_tool.input_schema }
|
|
45
|
+
|
|
46
|
+
define_method(:execute) do |**kwargs|
|
|
47
|
+
name = @agent_tool.name
|
|
48
|
+
args = kwargs.transform_keys(&:to_s)
|
|
49
|
+
|
|
50
|
+
if @tool_executor
|
|
51
|
+
# Full pipeline: approval check → tool.call → truncation → audit record
|
|
52
|
+
result = @tool_executor.execute(
|
|
53
|
+
name: name,
|
|
54
|
+
arguments: args,
|
|
55
|
+
call_id: nil
|
|
56
|
+
)
|
|
57
|
+
result.output
|
|
58
|
+
else
|
|
59
|
+
# Fallback: direct call (tests / one-shot mode without full Lifecycle)
|
|
60
|
+
@event_bus&.emit(Rubino::Interaction::Events::TOOL_STARTED, name: name)
|
|
61
|
+
@ui&.tool_started(name, arguments: args)
|
|
62
|
+
|
|
63
|
+
begin
|
|
64
|
+
output = @agent_tool.call(args)
|
|
65
|
+
result = Rubino::Tools::Result.success(
|
|
66
|
+
name: name, call_id: nil, output: output.to_s
|
|
67
|
+
)
|
|
68
|
+
@event_bus&.emit(Rubino::Interaction::Events::TOOL_FINISHED, name: name)
|
|
69
|
+
@ui&.tool_finished(name, result: result)
|
|
70
|
+
result.output
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
@event_bus&.emit(Rubino::Interaction::Events::TOOL_FINISHED, name: name)
|
|
73
|
+
@ui&.tool_finished(name)
|
|
74
|
+
"Error: #{e.message}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
const_name = "Bridge_#{tool_name.gsub(/[^a-zA-Z0-9]/, "_")}"
|
|
81
|
+
unless Rubino::LLM::ToolBridge.const_defined?(const_name, false)
|
|
82
|
+
Rubino::LLM::ToolBridge.const_set(const_name, klass)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
klass
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "logger"
|
|
5
|
+
|
|
6
|
+
module Rubino
|
|
7
|
+
# Structured JSON-line logger with built-in redaction of sensitive fields.
|
|
8
|
+
#
|
|
9
|
+
# Rubino.logger.info(event: "api.server.starting", port: 4820)
|
|
10
|
+
# #=> {"ts":"2026-05-31T...","level":"info","event":"api.server.starting","port":4820}
|
|
11
|
+
#
|
|
12
|
+
# Each level method (#debug, #info, #warn, #error, #fatal) takes **fields
|
|
13
|
+
# and emits one structured line per call.
|
|
14
|
+
#
|
|
15
|
+
# Configuration via environment:
|
|
16
|
+
# RUBINO_LOG_LEVEL — debug|info|warn|error|fatal (default: info)
|
|
17
|
+
# RUBINO_LOG_FORMAT — json|pretty (default: json)
|
|
18
|
+
#
|
|
19
|
+
# Redaction: any key whose name (case-insensitive) appears in REDACT_KEYS is
|
|
20
|
+
# replaced with REDACTED at any nesting depth before the line is serialized.
|
|
21
|
+
# This covers tokens, secrets, and raw Authorization headers passing through
|
|
22
|
+
# middleware logs.
|
|
23
|
+
class Logger
|
|
24
|
+
LEVELS = { debug: ::Logger::DEBUG, info: ::Logger::INFO, warn: ::Logger::WARN, error: ::Logger::ERROR,
|
|
25
|
+
fatal: ::Logger::FATAL }.freeze
|
|
26
|
+
|
|
27
|
+
# Keys (matched case-insensitively against String form) whose values are
|
|
28
|
+
# replaced with REDACTED before logging. Recursive — applies at any depth.
|
|
29
|
+
REDACT_KEYS = %w[
|
|
30
|
+
access_token refresh_token id_token
|
|
31
|
+
client_secret api_key password secret bearer
|
|
32
|
+
authorization http_authorization
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
# Replacement string written in place of redacted values.
|
|
36
|
+
REDACTED = "[REDACTED]"
|
|
37
|
+
|
|
38
|
+
def initialize(io: $stdout, level: ENV.fetch("RUBINO_LOG_LEVEL", "info"),
|
|
39
|
+
format: ENV.fetch("RUBINO_LOG_FORMAT", "json"))
|
|
40
|
+
@logger = ::Logger.new(io)
|
|
41
|
+
@logger.level = LEVELS.fetch(level.to_sym, ::Logger::INFO)
|
|
42
|
+
@format = format.to_sym
|
|
43
|
+
@logger.formatter = formatter
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Rebinds the underlying sink to a new IO (or path) WITHOUT replacing the
|
|
47
|
+
# Logger object, so existing references (and the memoized Rubino.logger)
|
|
48
|
+
# keep working. Level and format are preserved.
|
|
49
|
+
#
|
|
50
|
+
# The interactive CLI uses this to route structured JSON lines to a file
|
|
51
|
+
# instead of the terminal $stdout that the raw-mode TUI owns (#125):
|
|
52
|
+
# otherwise a warn/info event (e.g. a network blip during a background
|
|
53
|
+
# subagent) prints raw JSON into the rendered conversation and corrupts the
|
|
54
|
+
# bottom-composer frame. Returns the previous IO so the caller can restore
|
|
55
|
+
# it on exit.
|
|
56
|
+
def reopen(io)
|
|
57
|
+
previous = @logger.instance_variable_get(:@logdev)&.dev
|
|
58
|
+
@logger.reopen(io)
|
|
59
|
+
previous
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
LEVELS.each_key do |level|
|
|
63
|
+
define_method(level) do |**fields|
|
|
64
|
+
@logger.public_send(level) { self.class.redact(fields) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Recursively walk a value, masking entries whose key matches REDACT_KEYS.
|
|
69
|
+
# Hash and Array are descended; scalars pass through unchanged.
|
|
70
|
+
# Public because middleware and tests call it directly.
|
|
71
|
+
#
|
|
72
|
+
# @param value [Object] any value (typically Hash, Array, or scalar)
|
|
73
|
+
# @return [Object] same shape as input with sensitive values replaced by REDACTED
|
|
74
|
+
def self.redact(value)
|
|
75
|
+
case value
|
|
76
|
+
when Hash
|
|
77
|
+
value.each_with_object({}) do |(k, v), out|
|
|
78
|
+
out[k] = REDACT_KEYS.include?(k.to_s.downcase) ? REDACTED : redact(v)
|
|
79
|
+
end
|
|
80
|
+
when Array
|
|
81
|
+
value.map { |v| redact(v) }
|
|
82
|
+
else
|
|
83
|
+
value
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def formatter
|
|
90
|
+
if @format == :pretty
|
|
91
|
+
->(severity, time, _progname, fields) { "[#{time.iso8601}] #{severity.downcase} #{fields.inspect}\n" }
|
|
92
|
+
else
|
|
93
|
+
lambda { |severity, time, _progname, fields|
|
|
94
|
+
"#{JSON.generate({ ts: time.iso8601, level: severity.downcase }.merge(fields))}\n"
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/mcp"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module MCP
|
|
7
|
+
# Manages multiple MCP client connections.
|
|
8
|
+
# Reads server definitions from config, starts clients,
|
|
9
|
+
# and registers their tools into the agent's tool registry.
|
|
10
|
+
class Manager
|
|
11
|
+
# clients: name => live RubyLLM::MCP client.
|
|
12
|
+
# last_errors: name => the most recent start failure message (cleared on a
|
|
13
|
+
# successful start) — the "why is my server missing?" answer /mcp's
|
|
14
|
+
# drill-in shows (#182).
|
|
15
|
+
attr_reader :clients, :last_errors
|
|
16
|
+
|
|
17
|
+
def initialize(config: nil)
|
|
18
|
+
@config = config || Rubino.configuration
|
|
19
|
+
@clients = {}
|
|
20
|
+
@last_errors = {}
|
|
21
|
+
route_mcp_logging!
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Initializes all configured MCP servers
|
|
25
|
+
def start_all!
|
|
26
|
+
server_configs = @config.dig("mcp", "servers") || {}
|
|
27
|
+
|
|
28
|
+
server_configs.each do |name, server_config|
|
|
29
|
+
start_server(name, server_config)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
register_all_tools!
|
|
33
|
+
@clients
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Starts a single MCP server by name
|
|
37
|
+
def start_server(name, server_config)
|
|
38
|
+
transport = server_config["transport"] || "stdio"
|
|
39
|
+
client_opts = build_client_options(name, transport, server_config)
|
|
40
|
+
|
|
41
|
+
client = RubyLLM::MCP.client(**client_opts)
|
|
42
|
+
@clients[name.to_s] = client
|
|
43
|
+
@last_errors.delete(name.to_s)
|
|
44
|
+
|
|
45
|
+
Rubino.event_bus.emit(:mcp_server_started, name: name)
|
|
46
|
+
client
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
@last_errors[name.to_s] = e.message
|
|
49
|
+
Rubino.ui.warning("MCP server '#{name}' failed to start: #{e.message}")
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Stops all MCP clients (deregistering their tools — see #stop_server).
|
|
54
|
+
# `keys.each`, NOT `each_key`: stop_server deletes from @clients, which
|
|
55
|
+
# would raise mid-iteration without the snapshot.
|
|
56
|
+
def stop_all!
|
|
57
|
+
@clients.keys.each { |name| stop_server(name) } # rubocop:disable Style/HashEachMethods
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Stops a specific MCP client AND deregisters its MCPToolWrapper
|
|
61
|
+
# instances from Tools::Registry (#182) — before, nothing ever
|
|
62
|
+
# unregistered them, so a stopped server left dead tools the model could
|
|
63
|
+
# still call.
|
|
64
|
+
def stop_server(name)
|
|
65
|
+
client = @clients.delete(name.to_s)
|
|
66
|
+
return nil unless client
|
|
67
|
+
|
|
68
|
+
deregister_tools(name.to_s)
|
|
69
|
+
begin
|
|
70
|
+
client.stop
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
Rubino.ui.warning("Error stopping MCP '#{name}': #{e.message}")
|
|
73
|
+
end
|
|
74
|
+
Rubino.event_bus.emit(:mcp_server_stopped, name: name)
|
|
75
|
+
client
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Registers all MCP tools into the agent's tool registry.
|
|
79
|
+
# Per-agent mcp_servers scoping is NOT applied here — it lives in
|
|
80
|
+
# Agent::Definition#resolved_tools (#173), the single seam every
|
|
81
|
+
# consumer of an agent's tool set goes through.
|
|
82
|
+
def register_all_tools!
|
|
83
|
+
@clients.each_key { |server_name| register_server_tools(server_name) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Registers ONE started server's tools — the `/mcp <server> on` path
|
|
87
|
+
# (#182) re-registers only that server instead of re-reading every
|
|
88
|
+
# client's tool list.
|
|
89
|
+
def register_server_tools(name)
|
|
90
|
+
client = @clients[name.to_s]
|
|
91
|
+
return unless client
|
|
92
|
+
|
|
93
|
+
client.tools.each do |mcp_tool|
|
|
94
|
+
wrapped = MCPToolWrapper.new(mcp_tool, server_name: name.to_s)
|
|
95
|
+
Tools::Registry.register(wrapped)
|
|
96
|
+
end
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
Rubino.ui.warning("Failed to load tools from '#{name}': #{e.message}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Checks health of all connected servers
|
|
102
|
+
def health_check
|
|
103
|
+
@clients.map do |name, client|
|
|
104
|
+
alive = begin
|
|
105
|
+
client.alive?
|
|
106
|
+
rescue StandardError
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
{ name: name, alive: alive }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Returns true if any MCP servers are configured
|
|
114
|
+
def configured?
|
|
115
|
+
servers = @config.dig("mcp", "servers")
|
|
116
|
+
servers.is_a?(Hash) && !servers.empty?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Drops a stopped server's wrappers from the registry (keyed by the
|
|
122
|
+
# prefixed tool name, so only that server's entries match).
|
|
123
|
+
def deregister_tools(server_name)
|
|
124
|
+
Tools::Registry.all.each do |tool|
|
|
125
|
+
next unless tool.is_a?(MCPToolWrapper) && tool.server_name == server_name
|
|
126
|
+
|
|
127
|
+
Tools::Registry.unregister(tool.name)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# ruby_llm-mcp logs to $stdout by default — including every line the
|
|
132
|
+
# stdio server prints on ITS stderr (e.g. "Secure MCP Filesystem Server
|
|
133
|
+
# running on stdio"), relayed at INFO. That raw logger line pollutes
|
|
134
|
+
# one-shot `rubino prompt` output, doctor, tools and the chat banner
|
|
135
|
+
# (#174 — same class as the fixed #99). Route the gem's logger to a file
|
|
136
|
+
# under the resolved home, next to RUBYLLM_DEBUG's ruby_llm.log.
|
|
137
|
+
def route_mcp_logging!
|
|
138
|
+
log_path = File.join(Config::Loader.default_home_path, "logs", "mcp.log")
|
|
139
|
+
FileUtils.mkdir_p(File.dirname(log_path))
|
|
140
|
+
RubyLLM::MCP.config.logger = ::Logger.new(log_path, progname: "RubyLLM::MCP", level: ::Logger::INFO)
|
|
141
|
+
rescue StandardError
|
|
142
|
+
# Logging is never worth breaking MCP boot; worst case the gem keeps
|
|
143
|
+
# its default logger.
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def build_client_options(name, transport, server_config)
|
|
148
|
+
opts = {
|
|
149
|
+
name: name.to_s,
|
|
150
|
+
transport_type: transport.to_sym
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
case transport
|
|
154
|
+
when "stdio"
|
|
155
|
+
opts[:config] = {
|
|
156
|
+
command: server_config["command"],
|
|
157
|
+
args: server_config["args"] || [],
|
|
158
|
+
env: server_config["env"] || {}
|
|
159
|
+
}
|
|
160
|
+
when "sse"
|
|
161
|
+
opts[:config] = {
|
|
162
|
+
url: server_config["url"],
|
|
163
|
+
headers: server_config["headers"] || {}
|
|
164
|
+
}
|
|
165
|
+
when "streamable"
|
|
166
|
+
opts[:config] = {
|
|
167
|
+
url: server_config["url"],
|
|
168
|
+
headers: server_config["headers"] || {}
|
|
169
|
+
}
|
|
170
|
+
opts[:config][:oauth] = server_config["oauth"] if server_config["oauth"]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Optional: request timeout
|
|
174
|
+
opts[:request_timeout] = server_config["timeout"] if server_config["timeout"]
|
|
175
|
+
|
|
176
|
+
opts
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module MCP
|
|
5
|
+
# Wraps an MCP tool (from ruby_llm-mcp) into the Rubino::Tools::Base interface.
|
|
6
|
+
# This allows MCP tools to be used seamlessly alongside built-in tools.
|
|
7
|
+
class MCPToolWrapper < Tools::Base
|
|
8
|
+
attr_reader :mcp_tool, :server_name
|
|
9
|
+
|
|
10
|
+
def initialize(mcp_tool, server_name:)
|
|
11
|
+
@mcp_tool = mcp_tool
|
|
12
|
+
@server_name = server_name
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def name
|
|
16
|
+
# Prefix with server name to avoid collisions
|
|
17
|
+
"#{@server_name}_#{@mcp_tool.name}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def description
|
|
21
|
+
@mcp_tool.description
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def input_schema
|
|
25
|
+
# The server-advertised JSON schema lives in RubyLLM::MCP::Tool#params_schema.
|
|
26
|
+
# The inherited RubyLLM::Tool#parameters DSL accessor is ALWAYS empty for
|
|
27
|
+
# MCP tools — forwarding it sent every tool to the model with `parameters:
|
|
28
|
+
# {}`, so the model had to guess argument names and every call failed
|
|
29
|
+
# server-side validation with -32602 (#170).
|
|
30
|
+
schema = @mcp_tool.params_schema if @mcp_tool.respond_to?(:params_schema)
|
|
31
|
+
schema || { type: "object", properties: {} }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def risk_level
|
|
35
|
+
# MCP tools are external, default to medium risk
|
|
36
|
+
:medium
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call(arguments)
|
|
40
|
+
result = @mcp_tool.execute(**symbolize_keys(arguments))
|
|
41
|
+
# ruby_llm-mcp reports tool failures by RETURNING `{ error: "…" }`
|
|
42
|
+
# instead of raising. Map both failure paths onto the registry's
|
|
43
|
+
# "Error: …" convention (Tools::Result#errorish?) so an errored MCP
|
|
44
|
+
# call renders ✗ like any built-in tool, not "✓ done" (#172).
|
|
45
|
+
error = result[:error] || result["error"] if result.is_a?(Hash)
|
|
46
|
+
return "Error: MCP tool #{@server_name}/#{@mcp_tool.name}: #{error}" if error
|
|
47
|
+
|
|
48
|
+
result.to_s
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
"Error: MCP tool #{@server_name}/#{@mcp_tool.name}: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Override to provide the raw MCP tool definition for LLM
|
|
54
|
+
def to_tool_definition
|
|
55
|
+
{
|
|
56
|
+
name: name,
|
|
57
|
+
description: description,
|
|
58
|
+
parameters: input_schema
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def symbolize_keys(hash)
|
|
65
|
+
hash.transform_keys(&:to_sym)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/rubino/mcp.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
# MCP (Model Context Protocol) integration module.
|
|
5
|
+
# Manages connections to MCP servers and exposes their tools
|
|
6
|
+
# to the agent via the standard Tools::Registry.
|
|
7
|
+
module MCP
|
|
8
|
+
class << self
|
|
9
|
+
# The shared, booted Manager (nil until boot! succeeds).
|
|
10
|
+
attr_reader :manager
|
|
11
|
+
|
|
12
|
+
# MCP is opt-in by configuration (#95): a non-empty `mcp.servers`
|
|
13
|
+
# block enables it; an explicit `mcp.enabled: false` switches it off
|
|
14
|
+
# without deleting the server definitions.
|
|
15
|
+
def enabled?(config = Rubino.configuration)
|
|
16
|
+
servers = config.dig("mcp", "servers")
|
|
17
|
+
return false unless servers.is_a?(Hash) && !servers.empty?
|
|
18
|
+
|
|
19
|
+
config.dig("mcp", "enabled") != false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Boots the shared Manager once per process: connects to every
|
|
23
|
+
# configured server and registers their prefixed tools in
|
|
24
|
+
# Tools::Registry (#91). Best-effort — MCP is an optional
|
|
25
|
+
# integration and must never break boot, so any failure is a
|
|
26
|
+
# warning, not an error.
|
|
27
|
+
def boot!
|
|
28
|
+
return @manager if @manager
|
|
29
|
+
return nil unless enabled?
|
|
30
|
+
|
|
31
|
+
manager = Manager.new
|
|
32
|
+
manager.start_all!
|
|
33
|
+
@manager = manager
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
Rubino.ui.warning("MCP startup failed: #{e.message}")
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# `/mcp reload` (#182): stop every server (deregistering their tools),
|
|
40
|
+
# drop the memoized Manager, re-read config.yml fresh and boot again —
|
|
41
|
+
# so a server added to config becomes usable without restarting chat.
|
|
42
|
+
# Returns the new Manager, or nil when the re-read config leaves MCP
|
|
43
|
+
# disabled (no servers / mcp.enabled: false).
|
|
44
|
+
def reload!
|
|
45
|
+
@manager&.stop_all!
|
|
46
|
+
@manager = nil
|
|
47
|
+
Rubino.reload_configuration!
|
|
48
|
+
boot!
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Clears the booted Manager (used by tests).
|
|
52
|
+
def reset!
|
|
53
|
+
@manager = nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Memory
|
|
5
|
+
# Duck-typed contract for a pluggable memory backend.
|
|
6
|
+
#
|
|
7
|
+
# A backend owns the WRITE path (store / replace / forget / extract), the
|
|
8
|
+
# READ path the prompt assembler depends on (user_profile / project_context
|
|
9
|
+
# / retrieve), and the admin surface that powers `rubino memory ...`
|
|
10
|
+
# (list / find). The method set is the union of what the rest of the gem
|
|
11
|
+
# already calls today — extracting this interface is a mechanical refactor,
|
|
12
|
+
# not a rewrite.
|
|
13
|
+
#
|
|
14
|
+
# The injection-defense floor (ThreatScanner + the char-budget enforced in
|
|
15
|
+
# Memory::Store) lives in the shared write path, so no backend can splice
|
|
16
|
+
# tainted or over-budget content into a future system prompt. Concrete
|
|
17
|
+
# backends override only what they need; the base raises NotImplementedError
|
|
18
|
+
# for the operations that have no sensible default.
|
|
19
|
+
class Backend
|
|
20
|
+
# Backend registry key (e.g. "default"). Subclasses must override.
|
|
21
|
+
def self.backend_name
|
|
22
|
+
raise NotImplementedError, "#{self} must define .backend_name"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(config: nil)
|
|
26
|
+
@config = config || Rubino.configuration
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Deps present + configured (no network). Backends with optional
|
|
30
|
+
# dependencies override this; the default is always available.
|
|
31
|
+
def available?
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# -- WRITE path --
|
|
36
|
+
|
|
37
|
+
# Persist one memory entry. Returns the stored row (Hash) or raises a
|
|
38
|
+
# Memory::Store::ThreatDetectedError / BudgetExceededError on refusal.
|
|
39
|
+
def store(kind:, content:, source_session_id: nil, confidence: 1.0, metadata: {})
|
|
40
|
+
raise NotImplementedError, "#{self.class} must implement #store"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Replace the content of the first entry of `kind` whose content includes
|
|
44
|
+
# `old_text`. Returns the matched row, or nil if nothing matched.
|
|
45
|
+
def replace(kind:, old_text:, content:)
|
|
46
|
+
raise NotImplementedError, "#{self.class} must implement #replace"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Delete the first entry of `kind` whose content includes `old_text`.
|
|
50
|
+
# Returns the matched row, or nil if nothing matched.
|
|
51
|
+
def forget(kind:, old_text:)
|
|
52
|
+
raise NotImplementedError, "#{self.class} must implement #forget"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Mine a session's messages for durable facts and persist them.
|
|
56
|
+
# Returns the list of stored entries.
|
|
57
|
+
def extract(session_id)
|
|
58
|
+
raise NotImplementedError, "#{self.class} must implement #extract"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# -- READ path (consumed by lifecycle#load_memory -> PromptAssembler) --
|
|
62
|
+
|
|
63
|
+
# User-profile text (String) or nil.
|
|
64
|
+
def user_profile
|
|
65
|
+
raise NotImplementedError, "#{self.class} must implement #user_profile"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Project-context text (String) or nil.
|
|
69
|
+
def project_context
|
|
70
|
+
raise NotImplementedError, "#{self.class} must implement #project_context"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Memories relevant to the turn. `query` lets a relevance-aware backend
|
|
74
|
+
# rank by the last user message; the default backend ignores it and
|
|
75
|
+
# returns everything that fits, exactly as today. Returns an array of
|
|
76
|
+
# rows ([{id:, kind:, content:, ...}]).
|
|
77
|
+
def retrieve(session_id:, query: nil)
|
|
78
|
+
raise NotImplementedError, "#{self.class} must implement #retrieve"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# -- admin (powers `rubino memory list/show/delete`) --
|
|
82
|
+
|
|
83
|
+
# Live entries only by default; `include_retired: true` opts into the
|
|
84
|
+
# supersession history on backends that soft-retire (sqlite).
|
|
85
|
+
def list(kind: nil, limit: 20, include_retired: false)
|
|
86
|
+
raise NotImplementedError, "#{self.class} must implement #list"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def find(id)
|
|
90
|
+
raise NotImplementedError, "#{self.class} must implement #find"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def delete(id)
|
|
94
|
+
raise NotImplementedError, "#{self.class} must implement #delete"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Total number of stored memories. Powers the CLI /status line and the
|
|
98
|
+
# web dashboard's memory card via GET /v1/memory/stats.
|
|
99
|
+
def count
|
|
100
|
+
raise NotImplementedError, "#{self.class} must implement #count"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Memory
|
|
5
|
+
module Backends
|
|
6
|
+
# The default memory backend: a thin façade over the existing
|
|
7
|
+
# Store / Retriever / Extractor. Behavior is byte-identical to the
|
|
8
|
+
# pre-pluggable implementation — every call delegates to the same code
|
|
9
|
+
# paths (and therefore the same ThreatScanner + char-budget guards in
|
|
10
|
+
# Memory::Store) that the seams called directly before.
|
|
11
|
+
#
|
|
12
|
+
# Named "default" because it is SQLite-table-backed today but is the
|
|
13
|
+
# baseline every install gets unless `memory.backend` is changed.
|
|
14
|
+
class Default < Backend
|
|
15
|
+
def self.backend_name
|
|
16
|
+
"default"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(config: nil, store: nil, retriever: nil)
|
|
20
|
+
super(config: config)
|
|
21
|
+
@store = store || Store.new(config: @config)
|
|
22
|
+
@retriever = retriever || Retriever.new(store: @store, config: @config)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# -- WRITE path --
|
|
26
|
+
|
|
27
|
+
def store(kind:, content:, source_session_id: nil, confidence: 1.0, metadata: {})
|
|
28
|
+
@store.create(
|
|
29
|
+
kind: kind,
|
|
30
|
+
content: content,
|
|
31
|
+
source_session_id: source_session_id,
|
|
32
|
+
confidence: confidence,
|
|
33
|
+
metadata: metadata
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def replace(kind:, old_text:, content:)
|
|
38
|
+
target = find_by_substring(kind, old_text)
|
|
39
|
+
return nil unless target
|
|
40
|
+
|
|
41
|
+
@store.update(target[:id], content: content)
|
|
42
|
+
target
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def forget(kind:, old_text:)
|
|
46
|
+
target = find_by_substring(kind, old_text)
|
|
47
|
+
return nil unless target
|
|
48
|
+
|
|
49
|
+
@store.delete(target[:id])
|
|
50
|
+
target
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def extract(session_id)
|
|
54
|
+
Extractor.new(store: @store).extract_from_session(session_id)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# -- READ path --
|
|
58
|
+
|
|
59
|
+
def user_profile
|
|
60
|
+
@retriever.user_profile
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def project_context
|
|
64
|
+
@retriever.project_context
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# `query` is accepted for contract compatibility but ignored — the
|
|
68
|
+
# default backend returns "everything that fits", exactly as today.
|
|
69
|
+
def retrieve(session_id:, query: nil)
|
|
70
|
+
@retriever.relevant_for_session(session_id)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# -- admin --
|
|
74
|
+
|
|
75
|
+
# The legacy store hard-deletes on replace — there are no retired
|
|
76
|
+
# rows, so `include_retired` is accepted for contract parity only.
|
|
77
|
+
def list(kind: nil, limit: 20, include_retired: false)
|
|
78
|
+
@store.list(kind: kind, limit: limit)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def find(id)
|
|
82
|
+
@store.find(id)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def delete(id)
|
|
86
|
+
@store.delete(id)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def count
|
|
90
|
+
@store.count
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def find_by_substring(kind, needle)
|
|
96
|
+
@store.by_kind(kind, limit: 500).find { |m| m[:content].to_s.include?(needle.to_s) }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|