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,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Agent
|
|
5
|
+
# Routes user input to the appropriate agent.
|
|
6
|
+
# Handles @mention syntax for subagent invocation and agent switching.
|
|
7
|
+
class Router
|
|
8
|
+
MENTION_REGEX = /\A@(\w+)\s+(.+)/m
|
|
9
|
+
|
|
10
|
+
def initialize(registry:, ui:)
|
|
11
|
+
@registry = registry
|
|
12
|
+
@ui = ui
|
|
13
|
+
@current_agent = registry.default
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :current_agent
|
|
17
|
+
|
|
18
|
+
# Switches to a different primary agent
|
|
19
|
+
def switch_to(agent_name)
|
|
20
|
+
agent = @registry.find(agent_name)
|
|
21
|
+
unless agent
|
|
22
|
+
@ui.error("unknown agent: #{agent_name}")
|
|
23
|
+
return false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
unless agent.primary?
|
|
27
|
+
@ui.error("cannot switch to subagent '#{agent_name}'. Use @#{agent_name} to invoke it.")
|
|
28
|
+
return false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@current_agent = agent
|
|
32
|
+
@ui.info("Switched to agent: #{agent.name}")
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Routes input, returning [agent_definition, cleaned_input]
|
|
37
|
+
def route(input)
|
|
38
|
+
# Check for @mention
|
|
39
|
+
if input.match?(MENTION_REGEX)
|
|
40
|
+
match = input.match(MENTION_REGEX)
|
|
41
|
+
agent_name = match[1]
|
|
42
|
+
actual_input = match[2]
|
|
43
|
+
|
|
44
|
+
agent = @registry.find(agent_name)
|
|
45
|
+
return [agent, actual_input] if agent && (agent.subagent? || agent.primary?)
|
|
46
|
+
|
|
47
|
+
@ui.warning("Unknown agent '#{agent_name}', using current agent")
|
|
48
|
+
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
[@current_agent, input]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns available agent names for autocomplete
|
|
55
|
+
def available_mentions
|
|
56
|
+
@registry.subagents.map { |a| "@#{a.name}" }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns primary agent names for switching
|
|
60
|
+
def switchable_agents
|
|
61
|
+
@registry.primary_agents.map(&:name)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Agent
|
|
5
|
+
# Top-level orchestrator for a single user interaction.
|
|
6
|
+
# Coordinates session management, the agent loop, and post-turn jobs.
|
|
7
|
+
class Runner
|
|
8
|
+
attr_reader :session
|
|
9
|
+
|
|
10
|
+
# The resolved model id this runner runs against. Read by SubagentProbe so an
|
|
11
|
+
# ephemeral peek uses the child's OWN model, not the global default.
|
|
12
|
+
attr_reader :model_id
|
|
13
|
+
|
|
14
|
+
def initialize(session_id: nil, model_override: nil, provider_override: nil,
|
|
15
|
+
max_turns: nil, ignore_rules: false, ui: nil, agent_definition: nil,
|
|
16
|
+
event_bus: nil, announce_session: true)
|
|
17
|
+
@ui = ui || Rubino.ui
|
|
18
|
+
# An in-chat rewind/fork builds a runner on the child session but has its
|
|
19
|
+
# own purpose-built "┄ rewound to message N — editing ┄" marker, so the
|
|
20
|
+
# generic "Resuming session: <id>…" plumbing line must not also leak into
|
|
21
|
+
# the transcript (#220). Off-rewind callers keep the announcement.
|
|
22
|
+
@announce_session = announce_session
|
|
23
|
+
# Defaults to the process-global bus for the single-run CLI path; the
|
|
24
|
+
# HTTP Executor injects a fresh per-run bus so concurrent runs don't
|
|
25
|
+
# cross-contaminate each other's events/output (architecture audit A1).
|
|
26
|
+
@event_bus = event_bus || Rubino.event_bus
|
|
27
|
+
@config = Rubino.configuration
|
|
28
|
+
@session_repo = Session::Repository.new
|
|
29
|
+
@message_store = Session::Store.new
|
|
30
|
+
@explicit_model_override = model_override
|
|
31
|
+
@model_id = model_override || @config.model_default
|
|
32
|
+
@provider_override = provider_override
|
|
33
|
+
@max_turns = max_turns
|
|
34
|
+
@ignore_rules = ignore_rules
|
|
35
|
+
@agent_definition = agent_definition
|
|
36
|
+
# Pre-instantiate so cancel! is meaningful between turns and during the
|
|
37
|
+
# window between Signal.trap install and run() — a too-early Ctrl+C
|
|
38
|
+
# used to land on a nil token and silently no-op, then the next run
|
|
39
|
+
# started fresh and the user's cancel was lost.
|
|
40
|
+
@cancel_token = Interaction::CancelToken.new
|
|
41
|
+
@session = load_or_create_session(session_id)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Executes a full interaction turn, swallowing failures so CLI callers
|
|
45
|
+
# can stay in the REPL after a model/tool error. The friendly UI
|
|
46
|
+
# message is emitted, but the bus event INTERACTION_FAILED is NOT
|
|
47
|
+
# re-emitted here — Interaction::Lifecycle is the single source of
|
|
48
|
+
# truth for that, and it already emitted before re-raising. Use
|
|
49
|
+
# +run!+ from non-CLI callers (HTTP executor) that need the
|
|
50
|
+
# exception to propagate so the run row can be marked failed.
|
|
51
|
+
def run(input, image_paths: [], input_queue: nil, paste_expansions: [])
|
|
52
|
+
run!(input, image_paths: image_paths, input_queue: input_queue,
|
|
53
|
+
paste_expansions: paste_expansions)
|
|
54
|
+
rescue Interrupted
|
|
55
|
+
# Standardized single interrupt notice: a dim `⎿ interrupted` marker
|
|
56
|
+
# right after the partial answer the Loop already committed via
|
|
57
|
+
# #stream_end. Replaces the old "⚠ interrupted by user" warning so the
|
|
58
|
+
# Ctrl+C path and the interrupt-by-default type-ahead path read the same.
|
|
59
|
+
@ui.turn_interrupted
|
|
60
|
+
nil
|
|
61
|
+
rescue SystemExit, Interrupt, SignalException
|
|
62
|
+
raise
|
|
63
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
64
|
+
@ui.error(friendly_error_message(e))
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Like +run+ but propagates exceptions to the caller. The HTTP
|
|
69
|
+
# Executor uses this so it can transition the run row to "failed"
|
|
70
|
+
# (instead of mark_completed!) when the lifecycle raises. The
|
|
71
|
+
# ScriptError / Exception net is kept here too so the Executor sees
|
|
72
|
+
# LoadError etc. as a real failure rather than nil-and-completed.
|
|
73
|
+
def run!(input, image_paths: [], input_queue: nil, paste_expansions: [])
|
|
74
|
+
# Each turn gets a fresh token. A CancelToken is one-shot, so reusing a
|
|
75
|
+
# cancelled one would poison every subsequent turn (it would raise
|
|
76
|
+
# Interrupted immediately at the first poll point). The per-turn SIGINT
|
|
77
|
+
# trap (CLI) / stop-watcher (HTTP) is wired to #cancel! against this new
|
|
78
|
+
# token before any LLM/tool work runs, so an in-flight interrupt still
|
|
79
|
+
# cancels the current turn.
|
|
80
|
+
@cancel_token = Interaction::CancelToken.new
|
|
81
|
+
|
|
82
|
+
lifecycle = Interaction::Lifecycle.new(
|
|
83
|
+
session: @session,
|
|
84
|
+
event_bus: @event_bus,
|
|
85
|
+
ui: @ui,
|
|
86
|
+
config: @config,
|
|
87
|
+
ignore_rules: @ignore_rules,
|
|
88
|
+
agent_definition: @agent_definition,
|
|
89
|
+
cancel_token: @cancel_token,
|
|
90
|
+
model_override: @explicit_model_override,
|
|
91
|
+
provider_override: @provider_override,
|
|
92
|
+
max_tool_iterations: @max_turns
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
lifecycle.execute(input, image_paths: image_paths, input_queue: input_queue,
|
|
96
|
+
paste_expansions: paste_expansions)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Flips the current turn's cancel token. Called from the UI thread when
|
|
100
|
+
# the user hits Esc or a second Ctrl+C while the worker is mid-stream.
|
|
101
|
+
# No-op when no turn is in flight.
|
|
102
|
+
def cancel!
|
|
103
|
+
@cancel_token&.cancel!
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Switches the LIVE model for this runner (the in-chat `/model <name>`).
|
|
107
|
+
# Lifecycle builds the adapter per turn from
|
|
108
|
+
# `@explicit_model_override || @session[:model]`, and the CLI always
|
|
109
|
+
# passes a model_override at boot — so both fields must move for the
|
|
110
|
+
# NEXT turn to actually hit the new model. The session hash is mutated
|
|
111
|
+
# in place (statusbar and /status read it) and the persisted row is
|
|
112
|
+
# updated so resume/--continue agree; an unpersisted lazy session gets
|
|
113
|
+
# the new value via Repository#persist! on its first message instead.
|
|
114
|
+
def switch_model!(model_id)
|
|
115
|
+
@explicit_model_override = model_id
|
|
116
|
+
@model_id = model_id
|
|
117
|
+
@session[:model] = model_id
|
|
118
|
+
@session[:provider] = @provider_override ||
|
|
119
|
+
LLM::ProviderResolver.resolve(model_id, explicit_provider: @config.model_provider)
|
|
120
|
+
if @session_repo.persisted?(@session[:id])
|
|
121
|
+
@session_repo.update(@session[:id], model: model_id, provider: @session[:provider])
|
|
122
|
+
end
|
|
123
|
+
model_id
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Marks the current session ended (#100). Called from the CLI on a clean
|
|
127
|
+
# REPL teardown (and best-effort on terminal close) so a session stops
|
|
128
|
+
# showing as "active" forever and cleanup/list/--continue can tell a
|
|
129
|
+
# finished session from a live one. Best-effort: a failure here must never
|
|
130
|
+
# crash the exit path.
|
|
131
|
+
def end_session!
|
|
132
|
+
# Nothing to end for a session that was never persisted (the user opened
|
|
133
|
+
# chat and left without sending a message, #144) — there's no row.
|
|
134
|
+
return if @session.nil? || (@session[:persisted] == false && !@session_repo.persisted?(@session[:id]))
|
|
135
|
+
|
|
136
|
+
@session_repo.end_session!(@session[:id])
|
|
137
|
+
rescue StandardError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
# Translates upstream errors into actionable messages instead of
|
|
144
|
+
# bare stack-trace fragments. (issue #16)
|
|
145
|
+
def friendly_error_message(error)
|
|
146
|
+
msg = error.message.to_s
|
|
147
|
+
case msg
|
|
148
|
+
when /\b401\b|unauthorized|invalid[_ ]?api[_ ]?key/i
|
|
149
|
+
"authentication failed (#{msg}). Check your API key in ~/.rubino/.env " \
|
|
150
|
+
"or run `rubino setup`."
|
|
151
|
+
when /\b404\b|model.*not.*found|invalid[_ ]?model|unknown[_ ]?model/i
|
|
152
|
+
"model '#{@model_id}' not available with the current provider/plan. " \
|
|
153
|
+
"Check `model.default` in config.yml; details: #{msg}"
|
|
154
|
+
when /\b(429|rate[_ ]?limit)\b/i
|
|
155
|
+
"rate-limited by the provider. Wait a moment and retry. Details: #{msg}"
|
|
156
|
+
when /\b(timeout|timed out|connection reset)\b/i
|
|
157
|
+
"network error reaching the LLM (#{msg}). Check connectivity and retry."
|
|
158
|
+
else
|
|
159
|
+
"error: #{msg}"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def load_or_create_session(session_id)
|
|
164
|
+
if session_id
|
|
165
|
+
# Support resume by title/first-prompt substring as well as ID
|
|
166
|
+
session = @session_repo.find_by_id_or_title(session_id)
|
|
167
|
+
unless session
|
|
168
|
+
raise SessionError,
|
|
169
|
+
"Session not found: #{session_id}. " \
|
|
170
|
+
"Try `rubino sessions list`, or resume by id prefix."
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# An existing row is already in the DB; mark it so the lazy-persist
|
|
174
|
+
# path (#144) treats it as persisted and never re-inserts.
|
|
175
|
+
session[:persisted] = true
|
|
176
|
+
@ui.status("Resuming session: #{session[:id][0..7]}...") if @announce_session
|
|
177
|
+
session
|
|
178
|
+
else
|
|
179
|
+
# Build an UNSAVED session: no row is written until the first user
|
|
180
|
+
# message is committed (#144), so opening `chat` and leaving without
|
|
181
|
+
# typing anything never pollutes `/sessions` with empty rows. The
|
|
182
|
+
# record carries a real id so the whole turn pipeline works unchanged;
|
|
183
|
+
# Lifecycle#persist_user_message flips it to a real row on demand.
|
|
184
|
+
session = @session_repo.build(
|
|
185
|
+
source: "cli",
|
|
186
|
+
model: @model_id,
|
|
187
|
+
provider: @provider_override || LLM::ProviderResolver.resolve(@model_id)
|
|
188
|
+
)
|
|
189
|
+
@ui.status("New session: #{session[:id][0..7]}")
|
|
190
|
+
session
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Agent
|
|
5
|
+
# Executes tool calls with approval checks and result formatting.
|
|
6
|
+
class ToolExecutor
|
|
7
|
+
# The Loop registers its count+persist sink here after construction (the
|
|
8
|
+
# executor is built first so the adapter/ToolBridge can share it). See
|
|
9
|
+
# Loop#handle_tool_result.
|
|
10
|
+
attr_writer :on_result
|
|
11
|
+
|
|
12
|
+
def initialize(registry:, approval_policy:, ui:, config:,
|
|
13
|
+
tool_call_repository: Tools::ToolCallRepository.new,
|
|
14
|
+
cancel_token: nil, read_tracker: nil, event_bus: nil,
|
|
15
|
+
on_result: nil)
|
|
16
|
+
@registry = registry
|
|
17
|
+
@approval_policy = approval_policy
|
|
18
|
+
@ui = ui
|
|
19
|
+
@config = config
|
|
20
|
+
@tool_call_repository = tool_call_repository
|
|
21
|
+
@cancel_token = cancel_token
|
|
22
|
+
# Optional sink the Loop registers so a tool that runs on the STREAMING
|
|
23
|
+
# path (ruby_llm dispatches it mid-stream via ToolBridge → straight into
|
|
24
|
+
# #execute, never returning through Loop#execute_tool_calls) is still
|
|
25
|
+
# counted in the turn summary and persisted as a `tool` message. Called
|
|
26
|
+
# once per completed/denied tool with (name:, arguments:, call_id:,
|
|
27
|
+
# result:). The non-streaming path routes through the same sink so the
|
|
28
|
+
# count/persist happens in exactly one place regardless of mode.
|
|
29
|
+
@on_result = on_result
|
|
30
|
+
# Optional event bus so this executor emits TOOL_STARTED/TOOL_FINISHED
|
|
31
|
+
# for the API mode timeline. ToolBridge already emits these when no
|
|
32
|
+
# executor is wired (test/one-shot path); the production path went
|
|
33
|
+
# through here and dropped them, so the web UI timeline never saw
|
|
34
|
+
# the tool call as a discrete event.
|
|
35
|
+
@event_bus = event_bus
|
|
36
|
+
# One tracker shared across every tool call so the read registered by
|
|
37
|
+
# ReadTool is visible to a later EditTool. The production path
|
|
38
|
+
# (Interaction::Lifecycle) injects the SESSION-scoped tracker so the
|
|
39
|
+
# gate spans turns (#151). Default to a fresh tracker if the caller
|
|
40
|
+
# didn't supply one; an isolated unit test can pass
|
|
41
|
+
# `read_tracker: nil` to skip the gate.
|
|
42
|
+
@read_tracker = read_tracker.equal?(false) ? nil : (read_tracker || Tools::ReadTracker.new)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Executes a single tool call, returns a Tools::Result.
|
|
46
|
+
def execute(name:, arguments:, call_id:)
|
|
47
|
+
tool = @registry.find(name)
|
|
48
|
+
raise ToolError, "Unknown tool: #{name}" unless tool
|
|
49
|
+
|
|
50
|
+
case @approval_policy.decide(tool, arguments: arguments)
|
|
51
|
+
when :deny
|
|
52
|
+
# A policy denial must NOT read "denied by user" to the model — the
|
|
53
|
+
# policy records why it fired (#last_deny_reason) and the Result
|
|
54
|
+
# maps it to a reason-specific message, so a child agent never
|
|
55
|
+
# blames the human for an automatic deny (#143).
|
|
56
|
+
denied = Tools::Result.denied(name: name, call_id: call_id, reason: policy_deny_reason)
|
|
57
|
+
record_denied(name: name, call_id: call_id, arguments: arguments,
|
|
58
|
+
result: denied, reason: "policy-denied")
|
|
59
|
+
return finish(name, arguments, call_id, denied)
|
|
60
|
+
when :ask
|
|
61
|
+
unless request_approval(tool, arguments)
|
|
62
|
+
denied = Tools::Result.denied(name: name, call_id: call_id, reason: :user)
|
|
63
|
+
record_denied(name: name, call_id: call_id, arguments: arguments,
|
|
64
|
+
result: denied, reason: "user-denied")
|
|
65
|
+
return finish(name, arguments, call_id, denied)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
notify_yolo_if_applicable(tool, arguments)
|
|
70
|
+
emit_started(name, arguments)
|
|
71
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
72
|
+
result = nil
|
|
73
|
+
begin
|
|
74
|
+
result = run_tool(tool, name: name, arguments: arguments, call_id: call_id)
|
|
75
|
+
ensure
|
|
76
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
77
|
+
emit_artifact(result) if result.respond_to?(:artifact) && result&.artifact
|
|
78
|
+
emit_finished(name, result: result, duration_ms: duration_ms, arguments: arguments)
|
|
79
|
+
end
|
|
80
|
+
finish(name, arguments, call_id, result)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Single exit point: notifies the Loop's on_result sink (count + persist)
|
|
86
|
+
# for every completed/denied tool, then returns the result unchanged. This
|
|
87
|
+
# is the one place both the streaming (ToolBridge → #execute) and the
|
|
88
|
+
# non-streaming (Loop#execute_tool_calls → #execute) paths funnel through,
|
|
89
|
+
# so the turn-summary count and the `tool` message rows stay accurate
|
|
90
|
+
# regardless of streaming mode. Best-effort: a sink failure must not take
|
|
91
|
+
# down the tool call the model is waiting on.
|
|
92
|
+
def finish(name, arguments, call_id, result)
|
|
93
|
+
@on_result&.call(name: name, arguments: arguments, call_id: call_id, result: result)
|
|
94
|
+
result
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
Rubino.logger&.warn(event: "tool_executor.on_result_failed", error: e.message)
|
|
97
|
+
result
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def run_tool(tool, name:, arguments:, call_id:)
|
|
101
|
+
tool.cancel_token = @cancel_token if tool.respond_to?(:cancel_token=)
|
|
102
|
+
tool.read_tracker = @read_tracker if tool.respond_to?(:read_tracker=)
|
|
103
|
+
streamed = false
|
|
104
|
+
last_progress_at = nil
|
|
105
|
+
if tool.respond_to?(:stream_chunk=) && (@ui.respond_to?(:tool_chunk) || @event_bus)
|
|
106
|
+
tool.stream_chunk = lambda do |chunk|
|
|
107
|
+
streamed = true
|
|
108
|
+
@ui.tool_chunk(name, chunk) if @ui.respond_to?(:tool_chunk)
|
|
109
|
+
# Mirror the chunk onto the bus so the API/SSE stream isn't silent
|
|
110
|
+
# during a long tool call: the Recorder maps TOOL_PROGRESS to a
|
|
111
|
+
# `tool.progress` event, which resets the idle watchdog. Without
|
|
112
|
+
# this a busy tool (summarize_file: ~30 sequential aux-LLM calls,
|
|
113
|
+
# no run-events) is killed at the 300s idle timeout. Throttled so a
|
|
114
|
+
# chatty tool (shell streaming thousands of stdout lines) doesn't
|
|
115
|
+
# write a DB row + SSE frame per line — one heartbeat per interval
|
|
116
|
+
# is enough to keep the watchdog satisfied.
|
|
117
|
+
last_progress_at = emit_tool_progress(name, chunk, last_progress_at) if @event_bus
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
raw = tool.call(arguments)
|
|
121
|
+
# Tools can return either a String (plain output) or a Hash carrying
|
|
122
|
+
# {output:, metrics:, body:, body_kind:}. The Hash form lets a tool emit
|
|
123
|
+
# - a `metrics` one-liner for the done header ("42 lines · 0.1s")
|
|
124
|
+
# - a `body` block (diff, preview) printed inside the tool box
|
|
125
|
+
# - a `body_kind` (:diff | :plain) selecting the CLI coloring for body
|
|
126
|
+
# without having to reverse-engineer them from the formatted output.
|
|
127
|
+
if raw.is_a?(Hash)
|
|
128
|
+
text = raw[:output] || raw["output"]
|
|
129
|
+
metrics = raw[:metrics] || raw["metrics"]
|
|
130
|
+
body = raw[:body] || raw["body"]
|
|
131
|
+
body_kind = raw[:body_kind] || raw["body_kind"] || :plain
|
|
132
|
+
error_code = raw[:error_code] || raw["error_code"]
|
|
133
|
+
artifact = raw[:artifact] || raw["artifact"]
|
|
134
|
+
else
|
|
135
|
+
text = raw
|
|
136
|
+
metrics = nil
|
|
137
|
+
body = nil
|
|
138
|
+
body_kind = :plain
|
|
139
|
+
error_code = nil
|
|
140
|
+
artifact = nil
|
|
141
|
+
end
|
|
142
|
+
# Skip the body block when the tool already streamed its output line by
|
|
143
|
+
# line via #tool_chunk: `body` is the SAME content (e.g. ShellTool's
|
|
144
|
+
# Util::Output.preview of the captured stdout), so rendering it again
|
|
145
|
+
# would duplicate every line in the timeline. Tools that don't stream
|
|
146
|
+
# (read, grep, edit, glob, github) still render their body here.
|
|
147
|
+
@ui.tool_body(body, kind: body_kind.to_sym) if body && !body.to_s.empty? && !streamed
|
|
148
|
+
result = Tools::Result.success(
|
|
149
|
+
name: name,
|
|
150
|
+
call_id: call_id,
|
|
151
|
+
output: Util::Output.truncate(text, max_bytes: @config.tool_output_max_bytes,
|
|
152
|
+
max_lines: @config.tool_output_max_lines,
|
|
153
|
+
spill: ->(full) { spill_full_output(full, call_id) }),
|
|
154
|
+
metrics: metrics,
|
|
155
|
+
error_code: error_code&.to_sym,
|
|
156
|
+
artifact: artifact
|
|
157
|
+
)
|
|
158
|
+
@tool_call_repository.record(name: name, call_id: call_id, arguments: arguments,
|
|
159
|
+
result: result, status: "completed")
|
|
160
|
+
result
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
result = Tools::Result.error(name: name, call_id: call_id, error: e.message)
|
|
163
|
+
@tool_call_repository.record(name: name, call_id: call_id, arguments: arguments,
|
|
164
|
+
result: result, status: "failed", error: e.message)
|
|
165
|
+
result
|
|
166
|
+
ensure
|
|
167
|
+
tool.cancel_token = nil if tool.respond_to?(:cancel_token=)
|
|
168
|
+
tool.read_tracker = nil if tool.respond_to?(:read_tracker=)
|
|
169
|
+
tool.stream_chunk = nil if tool.respond_to?(:stream_chunk=)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Cap on per-event size we forward to SSE consumers (the web UI timeline,
|
|
173
|
+
# CLI logs). Tools already truncate their textual output via
|
|
174
|
+
# truncate_output for the model's eyes; this is a second guard so a
|
|
175
|
+
# huge payload doesn't bloat the event bus / DB run_events rows.
|
|
176
|
+
EVENT_PREVIEW_MAX = 4_000
|
|
177
|
+
|
|
178
|
+
# Minimum gap between TOOL_PROGRESS heartbeats forwarded to the bus. Well
|
|
179
|
+
# under the SSE idle watchdog window (300s) so the stream never goes
|
|
180
|
+
# silent, but coarse enough that a chatty per-line tool doesn't flood the
|
|
181
|
+
# event store. The first chunk always emits (nil last-emit time).
|
|
182
|
+
TOOL_PROGRESS_INTERVAL = 5.0
|
|
183
|
+
|
|
184
|
+
# Emits a throttled TOOL_PROGRESS heartbeat on the bus. Returns the
|
|
185
|
+
# monotonic time of this emit (or the unchanged previous time when the
|
|
186
|
+
# chunk was throttled) so the caller can track the cadence.
|
|
187
|
+
def emit_tool_progress(name, chunk, last_at)
|
|
188
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
189
|
+
return last_at if last_at && (now - last_at) < TOOL_PROGRESS_INTERVAL
|
|
190
|
+
|
|
191
|
+
@event_bus&.emit(Interaction::Events::TOOL_PROGRESS,
|
|
192
|
+
name: name, chunk: truncate_for_event(chunk.to_s))
|
|
193
|
+
now
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def emit_started(name, arguments)
|
|
197
|
+
sanitized = sanitize_arguments_for_event(arguments)
|
|
198
|
+
@ui.tool_started(name, arguments: arguments) if @ui.respond_to?(:tool_started)
|
|
199
|
+
payload = { name: name, arguments: sanitized }
|
|
200
|
+
# Boundary event for delegation: tag the `task` call with the target
|
|
201
|
+
# subagent name (+ the task prompt) so an SSE consumer (the web UI)
|
|
202
|
+
# can render "delegated to X" without parsing the raw arguments. The
|
|
203
|
+
# subagent's own inner events are NOT streamed in Phase 1 — boundary only.
|
|
204
|
+
payload.merge!(subagent_tag(arguments)) if name == "task"
|
|
205
|
+
@event_bus&.emit(Interaction::Events::TOOL_STARTED, **payload)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def emit_finished(name, result: nil, duration_ms: nil, arguments: nil)
|
|
209
|
+
@ui.tool_finished(name, result: result) if @ui.respond_to?(:tool_finished)
|
|
210
|
+
payload = {
|
|
211
|
+
name: name,
|
|
212
|
+
output: truncate_for_event(result&.output.to_s),
|
|
213
|
+
duration_ms: duration_ms,
|
|
214
|
+
error_code: result.respond_to?(:error_code) ? result&.error_code : nil
|
|
215
|
+
}
|
|
216
|
+
# On completion the `output` already carries the subagent's returned
|
|
217
|
+
# summary; tag the subagent name (recovered from the call arguments) so
|
|
218
|
+
# the consumer can render "X answered" and group it with the start.
|
|
219
|
+
if name == "task" && arguments.is_a?(Hash)
|
|
220
|
+
subagent = arguments["subagent"] || arguments[:subagent]
|
|
221
|
+
payload[:subagent] = subagent.to_s unless subagent.nil?
|
|
222
|
+
end
|
|
223
|
+
@event_bus&.emit(Interaction::Events::TOOL_FINISHED, **payload)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Extracts the { subagent:, prompt: } boundary tag from a `task` call's
|
|
227
|
+
# arguments. Nil-tolerant so a malformed call still emits the event.
|
|
228
|
+
def subagent_tag(arguments)
|
|
229
|
+
return {} unless arguments.is_a?(Hash)
|
|
230
|
+
|
|
231
|
+
subagent = arguments["subagent"] || arguments[:subagent]
|
|
232
|
+
prompt = arguments["prompt"] || arguments[:prompt]
|
|
233
|
+
tag = {}
|
|
234
|
+
tag[:subagent] = subagent.to_s unless subagent.nil?
|
|
235
|
+
tag[:prompt] = truncate_for_event(prompt.to_s) unless prompt.nil?
|
|
236
|
+
tag
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def sanitize_arguments_for_event(arguments)
|
|
240
|
+
return arguments unless arguments.is_a?(Hash)
|
|
241
|
+
|
|
242
|
+
arguments.each_with_object({}) do |(key, value), memo|
|
|
243
|
+
masked = Util::SecretsMask.mask_value(value, key: key)
|
|
244
|
+
memo[key.to_s] = truncate_for_event(masked.to_s)
|
|
245
|
+
end
|
|
246
|
+
rescue StandardError
|
|
247
|
+
# Never block the run because of a serialisation hiccup — drop the
|
|
248
|
+
# arguments rather than crash the tool emission path.
|
|
249
|
+
nil
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def truncate_for_event(text)
|
|
253
|
+
return text if text.nil? || text.bytesize <= EVENT_PREVIEW_MAX
|
|
254
|
+
|
|
255
|
+
head = text.byteslice(0, EVENT_PREVIEW_MAX).to_s.force_encoding(text.encoding).scrub("")
|
|
256
|
+
"#{head}\n…[truncated at #{EVENT_PREVIEW_MAX} bytes]"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# ARTIFACT_CREATED is what SSE consumers (e.g. the web UI) latch onto to
|
|
260
|
+
# render a download card for tools like attach_file. Emit it here so the
|
|
261
|
+
# streaming path (ToolBridge → ToolExecutor, never lands in Loop's
|
|
262
|
+
# execute_tool_calls) propagates the artifact too.
|
|
263
|
+
def emit_artifact(result)
|
|
264
|
+
@event_bus&.emit(Interaction::Events::ARTIFACT_CREATED, **result.artifact)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def record_denied(name:, call_id:, arguments:, result:, reason:)
|
|
268
|
+
@tool_call_repository.record(
|
|
269
|
+
name: name,
|
|
270
|
+
call_id: call_id,
|
|
271
|
+
arguments: arguments,
|
|
272
|
+
result: result,
|
|
273
|
+
status: "denied",
|
|
274
|
+
error: reason
|
|
275
|
+
)
|
|
276
|
+
rescue StandardError
|
|
277
|
+
# Don't fail the user's request just because the audit write failed.
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# The reason behind the policy's :deny, when the policy exposes one
|
|
281
|
+
# (test doubles may not). nil falls back to the generic policy message.
|
|
282
|
+
def policy_deny_reason
|
|
283
|
+
return :policy unless @approval_policy.respond_to?(:last_deny_reason)
|
|
284
|
+
|
|
285
|
+
@approval_policy.last_deny_reason || :policy
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def request_approval(tool, arguments)
|
|
289
|
+
command = Security::ApprovalPolicy.command_string(tool, arguments)
|
|
290
|
+
_hit, pattern_key, description = Security::DangerousPatterns.detect(command)
|
|
291
|
+
@ui.confirm(
|
|
292
|
+
approval_question(tool, arguments),
|
|
293
|
+
scope: approval_scope(tool, arguments),
|
|
294
|
+
tool: tool.name,
|
|
295
|
+
command: command,
|
|
296
|
+
pattern_key: pattern_key,
|
|
297
|
+
description: description
|
|
298
|
+
)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Build a stable string identifier for (tool, arguments) so the
|
|
302
|
+
# UI layer can short-circuit on a prior "session"/"always"
|
|
303
|
+
# decision. Reuses the same command extractor ApprovalPolicy
|
|
304
|
+
# already uses for pattern-rule matching to keep the granularity
|
|
305
|
+
# consistent — approving `shell ls` will NOT auto-approve
|
|
306
|
+
# `shell rm -rf /`.
|
|
307
|
+
def approval_scope(tool, arguments)
|
|
308
|
+
cmd = Security::ApprovalPolicy.command_string(tool, arguments)
|
|
309
|
+
cmd.empty? ? tool.name.to_s : "#{tool.name}:#{cmd}"
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# --yolo / approvals.mode: "skip" bypasses request_approval entirely.
|
|
313
|
+
# Without any visual signal the user can't tell that the model just
|
|
314
|
+
# ran (e.g.) `rm -rf` until it's done. Print a single-line warning for
|
|
315
|
+
# risky tools so silence can't mask the auto-approval. Low-risk tools
|
|
316
|
+
# (read, glob, grep) stay quiet — yolo for those is no different from
|
|
317
|
+
# the normal allow path.
|
|
318
|
+
def notify_yolo_if_applicable(tool, arguments)
|
|
319
|
+
return unless @config.dig("approvals", "mode") == "skip"
|
|
320
|
+
return unless tool.respond_to?(:risky?) && tool.risky?
|
|
321
|
+
|
|
322
|
+
preview = if arguments.is_a?(Hash)
|
|
323
|
+
arguments.map { |k, v| "#{k}=#{summarize_yolo_value(v, key: k)}" }.join(" ")
|
|
324
|
+
else
|
|
325
|
+
Util::SecretsMask.mask_inline(arguments.to_s)
|
|
326
|
+
end
|
|
327
|
+
@ui.warning("⚡ yolo: #{tool.name} #{preview}")
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def summarize_yolo_value(value, key: nil)
|
|
331
|
+
masked = Util::SecretsMask.mask_value(value, key: key).to_s
|
|
332
|
+
masked = masked.lines.first.to_s.rstrip if masked.include?("\n")
|
|
333
|
+
masked.length > 60 ? "#{masked[0, 57]}…" : masked
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Multi-line aware args formatter for the approval prompt.
|
|
337
|
+
#
|
|
338
|
+
# arguments.inspect on a Hash with newline values (shell scripts, file
|
|
339
|
+
# contents) collapses everything into one giant line, which the terminal
|
|
340
|
+
# then truncates at the right edge. The user sees "command=\"ls -la"
|
|
341
|
+
# and approves — without ever seeing the trailing `; rm -rf` that the
|
|
342
|
+
# model actually sent. Lay each key out on its own line; clip long
|
|
343
|
+
# values explicitly; tag dropped lines so silence can't mask intent.
|
|
344
|
+
def approval_question(tool, arguments)
|
|
345
|
+
pairs = Array(arguments)
|
|
346
|
+
# No arguments (e.g. a bare run_tests run) ⇒ no dangling "wants:" — a
|
|
347
|
+
# header followed by nothing reads as a truncated/broken card (#109).
|
|
348
|
+
return "#{tool.name} wants to run" if pairs.empty?
|
|
349
|
+
|
|
350
|
+
# The common case — ONE short single-line argument (a shell command, a
|
|
351
|
+
# file path) — inlines onto the header: `shell wants: touch hello.txt`
|
|
352
|
+
# (P7). Multi-arg / multi-line calls keep the per-key layout below.
|
|
353
|
+
if pairs.size == 1
|
|
354
|
+
key, value = pairs.first
|
|
355
|
+
text = Util::SecretsMask.mask_value(value, key: key).to_s
|
|
356
|
+
return "#{tool.name} wants: #{text}" if !text.include?("\n") && text.length <= 120
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
lines = ["#{tool.name} wants:"]
|
|
360
|
+
pairs.each { |key, value| lines.concat(format_arg_pair(key, value)) }
|
|
361
|
+
lines.join("\n")
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def format_arg_pair(key, value)
|
|
365
|
+
# Mask credentials before any rendering: the approval prompt is the
|
|
366
|
+
# one place a real secret value could land in the user's scrollback
|
|
367
|
+
# if the model passed it through unwrapped.
|
|
368
|
+
text = Util::SecretsMask.mask_value(value, key: key).to_s
|
|
369
|
+
if text.include?("\n")
|
|
370
|
+
body = text.lines
|
|
371
|
+
head = body.first(5).map(&:rstrip)
|
|
372
|
+
tail = body.size > 5 ? [" [… #{body.size - 5} more line(s)]"] : []
|
|
373
|
+
[" #{key}:", *head.map { |l| " #{l}" }, *tail]
|
|
374
|
+
elsif text.length > 120
|
|
375
|
+
[" #{key}: #{text[0, 117]}…"]
|
|
376
|
+
else
|
|
377
|
+
[" #{key}: #{text}"]
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Persists the complete (pre-truncation) output to a per-call file under
|
|
382
|
+
# the rubino home so the model can read back whatever the inline
|
|
383
|
+
# head+tail elided (the spill seam Util::Output.truncate calls back into
|
|
384
|
+
# on overflow — Util keeps the pure shaping, the executor keeps the IO).
|
|
385
|
+
# Best-effort: a write failure just yields no path and the marker falls
|
|
386
|
+
# back to its grep/head hint. Returns the path or nil.
|
|
387
|
+
def spill_full_output(text, call_id)
|
|
388
|
+
id = call_id.to_s.gsub(/[^a-zA-Z0-9_.-]/, "_")
|
|
389
|
+
return nil if id.empty?
|
|
390
|
+
|
|
391
|
+
dir = File.join(Rubino.home_path, "tool-results")
|
|
392
|
+
FileUtils.mkdir_p(dir)
|
|
393
|
+
path = File.join(dir, "#{id}.txt")
|
|
394
|
+
File.write(path, text)
|
|
395
|
+
path
|
|
396
|
+
rescue StandardError => e
|
|
397
|
+
Rubino.logger&.warn(event: "tool_output.spill_failed", error: e.message)
|
|
398
|
+
nil
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|