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,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../llm/content_builder"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Interaction
|
|
7
|
+
# Parses a raw CLI input line and pulls out image attachments so they can
|
|
8
|
+
# be routed to the model's native vision slot (image_paths) instead of being
|
|
9
|
+
# sent as literal text.
|
|
10
|
+
#
|
|
11
|
+
# Three input shapes are recognised, mirroring how Claude Code lets a user
|
|
12
|
+
# attach an image from the terminal:
|
|
13
|
+
#
|
|
14
|
+
# 1. `@path/to/pic.png` — the composer's `@` file-picker. When the picked
|
|
15
|
+
# file is an image it becomes an attachment; a non-image `@file` is left
|
|
16
|
+
# in the text untouched (the model reads it via the `read` tool, as
|
|
17
|
+
# before).
|
|
18
|
+
# 2. A dropped / pasted file path — terminals insert an absolute path when
|
|
19
|
+
# a file is dragged in, often single/double-quoted or backslash-escaped
|
|
20
|
+
# for spaces. An image path (quoted, escaped or bare) is attached.
|
|
21
|
+
#
|
|
22
|
+
# Only paths that (a) have a recognised image extension AND (b) exist on disk
|
|
23
|
+
# are attached; anything else is preserved verbatim in the returned text so
|
|
24
|
+
# we never silently eat a word that merely looked path-ish.
|
|
25
|
+
#
|
|
26
|
+
# Every candidate attachment is then gated through the SAME secure-by-default
|
|
27
|
+
# attachment layer the server/run path uses (Attachments::Classify + Policy:
|
|
28
|
+
# lstat/realpath safety pipeline, max_file_bytes cap, magic-byte kind check)
|
|
29
|
+
# — the CLI used to bypass it entirely, shipping oversize/spoofed files to
|
|
30
|
+
# the provider and burning the retry budget on the permanent error (#98).
|
|
31
|
+
# A rejected candidate is consumed from the text and reported in
|
|
32
|
+
# Result#rejected so the caller can surface a clean one-line error.
|
|
33
|
+
#
|
|
34
|
+
# Returns a Result with the cleaned text (image tokens removed, whitespace
|
|
35
|
+
# collapsed), the de-duplicated, expanded absolute image paths in order, and
|
|
36
|
+
# any policy rejections as { path:, reason: } hashes.
|
|
37
|
+
module ImageInput
|
|
38
|
+
Result = Struct.new(:text, :image_paths, :rejected, keyword_init: true) do
|
|
39
|
+
def images? = !image_paths.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# An `@token`: `@` followed by a run of non-space chars. Quoting inside an
|
|
43
|
+
# `@` token isn't a terminal convention, so we keep it simple.
|
|
44
|
+
AT_TOKEN = /(?<![^\s])@(\S+)/
|
|
45
|
+
|
|
46
|
+
# A quoted path: '...' or "..." (drag-drop on terminals that quote).
|
|
47
|
+
QUOTED_PATH = /(?<![^\s])(?:'([^']+)'|"([^"]+)")/
|
|
48
|
+
|
|
49
|
+
# A bare / backslash-escaped path token: a leading /, ./, ../ or ~/ then a
|
|
50
|
+
# run of non-space chars, allowing `\ ` escaped spaces (drag-drop default
|
|
51
|
+
# on iTerm/Terminal.app). Anchored at a word boundary so it doesn't bite
|
|
52
|
+
# into the middle of a URL or sentence.
|
|
53
|
+
BARE_PATH = %r{(?<![^\s])((?:~|\.{0,2})/(?:\\.|\S)+)}
|
|
54
|
+
|
|
55
|
+
module_function
|
|
56
|
+
|
|
57
|
+
# Extracts image attachments from +input+. +existing+ lets a caller carry
|
|
58
|
+
# forward images already attached to the pending turn (e.g. a clipboard
|
|
59
|
+
# paste) so a follow-up line's parse adds to them rather than replacing.
|
|
60
|
+
def parse(input, existing: [])
|
|
61
|
+
text = input.to_s
|
|
62
|
+
paths = []
|
|
63
|
+
rejected = []
|
|
64
|
+
|
|
65
|
+
text = text.gsub(AT_TOKEN) { capture_if_image(Regexp.last_match(1), Regexp.last_match(0), paths, rejected) }
|
|
66
|
+
text = text.gsub(QUOTED_PATH) do
|
|
67
|
+
token = Regexp.last_match(1) || Regexp.last_match(2)
|
|
68
|
+
capture_if_image(token, Regexp.last_match(0), paths, rejected)
|
|
69
|
+
end
|
|
70
|
+
text = text.gsub(BARE_PATH) { capture_if_image(Regexp.last_match(1), Regexp.last_match(0), paths, rejected) }
|
|
71
|
+
|
|
72
|
+
Result.new(
|
|
73
|
+
text: text.gsub(/[ \t]{2,}/, " ").strip,
|
|
74
|
+
image_paths: (Array(existing) + paths).uniq,
|
|
75
|
+
rejected: rejected.uniq
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# If +token+ resolves to an existing image file, record its absolute path
|
|
80
|
+
# and drop it from the text (returns ""); otherwise leave the original
|
|
81
|
+
# match (+original+) untouched. +original+ is captured by the caller before
|
|
82
|
+
# any path work, because #expand runs its own gsub and would clobber
|
|
83
|
+
# Regexp.last_match here. A candidate that LOOKS like an image but fails
|
|
84
|
+
# the attachment policy is consumed too — never shipped, never left as a
|
|
85
|
+
# path the model would chase with tools — and recorded in +rejected+.
|
|
86
|
+
def capture_if_image(token, original, paths, rejected)
|
|
87
|
+
path = expand(token)
|
|
88
|
+
return original unless LLM::ContentBuilder.image_file?(path) && File.file?(path)
|
|
89
|
+
|
|
90
|
+
if (reason = attachment_error(path))
|
|
91
|
+
rejected << { path: path, reason: reason }
|
|
92
|
+
else
|
|
93
|
+
paths << path unless paths.include?(path)
|
|
94
|
+
end
|
|
95
|
+
""
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Gates one candidate image through the universal attachment layer —
|
|
99
|
+
# Attachments::Classify (lstat/realpath safety pipeline, max_file_bytes
|
|
100
|
+
# cap, magic-byte classification) + Policy.allow_kind? — the SAME checks
|
|
101
|
+
# the server/run path applies (#98). Returns a one-line human reason when
|
|
102
|
+
# the file must NOT be attached, nil when it is safe to send.
|
|
103
|
+
def attachment_error(path)
|
|
104
|
+
cls = Attachments::Classify.call(path)
|
|
105
|
+
unless cls.safe
|
|
106
|
+
if cls.reason.to_s.start_with?("exceeds max_file_bytes")
|
|
107
|
+
return "exceeds the #{Attachments::Policy.max_file_bytes / 1_048_576} MB attachment limit"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
return cls.reason
|
|
111
|
+
end
|
|
112
|
+
return "not a valid image (content is #{cls.mime})" unless cls.kind == :image
|
|
113
|
+
return "image attachments are disabled by policy (allow_kinds)" unless Attachments::Policy.allow_kind?(:image)
|
|
114
|
+
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Normalises a raw token into an absolute filesystem path: strips
|
|
119
|
+
# backslash escapes (`\ ` → ` `) and expands `~`/relative paths.
|
|
120
|
+
def expand(token)
|
|
121
|
+
File.expand_path(token.to_s.gsub(/\\(.)/, '\1'))
|
|
122
|
+
rescue ArgumentError
|
|
123
|
+
token.to_s
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Interaction
|
|
5
|
+
# Thread-safe hand-off of typed-while-busy input to the REPL loop.
|
|
6
|
+
#
|
|
7
|
+
# The chat REPL runs one turn synchronously while a background reader
|
|
8
|
+
# thread keeps accepting keystrokes from the TTY (see
|
|
9
|
+
# CLI::ChatCommand#run_turn). Each completed line the reader sees is
|
|
10
|
+
# +push+-ed here; when the turn returns, the REPL +drain+s the queue and
|
|
11
|
+
# the captured lines become the NEXT user turn — never injected mid-tool.
|
|
12
|
+
#
|
|
13
|
+
# Mirrors Run::ApprovalGate's idiom: a plain +::Queue+ guarded by a
|
|
14
|
+
# +Mutex+ for the multi-line snapshot. Two threads touch it — the reader
|
|
15
|
+
# (push) and the main loop (drain/pending?) — so every read of the
|
|
16
|
+
# backing queue happens under the lock to keep +drain+ and +pending?+
|
|
17
|
+
# consistent against a concurrent +push+.
|
|
18
|
+
class InputQueue
|
|
19
|
+
def initialize
|
|
20
|
+
# An Array (under @mutex) rather than ::Queue: B4 consumes ONE line at a
|
|
21
|
+
# time (FIFO) and an interrupt line must be able to JUMP ahead of items
|
|
22
|
+
# explicitly parked earlier in the same turn (#push_front), neither of
|
|
23
|
+
# which ::Queue supports. The mutex still serialises the reader (push)
|
|
24
|
+
# against the main loop (shift/drain/pending?).
|
|
25
|
+
@lines = []
|
|
26
|
+
# Deterministic background notices (#push_notice) are held apart from
|
|
27
|
+
# typed lines: #shift never returns them, so a parked notice can't fire
|
|
28
|
+
# a standalone model turn at the idle prompt (#13).
|
|
29
|
+
@notices = []
|
|
30
|
+
@mutex = Mutex.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Records one completed line typed during the turn. Blank/nil lines are
|
|
34
|
+
# dropped so a stray Enter doesn't manufacture an empty next turn.
|
|
35
|
+
def push(line)
|
|
36
|
+
text = normalize(line)
|
|
37
|
+
return if text.nil?
|
|
38
|
+
|
|
39
|
+
@mutex.synchronize { @lines.push(text) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Records a line at the FRONT of the queue so it is the NEXT one #shift
|
|
43
|
+
# returns. Used by the interrupt-by-default Enter: the just-submitted line
|
|
44
|
+
# runs immediately next, AHEAD of any items the user explicitly parked
|
|
45
|
+
# (Alt+Enter / "/queued") earlier in the same turn, which then run in
|
|
46
|
+
# their own order behind it.
|
|
47
|
+
def push_front(line)
|
|
48
|
+
text = normalize(line)
|
|
49
|
+
return if text.nil?
|
|
50
|
+
|
|
51
|
+
@mutex.synchronize { @lines.unshift(text) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Records a deterministic background notice (the `[background-task] …
|
|
55
|
+
# completed/failed/stopped` lines). Notices are NOT user turns: #shift
|
|
56
|
+
# never returns them, so at the idle prompt a notice doesn't spend a
|
|
57
|
+
# whole model turn just to restate itself (#13). It rides along on the
|
|
58
|
+
# NEXT real turn instead — #drain (mid-turn steering boundary) and
|
|
59
|
+
# #drain_notices (turn start) both deliver it.
|
|
60
|
+
def push_notice(line)
|
|
61
|
+
text = normalize(line)
|
|
62
|
+
return if text.nil?
|
|
63
|
+
|
|
64
|
+
@mutex.synchronize { @notices.push(text) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Removes and returns the OLDEST queued line (FIFO), or nil when empty.
|
|
68
|
+
# The REPL consumes one queued message per turn so several lines parked
|
|
69
|
+
# during one turn each run as their OWN turn, in submission order (B4) —
|
|
70
|
+
# instead of #drain coalescing them into a single newline-joined message.
|
|
71
|
+
# Atomic against a concurrent #push.
|
|
72
|
+
def shift
|
|
73
|
+
@mutex.synchronize { @lines.shift }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Removes and returns every queued line, in arrival order — parked
|
|
77
|
+
# background notices first, then typed lines. Empty when nothing is
|
|
78
|
+
# waiting. Atomic against a concurrent #push.
|
|
79
|
+
def drain
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
lines = @notices + @lines
|
|
82
|
+
@notices = []
|
|
83
|
+
@lines = []
|
|
84
|
+
lines
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Removes and returns only the parked background notices. The turn-START
|
|
89
|
+
# injection (Loop, iteration 1) folds notices into the turn the user just
|
|
90
|
+
# submitted without consuming their typed-ahead lines, which must keep
|
|
91
|
+
# running as their own turns (#13).
|
|
92
|
+
def drain_notices
|
|
93
|
+
@mutex.synchronize do
|
|
94
|
+
notices = @notices
|
|
95
|
+
@notices = []
|
|
96
|
+
notices
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# True when at least one line or notice is waiting to be drained.
|
|
101
|
+
def pending?
|
|
102
|
+
@mutex.synchronize { !@lines.empty? || !@notices.empty? }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Normalizes a pushed line: nil → nil; blank → nil (dropped so a stray
|
|
108
|
+
# Enter never manufactures an empty turn); else the stringified line.
|
|
109
|
+
def normalize(line)
|
|
110
|
+
return nil if line.nil?
|
|
111
|
+
|
|
112
|
+
text = line.to_s
|
|
113
|
+
text.strip.empty? ? nil : text
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Interaction
|
|
5
|
+
# Orchestrates the full lifecycle of a single user interaction.
|
|
6
|
+
# Coordinates all phases from input to final response and post-turn jobs.
|
|
7
|
+
class Lifecycle
|
|
8
|
+
def initialize(session:, event_bus:, ui:, config:, ignore_rules: false,
|
|
9
|
+
agent_definition: nil, cancel_token: nil,
|
|
10
|
+
model_override: nil, provider_override: nil,
|
|
11
|
+
max_tool_iterations: nil)
|
|
12
|
+
@session = session
|
|
13
|
+
@event_bus = event_bus
|
|
14
|
+
@ui = ui
|
|
15
|
+
@config = config
|
|
16
|
+
@ignore_rules = ignore_rules
|
|
17
|
+
@agent_definition = agent_definition
|
|
18
|
+
@cancel_token = cancel_token
|
|
19
|
+
@model_override = model_override
|
|
20
|
+
@provider_override = provider_override
|
|
21
|
+
# Explicit per-run cap from `--max-turns` (Runner → here → IterationBudget).
|
|
22
|
+
# nil ⇒ use the configured agent_max_tool_iterations (#141).
|
|
23
|
+
@max_tool_iterations = max_tool_iterations
|
|
24
|
+
@state = State.new
|
|
25
|
+
@session_repo = Session::Repository.new
|
|
26
|
+
@message_store = Session::Store.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Executes the full interaction lifecycle for a user input.
|
|
30
|
+
# image_paths are vision-capable attachments routed natively to the
|
|
31
|
+
# primary model (ruby_llm `with:` slot); only consumed on the first
|
|
32
|
+
# iteration of the inner agent loop. Subsequent iterations carry tool
|
|
33
|
+
# results, not user input, and don't re-attach the images.
|
|
34
|
+
# +input_queue+ is the optional steering hand-off (Interaction::InputQueue)
|
|
35
|
+
# for mid-turn injection: when given, the inner agent loop drains any text
|
|
36
|
+
# the user typed while it was working and folds it into the turn at a safe
|
|
37
|
+
# iteration boundary. Nil for the API/server path and for nested SUBAGENT
|
|
38
|
+
# runs, which stay isolated — no user injection, exactly as before.
|
|
39
|
+
def execute(input, image_paths: [], input_queue: nil, paste_expansions: [])
|
|
40
|
+
@event_bus.emit(Events::INTERACTION_STARTED, input: input)
|
|
41
|
+
@state.transition_to!(:receiving_input, event_bus: @event_bus)
|
|
42
|
+
|
|
43
|
+
# 1. Persist user message
|
|
44
|
+
@state.transition_to!(:loading_session, event_bus: @event_bus)
|
|
45
|
+
persist_user_message(input, paste_expansions: paste_expansions)
|
|
46
|
+
|
|
47
|
+
# 2. Load memory (if enabled)
|
|
48
|
+
@state.transition_to!(:loading_memory, event_bus: @event_bus)
|
|
49
|
+
memory_context = load_memory(input)
|
|
50
|
+
|
|
51
|
+
# 3. Build prompt/context
|
|
52
|
+
@state.transition_to!(:building_context, event_bus: @event_bus)
|
|
53
|
+
messages = build_messages(input, memory_context)
|
|
54
|
+
tools = load_tools
|
|
55
|
+
|
|
56
|
+
# 4. Check token budget
|
|
57
|
+
@state.transition_to!(:checking_budget, event_bus: @event_bus)
|
|
58
|
+
messages = check_and_compact(messages)
|
|
59
|
+
|
|
60
|
+
# 5. Run agent loop
|
|
61
|
+
@state.transition_to!(:calling_model, event_bus: @event_bus)
|
|
62
|
+
response = run_agent_loop(messages, tools, image_paths: image_paths,
|
|
63
|
+
input_queue: input_queue)
|
|
64
|
+
|
|
65
|
+
# 6. Persist session state
|
|
66
|
+
@state.transition_to!(:persisting_session, event_bus: @event_bus)
|
|
67
|
+
update_session_state
|
|
68
|
+
|
|
69
|
+
# 7. Enqueue post-turn jobs
|
|
70
|
+
@state.transition_to!(:enqueueing_jobs, event_bus: @event_bus)
|
|
71
|
+
enqueue_post_turn_jobs
|
|
72
|
+
|
|
73
|
+
# 8. Finish
|
|
74
|
+
# Carry the final assistant text as the terminal event's authoritative
|
|
75
|
+
# output, regardless of streaming mode. Streaming consumers also receive
|
|
76
|
+
# it incrementally via MODEL_STREAM (message.delta), but the
|
|
77
|
+
# non-streaming path emits no deltas — so without this, a completed run
|
|
78
|
+
# would terminate with no final text for clients to display. This makes
|
|
79
|
+
# run.completed the single source of truth for the answer.
|
|
80
|
+
@state.transition_to!(:finished, event_bus: @event_bus)
|
|
81
|
+
@event_bus.emit(Events::INTERACTION_FINISHED, output: response.to_s)
|
|
82
|
+
|
|
83
|
+
response
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
@state.transition_to!(:failed, event_bus: @event_bus)
|
|
86
|
+
@event_bus.emit(Events::INTERACTION_FAILED, error: e.message)
|
|
87
|
+
raise
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def persist_user_message(input, paste_expansions: [])
|
|
93
|
+
# Lazily insert the session row on the first real message (#144). A
|
|
94
|
+
# session built by the CLI stays in-memory until now, so opening `chat`
|
|
95
|
+
# and exiting without sending anything never persists an empty row. The
|
|
96
|
+
# message table has a session_id FK, so the row must exist first.
|
|
97
|
+
@session_repo.persist!(@session)
|
|
98
|
+
|
|
99
|
+
# Persist the user's message verbatim. Image attachments are owned by
|
|
100
|
+
# the image_paths pipeline (Executor -> Runner -> Loop), routed natively
|
|
101
|
+
# to the model; we must not strip paths out of the stored/sent text.
|
|
102
|
+
# +input+ keeps any compact "[Pasted text #N …]" placeholder so the
|
|
103
|
+
# transcript echo stays clean on resume (#213); the matching expansion
|
|
104
|
+
# bodies ride as metadata and are folded into the model-facing content
|
|
105
|
+
# by Message#to_context, so the model still sees the full paste.
|
|
106
|
+
attrs = { session_id: @session[:id], role: "user", content: input }
|
|
107
|
+
attrs[:metadata] = { paste_expansions: paste_expansions } unless paste_expansions.empty?
|
|
108
|
+
@message_store.create(**attrs)
|
|
109
|
+
@session_repo.increment_message_count!(@session[:id])
|
|
110
|
+
maybe_set_title(input)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Auto-title a still-untitled session from its first user message (#103),
|
|
114
|
+
# so `/sessions` is navigable and `--resume <title>` can match. Cheap and
|
|
115
|
+
# deterministic (no model call). Set once: any session that already has a
|
|
116
|
+
# title is left alone. Title failures must never break the turn.
|
|
117
|
+
def maybe_set_title(input)
|
|
118
|
+
return if @session[:title] && !@session[:title].to_s.strip.empty?
|
|
119
|
+
|
|
120
|
+
title = Session::Repository.derive_title(input)
|
|
121
|
+
return unless title
|
|
122
|
+
|
|
123
|
+
@session_repo.update(@session[:id], title: title)
|
|
124
|
+
@session[:title] = title
|
|
125
|
+
rescue StandardError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def load_memory(query = nil)
|
|
130
|
+
return {} unless @config.memory_enabled?
|
|
131
|
+
|
|
132
|
+
# Route through the configured backend. `query` (the current user
|
|
133
|
+
# message) lets a relevance-aware backend rank recall; the default
|
|
134
|
+
# backend ignores it and returns "everything that fits", as before.
|
|
135
|
+
backend = Memory::Backends.build(config: @config)
|
|
136
|
+
{
|
|
137
|
+
user_profile: backend.user_profile,
|
|
138
|
+
project_context: backend.project_context,
|
|
139
|
+
relevant_memories: backend.retrieve(session_id: @session[:id], query: query)
|
|
140
|
+
}
|
|
141
|
+
rescue StandardError
|
|
142
|
+
{} # Don't fail the interaction if memory loading fails
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def build_messages(_input, memory_context)
|
|
146
|
+
assembler = Context::PromptAssembler.new(
|
|
147
|
+
session: @session,
|
|
148
|
+
memory_context: memory_context,
|
|
149
|
+
config: @config,
|
|
150
|
+
agent_definition: @agent_definition,
|
|
151
|
+
ignore_rules: @ignore_rules
|
|
152
|
+
)
|
|
153
|
+
assembler.build
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def load_tools
|
|
157
|
+
return [] if @config.agent_disabled_toolsets.include?("all")
|
|
158
|
+
|
|
159
|
+
# Honor the agent definition's tool restrictions (:all, :read_only, or
|
|
160
|
+
# an explicit list). Falls back to all enabled tools when no definition
|
|
161
|
+
# is present (e.g. one-shot CLI calls without an explicit agent).
|
|
162
|
+
if @agent_definition
|
|
163
|
+
@agent_definition.resolved_tools
|
|
164
|
+
else
|
|
165
|
+
Tools::Registry.instance.enabled_tools
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def check_and_compact(messages)
|
|
170
|
+
budget = Context::TokenBudget.new(
|
|
171
|
+
model_id: @session[:model],
|
|
172
|
+
config: @config
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if budget.needs_compaction?(messages)
|
|
176
|
+
@state.transition_to!(:compressing_context, event_bus: @event_bus)
|
|
177
|
+
@ui.compression_started
|
|
178
|
+
@event_bus.emit(Events::COMPRESSION_STARTED, session_id: @session[:id])
|
|
179
|
+
|
|
180
|
+
compressor = Context::Compressor.new(session_id: @session[:id])
|
|
181
|
+
result = compressor.compact!
|
|
182
|
+
|
|
183
|
+
@event_bus.emit(Events::COMPRESSION_FINISHED, **result)
|
|
184
|
+
@ui.compression_finished(result)
|
|
185
|
+
|
|
186
|
+
# Reload messages after compaction
|
|
187
|
+
assembler = Context::PromptAssembler.new(
|
|
188
|
+
session: @session,
|
|
189
|
+
memory_context: {},
|
|
190
|
+
config: @config,
|
|
191
|
+
agent_definition: @agent_definition,
|
|
192
|
+
ignore_rules: @ignore_rules
|
|
193
|
+
)
|
|
194
|
+
assembler.build
|
|
195
|
+
else
|
|
196
|
+
messages
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def run_agent_loop(messages, tools, image_paths: [], input_queue: nil)
|
|
201
|
+
tool_executor = Agent::ToolExecutor.new(
|
|
202
|
+
registry: Tools::Registry.instance,
|
|
203
|
+
approval_policy: Security::ApprovalPolicy.new,
|
|
204
|
+
ui: @ui,
|
|
205
|
+
config: @config,
|
|
206
|
+
cancel_token: @cancel_token,
|
|
207
|
+
# SESSION-scoped read-before-edit tracker (#151): a read in an
|
|
208
|
+
# earlier turn of this session still satisfies the gate while the
|
|
209
|
+
# file's mtime is unchanged, so an edit in the next turn doesn't
|
|
210
|
+
# force a redundant re-read + a second approval round-trip. The
|
|
211
|
+
# gate itself still re-prompts on any on-disk change.
|
|
212
|
+
read_tracker: Tools::ReadTracker.for_session(@session[:id]),
|
|
213
|
+
event_bus: @event_bus
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Dispatch through AdapterFactory so a "fake/..." model id (or an
|
|
217
|
+
# explicit provider: "fake") short-circuits to FakeProvider; every
|
|
218
|
+
# other model stays on RubyLLMAdapter unchanged.
|
|
219
|
+
#
|
|
220
|
+
# Per-run model/provider overrides win over the session defaults so
|
|
221
|
+
# the HTTP API client can pin a specific FakeProvider scenario (e.g.
|
|
222
|
+
# "fake/with-approvals") on an existing session without having to
|
|
223
|
+
# mutate the persisted session row.
|
|
224
|
+
llm_adapter = LLM::AdapterFactory.build(
|
|
225
|
+
model_id: @model_override || @session[:model],
|
|
226
|
+
provider: @provider_override || @config.model_provider,
|
|
227
|
+
ui: @ui,
|
|
228
|
+
event_bus: @event_bus,
|
|
229
|
+
tool_executor: tool_executor,
|
|
230
|
+
cancel_token: @cancel_token
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
budget = Agent::IterationBudget.new(config: @config, max_tool_iterations: @max_tool_iterations)
|
|
234
|
+
|
|
235
|
+
loop_runner = Agent::Loop.new(
|
|
236
|
+
session: @session,
|
|
237
|
+
llm_adapter: llm_adapter,
|
|
238
|
+
tool_executor: tool_executor,
|
|
239
|
+
message_store: @message_store,
|
|
240
|
+
budget: budget,
|
|
241
|
+
ui: @ui,
|
|
242
|
+
event_bus: @event_bus,
|
|
243
|
+
config: @config,
|
|
244
|
+
cancel_token: @cancel_token,
|
|
245
|
+
initial_image_paths: image_paths,
|
|
246
|
+
input_queue: input_queue
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Bind the parent's steering queue as the background-subagent
|
|
250
|
+
# notification sink for the duration of this turn. A backgrounded `task`
|
|
251
|
+
# subagent pushes its completion notice onto this same queue, so the
|
|
252
|
+
# parent loop folds it in at its next iteration boundary
|
|
253
|
+
# (Loop#inject_steered_input) — correct ordering for free. Nil queue
|
|
254
|
+
# (API/server) ⇒ no sink; the result stays reachable via `task_result`.
|
|
255
|
+
Rubino.with_background_sink(input_queue) do
|
|
256
|
+
Rubino.with_event_bus(@event_bus) do
|
|
257
|
+
loop_runner.run(messages: messages, tools: tools)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def update_session_state
|
|
263
|
+
token_count = @message_store.token_sum(@session[:id])
|
|
264
|
+
@session_repo.update_token_count!(@session[:id], token_count)
|
|
265
|
+
@session_repo.increment_message_count!(@session[:id])
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def enqueue_post_turn_jobs
|
|
269
|
+
queue = Jobs::Queue.new
|
|
270
|
+
|
|
271
|
+
# Extract memory if enabled
|
|
272
|
+
if @config.memory_auto_extract?
|
|
273
|
+
queue.enqueue("ExtractMemoryJob", { session_id: @session[:id] })
|
|
274
|
+
@event_bus.emit(Events::JOB_ENQUEUED, type: "ExtractMemoryJob")
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Variant B — deterministic post-turn skill distillation. Gated exactly
|
|
278
|
+
# like ExtractMemoryJob above: a dedicated config predicate guards the
|
|
279
|
+
# enqueue so this aux-spending background job only runs when explicitly
|
|
280
|
+
# enabled (skills.auto_distill, default true). The job then applies its
|
|
281
|
+
# own deterministic gate (run succeeded AND >= N tool calls AND not
|
|
282
|
+
# already covered) before spending one aux-model call. Handler lookup
|
|
283
|
+
# is load-order independent: Jobs::Registry resolves the class from
|
|
284
|
+
# the Handlers namespace on demand (#81).
|
|
285
|
+
if @config.skills_auto_distill?
|
|
286
|
+
queue.enqueue("DistillSkillJob", { session_id: @session[:id] })
|
|
287
|
+
@event_bus.emit(Events::JOB_ENQUEUED, type: "DistillSkillJob")
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Summarize if session is getting long
|
|
291
|
+
message_count = @message_store.count(@session[:id])
|
|
292
|
+
return unless message_count > 20
|
|
293
|
+
|
|
294
|
+
queue.enqueue("SummarizeSessionJob", { session_id: @session[:id] })
|
|
295
|
+
@event_bus.emit(Events::JOB_ENQUEUED, type: "SummarizeSessionJob")
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Interaction
|
|
5
|
+
# An ephemeral, read-only side-question against the CURRENT session.
|
|
6
|
+
#
|
|
7
|
+
# `probe` is the principal-chat counterpart of the subagent-level probe:
|
|
8
|
+
# it answers a lateral question from the session's context-so-far and then
|
|
9
|
+
# VANISHES — neither the question nor the answer is written to the session
|
|
10
|
+
# transcript, so the next real turn proceeds exactly as if it never happened
|
|
11
|
+
# (Claude Code's `/btw` semantics).
|
|
12
|
+
#
|
|
13
|
+
# Reuse, not reinvention:
|
|
14
|
+
# - Context::PromptAssembler.build gives the SAME message array a real
|
|
15
|
+
# turn would send (system + summary + history snapshot). We append the
|
|
16
|
+
# question as a final user message and call the adapter ONCE.
|
|
17
|
+
# - LLM::AdapterFactory.build(...).chat(messages:, tools: nil) is the
|
|
18
|
+
# existing one-shot completion seam — no Loop, no tools, no persistence.
|
|
19
|
+
#
|
|
20
|
+
# Nothing here touches Session::Store, so the probe is screen-only: the only
|
|
21
|
+
# artifact is the dim aside the CLI renders.
|
|
22
|
+
class Probe
|
|
23
|
+
Result = Struct.new(:question, :answer, keyword_init: true)
|
|
24
|
+
|
|
25
|
+
def initialize(session:, config: Rubino.configuration, model_override: nil,
|
|
26
|
+
provider_override: nil)
|
|
27
|
+
@session = session
|
|
28
|
+
@config = config
|
|
29
|
+
@model_override = model_override
|
|
30
|
+
@provider_override = provider_override
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Runs the one-shot side-inference over a SNAPSHOT of the session and the
|
|
34
|
+
# question. Returns a Result(question:, answer:). Read-only: the session's
|
|
35
|
+
# message store is never written.
|
|
36
|
+
def ask(question)
|
|
37
|
+
messages = snapshot_messages
|
|
38
|
+
messages << { role: "user", content: question }
|
|
39
|
+
|
|
40
|
+
adapter = LLM::AdapterFactory.build(
|
|
41
|
+
model_id: @model_override || @session[:model],
|
|
42
|
+
provider: @provider_override || @config.model_provider,
|
|
43
|
+
config: @config
|
|
44
|
+
)
|
|
45
|
+
response = adapter.chat(messages: messages, tools: nil)
|
|
46
|
+
|
|
47
|
+
Result.new(question: question, answer: response.content.to_s)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# The exact message array a real turn would assemble for this session —
|
|
53
|
+
# system prompt + summary + the full history so far — minus the new turn.
|
|
54
|
+
# Memory context is left empty: a probe is a quick aside, and skipping the
|
|
55
|
+
# memory snapshot keeps it cheap and side-effect-free.
|
|
56
|
+
def snapshot_messages
|
|
57
|
+
Context::PromptAssembler.new(
|
|
58
|
+
session: @session,
|
|
59
|
+
memory_context: {},
|
|
60
|
+
config: @config
|
|
61
|
+
).build
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Interaction
|
|
5
|
+
# Tracks the current state of an interaction.
|
|
6
|
+
# Implements a simple state machine with valid transitions.
|
|
7
|
+
class State
|
|
8
|
+
VALID_STATES = %i[
|
|
9
|
+
idle
|
|
10
|
+
receiving_input
|
|
11
|
+
loading_session
|
|
12
|
+
loading_memory
|
|
13
|
+
building_context
|
|
14
|
+
checking_budget
|
|
15
|
+
compressing_context
|
|
16
|
+
calling_model
|
|
17
|
+
persisting_session
|
|
18
|
+
enqueueing_jobs
|
|
19
|
+
finished
|
|
20
|
+
failed
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :current
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
@current = :idle
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Transitions to a new state, emitting an event
|
|
30
|
+
def transition_to!(new_state, event_bus: nil)
|
|
31
|
+
raise Error, "Invalid state: #{new_state}" unless VALID_STATES.include?(new_state)
|
|
32
|
+
|
|
33
|
+
old_state = @current
|
|
34
|
+
@current = new_state
|
|
35
|
+
|
|
36
|
+
event_bus&.emit(Events::STATUS_CHANGED, from: old_state, to: new_state)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def idle?
|
|
40
|
+
@current == :idle
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def finished?
|
|
44
|
+
@current == :finished
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def failed?
|
|
48
|
+
@current == :failed
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def terminal?
|
|
52
|
+
finished? || failed?
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|