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,383 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "attachment_downloader"
|
|
5
|
+
require_relative "../llm/content_builder"
|
|
6
|
+
require_relative "../tools/vision_tool"
|
|
7
|
+
|
|
8
|
+
module Rubino
|
|
9
|
+
module Run
|
|
10
|
+
# Runs an Agent::Runner in a background thread, persisting per-run events
|
|
11
|
+
# via Recorder. Returns immediately so the HTTP handler can respond 201.
|
|
12
|
+
#
|
|
13
|
+
# Per-run wiring done inside the spawned thread:
|
|
14
|
+
# - Recorder.attach! to mirror EventBus into EventStore + live queue.
|
|
15
|
+
# - A fresh ApprovalGate per run, published in GateRegistry under run_id
|
|
16
|
+
# so HTTP decision endpoints can resolve it.
|
|
17
|
+
# - UI::API instantiated with the gate and recorder so the runner can
|
|
18
|
+
# ask for approvals / clarifications.
|
|
19
|
+
# - +ensure+ block always detaches the recorder, unregisters the gate,
|
|
20
|
+
# and fires the +on_complete+ callback (used by Jobs::Scheduler to
|
|
21
|
+
# trigger webhook delivery).
|
|
22
|
+
#
|
|
23
|
+
# Metrics: +runs_total+ is incremented once per #start (tagged with
|
|
24
|
+
# +source+, defaulting to +"api"+); +runs_completed_total+ is incremented
|
|
25
|
+
# in the ensure block, tagged with the final +status+ (+completed+ or
|
|
26
|
+
# +failed+).
|
|
27
|
+
#
|
|
28
|
+
# Stop is cooperative via Run::Repository#stop_requested?. The worker
|
|
29
|
+
# spawns a short-tick watcher (#spawn_stop_watcher) that polls that flag
|
|
30
|
+
# and, on observing it, flips the runner's CancelToken via runner.cancel!.
|
|
31
|
+
# The token is the single halt mechanism: the agent loop / LLM stream
|
|
32
|
+
# poll it (CancelToken#check!) and raise Interrupted, which unwinds the
|
|
33
|
+
# turn the same way a chat Ctrl+C does. No second kill path.
|
|
34
|
+
class Executor
|
|
35
|
+
# How often the stop watcher polls the DB stop flag (seconds).
|
|
36
|
+
STOP_POLL_INTERVAL = 0.25
|
|
37
|
+
# Prompt sent to the auxiliary vision model when pre-describing an image
|
|
38
|
+
# for a text-only primary. Verbatim from the reference — broad
|
|
39
|
+
# enough that the description is useful regardless of the user's question.
|
|
40
|
+
VISION_ANALYSIS_PROMPT =
|
|
41
|
+
"Describe everything visible in this image in thorough detail. " \
|
|
42
|
+
"Include any text, code, data, objects, people, layout, colors, " \
|
|
43
|
+
"and any other notable visual information."
|
|
44
|
+
|
|
45
|
+
def initialize(repository: nil, recorder_factory: nil, vision_describer: nil)
|
|
46
|
+
@repository = repository || Repository.new
|
|
47
|
+
@recorder_factory = recorder_factory ||
|
|
48
|
+
lambda { |run_id:, session_id:, event_bus:|
|
|
49
|
+
Recorder.new(run_id: run_id, session_id: session_id, event_bus: event_bus)
|
|
50
|
+
}
|
|
51
|
+
# Callable(path) -> description String (or an "Error…" String on
|
|
52
|
+
# failure). Injectable so unit tests don't hit the aux model.
|
|
53
|
+
@vision_describer = vision_describer || method(:default_vision_describe)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Parses Run row's persisted attachments_json column (a JSON array of
|
|
57
|
+
# URL strings as sent on the CreateRun body). Returns [] on any
|
|
58
|
+
# malformed input so a broken attachment list never blocks the run.
|
|
59
|
+
def parse_attachment_urls(attachments_json)
|
|
60
|
+
return [] if attachments_json.nil? || attachments_json.to_s.empty?
|
|
61
|
+
|
|
62
|
+
parsed = JSON.parse(attachments_json)
|
|
63
|
+
parsed.is_a?(Array) ? parsed : []
|
|
64
|
+
rescue JSON::ParserError
|
|
65
|
+
[]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Pre-pends an "you have these local files" header to the user input so
|
|
69
|
+
# the model knows the attachments are on disk and doesn't try to webfetch
|
|
70
|
+
# them (binaries crash webfetch — v0.2.5). Pure function (no network) —
|
|
71
|
+
# any vision pre-description is computed upstream and passed in via
|
|
72
|
+
# +descriptions+. Putting the header FIRST anchors small models (MiniMax
|
|
73
|
+
# in particular) — a trailing block was ignored in prod session 33.
|
|
74
|
+
#
|
|
75
|
+
# Per file, mirroring the reference image-routing logic:
|
|
76
|
+
# - image sent natively (primary sees pixels): a [Image attached at: …]
|
|
77
|
+
# handle so the model can reference it in follow-up tool calls.
|
|
78
|
+
# - image on a text-only primary WITH a pre-description: inline the
|
|
79
|
+
# description so the model has the content without having to choose
|
|
80
|
+
# to call a tool (the prod failure mode — M2.7 said "no image" / ran
|
|
81
|
+
# `shell ls`). +descriptions+ maps such paths to their aux output.
|
|
82
|
+
# - image on a text-only primary WITHOUT a description (aux missing or
|
|
83
|
+
# errored): an explicit imperative to call `vision`, not shell.
|
|
84
|
+
# - non-image file: generic pointer; the preamble (PDF → markitdown)
|
|
85
|
+
# and tool descriptions tell the model which tool fits.
|
|
86
|
+
def augment_input_with_attachments(input_text, paths, native_image_paths: [], descriptions: {})
|
|
87
|
+
return input_text.to_s if paths.nil? || paths.empty?
|
|
88
|
+
|
|
89
|
+
native = Array(native_image_paths)
|
|
90
|
+
user_text = input_text.to_s
|
|
91
|
+
if user_text.strip.empty? && paths.any? { |p| LLM::ContentBuilder.image_file?(p) }
|
|
92
|
+
user_text = "What do you see in this image?"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
aux_vision = !Rubino.configuration.auxiliary_vision_config["model"].to_s.empty?
|
|
96
|
+
blocks = paths.filter_map do |p|
|
|
97
|
+
if LLM::ContentBuilder.image_file?(p) && image_by_magic?(p)
|
|
98
|
+
if native.include?(p)
|
|
99
|
+
"[Image attached at: #{p}]"
|
|
100
|
+
elsif descriptions[p]
|
|
101
|
+
"[The user attached an image. Here's what it contains:\n#{descriptions[p]}]\n" \
|
|
102
|
+
"[If you need a closer look, call the `vision` tool with file_path: #{p}.]"
|
|
103
|
+
elsif aux_vision
|
|
104
|
+
# Aux configured but pre-description failed: keep the on-demand
|
|
105
|
+
# `vision` imperative (the tool stays exposed in this case).
|
|
106
|
+
"[The user attached an image at #{p}. Call the `vision` tool with " \
|
|
107
|
+
"file_path: #{p} to see it — do not use shell/ls.]"
|
|
108
|
+
else
|
|
109
|
+
# Gap A: no native vision, no aux vision => the `vision` tool is
|
|
110
|
+
# HIDDEN from the model (Registry#aux_dependency_satisfied?). Do
|
|
111
|
+
# NOT instruct calling a hidden tool; warn instead.
|
|
112
|
+
cls = Attachments::Classify.call(p)
|
|
113
|
+
Attachments::Preamble.no_multimodal_warning(p, cls.mime || "image")
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
# Gap B + universal handling + MIME-spoof egress guard: classify by
|
|
117
|
+
# content (magic wins) and render a typed preamble. A file with an
|
|
118
|
+
# image extension whose magic is NOT an image (e.g. a .png-named
|
|
119
|
+
# zip) lands here too via #image_by_magic? above, so it is demoted
|
|
120
|
+
# to its real kind instead of being shipped to native/aux vision.
|
|
121
|
+
# Unsafe/oversize/disallowed => skip+warn.
|
|
122
|
+
attachment_block(p)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
"[Uploaded files — already in your workspace. Do not re-fetch the URLs.]\n" \
|
|
127
|
+
"#{blocks.join("\n\n")}\n\n" \
|
|
128
|
+
"#{user_text}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# True only when a file that LOOKS like an image by extension ALSO sniffs
|
|
132
|
+
# as a real image by content (Attachments::Classify, magic wins). Gates
|
|
133
|
+
# the native/aux-vision egress branch: a .png-named zip/text/binary fails
|
|
134
|
+
# here and is demoted to attachment_block (its real typed preamble), so a
|
|
135
|
+
# spoofed extension can never ship raw bytes to the native vision model or
|
|
136
|
+
# the EXTERNAL auxiliary vision model. The safety pipeline (lstat /
|
|
137
|
+
# realpath-confine / size cap) runs inside Classify, so image-extension
|
|
138
|
+
# files now get the same fail-closed checks as every other attachment.
|
|
139
|
+
def image_by_magic?(path)
|
|
140
|
+
cls = Attachments::Classify.call(path)
|
|
141
|
+
cls.safe && cls.kind == :image
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Classifies a non-image attachment by content (Attachments::Classify --
|
|
145
|
+
# magic wins, fail-closed safety pipeline) and renders the typed preamble
|
|
146
|
+
# (Attachments::Preamble). Returns nil to SKIP the attachment (with a
|
|
147
|
+
# warn) when the safety pipeline rejects it or its kind is disallowed by
|
|
148
|
+
# policy -- never inline/execute an unsafe file. Closes Gap B (archives /
|
|
149
|
+
# documents / binaries get typed guidance instead of a bare `- file:`).
|
|
150
|
+
def attachment_block(path)
|
|
151
|
+
cls = Attachments::Classify.call(path)
|
|
152
|
+
unless cls.safe
|
|
153
|
+
Rubino.logger.warn(event: "run.attachment_skipped", path: path.to_s, reason: cls.reason)
|
|
154
|
+
return nil
|
|
155
|
+
end
|
|
156
|
+
unless Attachments::Policy.allow_kind?(cls.kind)
|
|
157
|
+
Rubino.logger.warn(event: "run.attachment_skipped", path: path.to_s,
|
|
158
|
+
reason: "kind #{cls.kind} not in allow_kinds")
|
|
159
|
+
return nil
|
|
160
|
+
end
|
|
161
|
+
Attachments::Preamble.for(cls)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Spawns the worker thread and returns it immediately.
|
|
165
|
+
# @param run [Hash] row from Run::Repository; +:id+, +:session_id+,
|
|
166
|
+
# +:input_text+, +:model+, +:provider+ are read.
|
|
167
|
+
# @param on_complete [#call, nil] invoked from the +ensure+ block with
|
|
168
|
+
# +run_id:+, +session_id:+, +status:+; runs even when the run failed.
|
|
169
|
+
# @return [Thread] the worker thread (caller typically discards it).
|
|
170
|
+
def start(run, on_complete: nil)
|
|
171
|
+
Thread.new do
|
|
172
|
+
run_id = run[:id]
|
|
173
|
+
session_id = run[:session_id]
|
|
174
|
+
# A FRESH bus per run is the isolation boundary: the Recorder and the
|
|
175
|
+
# Runner share THIS instance only, so a run's emit reaches only its own
|
|
176
|
+
# recorder and its detach!/off only removes its own listeners. Without
|
|
177
|
+
# it every run bound the process-global bus and cross-contaminated
|
|
178
|
+
# peers' events/output (architecture audit A1).
|
|
179
|
+
bus = Interaction::EventBus.new
|
|
180
|
+
recorder = @recorder_factory.call(run_id: run_id, session_id: session_id, event_bus: bus)
|
|
181
|
+
gate = ApprovalGate.new
|
|
182
|
+
GateRegistry.register(run_id, gate)
|
|
183
|
+
recorder.attach!
|
|
184
|
+
final_status = "completed"
|
|
185
|
+
stopped = false
|
|
186
|
+
stop_watcher = nil
|
|
187
|
+
::Rubino::Metrics.counter(:runs_total, source: run[:source] || "api").increment
|
|
188
|
+
begin
|
|
189
|
+
@repository.mark_running!(run_id)
|
|
190
|
+
# Bind this run's gated UI as the thread-scoped Rubino.ui for the
|
|
191
|
+
# whole worker thread, so tools that look up the global adapter
|
|
192
|
+
# (QuestionTool#ask → clarify.required, TaskTool) hit THIS run's
|
|
193
|
+
# gate/recorder instead of the gate-less process global — without it
|
|
194
|
+
# the `question` tool's prompt is silently dropped and the web run
|
|
195
|
+
# hangs on an unanswerable question.
|
|
196
|
+
ui = UI::API.new(gate: gate, recorder: recorder, session_id: session_id)
|
|
197
|
+
runner = Agent::Runner.new(
|
|
198
|
+
session_id: session_id,
|
|
199
|
+
model_override: run[:model],
|
|
200
|
+
provider_override: run[:provider],
|
|
201
|
+
ui: ui,
|
|
202
|
+
event_bus: bus
|
|
203
|
+
)
|
|
204
|
+
# Bridge the cooperative HTTP stop flag to the runner's cancel
|
|
205
|
+
# token: poll #stop_requested? on a short tick and flip the token
|
|
206
|
+
# so the in-flight loop/stream unwinds via Interrupted. The flag in
|
|
207
|
+
# the closure lets the ensure record the run as "stopped" rather
|
|
208
|
+
# than "completed"/"failed".
|
|
209
|
+
stop_watcher = spawn_stop_watcher(run_id, runner) { stopped = true }
|
|
210
|
+
# Agent::Runner swallows Interrupted and StandardError internally
|
|
211
|
+
# and emits INTERACTION_FAILED on the bus, which Recorder maps to
|
|
212
|
+
# "run.failed". The lifecycle emits INTERACTION_FINISHED on the
|
|
213
|
+
# happy path → "run.completed". Don't re-emit either terminal
|
|
214
|
+
# event here or every run would broadcast two terminal frames
|
|
215
|
+
# (and the web UI would enqueue two title-generation jobs).
|
|
216
|
+
downloaded_paths = AttachmentDownloader.new.fetch_all(
|
|
217
|
+
run_id: run_id,
|
|
218
|
+
urls: parse_attachment_urls(run[:attachments_json])
|
|
219
|
+
)
|
|
220
|
+
# Emit a recorded event so SSE consumers (and post-hoc forensics)
|
|
221
|
+
# can confirm the augment fired and which paths the model saw.
|
|
222
|
+
# Only when something was actually downloaded — a plain chat with
|
|
223
|
+
# no upload has nothing to report, and emitting an empty event just
|
|
224
|
+
# rendered as noise in the timeline. Direct recorder.emit bypasses
|
|
225
|
+
# EventBus, same pattern as approval.required.
|
|
226
|
+
recorder.emit("run.attachments_downloaded", paths: downloaded_paths) if downloaded_paths.any?
|
|
227
|
+
# When the primary model supports vision, image files are passed
|
|
228
|
+
# natively (via ruby_llm `with:`) so the model can ingest the bytes
|
|
229
|
+
# directly. When the primary is text-only, image_paths stays empty
|
|
230
|
+
# and we pre-describe each image with the vision aux NOW, inlining
|
|
231
|
+
# the description into the prompt — so the model has the content
|
|
232
|
+
# without depending on choosing to call the `vision` tool (the prod
|
|
233
|
+
# failure mode in sessions 36/37). The tool stays exposed for
|
|
234
|
+
# on-demand re-inspection either way. Mirrors the reference text-mode
|
|
235
|
+
# _enrich_message_with_vision.
|
|
236
|
+
image_paths_for_native = native_image_paths(downloaded_paths)
|
|
237
|
+
descriptions = preprocess_images_with_vision(
|
|
238
|
+
downloaded_paths, image_paths_for_native, recorder
|
|
239
|
+
)
|
|
240
|
+
Rubino.with_ui(ui) do
|
|
241
|
+
runner.run!(
|
|
242
|
+
augment_input_with_attachments(
|
|
243
|
+
run[:input_text], downloaded_paths,
|
|
244
|
+
native_image_paths: image_paths_for_native,
|
|
245
|
+
descriptions: descriptions
|
|
246
|
+
),
|
|
247
|
+
image_paths: image_paths_for_native
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
@repository.mark_completed!(run_id)
|
|
251
|
+
rescue Rubino::Interrupted
|
|
252
|
+
# Cooperative stop won the race: the watcher flipped the token and
|
|
253
|
+
# the loop unwound via Interrupted. Record "stopped", not "failed"
|
|
254
|
+
# — this was a user-requested halt, not an error. Re-raise to a
|
|
255
|
+
# failed terminal state only if the token flipped for some other
|
|
256
|
+
# reason than a stop request (shouldn't happen in the API path).
|
|
257
|
+
if stopped || @repository.stop_requested?(run_id)
|
|
258
|
+
final_status = "stopped"
|
|
259
|
+
@repository.mark_stopped!(run_id)
|
|
260
|
+
recorder.emit("run.stopped", {})
|
|
261
|
+
else
|
|
262
|
+
final_status = "failed"
|
|
263
|
+
safe_mark_failed(run_id, "interrupted")
|
|
264
|
+
safe_emit_failed(recorder, "interrupted")
|
|
265
|
+
end
|
|
266
|
+
rescue SystemExit, Interrupt, SignalException
|
|
267
|
+
# Process is shutting down — re-raise so systemd / Puma can drain.
|
|
268
|
+
# Mark the run as failed first so it isn't left stuck in "running".
|
|
269
|
+
final_status = "failed"
|
|
270
|
+
safe_mark_failed(run_id, "agent process terminated")
|
|
271
|
+
safe_emit_failed(recorder, "agent process terminated")
|
|
272
|
+
raise
|
|
273
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
274
|
+
# Catch Exception (not just StandardError) — user-tool LoadError /
|
|
275
|
+
# SyntaxError / NoMemoryError can propagate from threads inside the
|
|
276
|
+
# runner via Thread#join, and without this the worker silently dies
|
|
277
|
+
# and the run is left as "running" forever (the recorder never sees
|
|
278
|
+
# INTERACTION_FAILED so the SSE stream also never gets a terminal
|
|
279
|
+
# frame). Emit run.failed directly via the recorder as a safety net
|
|
280
|
+
# in case the lifecycle didn't get a chance to.
|
|
281
|
+
final_status = "failed"
|
|
282
|
+
Rubino.logger.error(event: "run.exception", run_id: run_id, error: e.class.name, message: e.message)
|
|
283
|
+
safe_mark_failed(run_id, "#{e.class}: #{e.message}")
|
|
284
|
+
safe_emit_failed(recorder, "#{e.class}: #{e.message}")
|
|
285
|
+
ensure
|
|
286
|
+
stop_watcher&.kill
|
|
287
|
+
recorder.detach!
|
|
288
|
+
GateRegistry.unregister(run_id)
|
|
289
|
+
::Rubino::Metrics.counter(:runs_completed_total, status: final_status).increment
|
|
290
|
+
on_complete&.call(run_id: run_id, session_id: session_id, status: final_status)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
private
|
|
296
|
+
|
|
297
|
+
# Polls the run's stop flag on a short tick and, on observing it, flips
|
|
298
|
+
# the runner's CancelToken (the single halt mechanism). Yields once after
|
|
299
|
+
# the cancel so the caller can record that this was a stop, then exits.
|
|
300
|
+
# Returns the watcher Thread; the worker kills it in its ensure block.
|
|
301
|
+
def spawn_stop_watcher(run_id, runner)
|
|
302
|
+
Thread.new do
|
|
303
|
+
loop do
|
|
304
|
+
sleep STOP_POLL_INTERVAL
|
|
305
|
+
next unless @repository.stop_requested?(run_id)
|
|
306
|
+
|
|
307
|
+
runner.cancel!
|
|
308
|
+
# The CancelToken only halts the loop/stream at a poll point. If the
|
|
309
|
+
# worker is parked inside ApprovalGate#await (queue.pop, up to the
|
|
310
|
+
# configured wait bound — default 15 min) it never reaches one, so
|
|
311
|
+
# wake the gate too — it raises Interrupted in the awaiting thread
|
|
312
|
+
# and frees the worker. Without this a cancelled/abandoned approval
|
|
313
|
+
# holds a Solid Queue thread for the whole wait window (W1).
|
|
314
|
+
GateRegistry.fetch(run_id)&.cancel!
|
|
315
|
+
yield if block_given?
|
|
316
|
+
break
|
|
317
|
+
end
|
|
318
|
+
rescue StandardError => e
|
|
319
|
+
# A DB hiccup in the watcher must never take down the run; the worst
|
|
320
|
+
# case is the stop is observed a tick later or not at all.
|
|
321
|
+
Rubino.logger.error(event: "run.stop_watcher_error", run_id: run_id,
|
|
322
|
+
error: e.class.name, message: e.message)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Returns the subset of paths that are images AND can be ingested
|
|
327
|
+
# natively by the current primary model. Empty when either condition
|
|
328
|
+
# fails — in which case the `vision` tool path takes over.
|
|
329
|
+
def native_image_paths(paths)
|
|
330
|
+
return [] if paths.nil? || paths.empty?
|
|
331
|
+
return [] unless Rubino.configuration.model_supports_vision?
|
|
332
|
+
|
|
333
|
+
paths.select { |p| LLM::ContentBuilder.image_file?(p) }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# For images NOT sent natively (text-only primary), ask the vision aux to
|
|
337
|
+
# describe each up-front. Returns { path => description } for the ones that
|
|
338
|
+
# succeeded; the augment inlines them. No-op (empty hash) when no aux
|
|
339
|
+
# vision model is configured — the augment then falls back to an explicit
|
|
340
|
+
# "call the `vision` tool" imperative instead. Emits forensic events so a
|
|
341
|
+
# missing/failed pre-description is visible post-hoc (same reason
|
|
342
|
+
# run.attachments_downloaded exists).
|
|
343
|
+
def preprocess_images_with_vision(paths, native, recorder)
|
|
344
|
+
return {} if Rubino.configuration.auxiliary_vision_config["model"].to_s.empty?
|
|
345
|
+
|
|
346
|
+
text_only_images = paths.select { |p| LLM::ContentBuilder.image_file?(p) } - Array(native)
|
|
347
|
+
text_only_images.each_with_object({}) do |path, acc|
|
|
348
|
+
result = @vision_describer.call(path).to_s
|
|
349
|
+
if result.start_with?("Error")
|
|
350
|
+
recorder&.emit("run.vision_preprocess_failed", path: path, error: result.slice(0, 300))
|
|
351
|
+
else
|
|
352
|
+
recorder&.emit("run.vision_preprocessed", path: path, chars: result.length)
|
|
353
|
+
acc[path] = result
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Default describer: routes through the same VisionTool the model can call
|
|
359
|
+
# on demand, so pre-description and on-demand inspection share one path.
|
|
360
|
+
def default_vision_describe(path)
|
|
361
|
+
Tools::VisionTool.new.call("file_path" => path, "question" => VISION_ANALYSIS_PROMPT)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# mark_failed! can itself raise (DB locked, etc). The whole point of the
|
|
365
|
+
# outer rescue is to leave the row in a terminal state — if even that
|
|
366
|
+
# fails, log and move on; the watchdog in EventsOperation will catch it.
|
|
367
|
+
def safe_mark_failed(run_id, message)
|
|
368
|
+
@repository.mark_failed!(run_id, error: message.to_s.slice(0, 500))
|
|
369
|
+
rescue StandardError => e
|
|
370
|
+
Rubino.logger.error(event: "run.mark_failed_error", run_id: run_id, error: e.class.name, message: e.message)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Recorder may already be detached (race with the ensure block) — emit is
|
|
374
|
+
# best-effort. The DB row is the authoritative source of truth; SSE is a
|
|
375
|
+
# convenience.
|
|
376
|
+
def safe_emit_failed(recorder, message)
|
|
377
|
+
recorder&.emit("run.failed", error: message.to_s.slice(0, 500))
|
|
378
|
+
rescue StandardError => e
|
|
379
|
+
Rubino.logger.error(event: "run.emit_failed_error", error: e.class.name, message: e.message)
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Run
|
|
5
|
+
# Process-wide registry of ApprovalGate instances, keyed by run_id.
|
|
6
|
+
# Module-level state (no instance): one hash + one mutex held at the
|
|
7
|
+
# module singleton class.
|
|
8
|
+
#
|
|
9
|
+
# Lifecycle: Executor#start calls +register+ when a run begins and
|
|
10
|
+
# +unregister+ in its +ensure+ block; HTTP decision endpoints call
|
|
11
|
+
# +fetch+ to resolve the gate before forwarding a decision.
|
|
12
|
+
#
|
|
13
|
+
# Single-process only: the gate lives in the Ruby heap, so this does
|
|
14
|
+
# not survive multi-process scaling (Puma workers, forked servers).
|
|
15
|
+
# Decisions routed to the wrong worker silently fail #fetch.
|
|
16
|
+
module GateRegistry
|
|
17
|
+
@gates = {}
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def register(run_id, gate)
|
|
22
|
+
@mutex.synchronize { @gates[run_id] = gate }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fetch(run_id)
|
|
26
|
+
@mutex.synchronize { @gates[run_id] }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def unregister(run_id)
|
|
30
|
+
@mutex.synchronize { @gates.delete(run_id) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reset!
|
|
34
|
+
@mutex.synchronize { @gates.clear }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Run
|
|
5
|
+
# Bridges Interaction::EventBus to per-run persisted events. Subscribes
|
|
6
|
+
# to the bus, translates internal symbols to API event names via
|
|
7
|
+
# +EVENT_MAP+, and writes one row per emission through EventStore.
|
|
8
|
+
#
|
|
9
|
+
# +EVENT_MAP+ is the single source of truth for the internal-to-API
|
|
10
|
+
# event-type translation; anything not in the map is dropped on the
|
|
11
|
+
# floor (callers that need to bypass the bus, e.g. +approval.required+
|
|
12
|
+
# / +clarify.required+, must call #emit directly).
|
|
13
|
+
#
|
|
14
|
+
# Lifecycle:
|
|
15
|
+
# recorder = Recorder.new(run_id:, session_id:)
|
|
16
|
+
# recorder.attach!
|
|
17
|
+
# ... run loop ...
|
|
18
|
+
# recorder.detach!
|
|
19
|
+
class Recorder
|
|
20
|
+
EVENT_MAP = {
|
|
21
|
+
Interaction::Events::MODEL_STREAM => "message.delta",
|
|
22
|
+
Interaction::Events::MESSAGE_COMPLETED => "message.completed",
|
|
23
|
+
Interaction::Events::TOOL_STARTED => "tool.started",
|
|
24
|
+
Interaction::Events::TOOL_PROGRESS => "tool.progress",
|
|
25
|
+
Interaction::Events::TOOL_FINISHED => "tool.completed",
|
|
26
|
+
Interaction::Events::ARTIFACT_CREATED => "artifact.created",
|
|
27
|
+
Interaction::Events::INPUT_INJECTED => "input.injected",
|
|
28
|
+
Interaction::Events::SKILL_LOADED => "skill.loaded",
|
|
29
|
+
Interaction::Events::SUBAGENT_SPAWNED => "subagent.spawned",
|
|
30
|
+
Interaction::Events::SUBAGENT_COMPLETED => "subagent.completed",
|
|
31
|
+
Interaction::Events::SUBAGENT_FAILED => "subagent.failed",
|
|
32
|
+
Interaction::Events::INTERACTION_FINISHED => "run.completed",
|
|
33
|
+
Interaction::Events::INTERACTION_FAILED => "run.failed"
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
def initialize(run_id:, session_id:, event_bus: nil, store: nil)
|
|
37
|
+
@run_id = run_id
|
|
38
|
+
@session_id = session_id
|
|
39
|
+
@event_bus = event_bus || Rubino.event_bus
|
|
40
|
+
@store = store || EventStore.new
|
|
41
|
+
@subscribers = []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def attach!
|
|
45
|
+
EVENT_MAP.each do |internal_type, api_type|
|
|
46
|
+
handler = ->(payload) { record(api_type, payload) }
|
|
47
|
+
@event_bus.on(internal_type, &handler)
|
|
48
|
+
@subscribers << [internal_type, handler]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def detach!
|
|
53
|
+
@subscribers.each { |type, _| @event_bus.off(type) }
|
|
54
|
+
@subscribers.clear
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Direct emission bypassing EventBus (used for API-only events like approval.required).
|
|
58
|
+
def emit(api_type, payload)
|
|
59
|
+
record(api_type, payload)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def record(api_type, payload)
|
|
65
|
+
@store.append(session_id: @session_id, run_id: @run_id, type: api_type, payload: payload)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Rubino
|
|
7
|
+
module Run
|
|
8
|
+
# Repository for Run CRUD. A Run is one user-input -> assistant-response
|
|
9
|
+
# cycle within a Session, exposed as a first-class resource over the HTTP
|
|
10
|
+
# API and the only persistence point for cooperative cancellation.
|
|
11
|
+
#
|
|
12
|
+
# Status transitions are driven by the executor:
|
|
13
|
+
# queued -> running (#mark_running!)
|
|
14
|
+
# -> completed (#mark_completed!)
|
|
15
|
+
# -> failed (#mark_failed!)
|
|
16
|
+
# -> stopped (#mark_stopped!)
|
|
17
|
+
#
|
|
18
|
+
# Cooperative stop pattern:
|
|
19
|
+
# - +POST /v1/runs/:id/stop+ calls #request_stop! which flips the
|
|
20
|
+
# +stop_requested+ boolean on the row.
|
|
21
|
+
# - The run loop is expected to poll #stop_requested? between turns
|
|
22
|
+
# and bail out, then call #mark_stopped!. The flag is a hint, not
|
|
23
|
+
# a hard kill — the worker thread keeps the CPU until it observes
|
|
24
|
+
# it. In the current Executor the in-loop poll is not yet wired,
|
|
25
|
+
# so the flag is recorded and surfaced to clients but does not
|
|
26
|
+
# actually halt an in-flight run; downstream agents should add the
|
|
27
|
+
# check inside Agent::Runner.
|
|
28
|
+
#
|
|
29
|
+
# +last_for_session+ uses a (created_at DESC, rowid DESC) tuple to
|
|
30
|
+
# disambiguate rows created in the same second.
|
|
31
|
+
class Repository
|
|
32
|
+
def initialize(db: nil)
|
|
33
|
+
@db = db || Rubino.database.db
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def create(session_id:, input_text:, attachments: [], skills: [], model: nil, provider: nil, cron_job_id: nil)
|
|
37
|
+
now = Time.now.utc.iso8601
|
|
38
|
+
id = SecureRandom.uuid
|
|
39
|
+
|
|
40
|
+
@db[:runs].insert(
|
|
41
|
+
id: id,
|
|
42
|
+
session_id: session_id,
|
|
43
|
+
status: "queued",
|
|
44
|
+
input_text: input_text,
|
|
45
|
+
attachments_json: JSON.generate(attachments),
|
|
46
|
+
skills_json: JSON.generate(skills),
|
|
47
|
+
model: model,
|
|
48
|
+
provider: provider,
|
|
49
|
+
cron_job_id: cron_job_id,
|
|
50
|
+
stop_requested: false,
|
|
51
|
+
created_at: now,
|
|
52
|
+
updated_at: now
|
|
53
|
+
)
|
|
54
|
+
find(id)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def find(id)
|
|
58
|
+
@db[:runs].where(id: id).first
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def list_for_session(session_id)
|
|
62
|
+
@db[:runs].where(session_id: session_id).order(:created_at).all
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def last_for_session(session_id)
|
|
66
|
+
@db[:runs]
|
|
67
|
+
.where(session_id: session_id)
|
|
68
|
+
.order(Sequel.desc(:created_at), Sequel.desc(Sequel.lit("rowid")))
|
|
69
|
+
.first
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def mark_running!(id)
|
|
73
|
+
now = Time.now.utc.iso8601
|
|
74
|
+
@db[:runs].where(id: id).update(status: "running", started_at: now, updated_at: now)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def mark_completed!(id, tokens_input: 0, tokens_output: 0)
|
|
78
|
+
now = Time.now.utc.iso8601
|
|
79
|
+
@db[:runs].where(id: id).update(
|
|
80
|
+
status: "completed",
|
|
81
|
+
finished_at: now,
|
|
82
|
+
tokens_input: tokens_input,
|
|
83
|
+
tokens_output: tokens_output,
|
|
84
|
+
updated_at: now
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def mark_failed!(id, error:)
|
|
89
|
+
now = Time.now.utc.iso8601
|
|
90
|
+
@db[:runs].where(id: id).update(status: "failed", error: error, finished_at: now, updated_at: now)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def mark_stopped!(id)
|
|
94
|
+
now = Time.now.utc.iso8601
|
|
95
|
+
@db[:runs].where(id: id).update(status: "stopped", finished_at: now, updated_at: now)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Signals a cooperative stop. The run loop must observe this on its
|
|
99
|
+
# own; nothing in this class interrupts an in-flight thread.
|
|
100
|
+
def request_stop!(id)
|
|
101
|
+
@db[:runs].where(id: id).update(stop_requested: true, updated_at: Time.now.utc.iso8601)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def stop_requested?(id)
|
|
105
|
+
@db[:runs].where(id: id).get(:stop_requested) == true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Cascades: deletes the run's persisted events before the run row,
|
|
109
|
+
# in a single transaction (FKs are not declared at the schema level).
|
|
110
|
+
def destroy!(id)
|
|
111
|
+
@db.transaction do
|
|
112
|
+
@db[:events].where(run_id: id).delete
|
|
113
|
+
@db[:runs].where(id: id).delete
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|