rubino-agent 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +6 -0
- data/.rubocop_todo.yml +12 -2
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +454 -1
- data/CONTRIBUTING.md +10 -1
- data/README.md +69 -11
- data/Rakefile +48 -0
- data/docs/agents.md +82 -48
- data/docs/architecture.md +4 -11
- data/docs/commands.md +46 -7
- data/docs/configuration.md +174 -30
- data/docs/getting-started.md +5 -3
- data/docs/mcp.md +3 -3
- data/docs/memory.md +3 -3
- data/docs/security.md +17 -6
- data/docs/tools.md +45 -49
- data/docs/troubleshooting.md +1 -1
- data/exe/rubino +16 -2
- data/ext/landlock/extconf.rb +78 -0
- data/ext/landlock/landlock.c +253 -0
- data/install.sh +715 -54
- data/lib/rubino/active_agent.rb +73 -0
- data/lib/rubino/agent/action_claim_guard.rb +913 -0
- data/lib/rubino/agent/agent_registry.rb +5 -2
- data/lib/rubino/agent/definition.rb +4 -28
- data/lib/rubino/agent/fallback_chain.rb +0 -6
- data/lib/rubino/agent/iteration_budget.rb +109 -3
- data/lib/rubino/agent/loop.rb +664 -42
- data/lib/rubino/agent/model_call_runner.rb +81 -3
- data/lib/rubino/agent/prompts/build.txt +55 -7
- data/lib/rubino/agent/prompts/general.txt +8 -3
- data/lib/rubino/agent/response_validator.rb +8 -0
- data/lib/rubino/agent/runner.rb +307 -13
- data/lib/rubino/agent/tool_executor.rb +368 -31
- data/lib/rubino/agent/truncation_continuation.rb +11 -5
- data/lib/rubino/api/operations/approvals/decide_operation.rb +0 -4
- data/lib/rubino/api/operations/clarifications/decide_operation.rb +0 -4
- data/lib/rubino/api/operations/cron_jobs/create_operation.rb +0 -4
- data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +0 -4
- data/lib/rubino/api/operations/cron_jobs/list_operation.rb +0 -4
- data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +1 -5
- data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +1 -5
- data/lib/rubino/api/operations/cron_jobs/show_operation.rb +0 -4
- data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +0 -4
- data/lib/rubino/api/operations/cron_jobs/update_operation.rb +0 -4
- data/lib/rubino/api/operations/files/read_operation.rb +1 -5
- data/lib/rubino/api/operations/files/upload_operation.rb +0 -4
- data/lib/rubino/api/operations/health_operation.rb +1 -5
- data/lib/rubino/api/operations/memory/delete_operation.rb +0 -4
- data/lib/rubino/api/operations/memory/index_operation.rb +0 -4
- data/lib/rubino/api/operations/memory/stats_operation.rb +0 -4
- data/lib/rubino/api/operations/metrics_operation.rb +1 -1
- data/lib/rubino/api/operations/mode/show_operation.rb +0 -4
- data/lib/rubino/api/operations/mode/update_operation.rb +0 -4
- data/lib/rubino/api/operations/models/list_operation.rb +0 -4
- data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +0 -4
- data/lib/rubino/api/operations/oauth/connections/list_operation.rb +0 -4
- data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +0 -4
- data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +0 -4
- data/lib/rubino/api/operations/oauth/providers/list_operation.rb +0 -4
- data/lib/rubino/api/operations/runs/create_operation.rb +0 -4
- data/lib/rubino/api/operations/runs/events_operation.rb +0 -4
- data/lib/rubino/api/operations/runs/stop_operation.rb +0 -4
- data/lib/rubino/api/operations/sessions/create_operation.rb +0 -4
- data/lib/rubino/api/operations/sessions/delete_operation.rb +0 -4
- data/lib/rubino/api/operations/sessions/index_operation.rb +0 -4
- data/lib/rubino/api/operations/sessions/retry_operation.rb +0 -4
- data/lib/rubino/api/operations/sessions/show_operation.rb +0 -4
- data/lib/rubino/api/operations/sessions/undo_operation.rb +0 -4
- data/lib/rubino/api/operations/skills/list_operation.rb +0 -4
- data/lib/rubino/api/operations/skills/toggle_operation.rb +0 -4
- data/lib/rubino/api/operations/tasks/index_operation.rb +0 -4
- data/lib/rubino/api/operations/tasks/show_operation.rb +0 -4
- data/lib/rubino/api/operations/tasks/stop_operation.rb +0 -4
- data/lib/rubino/api/router.rb +2 -2
- data/lib/rubino/api/server.rb +19 -0
- data/lib/rubino/attachments/policy.rb +8 -0
- data/lib/rubino/attachments/preamble.rb +16 -8
- data/lib/rubino/boot/config_guard.rb +71 -0
- data/lib/rubino/cli/chat/completion_builder.rb +44 -8
- data/lib/rubino/cli/chat/idle_card_host.rb +7 -1
- data/lib/rubino/cli/chat/session_resolver.rb +186 -50
- data/lib/rubino/cli/chat_command.rb +1724 -91
- data/lib/rubino/cli/commands.rb +373 -1
- data/lib/rubino/cli/config_command.rb +118 -11
- data/lib/rubino/cli/doctor_command.rb +268 -23
- data/lib/rubino/cli/jobs_command.rb +42 -3
- data/lib/rubino/cli/memory_command.rb +76 -23
- data/lib/rubino/cli/onboarding_wizard.rb +85 -7
- data/lib/rubino/cli/server_command.rb +43 -1
- data/lib/rubino/cli/session_command.rb +272 -18
- data/lib/rubino/cli/setup_command.rb +293 -8
- data/lib/rubino/cli/skills_command.rb +88 -20
- data/lib/rubino/cli/trust_gate.rb +16 -7
- data/lib/rubino/commands/built_ins.rb +4 -2
- data/lib/rubino/commands/command.rb +12 -2
- data/lib/rubino/commands/executor.rb +161 -19
- data/lib/rubino/commands/handlers/agent_switch.rb +100 -0
- data/lib/rubino/commands/handlers/agents.rb +324 -60
- data/lib/rubino/commands/handlers/config.rb +8 -1
- data/lib/rubino/commands/handlers/display.rb +50 -0
- data/lib/rubino/commands/handlers/help.rb +106 -14
- data/lib/rubino/commands/handlers/mcp.rb +7 -32
- data/lib/rubino/commands/handlers/memory.rb +23 -38
- data/lib/rubino/commands/handlers/sessions.rb +70 -33
- data/lib/rubino/commands/handlers/skills.rb +47 -28
- data/lib/rubino/commands/handlers/status.rb +65 -10
- data/lib/rubino/commands/loader.rb +12 -0
- data/lib/rubino/compression/compression_result.rb +35 -0
- data/lib/rubino/compression/compressor.rb +109 -0
- data/lib/rubino/compression/content_router.rb +240 -0
- data/lib/rubino/compression/diff_compressor.rb +252 -0
- data/lib/rubino/compression/javascript_code_skeleton.rb +15 -0
- data/lib/rubino/compression/json_compressor.rb +274 -0
- data/lib/rubino/compression/line_skeleton.rb +92 -0
- data/lib/rubino/compression/log_compressor.rb +299 -0
- data/lib/rubino/compression/python_code_skeleton.rb +122 -0
- data/lib/rubino/compression/ruby_code_skeleton.rb +80 -0
- data/lib/rubino/compression/tree_sitter_code_skeleton.rb +118 -0
- data/lib/rubino/compression/tsx_code_skeleton.rb +15 -0
- data/lib/rubino/compression/typescript_code_skeleton.rb +15 -0
- data/lib/rubino/config/configuration.rb +151 -105
- data/lib/rubino/config/defaults.rb +369 -41
- data/lib/rubino/config/loader.rb +71 -13
- data/lib/rubino/config/reasoning_prefs.rb +23 -0
- data/lib/rubino/config/validator.rb +384 -0
- data/lib/rubino/config/writer.rb +123 -31
- data/lib/rubino/context/compressor.rb +185 -23
- data/lib/rubino/context/file_discovery.rb +0 -8
- data/lib/rubino/context/message_boundary.rb +26 -5
- data/lib/rubino/context/project_languages.rb +83 -0
- data/lib/rubino/context/prompt_assembler.rb +110 -22
- data/lib/rubino/context/summary_builder.rb +77 -27
- data/lib/rubino/context/token_budget.rb +38 -13
- data/lib/rubino/context/token_estimate.rb +45 -0
- data/lib/rubino/context/tool_result_pruner.rb +81 -0
- data/lib/rubino/database/connection.rb +154 -3
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +314 -40
- data/lib/rubino/database/migrator.rb +81 -14
- data/lib/rubino/documents/cap_exceeded.rb +13 -0
- data/lib/rubino/documents/converters/csv.rb +4 -3
- data/lib/rubino/documents/converters/docx.rb +29 -5
- data/lib/rubino/documents/converters/html.rb +5 -1
- data/lib/rubino/documents/converters/json.rb +2 -1
- data/lib/rubino/documents/converters/pdf.rb +11 -2
- data/lib/rubino/documents/converters/plain.rb +2 -1
- data/lib/rubino/documents/converters/pptx.rb +11 -2
- data/lib/rubino/documents/converters/xlsx.rb +35 -4
- data/lib/rubino/documents/converters/xml.rb +2 -1
- data/lib/rubino/documents/limits.rb +210 -0
- data/lib/rubino/documents.rb +10 -3
- data/lib/rubino/errors.rb +36 -5
- data/lib/rubino/files/workspace.rb +2 -2
- data/lib/rubino/interaction/cancel_token.rb +19 -3
- data/lib/rubino/interaction/events.rb +13 -3
- data/lib/rubino/interaction/input_queue.rb +11 -0
- data/lib/rubino/interaction/lifecycle.rb +238 -33
- data/lib/rubino/interaction/polishing.rb +184 -0
- data/lib/rubino/interaction/probe.rb +1 -1
- data/lib/rubino/jobs/cron_job_repository.rb +5 -12
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +11 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +67 -21
- data/lib/rubino/jobs/queue.rb +133 -13
- data/lib/rubino/jobs/runner.rb +24 -6
- data/lib/rubino/jobs/worker.rb +1 -5
- data/lib/rubino/llm/adapter_factory.rb +1 -1
- data/lib/rubino/llm/adapter_response.rb +47 -4
- data/lib/rubino/llm/auxiliary_client.rb +63 -3
- data/lib/rubino/llm/cache_breakpoint_middleware.rb +194 -0
- data/lib/rubino/llm/credential_check.rb +76 -20
- data/lib/rubino/llm/error_classifier.rb +186 -77
- data/lib/rubino/llm/fake_provider.rb +3 -3
- data/lib/rubino/llm/inline_think_filter.rb +103 -15
- data/lib/rubino/llm/reasoning_manager.rb +3 -26
- data/lib/rubino/llm/request.rb +26 -15
- data/lib/rubino/llm/ruby_llm_adapter.rb +623 -67
- data/lib/rubino/llm/scenario_loader.rb +10 -17
- data/lib/rubino/llm/scenarios/glued-table-prose.yml +36 -0
- data/lib/rubino/llm/scenarios/growing-table.yml +49 -0
- data/lib/rubino/llm/scenarios/narrow-terminal-table.yml +47 -0
- data/lib/rubino/llm/scenarios/streamed-table.yml +55 -0
- data/lib/rubino/llm/scenarios/table-then-prose.yml +34 -0
- data/lib/rubino/llm/scenarios/too-wide-table.yml +47 -0
- data/lib/rubino/llm/scenarios/wide-table.yml +1 -1
- data/lib/rubino/llm/thinking_support.rb +17 -12
- data/lib/rubino/llm/tool_bridge.rb +200 -32
- data/lib/rubino/mcp/manager.rb +71 -10
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +38 -3
- data/lib/rubino/memory/aux_retry.rb +107 -0
- data/lib/rubino/memory/backends/sqlite.rb +104 -67
- data/lib/rubino/memory/backends.rb +26 -10
- data/lib/rubino/memory/deduplicator.rb +22 -0
- data/lib/rubino/memory/flusher.rb +35 -1
- data/lib/rubino/memory/salience_gate.rb +129 -0
- data/lib/rubino/memory/sqlite_extraction.rb +70 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +16 -1
- data/lib/rubino/memory/store.rb +48 -20
- data/lib/rubino/memory/threat_scanner.rb +60 -0
- data/lib/rubino/memory.rb +47 -0
- data/lib/rubino/oauth/provider.rb +0 -5
- data/lib/rubino/output/cost.rb +52 -0
- data/lib/rubino/output/headless_block_latch.rb +53 -0
- data/lib/rubino/output/result_serializer.rb +222 -0
- data/lib/rubino/output/turn_recorder.rb +77 -0
- data/lib/rubino/run/event_store.rb +1 -6
- data/lib/rubino/run/repository.rb +0 -14
- data/lib/rubino/security/approval_policy.rb +314 -33
- data/lib/rubino/security/command_allowlist.rb +79 -4
- data/lib/rubino/security/command_normalizer.rb +36 -0
- data/lib/rubino/security/dangerous_patterns.rb +17 -4
- data/lib/rubino/security/doom_loop_detector.rb +21 -2
- data/lib/rubino/security/hardline_guard.rb +190 -16
- data/lib/rubino/security/pattern_matcher.rb +28 -5
- data/lib/rubino/security/prefix_deriver.rb +25 -6
- data/lib/rubino/security/readonly_commands.rb +442 -18
- data/lib/rubino/security/redactor.rb +272 -0
- data/lib/rubino/security/sandbox.rb +460 -0
- data/lib/rubino/security/secret_detector.rb +110 -0
- data/lib/rubino/security/secret_path.rb +263 -0
- data/lib/rubino/security/url_safety.rb +255 -0
- data/lib/rubino/session/lock.rb +91 -0
- data/lib/rubino/session/message.rb +38 -3
- data/lib/rubino/session/picker.rb +95 -0
- data/lib/rubino/session/repository.rb +249 -31
- data/lib/rubino/session/store.rb +135 -21
- data/lib/rubino/skills/installer.rb +116 -32
- data/lib/rubino/skills/prompt_index.rb +2 -2
- data/lib/rubino/skills/registry.rb +56 -6
- data/lib/rubino/skills/skill.rb +94 -12
- data/lib/rubino/skills/skill_tool.rb +21 -25
- data/lib/rubino/skills/state_repository.rb +0 -4
- data/lib/rubino/tools/background_tasks.rb +299 -47
- data/lib/rubino/tools/base.rb +219 -4
- data/lib/rubino/tools/edit_tool.rb +116 -31
- data/lib/rubino/tools/fuzzy_match.rb +212 -0
- data/lib/rubino/tools/glob_tool.rb +52 -9
- data/lib/rubino/tools/grep_tool.rb +71 -11
- data/lib/rubino/tools/multi_edit_tool.rb +88 -20
- data/lib/rubino/tools/patch_tool.rb +56 -10
- data/lib/rubino/tools/probe_tool.rb +0 -20
- data/lib/rubino/tools/question_tool.rb +54 -2
- data/lib/rubino/tools/read_attachment_tool.rb +24 -12
- data/lib/rubino/tools/read_tool.rb +159 -35
- data/lib/rubino/tools/read_tracker.rb +189 -35
- data/lib/rubino/tools/registry.rb +151 -31
- data/lib/rubino/tools/result.rb +48 -9
- data/lib/rubino/tools/retrieve_output_tool.rb +70 -0
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/shell_kill_tool.rb +6 -2
- data/lib/rubino/tools/shell_output_tool.rb +7 -1
- data/lib/rubino/tools/shell_registry.rb +229 -5
- data/lib/rubino/tools/shell_tail_tool.rb +6 -1
- data/lib/rubino/tools/shell_tool.rb +523 -54
- data/lib/rubino/tools/steer_tool.rb +2 -21
- data/lib/rubino/tools/subagent_probe.rb +1 -1
- data/lib/rubino/tools/summarize_file_tool.rb +12 -0
- data/lib/rubino/tools/task_result_tool.rb +8 -2
- data/lib/rubino/tools/task_stop_tool.rb +15 -22
- data/lib/rubino/tools/task_tool.rb +229 -104
- data/lib/rubino/tools/vision_tool.rb +37 -4
- data/lib/rubino/tools/webfetch_tool.rb +184 -7
- data/lib/rubino/tools/websearch_tool.rb +92 -30
- data/lib/rubino/tools/write_tool.rb +24 -5
- data/lib/rubino/ui/agent_menu.rb +179 -0
- data/lib/rubino/ui/api.rb +12 -3
- data/lib/rubino/ui/base.rb +13 -2
- data/lib/rubino/ui/bottom_composer.rb +1483 -203
- data/lib/rubino/ui/cli.rb +1340 -272
- data/lib/rubino/ui/completion_menu.rb +35 -50
- data/lib/rubino/ui/composer/input_line.rb +131 -0
- data/lib/rubino/ui/composer/subagent_panel.rb +35 -0
- data/lib/rubino/ui/headless_trace.rb +63 -0
- data/lib/rubino/ui/input_history.rb +90 -5
- data/lib/rubino/ui/live_region.rb +82 -7
- data/lib/rubino/ui/markdown_renderer.rb +214 -17
- data/lib/rubino/ui/menu_view.rb +117 -0
- data/lib/rubino/ui/notifier.rb +0 -2
- data/lib/rubino/ui/null.rb +53 -6
- data/lib/rubino/ui/paste_store.rb +49 -3
- data/lib/rubino/ui/printer_base.rb +135 -8
- data/lib/rubino/ui/queued_indicators.rb +6 -1
- data/lib/rubino/ui/status_bar.rb +61 -7
- data/lib/rubino/ui/streaming_markdown.rb +148 -6
- data/lib/rubino/ui/subagent_cards.rb +126 -25
- data/lib/rubino/ui/tool_label.rb +52 -0
- data/lib/rubino/update_check.rb +39 -4
- data/lib/rubino/util/atomic_file.rb +129 -0
- data/lib/rubino/util/duration.rb +8 -5
- data/lib/rubino/util/ignore_rules.rb +120 -0
- data/lib/rubino/util/output.rb +275 -13
- data/lib/rubino/util/secrets_mask.rb +70 -7
- data/lib/rubino/util/spill_store.rb +153 -0
- data/lib/rubino/version.rb +7 -1
- data/lib/rubino/workspace.rb +74 -3
- data/lib/rubino.rb +216 -25
- data/rubino-agent.gemspec +28 -1
- data/skills/ruby-expert/SKILL.md +1 -0
- metadata +116 -29
- data/docs/plugins.md +0 -195
- data/lib/rubino/agent/router.rb +0 -65
- data/lib/rubino/database/migrations/002_create_runs.rb +0 -45
- data/lib/rubino/database/migrations/003_create_skill_states.rb +0 -15
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +0 -36
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +0 -27
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +0 -34
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +0 -59
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +0 -75
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +0 -55
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +0 -20
- data/lib/rubino/interaction/state.rb +0 -56
- data/lib/rubino/memory/backends/default.rb +0 -101
- data/lib/rubino/memory/extractor.rb +0 -85
- data/lib/rubino/memory/retriever.rb +0 -50
- data/lib/rubino/plugins/registry.rb +0 -75
- data/lib/rubino/plugins.rb +0 -86
- data/lib/rubino/tools/answer_child_tool.rb +0 -83
- data/lib/rubino/tools/ask_parent_tool.rb +0 -232
- data/lib/rubino/tools/git_tool.rb +0 -71
- data/lib/rubino/tools/github_tool.rb +0 -233
- data/lib/rubino/tools/test_tool.rb +0 -454
- data/lib/rubino/ui/subagent_view.rb +0 -266
data/lib/rubino/mcp/manager.rb
CHANGED
|
@@ -18,16 +18,37 @@ module Rubino
|
|
|
18
18
|
@config = config || Rubino.configuration
|
|
19
19
|
@clients = {}
|
|
20
20
|
@last_errors = {}
|
|
21
|
+
# Guards @clients / @last_errors writes during the PARALLEL connect phase
|
|
22
|
+
# (start_all!). The single-server path (start_server) takes it too, so the
|
|
23
|
+
# invariant "shared-state mutations are serialized" holds on every caller.
|
|
24
|
+
@state_mutex = Mutex.new
|
|
21
25
|
route_mcp_logging!
|
|
22
26
|
end
|
|
23
27
|
|
|
24
|
-
# Initializes all configured MCP servers
|
|
28
|
+
# Initializes all configured MCP servers.
|
|
29
|
+
#
|
|
30
|
+
# The connect handshake is the slow part: each RubyLLM::MCP.client(**opts)
|
|
31
|
+
# blocks up to the per-server request_timeout (default 8 s) while it spawns
|
|
32
|
+
# the child / opens the socket and waits for `initialize`. Done SERIALLY, N
|
|
33
|
+
# hanging servers cost the SUM of their timeouts (#576 measured 17.6 s with
|
|
34
|
+
# two stalling servers). So we connect every server CONCURRENTLY — one
|
|
35
|
+
# thread each (the count is small and these threads are I/O-bound) — which
|
|
36
|
+
# bounds total connect time to roughly the slowest SINGLE server.
|
|
37
|
+
#
|
|
38
|
+
# Thread-safety: each thread only does the network/subprocess connect and
|
|
39
|
+
# writes its result into @clients/@last_errors UNDER @state_mutex (plain
|
|
40
|
+
# Hashes are not thread-safe). Tool registration is deferred to the MAIN
|
|
41
|
+
# thread (register_all_tools! below, after every join) because
|
|
42
|
+
# Tools::Registry is a process-wide singleton over a plain Hash and is NOT
|
|
43
|
+
# thread-safe. Best-effort semantics are preserved: start_server rescues
|
|
44
|
+
# per-server, so one server raising/stalling never aborts the others.
|
|
25
45
|
def start_all!
|
|
26
46
|
server_configs = @config.dig("mcp", "servers") || {}
|
|
27
47
|
|
|
28
|
-
server_configs.
|
|
29
|
-
start_server(name, server_config)
|
|
48
|
+
threads = server_configs.map do |name, server_config|
|
|
49
|
+
Thread.new { start_server(name, server_config) }
|
|
30
50
|
end
|
|
51
|
+
threads.each(&:join)
|
|
31
52
|
|
|
32
53
|
register_all_tools!
|
|
33
54
|
@clients
|
|
@@ -38,14 +59,19 @@ module Rubino
|
|
|
38
59
|
transport = server_config["transport"] || "stdio"
|
|
39
60
|
client_opts = build_client_options(name, transport, server_config)
|
|
40
61
|
|
|
62
|
+
# The slow, blocking connect runs OUTSIDE the lock so concurrent
|
|
63
|
+
# start_all! threads actually overlap; only the shared-Hash writes are
|
|
64
|
+
# serialized under @state_mutex.
|
|
41
65
|
client = RubyLLM::MCP.client(**client_opts)
|
|
42
|
-
@
|
|
43
|
-
|
|
66
|
+
@state_mutex.synchronize do
|
|
67
|
+
@clients[name.to_s] = client
|
|
68
|
+
@last_errors.delete(name.to_s)
|
|
69
|
+
end
|
|
44
70
|
|
|
45
71
|
Rubino.event_bus.emit(:mcp_server_started, name: name)
|
|
46
72
|
client
|
|
47
73
|
rescue StandardError => e
|
|
48
|
-
@last_errors[name.to_s] = e.message
|
|
74
|
+
@state_mutex.synchronize { @last_errors[name.to_s] = e.message }
|
|
49
75
|
Rubino.ui.warning("MCP server '#{name}' failed to start: #{e.message}")
|
|
50
76
|
nil
|
|
51
77
|
end
|
|
@@ -79,8 +105,13 @@ module Rubino
|
|
|
79
105
|
# Per-agent mcp_servers scoping is NOT applied here — it lives in
|
|
80
106
|
# Agent::Definition#resolved_tools (#173), the single seam every
|
|
81
107
|
# consumer of an agent's tool set goes through.
|
|
108
|
+
# Registers in a STABLE order (sorted by server name) rather than @clients'
|
|
109
|
+
# insertion order — under the parallel start_all! @clients is populated in
|
|
110
|
+
# connect-COMPLETION order, which is nondeterministic. Sorting keeps the
|
|
111
|
+
# resulting tool-registration order (and anything downstream that reads it)
|
|
112
|
+
# deterministic across boots.
|
|
82
113
|
def register_all_tools!
|
|
83
|
-
@clients.
|
|
114
|
+
@clients.keys.sort.each { |server_name| register_server_tools(server_name) }
|
|
84
115
|
end
|
|
85
116
|
|
|
86
117
|
# Registers ONE started server's tools — the `/mcp <server> on` path
|
|
@@ -94,11 +125,24 @@ module Rubino
|
|
|
94
125
|
wrapped = MCPToolWrapper.new(mcp_tool, server_name: name.to_s)
|
|
95
126
|
Tools::Registry.register(wrapped)
|
|
96
127
|
end
|
|
128
|
+
# A clean tools/list clears any prior failure so a recovered server
|
|
129
|
+
# stops showing degraded (mirrors start_server clearing on success).
|
|
130
|
+
@last_errors.delete(name.to_s)
|
|
97
131
|
rescue StandardError => e
|
|
132
|
+
# Record the failure so /mcp's drill-in (and the degraded glyph below)
|
|
133
|
+
# can explain a connected-but-toolless server (#575) — start_server
|
|
134
|
+
# records start failures the same way; a swallowed warning alone left
|
|
135
|
+
# the broken state invisible.
|
|
136
|
+
@last_errors[name.to_s] = e.message
|
|
98
137
|
Rubino.ui.warning("Failed to load tools from '#{name}': #{e.message}")
|
|
99
138
|
end
|
|
100
139
|
|
|
101
|
-
# Checks health of all connected servers
|
|
140
|
+
# Checks health of all connected servers. `alive` is process-liveness
|
|
141
|
+
# (the child is up); `degraded` is protocol-liveness (#575): the process
|
|
142
|
+
# is alive but tools/list/registration failed, so a recorded last_error
|
|
143
|
+
# exists despite a live client. Callers render degraded distinctly from
|
|
144
|
+
# plain reachable — an alive server that legitimately exposes zero tools
|
|
145
|
+
# has NO last_error and is NOT degraded.
|
|
102
146
|
def health_check
|
|
103
147
|
@clients.map do |name, client|
|
|
104
148
|
alive = begin
|
|
@@ -106,7 +150,7 @@ module Rubino
|
|
|
106
150
|
rescue StandardError
|
|
107
151
|
false
|
|
108
152
|
end
|
|
109
|
-
{ name: name, alive: alive }
|
|
153
|
+
{ name: name, alive: alive, degraded: alive && @last_errors.key?(name.to_s) }
|
|
110
154
|
end
|
|
111
155
|
end
|
|
112
156
|
|
|
@@ -154,7 +198,7 @@ module Rubino
|
|
|
154
198
|
when "stdio"
|
|
155
199
|
opts[:config] = {
|
|
156
200
|
command: server_config["command"],
|
|
157
|
-
args: server_config["args"]
|
|
201
|
+
args: validate_stdio_args!(name, server_config["args"]),
|
|
158
202
|
env: server_config["env"] || {}
|
|
159
203
|
}
|
|
160
204
|
when "sse"
|
|
@@ -175,6 +219,23 @@ module Rubino
|
|
|
175
219
|
|
|
176
220
|
opts
|
|
177
221
|
end
|
|
222
|
+
|
|
223
|
+
# stdio `args` MUST be a list (YAML sequence). A STRING — the natural typo,
|
|
224
|
+
# `args: "--root /data"` instead of `args: ["--root", "/data"]` — used to be
|
|
225
|
+
# passed straight to ruby_llm-mcp, which iterated the string into single
|
|
226
|
+
# characters, spawned a broken process, and surfaced ~8s later as a
|
|
227
|
+
# misleading "timed out" (the spawn never spoke MCP). Reject it HERE so
|
|
228
|
+
# start_server's rescue turns it into an immediate, clear config error
|
|
229
|
+
# instead of a hang. nil ⇒ no args (default []).
|
|
230
|
+
def validate_stdio_args!(name, args)
|
|
231
|
+
return [] if args.nil?
|
|
232
|
+
return args if args.is_a?(Array)
|
|
233
|
+
|
|
234
|
+
raise ArgumentError,
|
|
235
|
+
"MCP server '#{name}': `args` must be a list, got #{args.class} " \
|
|
236
|
+
"(#{args.inspect}). Use a YAML sequence, e.g. " \
|
|
237
|
+
"args: [\"--root\", \"/data\"] (not a single string)."
|
|
238
|
+
end
|
|
178
239
|
end
|
|
179
240
|
end
|
|
180
241
|
end
|
|
@@ -7,14 +7,21 @@ module Rubino
|
|
|
7
7
|
class MCPToolWrapper < Tools::Base
|
|
8
8
|
attr_reader :mcp_tool, :server_name
|
|
9
9
|
|
|
10
|
+
# Cap on the (prefixed) tool name length. A misbehaving MCP server can
|
|
11
|
+
# advertise an absurdly long tool name (e.g. 20k chars), which would be
|
|
12
|
+
# registered uncapped, blow up the `tools` table, and 400 at the provider.
|
|
13
|
+
MAX_NAME_LENGTH = 64
|
|
14
|
+
|
|
10
15
|
def initialize(mcp_tool, server_name:)
|
|
11
16
|
@mcp_tool = mcp_tool
|
|
12
17
|
@server_name = server_name
|
|
13
18
|
end
|
|
14
19
|
|
|
15
20
|
def name
|
|
16
|
-
# Prefix with server name to avoid collisions
|
|
17
|
-
|
|
21
|
+
# Prefix with server name to avoid collisions. Cap the length so a
|
|
22
|
+
# hostile/buggy server can't register a giant name that breaks the
|
|
23
|
+
# `tools` table or 400s the provider (S1-MCP-2).
|
|
24
|
+
"#{@server_name}_#{@mcp_tool.name}"[0, MAX_NAME_LENGTH]
|
|
18
25
|
end
|
|
19
26
|
|
|
20
27
|
def description
|
|
@@ -28,7 +35,11 @@ module Rubino
|
|
|
28
35
|
# {}`, so the model had to guess argument names and every call failed
|
|
29
36
|
# server-side validation with -32602 (#170).
|
|
30
37
|
schema = @mcp_tool.params_schema if @mcp_tool.respond_to?(:params_schema)
|
|
31
|
-
|
|
38
|
+
# Coerce anything that isn't a Hash (nil, or a truthy non-Hash like a
|
|
39
|
+
# string) to a valid empty object schema. A server advertising a
|
|
40
|
+
# non-Hash `inputSchema` would otherwise poison the whole wire tool
|
|
41
|
+
# list and 400 every subsequent model call (S1-MCP-1).
|
|
42
|
+
schema.is_a?(Hash) ? schema : { type: "object", properties: {} }
|
|
32
43
|
end
|
|
33
44
|
|
|
34
45
|
def risk_level
|
|
@@ -36,6 +47,30 @@ module Rubino
|
|
|
36
47
|
:medium
|
|
37
48
|
end
|
|
38
49
|
|
|
50
|
+
# True: this tool's code runs on an external MCP server. The display layer
|
|
51
|
+
# reads this (NOT the name shape) to mark the call/approval card.
|
|
52
|
+
def mcp?
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The MCP server this tool is provided by, used in the display marker.
|
|
57
|
+
def mcp_server
|
|
58
|
+
@server_name
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The original, UNPREFIXED tool name the server advertised (#name returns
|
|
62
|
+
# the collision-safe `<server>_<tool>` registry/model-facing name).
|
|
63
|
+
def bare_name
|
|
64
|
+
@mcp_tool.name
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The display label for the live tool card and approval card:
|
|
68
|
+
# `"<bare_tool> (mcp:<server>)"` — e.g. `echo (mcp:chaos)`. The
|
|
69
|
+
# model-facing #name (`chaos_echo`) is unchanged; this is display-only.
|
|
70
|
+
def display_name
|
|
71
|
+
"#{bare_name} (mcp:#{@server_name})"
|
|
72
|
+
end
|
|
73
|
+
|
|
39
74
|
def call(arguments)
|
|
40
75
|
result = @mcp_tool.execute(**symbolize_keys(arguments))
|
|
41
76
|
# ruby_llm-mcp reports tool failures by RETURNING `{ error: "…" }`
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Memory
|
|
5
|
+
# Bounded retry/backoff for the aux memory-extraction call (r5 C-2).
|
|
6
|
+
#
|
|
7
|
+
# The aux client calls the adapter directly and so — unlike the main
|
|
8
|
+
# conversation loop, whose Agent::ModelCallRunner owns retry/backoff — got NO
|
|
9
|
+
# retry: under concurrent load a single RubyLLM::RateLimitError (429) was
|
|
10
|
+
# caught at the call site, logged `memory.sqlite.skip`, and the extracted
|
|
11
|
+
# fact was DROPPED for good. This mixin wraps the aux call in the SAME
|
|
12
|
+
# jittered-backoff policy the main loop uses, retrying retryable errors
|
|
13
|
+
# (429/overloaded/5xx/transport, per LLM::ErrorClassifier) up to a small
|
|
14
|
+
# budget and honouring Retry-After on a rate-limit. After the budget is
|
|
15
|
+
# exhausted (or on a non-retryable error) it re-raises to the caller, which
|
|
16
|
+
# leaves the per-session cursor put so the turn is re-fed next time rather
|
|
17
|
+
# than silently lost.
|
|
18
|
+
#
|
|
19
|
+
# Host requirements: `@config` (a Config::Configuration answering #dig) and a
|
|
20
|
+
# `DEFAULT_EXTRACT_MAX_RETRIES` constant on the including class.
|
|
21
|
+
module AuxRetry
|
|
22
|
+
# Run `block` (the aux call), retrying transient errors. Re-raises the last
|
|
23
|
+
# error once the budget is exhausted or the error is non-retryable.
|
|
24
|
+
def with_aux_retry
|
|
25
|
+
attempts = 0
|
|
26
|
+
begin
|
|
27
|
+
# Honour a detached-polishing cancel (Esc to skip): the background
|
|
28
|
+
# housekeeping thread binds Rubino.aux_cancel_token, so an Esc that
|
|
29
|
+
# cancelled it must abort BEFORE spending another aux-LLM call rather
|
|
30
|
+
# than running to completion off-screen (#319).
|
|
31
|
+
aux_check_cancelled!
|
|
32
|
+
yield
|
|
33
|
+
rescue Rubino::Interrupted
|
|
34
|
+
# Cancellation is terminal — re-raise straight through so the detached
|
|
35
|
+
# polishing thread unwinds and leaves the cursor put (re-runs next turn).
|
|
36
|
+
raise
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
classified = LLM::ErrorClassifier.classify(e)
|
|
39
|
+
raise unless classified.retryable && attempts < extract_max_retries
|
|
40
|
+
|
|
41
|
+
attempts += 1
|
|
42
|
+
wait = aux_backoff.wait_seconds(
|
|
43
|
+
attempts,
|
|
44
|
+
base: Agent::BackoffPolicy::ERROR_PATH[:base],
|
|
45
|
+
max: Agent::BackoffPolicy::ERROR_PATH[:max],
|
|
46
|
+
retry_after: aux_rate_limit_retry_after(classified, e)
|
|
47
|
+
)
|
|
48
|
+
log_aux_retry(e, attempts, wait)
|
|
49
|
+
# Sleep in short slices so an Esc during the (possibly long, Retry-After
|
|
50
|
+
# honouring) backoff wait aborts within ~100ms instead of holding the
|
|
51
|
+
# detached worker for the full window (#319). On the foreground/API
|
|
52
|
+
# path no token is bound, so this is one uninterrupted sleep as before.
|
|
53
|
+
aux_cancellable_sleep(wait)
|
|
54
|
+
retry
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Raise Interrupted when the detached-polishing cancel token (if bound for
|
|
61
|
+
# this thread) has been flipped. No-op when no token is bound.
|
|
62
|
+
def aux_check_cancelled!
|
|
63
|
+
Rubino.aux_cancel_token&.check!
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Sleep +seconds+ in small slices, polling the aux cancel token between
|
|
67
|
+
# slices so a cancel aborts the wait promptly. Falls back to a single
|
|
68
|
+
# sleep when no token is bound (no detached polishing).
|
|
69
|
+
def aux_cancellable_sleep(seconds)
|
|
70
|
+
token = Rubino.aux_cancel_token
|
|
71
|
+
return aux_backoff.sleep(seconds) unless token
|
|
72
|
+
|
|
73
|
+
remaining = seconds.to_f
|
|
74
|
+
while remaining.positive?
|
|
75
|
+
token.check!
|
|
76
|
+
slice = [remaining, 0.1].min
|
|
77
|
+
aux_backoff.sleep(slice)
|
|
78
|
+
remaining -= slice
|
|
79
|
+
end
|
|
80
|
+
token.check!
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def extract_max_retries
|
|
84
|
+
@config.dig("memory", "extract_max_retries") ||
|
|
85
|
+
self.class::DEFAULT_EXTRACT_MAX_RETRIES
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def aux_backoff
|
|
89
|
+
@aux_backoff ||= Agent::BackoffPolicy.new
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Honour Retry-After only on a rate-limit, exactly as the main loop does.
|
|
93
|
+
def aux_rate_limit_retry_after(classified, error)
|
|
94
|
+
return unless classified.reason == LLM::FailoverReason::RATE_LIMIT
|
|
95
|
+
|
|
96
|
+
aux_backoff.parse_retry_after(error)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def log_aux_retry(error, attempt, wait)
|
|
100
|
+
Rubino.logger.warn(event: "memory.sqlite.extract_retry",
|
|
101
|
+
attempt: attempt, sleep: wait, error: error.class.name)
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -7,10 +7,10 @@ require "time"
|
|
|
7
7
|
module Rubino
|
|
8
8
|
module Memory
|
|
9
9
|
module Backends
|
|
10
|
-
#
|
|
11
|
-
# minus
|
|
10
|
+
# LLM-extracted, bi-temporal fact store on embedded SQLite with hybrid
|
|
11
|
+
# recall — minus a graph DB, a server, or a multi-LLM-call pipeline.
|
|
12
12
|
#
|
|
13
|
-
# Three ideas
|
|
13
|
+
# Three ideas drive the design:
|
|
14
14
|
# * ATOMIC LLM-extracted facts (one declarative fact per row), via a
|
|
15
15
|
# single aux-LLM call per turn that both ADDs new facts and SUPERSEDES
|
|
16
16
|
# contradicted ones (Graphiti edge-invalidation, collapsed to 1 call).
|
|
@@ -28,12 +28,22 @@ module Rubino
|
|
|
28
28
|
# tainted or over-budget content into a future system prompt.
|
|
29
29
|
class Sqlite < Backend
|
|
30
30
|
include SqliteGraph
|
|
31
|
+
include SqliteExtraction
|
|
32
|
+
include SalienceGate
|
|
33
|
+
include AuxRetry
|
|
31
34
|
|
|
32
35
|
TABLE = :memory_facts
|
|
33
36
|
FTS = :memory_facts_fts
|
|
34
37
|
RRF_K = 60
|
|
35
38
|
DEFAULT_K = 20
|
|
36
39
|
|
|
40
|
+
# Bounded retry budget for the aux extraction call on a transient error
|
|
41
|
+
# (429/overloaded/5xx). Small by design: extraction is best-effort
|
|
42
|
+
# background work, and the per-session cursor re-feeds an exhausted turn
|
|
43
|
+
# next time, so we ride out a brief rate-limit window without piling up
|
|
44
|
+
# background backoff. Overridable via `memory.extract_max_retries`.
|
|
45
|
+
DEFAULT_EXTRACT_MAX_RETRIES = 3
|
|
46
|
+
|
|
37
47
|
# Weighted-RRF list weights for the DIRECT relevance signals (FTS/BM25 and
|
|
38
48
|
# vector KNN). Graph (1-hop) and recency are no longer fused here — they
|
|
39
49
|
# are tail supplements (see #rank) so they can never outrank a direct
|
|
@@ -84,9 +94,17 @@ module Rubino
|
|
|
84
94
|
# -- WRITE path --
|
|
85
95
|
|
|
86
96
|
def store(kind:, content:, source_session_id: nil, confidence: 1.0, metadata: {})
|
|
97
|
+
k = normalize_kind(kind)
|
|
98
|
+
# Exact/normalized-verbatim dedup at the direct write seam (#Y4):
|
|
99
|
+
# MemoryTool#add bypasses the extraction near-dup gate, so the same fact
|
|
100
|
+
# saved twice used to mint two identical rows. Idempotent — a verbatim
|
|
101
|
+
# repeat (or whitespace/case variant) returns the existing row.
|
|
102
|
+
existing = verbatim_duplicate(k, content)
|
|
103
|
+
return present(existing) if existing
|
|
104
|
+
|
|
87
105
|
insert_fact(
|
|
88
106
|
text: content,
|
|
89
|
-
kind:
|
|
107
|
+
kind: k,
|
|
90
108
|
entities: Array(metadata[:entities]),
|
|
91
109
|
source_session_id: source_session_id,
|
|
92
110
|
confidence: confidence,
|
|
@@ -122,17 +140,40 @@ module Rubino
|
|
|
122
140
|
target
|
|
123
141
|
end
|
|
124
142
|
|
|
125
|
-
# ONE aux-LLM call over the
|
|
143
|
+
# ONE aux-LLM call over the turn's NEW messages: returns {add, supersede}.
|
|
126
144
|
# Apply is pure Ruby — insert adds (deduped + guarded), retire
|
|
127
145
|
# superseded rows and insert their replacement.
|
|
146
|
+
#
|
|
147
|
+
# Per-session cursor (#249): only messages newer than the session's
|
|
148
|
+
# `memory_extracted_msg_id` watermark are fed, so each turn's extraction
|
|
149
|
+
# is bounded to that turn's new messages instead of an overlapping
|
|
150
|
+
# recency window. When a turn added nothing new past the cursor, we skip
|
|
151
|
+
# the aux-LLM call entirely (no redundant duplicate extraction pass), and
|
|
152
|
+
# advance the cursor only once the apply has landed.
|
|
128
153
|
def extract(session_id)
|
|
129
|
-
|
|
154
|
+
new_messages = unextracted_messages(session_id)
|
|
155
|
+
turn = turn_text(new_messages)
|
|
130
156
|
return [] if turn.strip.empty?
|
|
131
157
|
|
|
158
|
+
# Salience gate (r5 F5/F6/F7): a greeting, a one-word "help", or any
|
|
159
|
+
# turn whose USER text asserts nothing durable is a NOOP — skip the aux
|
|
160
|
+
# call AND advance the cursor so it never mints a fact nor gets re-fed.
|
|
161
|
+
unless salient?(turn)
|
|
162
|
+
advance_extraction_cursor(session_id, new_messages)
|
|
163
|
+
return []
|
|
164
|
+
end
|
|
165
|
+
|
|
132
166
|
result = call_llm(session_id: session_id, turn: turn)
|
|
167
|
+
# A nil result means the aux call failed/parsed to nothing — leave the
|
|
168
|
+
# cursor put so this turn's messages are retried next time rather than
|
|
169
|
+
# silently dropped. A parsed result (even an empty {add,supersede})
|
|
170
|
+
# means these messages WERE processed: advance the watermark so they're
|
|
171
|
+
# never re-fed, which is the overlapping-window re-work #249 removes.
|
|
133
172
|
return [] unless result
|
|
134
173
|
|
|
135
|
-
apply(result, session_id)
|
|
174
|
+
stored = apply(result, session_id)
|
|
175
|
+
advance_extraction_cursor(session_id, new_messages)
|
|
176
|
+
stored
|
|
136
177
|
end
|
|
137
178
|
|
|
138
179
|
# -- READ path --
|
|
@@ -144,7 +185,7 @@ module Rubino
|
|
|
144
185
|
return nil if rows.empty?
|
|
145
186
|
|
|
146
187
|
text = rows.map { |r| r[:text] }.join("\n")
|
|
147
|
-
limit = @config.
|
|
188
|
+
limit = @config.dig("memory", "user_char_limit")
|
|
148
189
|
text.length > limit ? text[0...limit] : text
|
|
149
190
|
end
|
|
150
191
|
|
|
@@ -164,7 +205,7 @@ module Rubino
|
|
|
164
205
|
# ({id:, kind:, content:, ...}) so the prompt assembler is unchanged.
|
|
165
206
|
def retrieve(session_id:, query: nil, k: DEFAULT_K)
|
|
166
207
|
ranked = rank(query: query, k: k)
|
|
167
|
-
budget = @config.memory_char_limit
|
|
208
|
+
budget = @config.dig("memory", "memory_char_limit")
|
|
168
209
|
selected = []
|
|
169
210
|
total = 0
|
|
170
211
|
ranked.each do |row|
|
|
@@ -191,12 +232,19 @@ module Rubino
|
|
|
191
232
|
end
|
|
192
233
|
|
|
193
234
|
def find(id)
|
|
194
|
-
row =
|
|
235
|
+
row = resolve_row(id)
|
|
195
236
|
row && present(row)
|
|
196
237
|
end
|
|
197
238
|
|
|
198
239
|
def delete(id)
|
|
199
|
-
|
|
240
|
+
row = resolve_row(id)
|
|
241
|
+
row ? @db[TABLE].where(id: row[:id]).delete.positive? : false
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Resolve a caller-supplied id to AT MOST ONE row (shared with Store,
|
|
245
|
+
# parameterized by this backend's dataset).
|
|
246
|
+
def resolve_row(id)
|
|
247
|
+
Memory.resolve_row(@db[TABLE], id)
|
|
200
248
|
end
|
|
201
249
|
|
|
202
250
|
# Count only LIVE facts (valid_to IS NULL) — retired/superseded rows are
|
|
@@ -375,6 +423,12 @@ module Rubino
|
|
|
375
423
|
# default extractor, which silently skips dups).
|
|
376
424
|
def guarded_insert(text:, kind:, entities:, session_id:, valid_from:, id: nil)
|
|
377
425
|
return nil if text.to_s.strip.empty?
|
|
426
|
+
# NOOP error-derived tool-limitation claims (#69): after a transient
|
|
427
|
+
# tool failure the aux model can mint a durable-looking "the tool can't
|
|
428
|
+
# edit non-ASCII files" — a meta claim that is often wrong and primes
|
|
429
|
+
# future refusals. Drop it here, the single insert choke point shared by
|
|
430
|
+
# add[] and supersede[], so neither path can persist one.
|
|
431
|
+
return nil if tool_limitation_claim?(text)
|
|
378
432
|
|
|
379
433
|
insert_fact(
|
|
380
434
|
text: text, kind: normalize_kind(kind), entities: Array(entities),
|
|
@@ -463,6 +517,16 @@ module Rubino
|
|
|
463
517
|
str.to_s.downcase.split(/\W+/).reject(&:empty?).to_set
|
|
464
518
|
end
|
|
465
519
|
|
|
520
|
+
# First LIVE fact of `kind` whose normalized-verbatim form equals the
|
|
521
|
+
# candidate's (trim/collapse-whitespace + case-fold, #Y4), or nil.
|
|
522
|
+
def verbatim_duplicate(kind, content)
|
|
523
|
+
target = Deduplicator.normalize_verbatim(content)
|
|
524
|
+
return nil if target.empty?
|
|
525
|
+
|
|
526
|
+
live_dataset.where(kind: kind).all
|
|
527
|
+
.find { |row| Deduplicator.normalize_verbatim(row[:text]) == target }
|
|
528
|
+
end
|
|
529
|
+
|
|
466
530
|
# ---- guards (ThreatScanner + char-budget, same floor as Store) ----
|
|
467
531
|
|
|
468
532
|
def enforce_guards!(kind, text)
|
|
@@ -474,21 +538,18 @@ module Rubino
|
|
|
474
538
|
|
|
475
539
|
def enforce_char_budget!(kind, text)
|
|
476
540
|
group = kind == USER_KIND ? "user" : "memory"
|
|
477
|
-
# INGEST cap, decoupled from the injection budget.
|
|
541
|
+
# INGEST cap, decoupled from the injection budget. memory.memory_char_limit
|
|
478
542
|
# bounds only what `retrieve` packs into the prompt; storing facts is
|
|
479
|
-
# gated by
|
|
543
|
+
# gated by memory.ingest_char_limit (nil => unbounded) so long
|
|
480
544
|
# multi-session conversations don't stall once the injection budget
|
|
481
545
|
# fills. User facts keep their own (small) profile budget.
|
|
482
|
-
|
|
546
|
+
key = group == "user" ? "user_char_limit" : "ingest_char_limit"
|
|
547
|
+
limit = @config.dig("memory", key)
|
|
483
548
|
return unless limit&.positive?
|
|
484
549
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
raise Store::BudgetExceededError.new(
|
|
490
|
-
group: group, limit: limit, current: current, requested: requested
|
|
491
|
-
)
|
|
550
|
+
Memory.enforce_budget!(group: group, limit: limit,
|
|
551
|
+
current: current_chars(group),
|
|
552
|
+
requested: text.to_s.length)
|
|
492
553
|
end
|
|
493
554
|
|
|
494
555
|
# Budget is metered over LIVE facts only — superseded rows don't count
|
|
@@ -501,55 +562,35 @@ module Rubino
|
|
|
501
562
|
|
|
502
563
|
# ---- LLM ----
|
|
503
564
|
|
|
565
|
+
# ONE aux-LLM extraction call, retried on a transient error via AuxRetry
|
|
566
|
+
# (r5 C-2): a 429/overloaded/5xx backs off (honouring Retry-After) and
|
|
567
|
+
# retries up to `memory.extract_max_retries` instead of dropping the fact
|
|
568
|
+
# on the first RateLimitError. Only after the budget is exhausted (or on a
|
|
569
|
+
# non-retryable error) do we rescue and return nil — and the caller leaves
|
|
570
|
+
# the cursor put on nil, so even an exhausted turn is re-fed next time
|
|
571
|
+
# rather than silently lost.
|
|
504
572
|
def call_llm(session_id:, turn:)
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
573
|
+
with_aux_retry do
|
|
574
|
+
response = aux_client.call(
|
|
575
|
+
task: :compression,
|
|
576
|
+
messages: [
|
|
577
|
+
{ role: "system", content: SqliteExtractionPrompt::SYSTEM },
|
|
578
|
+
{ role: "user", content: SqliteExtractionPrompt.user_message(
|
|
579
|
+
now: Time.now.utc.iso8601, live_facts: live_facts_for_prompt, turn: turn
|
|
580
|
+
) }
|
|
581
|
+
]
|
|
582
|
+
)
|
|
583
|
+
parse_json(response&.content)
|
|
584
|
+
end
|
|
515
585
|
rescue StandardError => e
|
|
516
586
|
log_skip(e)
|
|
517
587
|
nil
|
|
518
588
|
end
|
|
519
589
|
|
|
520
|
-
def live_facts_for_prompt
|
|
521
|
-
live_dataset.order(Sequel.desc(:created_at)).limit(60).all.map do |r|
|
|
522
|
-
{ id: r[:id][0, 8], kind: r[:kind], text: r[:text] }
|
|
523
|
-
end
|
|
524
|
-
end
|
|
525
|
-
|
|
526
|
-
# The aux model may wrap JSON in prose or a fenced block; extract the
|
|
527
|
-
# outermost object and parse leniently.
|
|
528
|
-
def parse_json(content)
|
|
529
|
-
return nil if content.to_s.strip.empty?
|
|
530
|
-
|
|
531
|
-
str = content[/\{.*\}/m] || content
|
|
532
|
-
JSON.parse(str)
|
|
533
|
-
rescue JSON::ParserError
|
|
534
|
-
nil
|
|
535
|
-
end
|
|
536
|
-
|
|
537
590
|
def aux_client
|
|
538
591
|
@aux_client ||= LLM::AuxiliaryClient.new(config: @config)
|
|
539
592
|
end
|
|
540
593
|
|
|
541
|
-
def recent_turn_text(session_id)
|
|
542
|
-
msgs = Session::Store.new(db: @db).recent(session_id, count: 6)
|
|
543
|
-
msgs.filter_map do |m|
|
|
544
|
-
next if m.content.nil? || m.content.to_s.empty?
|
|
545
|
-
next unless %w[user assistant].include?(m.role)
|
|
546
|
-
|
|
547
|
-
"#{m.role.upcase}: #{m.content}"
|
|
548
|
-
end.join("\n")
|
|
549
|
-
rescue StandardError
|
|
550
|
-
""
|
|
551
|
-
end
|
|
552
|
-
|
|
553
594
|
# ---- embeddings (best-effort) ----
|
|
554
595
|
|
|
555
596
|
# Vector mode is opt-in (`memory.sqlite.vector: true`) AND requires
|
|
@@ -585,13 +626,9 @@ module Rubino
|
|
|
585
626
|
nil
|
|
586
627
|
end
|
|
587
628
|
|
|
588
|
-
def encode_embedding(vec)
|
|
589
|
-
vec.pack("e*")
|
|
590
|
-
end
|
|
629
|
+
def encode_embedding(vec) = vec.pack("e*")
|
|
591
630
|
|
|
592
|
-
def decode_embedding(blob)
|
|
593
|
-
blob && blob.to_s.unpack("e*")
|
|
594
|
-
end
|
|
631
|
+
def decode_embedding(blob) = blob && blob.to_s.unpack("e*")
|
|
595
632
|
|
|
596
633
|
def cosine(a, b)
|
|
597
634
|
return 0.0 if a.empty? || b.empty? || a.size != b.size
|
|
@@ -608,7 +645,7 @@ module Rubino
|
|
|
608
645
|
k = kind.to_s
|
|
609
646
|
return USER_KIND if k.empty?
|
|
610
647
|
|
|
611
|
-
# Map legacy/default-backend kinds onto the
|
|
648
|
+
# Map legacy/default-backend kinds onto the fact-store vocabulary so the
|
|
612
649
|
# backend tolerates store() calls from the existing MemoryTool/job.
|
|
613
650
|
case k
|
|
614
651
|
when "user_profile", "preference", "fact", "env" then k
|
|
@@ -4,9 +4,10 @@ module Rubino
|
|
|
4
4
|
module Memory
|
|
5
5
|
# Registry of pluggable memory backends, mirroring Tools::Registry: a
|
|
6
6
|
# name => class map with register/build. The active backend is selected by
|
|
7
|
-
# the `memory.backend` config key (default "sqlite" — the
|
|
8
|
-
# graph-lite backend). DEFAULT_NAME below is the registry fallback used only
|
|
9
|
-
# when the configured name is
|
|
7
|
+
# the `memory.backend` config key (default "sqlite" — the FTS5/
|
|
8
|
+
# graph-lite SQLite backend). DEFAULT_NAME below is the registry fallback used only
|
|
9
|
+
# when the configured name is BLANK/unset. An explicitly-set UNKNOWN name is
|
|
10
|
+
# a misconfiguration (a typo silently degrading memory) → rejected.
|
|
10
11
|
module Backends
|
|
11
12
|
@registry = {}
|
|
12
13
|
|
|
@@ -29,25 +30,40 @@ module Rubino
|
|
|
29
30
|
@registry[name.to_s]
|
|
30
31
|
end
|
|
31
32
|
|
|
32
|
-
# Builds the configured backend instance.
|
|
33
|
-
#
|
|
34
|
-
#
|
|
33
|
+
# Builds the configured backend instance. A BLANK/unset `memory.backend`
|
|
34
|
+
# falls back to the default backend (so a fresh config just works); an
|
|
35
|
+
# explicitly-set name that names NO registered backend is a typo that
|
|
36
|
+
# would otherwise silently degrade to the default — reject it with a
|
|
37
|
+
# clear error listing the known backends instead.
|
|
35
38
|
def build(config: nil)
|
|
36
39
|
cfg = config || Rubino.configuration
|
|
37
|
-
name = cfg.dig("memory", "backend").to_s
|
|
38
|
-
klass = @registry[
|
|
39
|
-
|
|
40
|
+
name = cfg.dig("memory", "backend").to_s.strip
|
|
41
|
+
klass = name.empty? ? @registry[DEFAULT_NAME] : @registry[name]
|
|
42
|
+
|
|
43
|
+
unless klass
|
|
44
|
+
raise Error, unknown_backend_message(name) unless name.empty?
|
|
45
|
+
|
|
46
|
+
raise Error, "no memory backend registered (looked for #{DEFAULT_NAME.inspect})"
|
|
47
|
+
end
|
|
40
48
|
|
|
41
49
|
klass.new(config: cfg)
|
|
42
50
|
end
|
|
43
51
|
|
|
52
|
+
# A clear, actionable rejection for an unknown `memory.backend` name,
|
|
53
|
+
# listing the registered backends so the user can fix the typo.
|
|
54
|
+
def unknown_backend_message(name)
|
|
55
|
+
known = names.sort.join(", ")
|
|
56
|
+
"unknown memory backend #{name.inspect}: set memory.backend to one of " \
|
|
57
|
+
"[#{known}] (or leave it unset for the default)."
|
|
58
|
+
end
|
|
59
|
+
|
|
44
60
|
# For tests.
|
|
45
61
|
def reset!
|
|
46
62
|
@registry = {}
|
|
47
63
|
end
|
|
48
64
|
end
|
|
49
65
|
|
|
50
|
-
DEFAULT_NAME = "
|
|
66
|
+
DEFAULT_NAME = "sqlite"
|
|
51
67
|
end
|
|
52
68
|
end
|
|
53
69
|
end
|