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,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# Blocking variant of shell_output: waits up to `timeout` seconds for new
|
|
6
|
+
# bytes to arrive on a background shell, then returns them. Returns
|
|
7
|
+
# immediately if bytes are already buffered or if the process has exited.
|
|
8
|
+
#
|
|
9
|
+
# Lets an agent "follow" a long-running job (CI, build, watcher) without
|
|
10
|
+
# busy-polling shell_output. The polling itself is implemented internally
|
|
11
|
+
# as a short-interval loop on ShellRegistry.read_new — switching to a
|
|
12
|
+
# condition variable would shave ~50ms of jitter and is a refactor for
|
|
13
|
+
# later; for v1, 100ms polling under the agent's tool-call latency is
|
|
14
|
+
# invisible.
|
|
15
|
+
class ShellTailTool < Base
|
|
16
|
+
DEFAULT_TIMEOUT = 30
|
|
17
|
+
MAX_TIMEOUT = 300
|
|
18
|
+
POLL_INTERVAL = 0.1
|
|
19
|
+
|
|
20
|
+
def name
|
|
21
|
+
"shell_tail"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def description
|
|
25
|
+
"Follow a background shell — block until new stdout/stderr bytes " \
|
|
26
|
+
"arrive on its run_id, the process exits, or `timeout` seconds " \
|
|
27
|
+
"elapse. Default timeout #{DEFAULT_TIMEOUT}s (max #{MAX_TIMEOUT}s). " \
|
|
28
|
+
"Returns the new bytes plus a status header. Use for `tail -F`-style " \
|
|
29
|
+
"following; use shell_output for a one-shot read."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def input_schema
|
|
33
|
+
{
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
run_id: { type: "string", description: "run_id from shell run_in_background:true" },
|
|
37
|
+
timeout: { type: "integer",
|
|
38
|
+
description: "Max seconds to block (default #{DEFAULT_TIMEOUT}, max #{MAX_TIMEOUT})" }
|
|
39
|
+
},
|
|
40
|
+
required: %w[run_id]
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def risk_level
|
|
45
|
+
:low
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def call(arguments)
|
|
49
|
+
run_id = arguments["run_id"] || arguments[:run_id]
|
|
50
|
+
timeout = (arguments["timeout"] || arguments[:timeout] || DEFAULT_TIMEOUT).to_i
|
|
51
|
+
timeout = timeout.clamp(1, MAX_TIMEOUT)
|
|
52
|
+
|
|
53
|
+
return "Error: run_id is required" if run_id.nil? || run_id.to_s.empty?
|
|
54
|
+
|
|
55
|
+
registry = ShellRegistry.instance
|
|
56
|
+
entry = registry.find(run_id)
|
|
57
|
+
return "Error: no background shell with run_id=#{run_id}" unless entry
|
|
58
|
+
|
|
59
|
+
body = ""
|
|
60
|
+
deadline = Time.now + timeout
|
|
61
|
+
|
|
62
|
+
loop do
|
|
63
|
+
body = registry.read_new(entry)
|
|
64
|
+
break unless body.empty?
|
|
65
|
+
|
|
66
|
+
# Process has exited and no bytes left to drain — return now with
|
|
67
|
+
# whatever the final status says.
|
|
68
|
+
break if registry.status(entry) != :running
|
|
69
|
+
|
|
70
|
+
# User pressed Ctrl+C during a tail. Don't keep blocking — return
|
|
71
|
+
# an empty body with a "cancelled" hint so the model can react.
|
|
72
|
+
if cancellation_requested?
|
|
73
|
+
return { output: tail_header(run_id, registry, entry, body, cancelled: true),
|
|
74
|
+
error_code: :cancelled }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
break if Time.now >= deadline
|
|
78
|
+
|
|
79
|
+
sleep POLL_INTERVAL
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
status = registry.status(entry)
|
|
83
|
+
exit_code = registry.exit_code(entry)
|
|
84
|
+
registry.remove(run_id) unless status == :running
|
|
85
|
+
|
|
86
|
+
text = if body.empty?
|
|
87
|
+
tail_header(run_id, registry, entry, body)
|
|
88
|
+
else
|
|
89
|
+
"#{tail_header(run_id, registry, entry, body)}\n#{body}"
|
|
90
|
+
end
|
|
91
|
+
{ output: text,
|
|
92
|
+
metrics: "#{body.bytesize}B · #{status}",
|
|
93
|
+
exit_code: exit_code,
|
|
94
|
+
error_code: tail_error_code(status, exit_code) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def tail_header(run_id, registry, entry, body, cancelled: false)
|
|
100
|
+
status = registry.status(entry)
|
|
101
|
+
exit_code = registry.exit_code(entry)
|
|
102
|
+
header = "[#{run_id}] status=#{status}"
|
|
103
|
+
header << " exit=#{exit_code}" if exit_code
|
|
104
|
+
header << " (#{body.bytesize} new bytes)"
|
|
105
|
+
header << " (cancelled by user)" if cancelled
|
|
106
|
+
header << "\n(no new output before deadline)" if body.empty? && status == :running && !cancelled
|
|
107
|
+
header
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def tail_error_code(status, exit_code)
|
|
111
|
+
return nil if %i[running completed].include?(status)
|
|
112
|
+
return :exit_nonzero if exit_code && exit_code != 0
|
|
113
|
+
|
|
114
|
+
:shell_error
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Tools
|
|
7
|
+
# Executes shell commands.
|
|
8
|
+
#
|
|
9
|
+
# Modes:
|
|
10
|
+
# - foreground (default): blocks until exit or `timeout` seconds, then
|
|
11
|
+
# SIGTERMs the process group and returns whatever was captured.
|
|
12
|
+
# - background (`run_in_background: true`): registers the process with
|
|
13
|
+
# ShellRegistry, returns a run_id immediately. Read its output later
|
|
14
|
+
# with `shell_output`, terminate it with `shell_kill`.
|
|
15
|
+
#
|
|
16
|
+
# Gatekeeping (allowlist, deny rules, approval prompts) lives in
|
|
17
|
+
# Security::ApprovalPolicy and is enforced by the ToolExecutor before we
|
|
18
|
+
# get here — this class only runs the command and resolves cwd.
|
|
19
|
+
#
|
|
20
|
+
# As defense-in-depth, #call re-checks the command against the hardline
|
|
21
|
+
# blocklist (Security::HardlineGuard — the single source of truth, also
|
|
22
|
+
# used by ApprovalPolicy). yolo skips approvals by design, but the point
|
|
23
|
+
# of yolo is "trust the model to move fast", not "let it wipe the root
|
|
24
|
+
# filesystem if it confuses paths" — so catastrophic, unrecoverable
|
|
25
|
+
# commands are refused here even if the policy was somehow bypassed.
|
|
26
|
+
class ShellTool < Base
|
|
27
|
+
DEFAULT_TIMEOUT = 120
|
|
28
|
+
MAX_TIMEOUT = 600
|
|
29
|
+
|
|
30
|
+
# 128 + SIGPIPE(13): under `pipefail`, a benign early-exit consumer
|
|
31
|
+
# (`cmd | head -1`) makes an upstream stage report SIGPIPE and the
|
|
32
|
+
# pipeline returns 141 even though nothing actually went wrong.
|
|
33
|
+
SIGPIPE_EXIT = 141
|
|
34
|
+
|
|
35
|
+
# Single decision point for "does this exit code count as success?".
|
|
36
|
+
# Used by both the [Exit code: …] suffix and the ✓/✗ presentation
|
|
37
|
+
# (via shell_error_code → Result#errorish?) so the two can't drift.
|
|
38
|
+
def self.success_exit?(code)
|
|
39
|
+
code.zero? || code == SIGPIPE_EXIT
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def name
|
|
43
|
+
"shell"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def description
|
|
47
|
+
"Execute a shell command. " \
|
|
48
|
+
"Foreground: blocks until the command exits or `timeout` seconds elapse " \
|
|
49
|
+
"(default #{DEFAULT_TIMEOUT}s, max #{MAX_TIMEOUT}s). " \
|
|
50
|
+
"Background: pass `run_in_background: true` to fire-and-forget; the tool " \
|
|
51
|
+
"returns a run_id. Use the `shell_output` tool to read its stdout/stderr, " \
|
|
52
|
+
"`shell_input` to answer an interactive prompt it emits (Y/N, menu), " \
|
|
53
|
+
"and `shell_kill` to terminate it."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def input_schema
|
|
57
|
+
{
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: {
|
|
60
|
+
command: {
|
|
61
|
+
type: "string",
|
|
62
|
+
description: "The shell command to execute"
|
|
63
|
+
},
|
|
64
|
+
cwd: {
|
|
65
|
+
type: "string",
|
|
66
|
+
description: "Working directory (defaults to current)"
|
|
67
|
+
},
|
|
68
|
+
timeout: {
|
|
69
|
+
type: "integer",
|
|
70
|
+
description: "Foreground timeout in seconds (default #{DEFAULT_TIMEOUT}, max #{MAX_TIMEOUT}). Ignored when run_in_background is true."
|
|
71
|
+
},
|
|
72
|
+
run_in_background: {
|
|
73
|
+
type: "boolean",
|
|
74
|
+
description: "If true, start the command detached and return a run_id immediately."
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
required: %w[command]
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def risk_level
|
|
82
|
+
:high
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def call(arguments)
|
|
86
|
+
command = arguments["command"] || arguments[:command]
|
|
87
|
+
cwd = arguments["cwd"] || arguments[:cwd]
|
|
88
|
+
background = arguments["run_in_background"] || arguments[:run_in_background] || false
|
|
89
|
+
timeout = arguments["timeout"] || arguments[:timeout] || DEFAULT_TIMEOUT
|
|
90
|
+
timeout = [[timeout.to_i, 1].max, MAX_TIMEOUT].min
|
|
91
|
+
|
|
92
|
+
return "Error: command is required" if command.nil? || command.to_s.empty?
|
|
93
|
+
|
|
94
|
+
if (denied = destructive_pattern_match(command))
|
|
95
|
+
return { output: "Error: refusing to run #{denied} — this is hardcoded as " \
|
|
96
|
+
"destructive and not overridable by --yolo. " \
|
|
97
|
+
"If you genuinely need this, run it manually outside the agent.",
|
|
98
|
+
error_code: :denied_command }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
working_dir = resolve_cwd(cwd)
|
|
102
|
+
return "Error: cannot access working directory: #{cwd.inspect}" unless working_dir
|
|
103
|
+
|
|
104
|
+
if background
|
|
105
|
+
spawn_background(command, working_dir)
|
|
106
|
+
else
|
|
107
|
+
run = execute_foreground(command, working_dir, timeout)
|
|
108
|
+
# exit_code / timed_out / cancelled are surfaced as structured
|
|
109
|
+
# keys so downstream code (and the model) doesn't have to parse
|
|
110
|
+
# `[Exit code: N]` out of free-form text to know whether the
|
|
111
|
+
# command succeeded. The text suffix stays for visual continuity
|
|
112
|
+
# in the scrollback and for tests that grep for it.
|
|
113
|
+
{ output: run[:text],
|
|
114
|
+
metrics: foreground_metric(run),
|
|
115
|
+
body: Util::Output.preview(run[:text]),
|
|
116
|
+
body_kind: :plain,
|
|
117
|
+
exit_code: run[:exit_code],
|
|
118
|
+
timed_out: run[:timed_out],
|
|
119
|
+
cancelled: run[:cancelled],
|
|
120
|
+
error_code: shell_error_code(run) }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def shell_error_code(run)
|
|
125
|
+
return :timeout if run[:timed_out]
|
|
126
|
+
return :cancelled if run[:cancelled]
|
|
127
|
+
return :shell_error if run[:shell_error]
|
|
128
|
+
return :exit_nonzero if run[:exit_code] && !self.class.success_exit?(run[:exit_code])
|
|
129
|
+
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# One-liner for the `done · shell` header. Reads the structured run
|
|
134
|
+
# fields directly — no regex archaeology on the text suffix.
|
|
135
|
+
def foreground_metric(run)
|
|
136
|
+
status = if run[:timed_out] then "timeout"
|
|
137
|
+
elsif run[:cancelled] then "cancelled"
|
|
138
|
+
elsif run[:shell_error] then "shell error"
|
|
139
|
+
elsif run[:exit_code].nil? then "no exit"
|
|
140
|
+
elsif run[:exit_code].zero? then "exit 0"
|
|
141
|
+
else "exit #{run[:exit_code]}"
|
|
142
|
+
end
|
|
143
|
+
"#{status} · #{format_ms(run[:duration_ms])}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def format_ms(ms)
|
|
147
|
+
if ms < 1000 then "#{ms}ms"
|
|
148
|
+
elsif ms < 60_000 then "#{(ms / 1000.0).round(1)}s"
|
|
149
|
+
else
|
|
150
|
+
mins, rem = ms.divmod(60_000)
|
|
151
|
+
"#{mins}m#{(rem / 1000.0).round}s"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
# Defense-in-depth: the ApprovalPolicy already denies hardline commands
|
|
158
|
+
# before we get here, but the tool re-checks against the SAME single
|
|
159
|
+
# source (Security::HardlineGuard) so a future caller that bypasses the
|
|
160
|
+
# policy still can't wipe the host. No divergent inline list.
|
|
161
|
+
def destructive_pattern_match(command)
|
|
162
|
+
Security::HardlineGuard.block_reason(command)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Resolves cwd via realpath so symlinks and "../" are fully expanded;
|
|
166
|
+
# returns nil if the directory does not exist or is unreadable.
|
|
167
|
+
def resolve_cwd(cwd)
|
|
168
|
+
candidate = cwd || Rubino::Workspace.primary_root
|
|
169
|
+
path = File.realpath(File.expand_path(candidate))
|
|
170
|
+
File.directory?(path) ? path : nil
|
|
171
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def spawn_background(command, cwd)
|
|
176
|
+
entry = ShellRegistry.instance.spawn(command: command, cwd: cwd)
|
|
177
|
+
"Started background shell #{entry.id} (pid #{entry.pid})\n " \
|
|
178
|
+
"command: #{command}\n " \
|
|
179
|
+
"cwd: #{cwd}\n" \
|
|
180
|
+
"Read output: shell_output run_id=#{entry.id}\n" \
|
|
181
|
+
"Send input: shell_input run_id=#{entry.id} text=...\n" \
|
|
182
|
+
"Terminate: shell_kill run_id=#{entry.id}"
|
|
183
|
+
rescue StandardError => e
|
|
184
|
+
"Error starting background shell: #{e.message}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Runs in its own process group so we can SIGTERM the whole subtree on
|
|
188
|
+
# timeout (a bare `kill pid` would leave child processes orphaned).
|
|
189
|
+
# Returns a structured hash — the wrapper builds the model-facing text
|
|
190
|
+
# from the same data, keeping the parse path single-sourced.
|
|
191
|
+
def execute_foreground(command, cwd, timeout)
|
|
192
|
+
rd = nil
|
|
193
|
+
rd, wr = IO.pipe
|
|
194
|
+
# bash -o pipefail (instead of bare `/bin/sh -c`) so a crash in the
|
|
195
|
+
# MIDDLE of a pipeline surfaces as the pipeline's exit status instead
|
|
196
|
+
# of being masked by an innocuous last stage (#156).
|
|
197
|
+
pid = Process.spawn("bash", "-o", "pipefail", "-c", command,
|
|
198
|
+
chdir: cwd, pgroup: true, out: wr, err: wr)
|
|
199
|
+
pgid = pid
|
|
200
|
+
wr.close
|
|
201
|
+
|
|
202
|
+
# Drain the merged stdout+stderr pipe line-by-line so each chunk can
|
|
203
|
+
# be streamed to the UI/event stream as the subprocess writes it,
|
|
204
|
+
# not just at end-of-command. The accumulated string is still the
|
|
205
|
+
# canonical model-facing output. `each_line` only yields on \n or
|
|
206
|
+
# EOF, so a process emitting unterminated progress (`\r`-only) will
|
|
207
|
+
# still buffer until newline — acceptable for v1; live progress
|
|
208
|
+
# bars are a separate problem.
|
|
209
|
+
output_buf = +""
|
|
210
|
+
output_thr = Thread.new do
|
|
211
|
+
begin
|
|
212
|
+
rd.each_line do |line|
|
|
213
|
+
output_buf << line
|
|
214
|
+
emit_chunk(line)
|
|
215
|
+
end
|
|
216
|
+
rescue IOError, Errno::EBADF
|
|
217
|
+
# pipe closed under us — process exited
|
|
218
|
+
ensure
|
|
219
|
+
rd.close unless rd.closed?
|
|
220
|
+
end
|
|
221
|
+
output_buf
|
|
222
|
+
end
|
|
223
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
224
|
+
|
|
225
|
+
begin
|
|
226
|
+
deadline = Time.now + timeout
|
|
227
|
+
status = nil
|
|
228
|
+
loop do
|
|
229
|
+
wpid, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
230
|
+
break if wpid
|
|
231
|
+
|
|
232
|
+
if cancellation_requested?
|
|
233
|
+
terminate_group(pgid)
|
|
234
|
+
sleep 0.5
|
|
235
|
+
begin
|
|
236
|
+
Process.kill("KILL", -pgid)
|
|
237
|
+
rescue StandardError
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
begin
|
|
241
|
+
Process.waitpid2(pid)
|
|
242
|
+
rescue StandardError
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
return foreground_result(
|
|
246
|
+
stdout: output_thr.value,
|
|
247
|
+
suffix: "[Command cancelled by user — SIGTERM sent]",
|
|
248
|
+
cancelled: true,
|
|
249
|
+
duration_ms: elapsed_ms(started_at)
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
if Time.now >= deadline
|
|
254
|
+
terminate_group(pgid)
|
|
255
|
+
_, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
256
|
+
unless status
|
|
257
|
+
sleep 2
|
|
258
|
+
_, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
259
|
+
end
|
|
260
|
+
unless status
|
|
261
|
+
begin
|
|
262
|
+
Process.kill("KILL", -pgid)
|
|
263
|
+
rescue StandardError
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
_, status = Process.waitpid2(pid)
|
|
267
|
+
end
|
|
268
|
+
return foreground_result(
|
|
269
|
+
stdout: output_thr.value,
|
|
270
|
+
suffix: "[Command timed out after #{timeout}s — SIGTERM sent]",
|
|
271
|
+
timed_out: true,
|
|
272
|
+
duration_ms: elapsed_ms(started_at)
|
|
273
|
+
)
|
|
274
|
+
end
|
|
275
|
+
sleep 0.05
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
code = status&.exitstatus
|
|
279
|
+
foreground_result(stdout: output_thr.value,
|
|
280
|
+
suffix: exit_suffix(code),
|
|
281
|
+
exit_code: code,
|
|
282
|
+
duration_ms: elapsed_ms(started_at))
|
|
283
|
+
rescue Errno::ECHILD
|
|
284
|
+
foreground_result(stdout: output_thr.value,
|
|
285
|
+
duration_ms: elapsed_ms(started_at))
|
|
286
|
+
end
|
|
287
|
+
rescue StandardError => e
|
|
288
|
+
{ text: "Shell error: #{e.message}", exit_code: nil, timed_out: false,
|
|
289
|
+
cancelled: false, shell_error: true, duration_ms: 0 }
|
|
290
|
+
ensure
|
|
291
|
+
rd.close if rd && !rd.closed?
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# nil for a clean exit; an honest [Exit code: N] otherwise. 141 keeps
|
|
295
|
+
# the real code in the text but carries the SIGPIPE note so neither
|
|
296
|
+
# the human nor the model reads it as a failure.
|
|
297
|
+
def exit_suffix(code)
|
|
298
|
+
return nil if code.nil? || code.zero?
|
|
299
|
+
|
|
300
|
+
if code == SIGPIPE_EXIT
|
|
301
|
+
"[Exit code: #{code} — SIGPIPE: downstream consumer closed early; treated as success]"
|
|
302
|
+
else
|
|
303
|
+
"[Exit code: #{code}]"
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def foreground_result(stdout:, duration_ms:, suffix: nil,
|
|
308
|
+
exit_code: nil, timed_out: false, cancelled: false)
|
|
309
|
+
text = stdout.to_s
|
|
310
|
+
text = "#{text}\n#{suffix}" if suffix
|
|
311
|
+
{ text: text,
|
|
312
|
+
exit_code: exit_code,
|
|
313
|
+
timed_out: timed_out,
|
|
314
|
+
cancelled: cancelled,
|
|
315
|
+
shell_error: false,
|
|
316
|
+
duration_ms: duration_ms }
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def elapsed_ms(started_at)
|
|
320
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def terminate_group(pgid)
|
|
324
|
+
Process.kill("TERM", -pgid)
|
|
325
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
326
|
+
# Already dead or not ours — fine.
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# steer — the MODEL-callable parent->child steering note (S2). The model
|
|
6
|
+
# counterpart of the human `/agents <id> steer "..."` affordance: a parent
|
|
7
|
+
# agent parks a note onto one of ITS OWN running children; the note is folded
|
|
8
|
+
# into that child's context at its next turn boundary (Loop#inject_steered_input
|
|
9
|
+
# via the child's steer_queue) and PERSISTS — it changes the child's
|
|
10
|
+
# trajectory, unlike the ephemeral `probe`.
|
|
11
|
+
#
|
|
12
|
+
# SCOPED AT CALL (the S1 correction): steer is registered for ALL agents and
|
|
13
|
+
# authorized by OWNERSHIP at call time. The caller is the thread-local
|
|
14
|
+
# Rubino.current_subagent_id (nil ⇒ the human / top-level agent). The target
|
|
15
|
+
# must be the caller's OWN DIRECT child (BackgroundTasks.owned_by?), so a node
|
|
16
|
+
# with no children simply gets a "not your child" error. This tool does NOT
|
|
17
|
+
# touch the human CLI path (executor.rb's steer_agent stays unscoped) and is
|
|
18
|
+
# NOT on any strip list.
|
|
19
|
+
#
|
|
20
|
+
# Mechanism reuse: it wraps BackgroundTasks#steer verbatim (the SAME wire the
|
|
21
|
+
# human CLI uses) — no new transport, no new state.
|
|
22
|
+
class SteerTool < Base
|
|
23
|
+
def name
|
|
24
|
+
"steer"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Gated by the same `tools.task` delegation key — steering a child is
|
|
28
|
+
# meaningless without the delegation substrate. Disabling delegation
|
|
29
|
+
# disables steer too.
|
|
30
|
+
def config_key
|
|
31
|
+
"task"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def description
|
|
35
|
+
"Steer one of YOUR OWN running subagents: park a short note that is " \
|
|
36
|
+
"folded into that child's context at its NEXT turn (it persists and " \
|
|
37
|
+
"changes what the child does). Use it to course-correct a child you " \
|
|
38
|
+
"started — add a constraint, narrow the scope, flag something it missed. " \
|
|
39
|
+
"You can ONLY steer subagents you started (your direct children); you " \
|
|
40
|
+
"cannot steer yourself, a sibling, or a finished child. The note is " \
|
|
41
|
+
"queued, not delivered instantly — the child sees it between turns."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def input_schema
|
|
45
|
+
{
|
|
46
|
+
type: "object",
|
|
47
|
+
properties: {
|
|
48
|
+
task_id: { type: "string", description: "The id (sa_…) of YOUR running subagent to steer." },
|
|
49
|
+
note: { type: "string",
|
|
50
|
+
description: "The steering note to fold into the child's next turn. Keep it short and self-contained." }
|
|
51
|
+
},
|
|
52
|
+
required: %w[task_id note]
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Steering a child is a low-risk, non-destructive nudge (the child carries
|
|
57
|
+
# its own approval/risk gates for anything it does next).
|
|
58
|
+
def risk_level
|
|
59
|
+
:low
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def call(arguments)
|
|
63
|
+
task_id = (arguments["task_id"] || arguments[:task_id]).to_s.strip
|
|
64
|
+
note = (arguments["note"] || arguments[:note]).to_s.strip
|
|
65
|
+
return "Error: note is required" if note.empty?
|
|
66
|
+
|
|
67
|
+
caller_id = Rubino.current_subagent_id
|
|
68
|
+
registry = BackgroundTasks.instance
|
|
69
|
+
entry = task_id.empty? ? nil : registry.find(task_id)
|
|
70
|
+
|
|
71
|
+
# No such id at all → it is not a steerable running subagent.
|
|
72
|
+
return "Cannot steer #{task_id} — no such running subagent." unless entry
|
|
73
|
+
# Self-steer is meaningless and would loop a note into your own context.
|
|
74
|
+
return "Error: cannot steer yourself." if task_id == caller_id
|
|
75
|
+
# Ownership: only a DIRECT child of the caller may be steered.
|
|
76
|
+
unless registry.owned_by?(caller_id, task_id)
|
|
77
|
+
return "Error: #{task_id} is not one of your subagents — you can only steer children you started."
|
|
78
|
+
end
|
|
79
|
+
# A finished child has no live loop to fold the note into.
|
|
80
|
+
return "Cannot steer #{task_id} — it already finished (#{entry.status})." unless live?(entry.status)
|
|
81
|
+
|
|
82
|
+
# Wraps the SAME wire the human CLI uses. A false here means the child's
|
|
83
|
+
# queue vanished between checks (a just-finished child) — treat as gone.
|
|
84
|
+
return "Cannot steer #{task_id} — no such running subagent." unless registry.steer(task_id, note)
|
|
85
|
+
|
|
86
|
+
# A child parked on a BLOCKING ask_parent has no next turn until the ask
|
|
87
|
+
# is answered — the note IS queued (deliver-on-unblock), but saying
|
|
88
|
+
# "enters child context next turn" would let the parent believe the
|
|
89
|
+
# redirect took effect (#198). Be honest and point at the one action
|
|
90
|
+
# that unblocks the child.
|
|
91
|
+
if parked_on_ask?(entry)
|
|
92
|
+
return "steer ▸ #{task_id} ← #{Rubino::Util::Output.elide(note, 80)} (queued — but #{task_id} is BLOCKED " \
|
|
93
|
+
"on ask_parent and will NOT see it until you answer its question: " \
|
|
94
|
+
"#{Rubino::Util::Output.elide(entry.ask_question, 120)} — unblock it with " \
|
|
95
|
+
"answer_child(task_id: \"#{task_id}\", answer: \"…\"))"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
"steer ▸ #{task_id} ← #{Rubino::Util::Output.elide(note, 80)} (parked · enters child context next turn)"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Mirrors BackgroundTasks#live_status? — a child still holds a loop (its
|
|
104
|
+
# thread is alive) while running, awaiting approval, or blocked on an
|
|
105
|
+
# escalated ask_parent (waiting on the human OR on its agent-parent).
|
|
106
|
+
def live?(status)
|
|
107
|
+
%i[running needs_approval blocked_on_human blocked_on_parent].include?(status)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# True when the child's thread is PARKED on a blocking ask_parent gate (a
|
|
111
|
+
# non-blocking ask keeps working, so its steer is consumable as normal).
|
|
112
|
+
def parked_on_ask?(entry)
|
|
113
|
+
entry.ask_gate && entry.ask_blocking &&
|
|
114
|
+
%i[blocked_on_human blocked_on_parent].include?(entry.status)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../llm/adapter_factory"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Tools
|
|
7
|
+
# probe — the parent\'s EPHEMERAL read-only peek into a running subagent (the
|
|
8
|
+
# second mechanism of the parent<->subagent comm design).
|
|
9
|
+
#
|
|
10
|
+
# Unlike `steer` (a persisted note that changes the child\'s trajectory),
|
|
11
|
+
# `probe` is read-only and DISCARDED: it takes a SNAPSHOT of the child\'s
|
|
12
|
+
# current messages, runs ONE side-inference ([child messages] + question) on
|
|
13
|
+
# the child\'s own model, and returns the answer to the parent. Nothing is
|
|
14
|
+
# appended to the child\'s history, the child\'s loop is never touched, and
|
|
15
|
+
# the Q&A never enters the timeline — so a probe can never alter what the
|
|
16
|
+
# subagent does. The cost is one extra model round-trip that is billed but
|
|
17
|
+
# thrown away (keep probes short).
|
|
18
|
+
#
|
|
19
|
+
# Reuse: the snapshot is just Session::Store#for_session on the child\'s own
|
|
20
|
+
# session id (the child Runner exposes #session); the inference is a one-shot
|
|
21
|
+
# AdapterFactory.build(...).chat — the SAME adapter seam Lifecycle and the
|
|
22
|
+
# auxiliary client use. No new transport, no shared state.
|
|
23
|
+
class SubagentProbe
|
|
24
|
+
# The instruction prepended to the one-shot so the child\'s model answers AS
|
|
25
|
+
# the subagent, from its context-so-far, without trying to continue the task.
|
|
26
|
+
PREAMBLE = "You are the subagent above. Answer the following question from " + "your current context ONLY — do not take any action or continue " + "your task; this is a read-only check. Be brief."
|
|
27
|
+
|
|
28
|
+
# @param adapter_factory [#call] test seam: a callable taking the resolved
|
|
29
|
+
# model id and returning an LLM adapter (anything responding to #chat).
|
|
30
|
+
# Defaults to the real AdapterFactory.build for the child\'s model.
|
|
31
|
+
def initialize(adapter_factory: nil, message_store: nil)
|
|
32
|
+
@adapter_factory = adapter_factory
|
|
33
|
+
@message_store = message_store
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Runs the ephemeral peek and returns the answer string. Best-effort: any
|
|
37
|
+
# failure (no session yet, model error) returns a short diagnostic rather
|
|
38
|
+
# than raising — a probe must never break the parent REPL.
|
|
39
|
+
def peek(entry:, question:)
|
|
40
|
+
snapshot = snapshot_messages(entry)
|
|
41
|
+
messages = [{ role: "user", content: PREAMBLE }] + snapshot +
|
|
42
|
+
[{ role: "user", content: question.to_s }]
|
|
43
|
+
|
|
44
|
+
adapter = build_adapter(entry)
|
|
45
|
+
response = adapter.chat(messages: messages)
|
|
46
|
+
text = response.respond_to?(:content) ? response.content.to_s : response.to_s
|
|
47
|
+
text.strip.empty? ? "(no answer)" : text.strip
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
"(probe failed: #{e.message})"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# The child\'s current transcript as plain {role:, content:} text messages.
|
|
55
|
+
# Tool/assistant rows with no textual content are dropped (the peek only
|
|
56
|
+
# needs the readable context, not the tool_use/result wiring), so the
|
|
57
|
+
# snapshot is a clean prompt the one-shot model can answer from.
|
|
58
|
+
def snapshot_messages(entry)
|
|
59
|
+
session = entry.runner&.session
|
|
60
|
+
return [] unless session && session[:id]
|
|
61
|
+
|
|
62
|
+
store.for_session(session[:id]).filter_map do |m|
|
|
63
|
+
c = m.content.to_s
|
|
64
|
+
next if c.strip.empty?
|
|
65
|
+
|
|
66
|
+
{ role: normalize_role(m.role), content: c }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Map persisted roles to the chat roles the adapter expects; a `tool` row
|
|
71
|
+
# becomes a user-visible context line (its content is the tool output).
|
|
72
|
+
def normalize_role(role)
|
|
73
|
+
%w[user assistant].include?(role.to_s) ? role.to_s : "user"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def store
|
|
77
|
+
@message_store ||= Session::Store.new
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_adapter(entry)
|
|
81
|
+
model = (entry.runner.respond_to?(:model_id) ? entry.runner.model_id : nil) if entry.runner
|
|
82
|
+
model ||= Rubino.configuration.model_default
|
|
83
|
+
return @adapter_factory.call(model) if @adapter_factory
|
|
84
|
+
|
|
85
|
+
LLM::AdapterFactory.build(model_id: model, config: Rubino.configuration)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|