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,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# Applies an ordered list of exact string replacements to a single file
|
|
6
|
+
# in one transactional shot. If any edit fails (string not found, or
|
|
7
|
+
# non-unique without replace_all) the file is left untouched — the LLM
|
|
8
|
+
# gets a single error pointing at the offending edit index.
|
|
9
|
+
#
|
|
10
|
+
# Each subsequent edit sees the result of prior edits in the same call,
|
|
11
|
+
# so you can rename A→B and then change a line that contains B.
|
|
12
|
+
class MultiEditTool < Base
|
|
13
|
+
def name
|
|
14
|
+
"multi_edit"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def description
|
|
18
|
+
"Apply multiple exact string replacements to a single file atomically. " \
|
|
19
|
+
"Edits are applied sequentially in the given order; later edits see " \
|
|
20
|
+
"the result of earlier ones. If any edit fails, NO changes are written."
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def input_schema
|
|
24
|
+
{
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
file_path: {
|
|
28
|
+
type: "string",
|
|
29
|
+
description: "Path to the file to edit"
|
|
30
|
+
},
|
|
31
|
+
edits: {
|
|
32
|
+
type: "array",
|
|
33
|
+
description: "Ordered list of edits to apply",
|
|
34
|
+
items: {
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: {
|
|
37
|
+
old_string: { type: "string", description: "Exact text to find" },
|
|
38
|
+
new_string: { type: "string", description: "Replacement text" },
|
|
39
|
+
replace_all: { type: "boolean", description: "Replace all occurrences (default false)" }
|
|
40
|
+
},
|
|
41
|
+
required: %w[old_string new_string]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
required: %w[file_path edits]
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def risk_level
|
|
50
|
+
:medium
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def call(arguments)
|
|
54
|
+
file_path = arguments["file_path"] || arguments[:file_path]
|
|
55
|
+
edits = arguments["edits"] || arguments[:edits] || []
|
|
56
|
+
|
|
57
|
+
return "Error: file_path is required" if file_path.nil? || file_path.to_s.empty?
|
|
58
|
+
return "Error: edits must be a non-empty array" if !edits.is_a?(Array) || edits.empty?
|
|
59
|
+
|
|
60
|
+
expanded = File.expand_path(file_path)
|
|
61
|
+
return workspace_violation_message(file_path) unless within_workspace?(expanded)
|
|
62
|
+
return "Error: File not found: #{file_path}" unless File.exist?(expanded)
|
|
63
|
+
|
|
64
|
+
if (gate = read_gate_error(expanded, file_path, verb: "edits"))
|
|
65
|
+
return gate
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
content = File.read(expanded)
|
|
69
|
+
working = content.dup
|
|
70
|
+
applied_count = 0
|
|
71
|
+
|
|
72
|
+
edits.each_with_index do |edit, idx|
|
|
73
|
+
if cancellation_requested?
|
|
74
|
+
return "Cancelled before edit ##{idx + 1} — no changes written " \
|
|
75
|
+
"(multi_edit is atomic: stages in memory, writes once)"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
old_s = edit["old_string"] || edit[:old_string]
|
|
79
|
+
new_s = edit["new_string"] || edit[:new_string]
|
|
80
|
+
replace_all = edit["replace_all"] || edit[:replace_all] || false
|
|
81
|
+
|
|
82
|
+
return "Error: edit ##{idx + 1} is missing old_string or new_string" if old_s.nil? || new_s.nil?
|
|
83
|
+
return "Error: edit ##{idx + 1}: old_string and new_string are identical" if old_s == new_s
|
|
84
|
+
unless working.include?(old_s)
|
|
85
|
+
return "Error: edit ##{idx + 1}: old_string not found (check whitespace; " \
|
|
86
|
+
"remember edits see the result of prior edits)"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
count = working.scan(old_s).size
|
|
90
|
+
if count > 1 && !replace_all
|
|
91
|
+
return "Error: edit ##{idx + 1}: #{count} matches for old_string. " \
|
|
92
|
+
"Add surrounding context to disambiguate, or set replace_all: true."
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
working = if replace_all
|
|
96
|
+
working.gsub(old_s) { new_s }
|
|
97
|
+
else
|
|
98
|
+
working.sub(old_s) { new_s }
|
|
99
|
+
end
|
|
100
|
+
applied_count += replace_all ? count : 1
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
File.write(expanded, working)
|
|
104
|
+
"Applied #{edits.size} edit(s), #{applied_count} replacement(s) in #{file_path}"
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
"Error: #{e.message}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Tools
|
|
7
|
+
# Tool for applying unified diff patches to files.
|
|
8
|
+
class PatchTool < Base
|
|
9
|
+
def name
|
|
10
|
+
"apply_patch"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def description
|
|
14
|
+
"Apply a unified diff patch to one or more files. " \
|
|
15
|
+
"Accepts standard unified diff format (like output from 'git diff')."
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def input_schema
|
|
19
|
+
{
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
patch: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "The unified diff patch content to apply"
|
|
25
|
+
},
|
|
26
|
+
base_path: {
|
|
27
|
+
type: "string",
|
|
28
|
+
description: "Base directory for relative paths in the patch (defaults to cwd)"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
required: %w[patch]
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def risk_level
|
|
36
|
+
:medium
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call(arguments)
|
|
40
|
+
patch = arguments["patch"] || arguments[:patch]
|
|
41
|
+
base_path = arguments["base_path"] || arguments[:base_path] || Dir.pwd
|
|
42
|
+
|
|
43
|
+
hunks = parse_patch(patch)
|
|
44
|
+
return "No changes applied" if hunks.empty?
|
|
45
|
+
|
|
46
|
+
# Pass 1: validate every hunk against current disk state and compute
|
|
47
|
+
# the new content for each file in memory. NO disk writes here. If
|
|
48
|
+
# any single hunk fails (missing file, context mismatch, workspace
|
|
49
|
+
# escape) we abort the whole patch — partial application across
|
|
50
|
+
# multiple files leaves the tree in a state neither the user nor
|
|
51
|
+
# the agent can easily reason about, and reverting it requires
|
|
52
|
+
# knowing the prior contents which we no longer have.
|
|
53
|
+
pending, error = plan_operations(hunks, base_path)
|
|
54
|
+
return error if error
|
|
55
|
+
|
|
56
|
+
# Pass 2: execute. cancellation_requested? polled between operations
|
|
57
|
+
# so a Ctrl+C lands cleanly — at most one application is in flight.
|
|
58
|
+
apply_operations(pending)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def plan_operations(hunks, base_path)
|
|
64
|
+
pending = []
|
|
65
|
+
|
|
66
|
+
hunks.each do |hunk|
|
|
67
|
+
file_path = File.expand_path(hunk[:file], base_path)
|
|
68
|
+
|
|
69
|
+
unless within_workspace?(file_path)
|
|
70
|
+
return [nil, workspace_violation_message(hunk[:file]) +
|
|
71
|
+
" (no changes applied — apply_patch is two-phase)"]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if hunk[:new_file]
|
|
75
|
+
pending << { kind: :create,
|
|
76
|
+
path: file_path,
|
|
77
|
+
display: hunk[:file],
|
|
78
|
+
content: hunk[:additions].join("\n") + "\n" }
|
|
79
|
+
elsif hunk[:delete_file]
|
|
80
|
+
pending << { kind: :delete,
|
|
81
|
+
path: file_path,
|
|
82
|
+
display: hunk[:file] }
|
|
83
|
+
else
|
|
84
|
+
return [nil, "Error: File not found: #{hunk[:file]} (no changes applied)"] unless File.exist?(file_path)
|
|
85
|
+
|
|
86
|
+
content = File.read(file_path)
|
|
87
|
+
new_content, drift, fuzzy = apply_hunk(content, hunk)
|
|
88
|
+
if new_content.nil?
|
|
89
|
+
return [nil, "Error: Could not apply hunk to #{hunk[:file]} - " \
|
|
90
|
+
"context mismatch (no changes applied)"]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
pending << { kind: :patch,
|
|
94
|
+
path: file_path,
|
|
95
|
+
display: hunk[:file],
|
|
96
|
+
content: new_content,
|
|
97
|
+
drift: drift,
|
|
98
|
+
fuzzy: fuzzy,
|
|
99
|
+
adds: hunk[:additions].size,
|
|
100
|
+
dels: hunk[:deletions].size }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
[pending, nil]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def apply_operations(pending)
|
|
108
|
+
results = []
|
|
109
|
+
|
|
110
|
+
pending.each do |op|
|
|
111
|
+
if cancellation_requested?
|
|
112
|
+
remaining = pending.size - results.size
|
|
113
|
+
results << "[cancelled — #{remaining} operation(s) skipped]"
|
|
114
|
+
break
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
case op[:kind]
|
|
118
|
+
when :create
|
|
119
|
+
FileUtils.mkdir_p(File.dirname(op[:path]))
|
|
120
|
+
File.write(op[:path], op[:content])
|
|
121
|
+
results << "Created: #{op[:display]}"
|
|
122
|
+
when :delete
|
|
123
|
+
File.delete(op[:path]) if File.exist?(op[:path])
|
|
124
|
+
results << "Deleted: #{op[:display]}"
|
|
125
|
+
when :patch
|
|
126
|
+
File.write(op[:path], op[:content])
|
|
127
|
+
results << patch_result_line(op)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
results.join("\n")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The drift note is the bit that distinguishes "applied exactly where
|
|
135
|
+
# the diff said" from "found by fuzzy search ±20 lines away". The old
|
|
136
|
+
# tool silently let the fuzzy case through and reported success — if
|
|
137
|
+
# the model was off by 50 lines we'd write to the wrong place and
|
|
138
|
+
# claim it worked.
|
|
139
|
+
def patch_result_line(op)
|
|
140
|
+
base = "Patched: #{op[:display]} (#{op[:adds]} additions, #{op[:dels]} deletions)"
|
|
141
|
+
return base unless op[:fuzzy]
|
|
142
|
+
|
|
143
|
+
offset = op[:drift]
|
|
144
|
+
signed = "#{"+" if offset.positive?}#{offset}"
|
|
145
|
+
"#{base} [fuzzy match: applied #{signed} line(s) from requested position]"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def parse_patch(patch)
|
|
149
|
+
hunks = []
|
|
150
|
+
current_file = nil
|
|
151
|
+
# Flags carried at the file level — set before any @@ hunk header
|
|
152
|
+
pending_new_file = false
|
|
153
|
+
pending_delete_file = false
|
|
154
|
+
|
|
155
|
+
patch.each_line do |line|
|
|
156
|
+
case line
|
|
157
|
+
when %r{^--- /dev/null}
|
|
158
|
+
# New file: source is /dev/null
|
|
159
|
+
pending_new_file = true
|
|
160
|
+
when %r{^--- a/(.*)}
|
|
161
|
+
# Normal source file — set current_file and reset pending flags
|
|
162
|
+
current_file = Regexp.last_match(1).strip
|
|
163
|
+
pending_new_file = false
|
|
164
|
+
pending_delete_file = false
|
|
165
|
+
when %r{^\+\+\+ /dev/null}
|
|
166
|
+
# Delete file: destination is /dev/null; current_file already set by --- a/
|
|
167
|
+
pending_delete_file = true
|
|
168
|
+
when %r{^\+\+\+ b/(.*)}
|
|
169
|
+
current_file = Regexp.last_match(1).strip
|
|
170
|
+
when /^@@ -(\d+),?\d* \+(\d+),?\d* @@/
|
|
171
|
+
hunk = {
|
|
172
|
+
file: current_file,
|
|
173
|
+
start_line: Regexp.last_match(1).to_i,
|
|
174
|
+
new_start: Regexp.last_match(2).to_i,
|
|
175
|
+
context: [],
|
|
176
|
+
additions: [],
|
|
177
|
+
deletions: [],
|
|
178
|
+
lines: [],
|
|
179
|
+
new_file: pending_new_file,
|
|
180
|
+
delete_file: pending_delete_file
|
|
181
|
+
}
|
|
182
|
+
hunks << hunk
|
|
183
|
+
# Reset pending flags after consuming them
|
|
184
|
+
pending_new_file = false
|
|
185
|
+
pending_delete_file = false
|
|
186
|
+
else
|
|
187
|
+
hunk = hunks.last
|
|
188
|
+
next unless hunk
|
|
189
|
+
|
|
190
|
+
if line.start_with?("+")
|
|
191
|
+
hunk[:additions] << line[1..].rstrip
|
|
192
|
+
hunk[:lines] << { type: :add, content: line[1..].rstrip }
|
|
193
|
+
elsif line.start_with?("-")
|
|
194
|
+
hunk[:deletions] << line[1..].rstrip
|
|
195
|
+
hunk[:lines] << { type: :del, content: line[1..].rstrip }
|
|
196
|
+
elsif line.start_with?(" ")
|
|
197
|
+
hunk[:context] << line[1..].rstrip
|
|
198
|
+
hunk[:lines] << { type: :ctx, content: line[1..].rstrip }
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
hunks
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Returns [new_content, drift, fuzzy].
|
|
207
|
+
# new_content: the rewritten file content, or nil if context can't
|
|
208
|
+
# be found anywhere within the fuzzy search window.
|
|
209
|
+
# drift: signed line offset from the hunk's requested start
|
|
210
|
+
# (0 = exact match).
|
|
211
|
+
# fuzzy: true iff the match was found by find_context rather
|
|
212
|
+
# than at the requested line. The caller surfaces this
|
|
213
|
+
# so the model can see "I asked line 10, you applied
|
|
214
|
+
# at line 13" instead of trusting a silent fuzzy match.
|
|
215
|
+
def apply_hunk(content, hunk)
|
|
216
|
+
lines = content.lines.map(&:rstrip)
|
|
217
|
+
requested_ix = hunk[:start_line] - 1
|
|
218
|
+
start_idx = requested_ix
|
|
219
|
+
fuzzy = false
|
|
220
|
+
|
|
221
|
+
expected = hunk[:lines].reject { |l| l[:type] == :add }.map { |l| l[:content] }
|
|
222
|
+
|
|
223
|
+
actual = lines[start_idx, expected.size]
|
|
224
|
+
unless actual && actual.map(&:rstrip) == expected.map(&:rstrip)
|
|
225
|
+
found_idx = find_context(lines, expected, start_idx)
|
|
226
|
+
return [nil, 0, false] unless found_idx
|
|
227
|
+
|
|
228
|
+
start_idx = found_idx
|
|
229
|
+
fuzzy = (found_idx != requested_ix)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
new_lines = lines[0...start_idx]
|
|
233
|
+
hunk[:lines].each do |line|
|
|
234
|
+
case line[:type]
|
|
235
|
+
when :add, :ctx
|
|
236
|
+
new_lines << line[:content]
|
|
237
|
+
when :del
|
|
238
|
+
# removed — skip
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
new_lines.concat(lines[(start_idx + expected.size)..] || [])
|
|
242
|
+
|
|
243
|
+
[new_lines.join("\n") + "\n", start_idx - requested_ix, fuzzy]
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def find_context(lines, expected, hint_idx)
|
|
247
|
+
search_range = 20
|
|
248
|
+
start = [0, hint_idx - search_range].max
|
|
249
|
+
finish = [lines.size - expected.size, hint_idx + search_range].min
|
|
250
|
+
|
|
251
|
+
(start..finish).each do |idx|
|
|
252
|
+
actual = lines[idx, expected.size]
|
|
253
|
+
return idx if actual && actual.map(&:rstrip) == expected.map(&:rstrip)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
nil
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "subagent_probe"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Tools
|
|
7
|
+
# probe — the MODEL-callable EPHEMERAL peek into one of the caller's OWN
|
|
8
|
+
# running children (S3). The model counterpart of the human
|
|
9
|
+
# `/agents <id> probe "..."`. Two paths, both read-only (they append NOTHING
|
|
10
|
+
# to the child's session — the EPHEMERAL invariant):
|
|
11
|
+
#
|
|
12
|
+
# live:false (DEFAULT, FREE): build the answer from the registry's
|
|
13
|
+
# live-progress fields ONLY (status / tool_count / last_activity + the
|
|
14
|
+
# bounded activity_log ring the /agents drill-in already tails). NO model
|
|
15
|
+
# call — unlimited.
|
|
16
|
+
# live:true (BILLED): run ONE side-inference over a read-only snapshot of
|
|
17
|
+
# the child's transcript (SubagentProbe#peek) and return the answer. This
|
|
18
|
+
# costs a model round-trip, so it is BUDGETED per child
|
|
19
|
+
# (tasks.max_live_probes_per_child, default 5). Over budget → the model is
|
|
20
|
+
# told to use the free snapshot.
|
|
21
|
+
#
|
|
22
|
+
# SCOPED AT CALL (the S1 correction): probe is registered for ALL agents and
|
|
23
|
+
# authorized by OWNERSHIP at call time — the target must be the caller's OWN
|
|
24
|
+
# direct child (BackgroundTasks.owned_by?). Registered normally, NOT on any
|
|
25
|
+
# strip list. Does NOT touch the human CLI probe path (executor.rb).
|
|
26
|
+
class ProbeTool < Base
|
|
27
|
+
# How many activity_log lines the cheap snapshot renders (matches the
|
|
28
|
+
# /agents drill-in's `recent:` ring).
|
|
29
|
+
RECENT_MAX = 6
|
|
30
|
+
|
|
31
|
+
# A probe is a snapshot at this instant: a child probed right after
|
|
32
|
+
# spawn has run nothing yet and honestly reports an empty/confused
|
|
33
|
+
# context, which reads as broken without this hint (#112).
|
|
34
|
+
JUST_STARTED_HINT = "(snapshot at this instant — the child just started and its " \
|
|
35
|
+
"context is still empty; probe again in a moment)"
|
|
36
|
+
|
|
37
|
+
def initialize(probe: nil)
|
|
38
|
+
# Test seam: inject a SubagentProbe (or any object responding to #peek)
|
|
39
|
+
# so the live path can be driven without a real model.
|
|
40
|
+
@probe = probe
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def name
|
|
44
|
+
"probe"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Gated by the same `tools.task` delegation key — probing a child is
|
|
48
|
+
# meaningless without the delegation substrate.
|
|
49
|
+
def config_key
|
|
50
|
+
"task"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def description
|
|
54
|
+
"Check on one of YOUR OWN running subagents WITHOUT disturbing it (this " \
|
|
55
|
+
"is read-only — it changes nothing about what the child does). By default " \
|
|
56
|
+
"(live:false) it returns a FREE instant snapshot: the child's status, how " \
|
|
57
|
+
"many tools it has run, its last activity, and a few recent lines — no " \
|
|
58
|
+
"model call. Set live:true to ask the child a specific question answered " \
|
|
59
|
+
"from its current context by a one-shot model peek (this costs a billed " \
|
|
60
|
+
"round-trip and is budgeted per child; prefer the free snapshot). You can " \
|
|
61
|
+
"ONLY probe subagents you started (your direct children)."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def input_schema
|
|
65
|
+
{
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
task_id: { type: "string", description: "The id (sa_…) of YOUR subagent to probe." },
|
|
69
|
+
question: { type: "string",
|
|
70
|
+
description: "What you want to know. For a free snapshot this frames the check; for live:true it is the question the child answers from its context." },
|
|
71
|
+
live: {
|
|
72
|
+
type: "boolean",
|
|
73
|
+
description: "false (default) = FREE instant snapshot from the registry, no model call. " \
|
|
74
|
+
"true = billed one-shot model peek over the child's transcript (budgeted per child)."
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
required: %w[task_id question]
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def risk_level
|
|
82
|
+
:low
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def call(arguments)
|
|
86
|
+
task_id = (arguments["task_id"] || arguments[:task_id]).to_s.strip
|
|
87
|
+
question = (arguments["question"] || arguments[:question]).to_s.strip
|
|
88
|
+
live = live_arg(arguments)
|
|
89
|
+
|
|
90
|
+
caller_id = Rubino.current_subagent_id
|
|
91
|
+
registry = BackgroundTasks.instance
|
|
92
|
+
entry = task_id.empty? ? nil : registry.find(task_id)
|
|
93
|
+
|
|
94
|
+
return "Cannot probe #{task_id} — no such subagent." unless entry
|
|
95
|
+
return "Error: question is required" if question.empty?
|
|
96
|
+
unless registry.owned_by?(caller_id, task_id)
|
|
97
|
+
return "Error: #{task_id} is not one of your subagents — you can only probe children you started."
|
|
98
|
+
end
|
|
99
|
+
# A child parked on a BLOCKING ask_parent has no live activity to peek
|
|
100
|
+
# at — its pending tool_use is not in the persisted snapshot, so a
|
|
101
|
+
# billed live peek would honestly answer "I never called ask_parent"
|
|
102
|
+
# while task_result says blocked (#198). Short-circuit with the parked
|
|
103
|
+
# question and the one action that unblocks it (no billed peek).
|
|
104
|
+
return blocked_on_ask_answer(entry) if parked_on_ask?(entry)
|
|
105
|
+
|
|
106
|
+
live ? probe_live(registry, entry, question) : probe_cheap(entry)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# live defaults to FALSE (the free, unbilled snapshot). Only an explicit
|
|
112
|
+
# true opts into the billed model peek.
|
|
113
|
+
def live_arg(arguments)
|
|
114
|
+
raw = arguments.key?("live") ? arguments["live"] : arguments[:live]
|
|
115
|
+
[true, "true", 1, "1"].include?(raw)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def just_started?(entry)
|
|
119
|
+
entry.tool_count.to_i.zero?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# True when the child's thread is PARKED on a blocking ask_parent gate
|
|
123
|
+
# (a non-blocking ask keeps the blocked status on the card but the child
|
|
124
|
+
# keeps working, so it stays probeable).
|
|
125
|
+
def parked_on_ask?(entry)
|
|
126
|
+
entry.ask_gate && entry.ask_blocking &&
|
|
127
|
+
%i[blocked_on_human blocked_on_parent].include?(entry.status)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def blocked_on_ask_answer(entry)
|
|
131
|
+
"probe #{entry.id} · #{entry.subagent} · BLOCKED on ask_parent waiting for YOUR answer — " \
|
|
132
|
+
"question:\n#{entry.ask_question}\n" \
|
|
133
|
+
"Answer with answer_child(task_id: \"#{entry.id}\", answer: \"…\") to unblock it."
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# FREE path: render the live-progress fields only. NO model call.
|
|
137
|
+
def probe_cheap(entry)
|
|
138
|
+
recent = Array(entry.activity_log).last(RECENT_MAX)
|
|
139
|
+
lines = recent.empty? ? "(none yet)" : recent.join("\n")
|
|
140
|
+
out = "probe #{entry.id} · #{entry.subagent} · #{entry.status} · " \
|
|
141
|
+
"#{entry.tool_count.to_i} tools · last: #{entry.last_activity || "—"}\n" \
|
|
142
|
+
"recent:\n#{lines}"
|
|
143
|
+
out += "\n#{JUST_STARTED_HINT}" if just_started?(entry)
|
|
144
|
+
out
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# BILLED path: enforce the per-child budget, then run the one-shot peek.
|
|
148
|
+
# peek is best-effort (never raises) — a failure is reported inline.
|
|
149
|
+
def probe_live(registry, entry, question)
|
|
150
|
+
max = max_live_probes
|
|
151
|
+
if entry.probe_count.to_i >= max
|
|
152
|
+
return "Error: live-probe budget exhausted for #{entry.id} (max #{max} per child). " \
|
|
153
|
+
"Use live:false for a free snapshot."
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
registry.record_live_probe(entry.id)
|
|
157
|
+
answer = probe_engine.peek(entry: entry, question: question)
|
|
158
|
+
out = "probe #{entry.id} (live) ⟵ #{answer}"
|
|
159
|
+
out += "\n#{JUST_STARTED_HINT}" if just_started?(entry)
|
|
160
|
+
out
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def probe_engine
|
|
164
|
+
@probe ||= SubagentProbe.new
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def max_live_probes
|
|
168
|
+
cfg = Rubino.configuration if Rubino.respond_to?(:configuration)
|
|
169
|
+
Integer(cfg&.tasks_max_live_probes_per_child)
|
|
170
|
+
rescue StandardError, TypeError, ArgumentError
|
|
171
|
+
5
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# Tool that asks the user interactive questions with predefined options.
|
|
6
|
+
# Allows the agent to gather clarification or preferences from the user.
|
|
7
|
+
class QuestionTool < Base
|
|
8
|
+
def name
|
|
9
|
+
"question"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def description
|
|
13
|
+
"Ask the user a question with optional predefined choices. " \
|
|
14
|
+
"Use this when you need clarification, user preferences, or a decision. " \
|
|
15
|
+
"The user can select from options or type a custom answer."
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def input_schema
|
|
19
|
+
{
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
question: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "The question to ask the user"
|
|
25
|
+
},
|
|
26
|
+
options: {
|
|
27
|
+
type: "array",
|
|
28
|
+
items: {
|
|
29
|
+
type: "object",
|
|
30
|
+
properties: {
|
|
31
|
+
label: { type: "string", description: "Short display text for the option" },
|
|
32
|
+
description: { type: "string", description: "Explanation of this choice" }
|
|
33
|
+
},
|
|
34
|
+
required: %w[label]
|
|
35
|
+
},
|
|
36
|
+
description: "Available choices (optional). A 'Type your own' option is added automatically."
|
|
37
|
+
},
|
|
38
|
+
multiple: {
|
|
39
|
+
type: "boolean",
|
|
40
|
+
description: "Allow selecting multiple choices (default: false)"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
required: %w[question]
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def risk_level
|
|
48
|
+
:low
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Deterministic result when no user answer is available — the UI's #ask
|
|
52
|
+
# returned nil (non-interactive / piped session, or the user gave no
|
|
53
|
+
# response). Fail closed instead of reading ambient stdin or silently
|
|
54
|
+
# picking an option (#107): never assume a choice on the user's behalf.
|
|
55
|
+
NO_ANSWER = "No answer: no interactive user input available " \
|
|
56
|
+
"(non-interactive session, or the user gave no response). " \
|
|
57
|
+
"Do not assume a choice on the user's behalf; proceed with the " \
|
|
58
|
+
"safest option and state the assumption, or finish and report " \
|
|
59
|
+
"the open question."
|
|
60
|
+
|
|
61
|
+
def call(arguments)
|
|
62
|
+
question = arguments["question"] || arguments[:question]
|
|
63
|
+
options = arguments["options"] || arguments[:options]
|
|
64
|
+
multiple = arguments["multiple"] || arguments[:multiple] || false
|
|
65
|
+
|
|
66
|
+
ui = Rubino.ui
|
|
67
|
+
|
|
68
|
+
if options && !options.empty?
|
|
69
|
+
ask_with_options(ui, question, options, multiple)
|
|
70
|
+
else
|
|
71
|
+
ask_freeform(ui, question)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def ask_with_options(ui, question, options, multiple)
|
|
78
|
+
# Format options for display
|
|
79
|
+
formatted = options.map do |opt|
|
|
80
|
+
label = opt["label"] || opt[:label]
|
|
81
|
+
desc = opt["description"] || opt[:description]
|
|
82
|
+
desc ? "#{label} - #{desc}" : label
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Build a SINGLE prompt carrying the question, the numbered options, the
|
|
86
|
+
# multiple-select hint, and the trailing instruction. On the API path the
|
|
87
|
+
# whole prompt becomes the clarify.required event's `question` payload, so
|
|
88
|
+
# the web clarify box renders the question next to the input (instead of
|
|
89
|
+
# only the generic "Your choice…" line, with the question lost up top).
|
|
90
|
+
lines = [question]
|
|
91
|
+
formatted.each_with_index do |opt, i|
|
|
92
|
+
lines << " #{i + 1}. #{opt}"
|
|
93
|
+
end
|
|
94
|
+
lines << " (Select multiple numbers separated by commas, or type a custom answer)" if multiple
|
|
95
|
+
lines << "Your choice#{"(s)" if multiple} (number or custom answer):"
|
|
96
|
+
|
|
97
|
+
answer = ui.ask(lines.join("\n"))
|
|
98
|
+
return NO_ANSWER if answer.nil?
|
|
99
|
+
|
|
100
|
+
# Parse single or multiple numeric selections
|
|
101
|
+
if multiple && answer&.match?(/\A[\d,\s]+\z/)
|
|
102
|
+
indices = answer.scan(/\d+/).map { |n| n.to_i - 1 }
|
|
103
|
+
selected = indices.filter_map do |idx|
|
|
104
|
+
options[idx]["label"] || options[idx][:label] if idx >= 0 && idx < options.size
|
|
105
|
+
end
|
|
106
|
+
selected.empty? ? "User answered: #{answer}" : "User selected: #{selected.join(", ")}"
|
|
107
|
+
elsif answer&.match?(/\A\d+\z/)
|
|
108
|
+
idx = answer.to_i - 1
|
|
109
|
+
if idx >= 0 && idx < options.size
|
|
110
|
+
selected = options[idx]
|
|
111
|
+
"User selected: #{selected["label"] || selected[:label]}"
|
|
112
|
+
else
|
|
113
|
+
"User answered: #{answer}"
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
"User answered: #{answer}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def ask_freeform(ui, question)
|
|
121
|
+
answer = ui.ask(question)
|
|
122
|
+
return NO_ANSWER if answer.nil?
|
|
123
|
+
|
|
124
|
+
"User answered: #{answer}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|