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,454 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# Runs the workspace project's test suite and returns a STRUCTURED result
|
|
6
|
+
# instead of the raw toolchain firehose the `shell` tool emits.
|
|
7
|
+
#
|
|
8
|
+
# Why this exists (issue #101): to run tests the model used to drive `shell`
|
|
9
|
+
# and reason its way through the whole Ruby toolchain — bundler version
|
|
10
|
+
# mismatches, missing gems, which command to use. On real tasks that burned
|
|
11
|
+
# several tool calls and twice sent the agent chasing toolchain errors
|
|
12
|
+
# (bundler `GemNotFound`, an `undefined method 'untaint'` crash from an old
|
|
13
|
+
# pinned bundler) instead of the user's actual request; one earlier run even
|
|
14
|
+
# drifted toward `gem uninstall bundler` / `rm -rf …`. This tool:
|
|
15
|
+
#
|
|
16
|
+
# - auto-detects the framework (rspec / minitest / rake) and the right
|
|
17
|
+
# invocation, preferring `bundle exec` when a Gemfile is present and the
|
|
18
|
+
# bundle is usable, falling back to the bare runner when it is not (so a
|
|
19
|
+
# stale lockfile degrades gracefully rather than making the model fight
|
|
20
|
+
# bundler),
|
|
21
|
+
# - returns pass/fail counts, the failing examples (name + file:line +
|
|
22
|
+
# short message) parsed from the runner output, and a short raw tail —
|
|
23
|
+
# not the full backtrace,
|
|
24
|
+
# - distinguishes "the suite could not even start" (toolchain error) from
|
|
25
|
+
# "the suite ran and N failed", via the structured `error_code`.
|
|
26
|
+
#
|
|
27
|
+
# Execution mirrors ShellTool's foreground path: own process group, SIGTERM
|
|
28
|
+
# on timeout/cancel, cwd = workspace root (same resolution as ruby/shell).
|
|
29
|
+
class TestTool < Base
|
|
30
|
+
DEFAULT_TIMEOUT = 300
|
|
31
|
+
MAX_TIMEOUT = 600
|
|
32
|
+
TICK = 0.05
|
|
33
|
+
# Lines of raw runner output to keep for context. Enough to show the
|
|
34
|
+
# tail of a failure dump without dragging the full backtrace into context.
|
|
35
|
+
RAW_TAIL_LINES = 40
|
|
36
|
+
|
|
37
|
+
def name
|
|
38
|
+
"run_tests"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def description
|
|
42
|
+
"Run the workspace project's test suite and return a structured result " \
|
|
43
|
+
"(framework, command, exit status, example/failure counts, and the " \
|
|
44
|
+
"failing examples with file:line and message). Auto-detects RSpec, " \
|
|
45
|
+
"Minitest, or a Rakefile default task; prefers `bundle exec` when a " \
|
|
46
|
+
"Gemfile is present and falls back to the bare runner if the bundle is " \
|
|
47
|
+
"broken. Optional `path` runs a single file or pattern; optional " \
|
|
48
|
+
"`framework` (rspec/minitest/rake) overrides detection. Use this " \
|
|
49
|
+
"instead of driving `shell` by hand to run tests."
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def input_schema
|
|
53
|
+
{
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: {
|
|
56
|
+
path: {
|
|
57
|
+
type: "string",
|
|
58
|
+
description: "Optional file or pattern to run a subset (e.g. " \
|
|
59
|
+
"'spec/models/user_spec.rb' or 'spec/models/'). " \
|
|
60
|
+
"Runs the whole suite when omitted."
|
|
61
|
+
},
|
|
62
|
+
framework: {
|
|
63
|
+
type: "string",
|
|
64
|
+
enum: %w[rspec minitest rake],
|
|
65
|
+
description: "Override framework detection. Omit to auto-detect."
|
|
66
|
+
},
|
|
67
|
+
timeout: {
|
|
68
|
+
type: "integer",
|
|
69
|
+
description: "Timeout in seconds (default #{DEFAULT_TIMEOUT}, max #{MAX_TIMEOUT})."
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
required: []
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Runs project code (the test suite), so gated like `ruby`: not
|
|
77
|
+
# destructive, but it does execute arbitrary code. :medium → asks in
|
|
78
|
+
# manual mode, auto-allowed in auto mode.
|
|
79
|
+
def risk_level
|
|
80
|
+
:medium
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def call(arguments)
|
|
84
|
+
args = arguments.is_a?(Hash) ? arguments : {}
|
|
85
|
+
path = args["path"] || args[:path]
|
|
86
|
+
override = args["framework"] || args[:framework]
|
|
87
|
+
timeout = (args["timeout"] || args[:timeout] || DEFAULT_TIMEOUT).to_i
|
|
88
|
+
timeout = [[timeout, 1].max, MAX_TIMEOUT].min
|
|
89
|
+
|
|
90
|
+
root = resolve_workspace
|
|
91
|
+
return { output: "Error: cannot access workspace directory", error_code: :workspace_error } unless root
|
|
92
|
+
|
|
93
|
+
framework = (override && !override.to_s.empty? ? override.to_s : detect_framework(root))
|
|
94
|
+
unless framework
|
|
95
|
+
return { output: "Error: no test setup detected in #{root} — looked for " \
|
|
96
|
+
"spec/ (.rspec), test/, and a Rakefile. Pass `framework` " \
|
|
97
|
+
"to override, or use the shell tool for a custom command.",
|
|
98
|
+
error_code: :no_test_setup }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
command = build_command(root, framework, path)
|
|
102
|
+
run = execute(command, root, timeout)
|
|
103
|
+
|
|
104
|
+
build_result(framework, command, run)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# Same cwd resolution as ruby_tool/shell_tool: terminal.cwd or Dir.pwd,
|
|
110
|
+
# fully resolved through symlinks. nil if it can't be reached.
|
|
111
|
+
def resolve_workspace
|
|
112
|
+
candidate = Rubino::Workspace.primary_root
|
|
113
|
+
path = File.realpath(File.expand_path(candidate))
|
|
114
|
+
File.directory?(path) ? path : nil
|
|
115
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Detection order mirrors the issue: RSpec first (most common in gems),
|
|
120
|
+
# then Minitest, then a bare Rakefile default task.
|
|
121
|
+
def detect_framework(root)
|
|
122
|
+
return "rspec" if rspec?(root)
|
|
123
|
+
return "minitest" if minitest?(root)
|
|
124
|
+
return "rake" if File.exist?(File.join(root, "Rakefile"))
|
|
125
|
+
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def rspec?(root)
|
|
130
|
+
File.exist?(File.join(root, ".rspec")) ||
|
|
131
|
+
File.directory?(File.join(root, "spec"))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def minitest?(root)
|
|
135
|
+
return false unless File.directory?(File.join(root, "test"))
|
|
136
|
+
|
|
137
|
+
# A `test/` dir alone is the signal; rake/rails drive it. We don't try
|
|
138
|
+
# to grep for `require "minitest"` — too fragile across layouts.
|
|
139
|
+
true
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def gemfile?(root)
|
|
143
|
+
File.exist?(File.join(root, "Gemfile"))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Prefer `bundle exec` when a Gemfile is present AND the bundle resolves;
|
|
147
|
+
# otherwise fall back to the bare runner. The fallback is the whole point
|
|
148
|
+
# of #101: a stale/pinned lockfile must not make the model fight bundler.
|
|
149
|
+
def build_command(root, framework, path)
|
|
150
|
+
bundle = gemfile?(root) && bundle_usable?(root)
|
|
151
|
+
prefix = bundle ? "bundle exec " : ""
|
|
152
|
+
|
|
153
|
+
case framework
|
|
154
|
+
when "rspec"
|
|
155
|
+
target = path && !path.to_s.empty? ? " #{shellescape(path)}" : ""
|
|
156
|
+
"#{prefix}rspec#{target}"
|
|
157
|
+
when "minitest"
|
|
158
|
+
build_minitest_command(root, prefix, path)
|
|
159
|
+
when "rake"
|
|
160
|
+
"#{prefix}rake"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# `rake test` is the canonical entry for a Minitest project (it sets up
|
|
165
|
+
# $LOAD_PATH and picks up test/**). When the model wants a single file we
|
|
166
|
+
# can't go through rake's task, so run it with ruby -Itest -Ilib directly.
|
|
167
|
+
def build_minitest_command(root, prefix, path)
|
|
168
|
+
if path && !path.to_s.empty?
|
|
169
|
+
"#{prefix}ruby -Itest -Ilib #{shellescape(path)}"
|
|
170
|
+
elsif rails?(root)
|
|
171
|
+
"#{prefix}bin/rails test"
|
|
172
|
+
else
|
|
173
|
+
"#{prefix}rake test"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def rails?(root)
|
|
178
|
+
File.exist?(File.join(root, "bin", "rails"))
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Cheap, non-mutating bundle check: `bundle check` exits 0 only when the
|
|
182
|
+
# gems in the lockfile are installed and satisfiable. Catches the #101
|
|
183
|
+
# cases (version-mismatched / pinned-bundler lockfiles) before we commit
|
|
184
|
+
# to `bundle exec`, so we degrade to the bare runner instead of letting
|
|
185
|
+
# the model watch a bundler backtrace scroll by. Capped tight so a slow
|
|
186
|
+
# `bundle check` never dominates the call.
|
|
187
|
+
def bundle_usable?(root)
|
|
188
|
+
_, status = Open3.capture2e(
|
|
189
|
+
{ "BUNDLE_GEMFILE" => File.join(root, "Gemfile") },
|
|
190
|
+
"bundle", "check",
|
|
191
|
+
chdir: root
|
|
192
|
+
)
|
|
193
|
+
status&.success?
|
|
194
|
+
rescue StandardError
|
|
195
|
+
# bundler not installed, or it crashed (the untaint-style failure):
|
|
196
|
+
# treat the bundle as unusable and fall back to the bare runner.
|
|
197
|
+
false
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def shellescape(str)
|
|
201
|
+
require "shellwords"
|
|
202
|
+
Shellwords.escape(str.to_s)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Foreground exec in its own process group, SIGTERM on timeout/cancel.
|
|
206
|
+
# Merged stdout+stderr — the runners interleave results and warnings, and
|
|
207
|
+
# we parse the combined stream anyway. Returns a structured run hash.
|
|
208
|
+
def execute(command, cwd, timeout)
|
|
209
|
+
require "open3"
|
|
210
|
+
rd, wr = IO.pipe
|
|
211
|
+
pid = Process.spawn(command, chdir: cwd, pgroup: true, out: wr, err: wr)
|
|
212
|
+
pgid = pid
|
|
213
|
+
wr.close
|
|
214
|
+
|
|
215
|
+
buf = +""
|
|
216
|
+
reader = Thread.new do
|
|
217
|
+
rd.each_line do |line|
|
|
218
|
+
buf << line
|
|
219
|
+
emit_chunk(line)
|
|
220
|
+
end
|
|
221
|
+
rescue IOError, Errno::EBADF
|
|
222
|
+
# pipe closed — process exited
|
|
223
|
+
ensure
|
|
224
|
+
rd.close unless rd.closed?
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
228
|
+
deadline = Time.now + timeout
|
|
229
|
+
status = nil
|
|
230
|
+
|
|
231
|
+
loop do
|
|
232
|
+
wpid, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
233
|
+
break if wpid
|
|
234
|
+
|
|
235
|
+
if cancellation_requested?
|
|
236
|
+
terminate_group(pgid)
|
|
237
|
+
reader.join(0.5)
|
|
238
|
+
begin
|
|
239
|
+
Process.kill("KILL", -pgid)
|
|
240
|
+
rescue StandardError
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
begin
|
|
244
|
+
Process.waitpid2(pid)
|
|
245
|
+
rescue StandardError
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
return { output: buf.dup, exit_code: nil, cancelled: true, timed_out: false,
|
|
249
|
+
duration_ms: elapsed_ms(started) }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
if Time.now >= deadline
|
|
253
|
+
terminate_group(pgid)
|
|
254
|
+
_, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
255
|
+
unless status
|
|
256
|
+
reader.join(2)
|
|
257
|
+
begin
|
|
258
|
+
Process.kill("KILL", -pgid)
|
|
259
|
+
rescue StandardError
|
|
260
|
+
nil
|
|
261
|
+
end
|
|
262
|
+
_, status = Process.waitpid2(pid)
|
|
263
|
+
end
|
|
264
|
+
reader.join(0.5)
|
|
265
|
+
return { output: buf.dup, exit_code: nil, cancelled: false, timed_out: true,
|
|
266
|
+
duration_ms: elapsed_ms(started) }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
sleep TICK
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
reader.join
|
|
273
|
+
{ output: buf, exit_code: status&.exitstatus, cancelled: false, timed_out: false,
|
|
274
|
+
duration_ms: elapsed_ms(started) }
|
|
275
|
+
rescue StandardError => e
|
|
276
|
+
{ output: "Error launching tests: #{e.message}", exit_code: nil, cancelled: false,
|
|
277
|
+
timed_out: false, started_error: true, duration_ms: 0 }
|
|
278
|
+
ensure
|
|
279
|
+
rd.close if rd && !rd.closed?
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def terminate_group(pgid)
|
|
283
|
+
Process.kill("TERM", -pgid)
|
|
284
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
285
|
+
# already gone / not ours
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def elapsed_ms(started)
|
|
289
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Turns the run hash into the model-facing structured Result. Parses the
|
|
293
|
+
# combined output into counts + failing examples, classifies the outcome,
|
|
294
|
+
# and keeps a short raw tail for context.
|
|
295
|
+
def build_result(framework, command, run)
|
|
296
|
+
raw = run[:output].to_s
|
|
297
|
+
parsed = parse_output(framework, raw)
|
|
298
|
+
ran = parsed[:ran]
|
|
299
|
+
tail = tail_lines(raw)
|
|
300
|
+
|
|
301
|
+
outcome, error_code = classify(run, parsed)
|
|
302
|
+
|
|
303
|
+
summary = build_summary(framework, command, run, parsed, outcome)
|
|
304
|
+
body = [summary, "", "--- raw output (tail) ---", tail].join("\n")
|
|
305
|
+
|
|
306
|
+
{
|
|
307
|
+
output: body,
|
|
308
|
+
body: summary,
|
|
309
|
+
body_kind: :plain,
|
|
310
|
+
metrics: "#{outcome} · #{format_ms(run[:duration_ms])}",
|
|
311
|
+
error_code: error_code,
|
|
312
|
+
# Structured fields, so the executor / future contract tests can
|
|
313
|
+
# branch without re-parsing the text.
|
|
314
|
+
framework: framework,
|
|
315
|
+
command: command,
|
|
316
|
+
exit_code: run[:exit_code],
|
|
317
|
+
ran: ran,
|
|
318
|
+
examples: parsed[:examples],
|
|
319
|
+
failures: parsed[:failures],
|
|
320
|
+
failing: parsed[:failing]
|
|
321
|
+
}
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# outcome label + error_code symbol. The critical distinction (#101):
|
|
325
|
+
# the suite NOT starting (toolchain error) vs. running with failures.
|
|
326
|
+
def classify(run, parsed)
|
|
327
|
+
return ["cancelled", :cancelled] if run[:cancelled]
|
|
328
|
+
return ["timeout", :timeout] if run[:timed_out]
|
|
329
|
+
return ["could not start", :test_runner_error] if run[:started_error] || !parsed[:ran]
|
|
330
|
+
return ["#{parsed[:failures]} failed", :tests_failed] if parsed[:failures].to_i.positive?
|
|
331
|
+
return ["nonzero exit", :exit_nonzero] if run[:exit_code] && run[:exit_code] != 0
|
|
332
|
+
|
|
333
|
+
["passed", nil]
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def build_summary(framework, command, run, parsed, outcome)
|
|
337
|
+
lines = []
|
|
338
|
+
lines << "framework: #{framework}"
|
|
339
|
+
lines << "command: #{command}"
|
|
340
|
+
lines << "exit: #{run[:exit_code].nil? ? "(none)" : run[:exit_code]}"
|
|
341
|
+
lines << "outcome: #{outcome}"
|
|
342
|
+
if parsed[:ran]
|
|
343
|
+
lines << "examples: #{parsed[:examples].nil? ? "?" : parsed[:examples]}"
|
|
344
|
+
lines << "failures: #{parsed[:failures].nil? ? "?" : parsed[:failures]}"
|
|
345
|
+
unless parsed[:failing].empty?
|
|
346
|
+
lines << "failing:"
|
|
347
|
+
parsed[:failing].each do |f|
|
|
348
|
+
loc = f[:location] ? " (#{f[:location]})" : ""
|
|
349
|
+
desc = f[:description].to_s
|
|
350
|
+
msg = f[:message].to_s.empty? ? "" : " — #{f[:message]}"
|
|
351
|
+
lines << " - #{desc}#{loc}#{msg}"
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
else
|
|
355
|
+
lines << "note: the suite did not run (toolchain/setup error) — " \
|
|
356
|
+
"see the raw tail below"
|
|
357
|
+
end
|
|
358
|
+
lines.join("\n")
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def tail_lines(raw)
|
|
362
|
+
lines = raw.lines.map(&:chomp)
|
|
363
|
+
return raw.chomp if lines.size <= RAW_TAIL_LINES
|
|
364
|
+
|
|
365
|
+
["… [#{lines.size - RAW_TAIL_LINES} earlier lines omitted] …"]
|
|
366
|
+
.concat(lines.last(RAW_TAIL_LINES)).join("\n")
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def parse_output(framework, raw)
|
|
370
|
+
case framework
|
|
371
|
+
when "rspec" then parse_rspec(raw)
|
|
372
|
+
when "minitest" then parse_minitest(raw)
|
|
373
|
+
else parse_generic(raw)
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# RSpec: "N examples, M failures[, K pending]" summary line, and the
|
|
378
|
+
# "Failures:" block with "rspec ./path:line # description".
|
|
379
|
+
def parse_rspec(raw)
|
|
380
|
+
summary = raw.match(/(\d+)\s+examples?,\s+(\d+)\s+failures?/)
|
|
381
|
+
return parse_generic(raw) unless summary
|
|
382
|
+
|
|
383
|
+
examples = summary[1].to_i
|
|
384
|
+
failures = summary[2].to_i
|
|
385
|
+
|
|
386
|
+
failing = []
|
|
387
|
+
# The rerun lines RSpec prints at the bottom give location +
|
|
388
|
+
# description; the numbered Failures: block gives the message.
|
|
389
|
+
messages = rspec_failure_messages(raw)
|
|
390
|
+
raw.scan(%r{^rspec\s+(\.?/?\S+:\d+)\s+#\s+(.+)$}).each_with_index do |(loc, desc), i|
|
|
391
|
+
failing << { description: desc.strip, location: loc.strip, message: messages[i] }
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
{ ran: true, examples: examples, failures: failures, failing: failing }
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Pulls the first line of each numbered failure block in RSpec's
|
|
398
|
+
# "Failures:" section: " 1) Some description\n Failure/Error: ...\n
|
|
399
|
+
# <message>". We grab the message line(s) after Failure/Error.
|
|
400
|
+
def rspec_failure_messages(raw)
|
|
401
|
+
section = raw[/^Failures:\n(.*?)(?:\n\nFinished|\n\n\d+ examples?)/m, 1]
|
|
402
|
+
return [] unless section
|
|
403
|
+
|
|
404
|
+
section.split(/^\s*\d+\)\s/).reject(&:empty?).map do |block|
|
|
405
|
+
msg = block[%r{Failure/Error:.*?\n\s*\n?\s*(.+)}m, 1] ||
|
|
406
|
+
block[%r{Failure/Error:\s*(.+)}, 1]
|
|
407
|
+
msg.to_s.lines.first.to_s.strip
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Minitest: "N runs, M assertions, F failures, E errors, S skips".
|
|
412
|
+
# Failures/errors print as numbered blocks headed by
|
|
413
|
+
# "TestClass#test_name [file:line]:".
|
|
414
|
+
def parse_minitest(raw)
|
|
415
|
+
summary = raw.match(/(\d+)\s+runs?,\s+(\d+)\s+assertions?,\s+(\d+)\s+failures?,\s+(\d+)\s+errors?/)
|
|
416
|
+
return parse_generic(raw) unless summary
|
|
417
|
+
|
|
418
|
+
runs = summary[1].to_i
|
|
419
|
+
failures = summary[3].to_i + summary[4].to_i # failures + errors
|
|
420
|
+
|
|
421
|
+
failing = []
|
|
422
|
+
raw.scan(/^\s*\d+\)\s+(?:Failure|Error):\n\s*(\S+)\s*\[([^\]]+)\]:\n(.+)/).each do |name, loc, msg|
|
|
423
|
+
failing << { description: name.strip, location: loc.strip, message: msg.to_s.lines.first.to_s.strip }
|
|
424
|
+
end
|
|
425
|
+
# Some minitest reporters omit the "Failure:/Error:" label line.
|
|
426
|
+
if failing.empty?
|
|
427
|
+
raw.scan(/^\s*\d+\)\s+(\S+#\S+)\s*\[([^\]]+)\]:\n(.+)/).each do |name, loc, msg|
|
|
428
|
+
failing << { description: name.strip, location: loc.strip, message: msg.to_s.lines.first.to_s.strip }
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
{ ran: true, examples: runs, failures: failures, failing: failing }
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# No recognizable summary line: we can't trust counts. Treat as "ran" only
|
|
436
|
+
# if there's a hint the runner produced test output; otherwise leave ran
|
|
437
|
+
# to the exit-code classifier (started_error / nonzero) upstream.
|
|
438
|
+
def parse_generic(raw)
|
|
439
|
+
ran = raw.match?(/\d+\s+(examples?|runs?|tests?)/) ||
|
|
440
|
+
raw.match?(/Finished in/)
|
|
441
|
+
{ ran: ran, examples: nil, failures: nil, failing: [] }
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def format_ms(ms)
|
|
445
|
+
if ms < 1000 then "#{ms}ms"
|
|
446
|
+
elsif ms < 60_000 then "#{(ms / 1000.0).round(1)}s"
|
|
447
|
+
else
|
|
448
|
+
mins, rem = ms.divmod(60_000)
|
|
449
|
+
"#{mins}m#{(rem / 1000.0).round}s"
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# Tool for managing a task/todo list during a session.
|
|
6
|
+
# Allows the agent to track progress on complex multi-step tasks.
|
|
7
|
+
class TodoTool < Base
|
|
8
|
+
def name
|
|
9
|
+
"todowrite"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def description
|
|
13
|
+
"Create and manage a structured task list for the current session. " \
|
|
14
|
+
"Use this to track progress on complex multi-step tasks. " \
|
|
15
|
+
"Tasks have content, status (pending/in_progress/completed/cancelled), and priority."
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def input_schema
|
|
19
|
+
{
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
todos: {
|
|
23
|
+
type: "array",
|
|
24
|
+
items: {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
content: { type: "string", description: "Brief description of the task" },
|
|
28
|
+
status: {
|
|
29
|
+
type: "string",
|
|
30
|
+
enum: %w[pending in_progress completed cancelled],
|
|
31
|
+
description: "Current task status"
|
|
32
|
+
},
|
|
33
|
+
priority: {
|
|
34
|
+
type: "string",
|
|
35
|
+
enum: %w[high medium low],
|
|
36
|
+
description: "Task priority level"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
required: %w[content status priority]
|
|
40
|
+
},
|
|
41
|
+
description: "The complete updated todo list"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
required: %w[todos]
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def risk_level
|
|
49
|
+
:low
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def call(arguments)
|
|
53
|
+
todos = arguments["todos"] || arguments[:todos]
|
|
54
|
+
return "Error: No todos provided" unless todos.is_a?(Array)
|
|
55
|
+
|
|
56
|
+
format_todo_summary(todos)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def format_todo_summary(todos)
|
|
62
|
+
completed = todos.count { |t| t["status"] == "completed" || t[:status] == "completed" }
|
|
63
|
+
in_progress = todos.count { |t| t["status"] == "in_progress" || t[:status] == "in_progress" }
|
|
64
|
+
pending = todos.count { |t| t["status"] == "pending" || t[:status] == "pending" }
|
|
65
|
+
cancelled = todos.count { |t| t["status"] == "cancelled" || t[:status] == "cancelled" }
|
|
66
|
+
|
|
67
|
+
lines = ["Todo list updated (#{todos.size} items):"]
|
|
68
|
+
lines << " Completed: #{completed}" if completed > 0
|
|
69
|
+
lines << " In Progress: #{in_progress}" if in_progress > 0
|
|
70
|
+
lines << " Pending: #{pending}" if pending > 0
|
|
71
|
+
lines << " Cancelled: #{cancelled}" if cancelled > 0
|
|
72
|
+
lines << ""
|
|
73
|
+
|
|
74
|
+
todos.each do |todo|
|
|
75
|
+
content = todo["content"] || todo[:content]
|
|
76
|
+
status = todo["status"] || todo[:status]
|
|
77
|
+
priority = todo["priority"] || todo[:priority]
|
|
78
|
+
|
|
79
|
+
icon = case status
|
|
80
|
+
when "completed" then "[x]"
|
|
81
|
+
when "in_progress" then "[>]"
|
|
82
|
+
when "cancelled" then "[-]"
|
|
83
|
+
else "[ ]"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
lines << " #{icon} #{content} (#{priority})"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
lines.join("\n")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Rubino
|
|
7
|
+
module Tools
|
|
8
|
+
# Persists tool call audit records to the database.
|
|
9
|
+
# Extracted from Agent::ToolExecutor to respect the separation between
|
|
10
|
+
# domain execution logic and storage concerns.
|
|
11
|
+
class ToolCallRepository
|
|
12
|
+
# Persists a tool call record. Failures are swallowed so that a
|
|
13
|
+
# database outage never causes a tool call to fail.
|
|
14
|
+
def record(name:, call_id:, arguments:, result:, status:, error: nil)
|
|
15
|
+
now = Time.now.utc.iso8601
|
|
16
|
+
Rubino.database.db[:tool_calls].insert(
|
|
17
|
+
id: call_id || SecureRandom.uuid,
|
|
18
|
+
session_id: result.session_id,
|
|
19
|
+
tool_name: name,
|
|
20
|
+
input_json: JSON.generate(arguments),
|
|
21
|
+
output: result.output,
|
|
22
|
+
status: status,
|
|
23
|
+
started_at: now,
|
|
24
|
+
finished_at: now,
|
|
25
|
+
error: error
|
|
26
|
+
)
|
|
27
|
+
rescue StandardError
|
|
28
|
+
# Don't fail the tool call just because audit persistence failed.
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../llm/auxiliary_client"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Tools
|
|
7
|
+
# Delegates image-understanding to a multimodal aux model so a text-only
|
|
8
|
+
# primary can still "see" what the user uploaded. Implements the
|
|
9
|
+
# agent-as-tool semantics from the OpenAI Agents SDK: the primary stays
|
|
10
|
+
# in control, calls this tool with a focused question, and receives a
|
|
11
|
+
# structured (text) reply — no conversation handoff, no shared history.
|
|
12
|
+
#
|
|
13
|
+
# The aux model is resolved from `auxiliary.vision` in config. When the
|
|
14
|
+
# primary already supports vision (per Configuration#model_supports_vision?)
|
|
15
|
+
# AND no aux is configured, Registry hides this tool — there's no useful
|
|
16
|
+
# delegation to perform.
|
|
17
|
+
class VisionTool < Base
|
|
18
|
+
def name
|
|
19
|
+
"vision"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def description
|
|
23
|
+
"Ask a multimodal model to describe or interpret an image. " \
|
|
24
|
+
"Use when you need to understand visual content (charts, screenshots, " \
|
|
25
|
+
"diagrams, photos). Provide an optional focused question to direct the " \
|
|
26
|
+
"analysis; default is a full markdown description."
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def input_schema
|
|
30
|
+
{
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: {
|
|
33
|
+
file_path: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "Absolute path to an image file (.png .jpg .jpeg .webp .gif .bmp)"
|
|
36
|
+
},
|
|
37
|
+
question: {
|
|
38
|
+
type: "string",
|
|
39
|
+
description: "Optional focused question. Default: 'Describe what you see in markdown.'"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
required: %w[file_path]
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def risk_level
|
|
47
|
+
:low
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def call(arguments)
|
|
51
|
+
path = (arguments["file_path"] || arguments[:file_path]).to_s
|
|
52
|
+
question = (arguments["question"] || arguments[:question] ||
|
|
53
|
+
"Describe what you see in markdown.").to_s
|
|
54
|
+
|
|
55
|
+
return "Error: file_path is required" if path.empty?
|
|
56
|
+
|
|
57
|
+
expanded = File.expand_path(path)
|
|
58
|
+
return "Error: file not found: #{path}" unless File.exist?(expanded)
|
|
59
|
+
return "Error: not a regular file: #{path}" unless File.file?(expanded)
|
|
60
|
+
|
|
61
|
+
ext = File.extname(expanded).downcase
|
|
62
|
+
unless LLM::ContentBuilder::SUPPORTED_IMAGE_TYPES.include?(ext)
|
|
63
|
+
return "Error: unsupported image extension '#{ext}'. " \
|
|
64
|
+
"Supported: #{LLM::ContentBuilder::SUPPORTED_IMAGE_TYPES.join(", ")}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Pass the image through ruby_llm's native `with:` slot (image_paths),
|
|
68
|
+
# NOT as an OpenAI-style content array. ruby_llm's `ask` stringifies an
|
|
69
|
+
# array content, so the base64 bytes would reach the model as TEXT and
|
|
70
|
+
# it hallucinates (prod sessions 38/41: M3 saw the image perfectly when
|
|
71
|
+
# called directly, but got a text blob through this path). image_paths
|
|
72
|
+
# attaches the file as a real multimodal part — same route the primary
|
|
73
|
+
# uses for native vision.
|
|
74
|
+
response = LLM::AuxiliaryClient.new.call(
|
|
75
|
+
task: :vision,
|
|
76
|
+
messages: [{ role: "user", content: question }],
|
|
77
|
+
image_paths: [expanded]
|
|
78
|
+
)
|
|
79
|
+
response.content.to_s
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
"Error calling vision model: #{e.class}: #{e.message}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|