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,622 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# Delegates a bounded sub-task to a specialized subagent (the "agents-as-tools"
|
|
6
|
+
# pattern). Modeled on Claude Code's Task/Agent tool, which runs subagents in
|
|
7
|
+
# the BACKGROUND via `run_in_background` — here background is the DEFAULT:
|
|
8
|
+
#
|
|
9
|
+
# - background (default): spawn the subagent on its own thread and return
|
|
10
|
+
# IMMEDIATELY with a task id (`sa_…`). The subagent works while the parent
|
|
11
|
+
# keeps going. On completion the parent is NOTIFIED — a `[background-task]`
|
|
12
|
+
# message is injected into its live turn (via the parent's InputQueue, the
|
|
13
|
+
# same channel mid-turn steering uses) — and the result is also fetchable
|
|
14
|
+
# with `task_result(<id>)` or stoppable with `task_stop(<id>)`. This is
|
|
15
|
+
# the SendMessage/poll/notify trio Claude Code exposes for background
|
|
16
|
+
# agents, mapped onto the gem's existing async substrate.
|
|
17
|
+
# - synchronous (`background: false`): the legacy path — run the nested turn
|
|
18
|
+
# to completion inline and return ONLY the subagent's final message as the
|
|
19
|
+
# tool result. For callers that cannot proceed without the answer now.
|
|
20
|
+
#
|
|
21
|
+
# Isolation contract (unchanged, both paths):
|
|
22
|
+
# - the nested run gets a FRESH session seeded with ONLY the `prompt`
|
|
23
|
+
# string — the parent transcript never leaks into the child;
|
|
24
|
+
# - each background child gets its OWN Interaction::EventBus (like
|
|
25
|
+
# Run::Executor does per top-level run) so its tool events never pollute
|
|
26
|
+
# the parent recorder;
|
|
27
|
+
# - the only parent→child channel is the `prompt`, so the parent model must
|
|
28
|
+
# put any needed file paths / errors into it.
|
|
29
|
+
#
|
|
30
|
+
# Scoped nesting (S1): a subagent CAN now spawn its own subagents (the
|
|
31
|
+
# delegation tools are no longer stripped from a subagent's tool list). The
|
|
32
|
+
# tree is bounded in ONE place — BackgroundTasks#reserve — by three caps:
|
|
33
|
+
# max nesting depth (tasks.max_depth), per-owner live children
|
|
34
|
+
# (tasks.max_children_per_node), and a global live ceiling
|
|
35
|
+
# (tasks.max_concurrent_total). When a cap is hit reserve returns nil and this
|
|
36
|
+
# tool surfaces a clear, reason-specific message (#capacity_message) so the
|
|
37
|
+
# model knows whether to retry later, do the work inline, or report back.
|
|
38
|
+
class TaskTool < Base
|
|
39
|
+
# Suffix of the placeholder a subagent run lands on when it produced no
|
|
40
|
+
# final assistant text — a no-op or a fully-denied run (every tool denied,
|
|
41
|
+
# nothing said). Used as the single signal that a completion was a no-op so
|
|
42
|
+
# both the background completion line and the foreground delegation row can
|
|
43
|
+
# show a neutral indicator instead of a misleading green ✓ (#16).
|
|
44
|
+
NOOP_RESULT_SUFFIX = "returned no output)"
|
|
45
|
+
|
|
46
|
+
# True when a subagent's final result text is the no-op placeholder, i.e.
|
|
47
|
+
# the run did nothing / was denied. Shared by completion_summary so the
|
|
48
|
+
# background path mirrors the foreground delegation row.
|
|
49
|
+
def self.noop_result?(text)
|
|
50
|
+
text.to_s.strip.end_with?(NOOP_RESULT_SUFFIX)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def name
|
|
54
|
+
"task"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# `task` is the config gate; absent from config ⇒ enabled (opt-out model),
|
|
58
|
+
# same as every other tool.
|
|
59
|
+
def config_key
|
|
60
|
+
"task"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The "NEVER claim a task was started…" sentence is the #149 guardrail:
|
|
64
|
+
# the model was observed confirming a spawn ("Started: sa_…") with a task
|
|
65
|
+
# id RECYCLED from earlier context, without calling this tool at all. The
|
|
66
|
+
# ids are unguessable (SecureRandom), so the only honest source of a NEW
|
|
67
|
+
# id is this tool's own return value — the description says so explicitly.
|
|
68
|
+
# Prompt-level by design: a render-time transcript scanner would be a far
|
|
69
|
+
# bigger surface for a model-behavior bug the user can already audit via
|
|
70
|
+
# the turn footer (0 tools) and /agents.
|
|
71
|
+
def description
|
|
72
|
+
"Delegate a bounded sub-task to a specialized subagent. By DEFAULT the " \
|
|
73
|
+
"subagent runs in the BACKGROUND: this call returns immediately with a " \
|
|
74
|
+
"task id and the subagent keeps working while you continue with other " \
|
|
75
|
+
"tools or reasoning — do NOT wait for it. When it finishes you will " \
|
|
76
|
+
"automatically receive a `[background-task] <id> completed` message with " \
|
|
77
|
+
"its result; you can also fetch the result anytime with `task_result(<id>)` " \
|
|
78
|
+
"or stop it with `task_stop(<id>)`. Set `background: false` ONLY when you " \
|
|
79
|
+
"cannot proceed without the subagent's answer in this same step (this " \
|
|
80
|
+
"blocks until it finishes and returns the result inline). The subagent " \
|
|
81
|
+
"runs in an isolated fresh context (it does NOT see this conversation) and " \
|
|
82
|
+
"returns only its final message — put every file path / error / detail it " \
|
|
83
|
+
"needs into `prompt`. NEVER claim a task was started unless THIS call just " \
|
|
84
|
+
"returned its id in the current turn — `sa_…` ids from earlier in the " \
|
|
85
|
+
"conversation belong to old tasks and must not be reported as new ones. " \
|
|
86
|
+
"Available subagents: #{available_subagents_description}."
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def input_schema
|
|
90
|
+
{
|
|
91
|
+
type: "object",
|
|
92
|
+
properties: {
|
|
93
|
+
subagent: { type: "string",
|
|
94
|
+
description: "Name of the subagent to delegate to (#{available_subagent_names.join(", ")})" },
|
|
95
|
+
prompt: { type: "string",
|
|
96
|
+
description: "The full self-contained task for the subagent (the only context it receives)" },
|
|
97
|
+
background: {
|
|
98
|
+
type: "boolean",
|
|
99
|
+
description: "Run the subagent in the background (default true). " \
|
|
100
|
+
"true = return immediately with a task id, keep working, get " \
|
|
101
|
+
"notified on completion. false = block until the subagent " \
|
|
102
|
+
"finishes and return its result inline."
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
required: %w[subagent prompt]
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Spawns a gated nested run, not a destructive op — the nested tools carry
|
|
110
|
+
# their own approval/risk gates. Low risk keeps it auto-available so the
|
|
111
|
+
# model can auto-delegate from the description.
|
|
112
|
+
def risk_level
|
|
113
|
+
:low
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def call(arguments)
|
|
117
|
+
subagent = (arguments["subagent"] || arguments[:subagent]).to_s.strip
|
|
118
|
+
prompt = (arguments["prompt"] || arguments[:prompt]).to_s
|
|
119
|
+
background = background_arg(arguments)
|
|
120
|
+
|
|
121
|
+
return "Error: subagent is required" if subagent.empty?
|
|
122
|
+
return "Error: prompt is required" if prompt.strip.empty?
|
|
123
|
+
|
|
124
|
+
definition = registry.find(subagent)
|
|
125
|
+
unless definition&.subagent?
|
|
126
|
+
return "Error: unknown subagent '#{subagent}'. " \
|
|
127
|
+
"Valid subagents: #{available_subagent_names.join(", ")}."
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if background
|
|
131
|
+
run_background(definition, prompt)
|
|
132
|
+
else
|
|
133
|
+
run_subagent(definition, prompt)
|
|
134
|
+
end
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
"Error: subagent '#{subagent}' failed: #{e.message}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
# background defaults to TRUE (Claude-Code-style background-by-default).
|
|
142
|
+
# Absent ⇒ true; only an explicit false (bool or "false") opts into the
|
|
143
|
+
# synchronous path. A nil from a caller that omitted the key stays true.
|
|
144
|
+
def background_arg(arguments)
|
|
145
|
+
raw = arguments.key?("background") ? arguments["background"] : arguments[:background]
|
|
146
|
+
return true if raw.nil?
|
|
147
|
+
|
|
148
|
+
![false, "false", 0, "0"].include?(raw)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Background spawn (the default). Reserves a registry slot, builds the
|
|
152
|
+
# child Runner with its OWN EventBus (isolation), launches it on a thread,
|
|
153
|
+
# and returns a handle string IMMEDIATELY — the parent never blocks.
|
|
154
|
+
#
|
|
155
|
+
# On completion the worker's `ensure` records terminal state, emits a
|
|
156
|
+
# SUBAGENT_COMPLETED/FAILED event (so the CLI/web can surface it), and
|
|
157
|
+
# pushes a `[background-task]` notice onto the captured parent sink (the
|
|
158
|
+
# parent's InputQueue) so the parent folds the result in at its next
|
|
159
|
+
# iteration boundary — the Claude Code "auto-notify on completion" contract.
|
|
160
|
+
def run_background(definition, prompt)
|
|
161
|
+
registry_bg = BackgroundTasks.instance
|
|
162
|
+
# Ownership link (S1): when THIS run is itself a subagent, the thread-local
|
|
163
|
+
# current-subagent id is the spawner — the new child's owner. nil ⇒ the
|
|
164
|
+
# human / top-level agent is spawning (depth 0). The owner's depth is what
|
|
165
|
+
# reserve uses to stamp the child (owner.depth + 1); we pass 0 only as the
|
|
166
|
+
# human-spawned default. reserve recomputes depth from the owner entry, so
|
|
167
|
+
# this is just the top-level base case.
|
|
168
|
+
owner_id = Rubino.current_subagent_id
|
|
169
|
+
entry = registry_bg.reserve(
|
|
170
|
+
subagent: definition.name, prompt: prompt,
|
|
171
|
+
owner_subagent_id: owner_id, depth: 0
|
|
172
|
+
)
|
|
173
|
+
return capacity_message(registry_bg) unless entry
|
|
174
|
+
|
|
175
|
+
# Captured on the PARENT thread, before we spawn — the child thread has
|
|
176
|
+
# no access to the parent's thread-locals. The sink is the parent's
|
|
177
|
+
# InputQueue (completion notice), event_bus is the turn-scoped bus (so
|
|
178
|
+
# SSE/recorder sees the lifecycle), parent_ui is the parent's CLI view
|
|
179
|
+
# (so completion surfaces as a line, like background-shell does).
|
|
180
|
+
sink = Rubino.background_sink
|
|
181
|
+
event_bus = Rubino.active_event_bus
|
|
182
|
+
parent_ui = Rubino.ui
|
|
183
|
+
# Stash the spawn-captured sink on the entry so a tool running on the
|
|
184
|
+
# CHILD's thread (ask_parent) can notify the parent MODEL without
|
|
185
|
+
# reading the child's own thread-local sink — which is the child's own
|
|
186
|
+
# steer_queue, not the parent's queue (#195).
|
|
187
|
+
entry.parent_sink = sink
|
|
188
|
+
# Build the child UI on the PARENT thread so the collapsed-card view is
|
|
189
|
+
# wired with this run's entry id + the parent CLI (whose live region hosts
|
|
190
|
+
# the card) + the approval handler. In card mode the child's per-tool
|
|
191
|
+
# activity feeds the registry instead of flooding $stdout (#124).
|
|
192
|
+
child_ui = nested_ui_for(entry, parent_ui)
|
|
193
|
+
runner = build_background_runner(definition, child_ui)
|
|
194
|
+
|
|
195
|
+
thread = Thread.new do
|
|
196
|
+
run_child_thread(entry, runner, prompt, sink, event_bus, parent_ui, child_ui)
|
|
197
|
+
end
|
|
198
|
+
registry_bg.attach(entry, thread: thread, runner: runner)
|
|
199
|
+
|
|
200
|
+
event_bus&.emit(Interaction::Events::SUBAGENT_SPAWNED,
|
|
201
|
+
task_id: entry.id, subagent: definition.name,
|
|
202
|
+
prompt: Rubino::Util::Output.elide(prompt, 200))
|
|
203
|
+
# Paint the collapsed card for this just-spawned subagent immediately so
|
|
204
|
+
# it shows "running · 0 tools" the instant delegation starts, not only
|
|
205
|
+
# after its first child tool fires.
|
|
206
|
+
repaint_parent_cards(parent_ui)
|
|
207
|
+
|
|
208
|
+
spawn_handle(entry, definition)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The child worker body. Runs the nested loop under the child UI, then —
|
|
212
|
+
# ALWAYS, even on a child exception (Exception net like Run::Executor) —
|
|
213
|
+
# records terminal state, notifies the parent, and emits the lifecycle
|
|
214
|
+
# event. A child LoadError/SyntaxError must not wedge the task as
|
|
215
|
+
# "running" forever.
|
|
216
|
+
def run_child_thread(entry, runner, prompt, sink, event_bus, parent_ui, child_ui = nil)
|
|
217
|
+
# The runner already renders through the card-mode child UI (wired at
|
|
218
|
+
# spawn); with_ui binds that SAME instance thread-locally so any global
|
|
219
|
+
# Rubino.ui lookup inside the nested loop also resolves to it.
|
|
220
|
+
ui_for_child = child_ui || nested_ui_for(entry, parent_ui)
|
|
221
|
+
# Wire the child Loop with the entry's OWN steering queue (parent->child
|
|
222
|
+
# `steer` channel) and bind the current-subagent id so a tool the child
|
|
223
|
+
# invokes (ask_parent) can find its own registry entry. The steer queue
|
|
224
|
+
# is the SAME InputQueue the human uses to steer the parent: the parent
|
|
225
|
+
# pushes a note via BackgroundTasks#steer, the child folds it in at its
|
|
226
|
+
# next iteration boundary (Loop#inject_steered_input).
|
|
227
|
+
result = Rubino.with_current_subagent_id(entry.id) do
|
|
228
|
+
Rubino.with_ui(ui_for_child) do
|
|
229
|
+
runner.run!(prompt, input_queue: entry.steer_queue)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
text = result.to_s.strip
|
|
233
|
+
text = "(subagent '#{entry.subagent}' #{NOOP_RESULT_SUFFIX}" if text.empty?
|
|
234
|
+
|
|
235
|
+
record_completion(entry, text, sink, parent_ui)
|
|
236
|
+
repaint_parent_cards(parent_ui)
|
|
237
|
+
event_bus&.emit(Interaction::Events::SUBAGENT_COMPLETED,
|
|
238
|
+
task_id: entry.id, subagent: entry.subagent,
|
|
239
|
+
status: "completed", output: Rubino::Util::Output.elide(text, 400))
|
|
240
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
241
|
+
BackgroundTasks.instance.complete(entry, status: :failed, error: e.message)
|
|
242
|
+
# A failure landing on a stop-requested entry was recorded as :stopped
|
|
243
|
+
# (BackgroundTasks#complete): a deliberate /agents --stop / task_stop
|
|
244
|
+
# must not surface as a ✗ "failed" notice (#108/#13).
|
|
245
|
+
if entry.status == :stopped
|
|
246
|
+
notify(sink, stopped_notice(entry))
|
|
247
|
+
surface_completion(parent_ui, "▸ #{entry.id} · #{entry.subagent} · stopped at your request",
|
|
248
|
+
id: entry.id, status: "stopped")
|
|
249
|
+
else
|
|
250
|
+
notify(sink, failure_notice(entry, e.message))
|
|
251
|
+
surface_completion(parent_ui, "▸ #{entry.id} · #{entry.subagent} · failed: #{e.message}",
|
|
252
|
+
id: entry.id, status: "failed")
|
|
253
|
+
end
|
|
254
|
+
repaint_parent_cards(parent_ui)
|
|
255
|
+
event_bus&.emit(Interaction::Events::SUBAGENT_FAILED,
|
|
256
|
+
task_id: entry.id, subagent: entry.subagent,
|
|
257
|
+
status: entry.status == :stopped ? "stopped" : "failed",
|
|
258
|
+
error: e.message)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Records the terminal :completed state and notifies the parent.
|
|
262
|
+
# Deliver-or-report for /agents steer (#140): a parked note the child
|
|
263
|
+
# never got another turn to fold in would otherwise vanish silently —
|
|
264
|
+
# the user believes the child was steered when it wasn't. Drain what's
|
|
265
|
+
# left NOW (the child is done; nothing can consume it anymore) and say
|
|
266
|
+
# so, on the parent UI and in the completion notice.
|
|
267
|
+
def record_completion(entry, text, sink, parent_ui)
|
|
268
|
+
undelivered = entry.steer_queue&.drain || []
|
|
269
|
+
BackgroundTasks.instance.complete(entry, status: :completed, result: text)
|
|
270
|
+
notify(sink, completion_notice(entry, text, undelivered: undelivered))
|
|
271
|
+
unless undelivered.empty?
|
|
272
|
+
surface_completion(parent_ui,
|
|
273
|
+
"⚠ #{entry.id} · steer note not delivered (task completed first): " \
|
|
274
|
+
"#{Rubino::Util::Output.elide(undelivered.join(" | "), 80)}")
|
|
275
|
+
end
|
|
276
|
+
surface_completion(parent_ui, completion_summary(entry, text),
|
|
277
|
+
id: entry.id, status: self.class.noop_result?(text) ? "no-op" : "done",
|
|
278
|
+
report: text)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# One committed summary line for a finished subagent, folded above the
|
|
282
|
+
# prompt by #surface_completion (the card itself clears when the registry
|
|
283
|
+
# snapshot no longer lists it as running). Reuses the LIVE-CARD row shape
|
|
284
|
+
# (P6) so the lifecycle reads in one grammar:
|
|
285
|
+
# ▸ sa_e488 · explore · completed · 1 tool · 12s
|
|
286
|
+
# The status word reflects the OUTCOME: a no-op / fully-denied run (final
|
|
287
|
+
# text is the no-op placeholder) says "no-op" — a denied subagent that
|
|
288
|
+
# did nothing must not read as a success (#16). The full report travels
|
|
289
|
+
# SEPARATELY (surface_completion report:) so the CLI can render it whole
|
|
290
|
+
# under `↳ report:` instead of an amputated one-line head.
|
|
291
|
+
def completion_summary(entry, text)
|
|
292
|
+
count = entry.tool_count.to_i
|
|
293
|
+
tools = "#{count} tool#{"s" if count != 1}"
|
|
294
|
+
word = self.class.noop_result?(text) ? "no-op" : "completed"
|
|
295
|
+
["▸ #{entry.id}", entry.subagent, word, tools, entry_elapsed(entry)].compact.join(" · ")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Human elapsed time for the lifecycle row, or nil when unknown.
|
|
299
|
+
def entry_elapsed(entry)
|
|
300
|
+
return nil unless entry.started_at
|
|
301
|
+
|
|
302
|
+
Util::Duration.human_duration((entry.finished_at || Time.now) - entry.started_at)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Rings the parent's attention notifier (bell/command hook) for a child
|
|
306
|
+
# parked on an approval — the same best-effort contract as the card
|
|
307
|
+
# repaint. No-op off the CLI (Null/API expose no notifier).
|
|
308
|
+
def ring_parent_attention(entry, preview)
|
|
309
|
+
parent_ui = entry_parent_ui
|
|
310
|
+
return unless parent_ui.respond_to?(:notifier)
|
|
311
|
+
|
|
312
|
+
parent_ui.notifier.needs_approval("subagent #{entry.id} needs approval: #{preview}")
|
|
313
|
+
rescue StandardError
|
|
314
|
+
nil
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Repaints the parent's collapsed card block from the registry snapshot.
|
|
318
|
+
# Best-effort: cosmetic, never breaks the worker. No-op off the CLI.
|
|
319
|
+
def repaint_parent_cards(parent_ui)
|
|
320
|
+
parent_ui.set_subagent_cards if parent_ui.respond_to?(:set_subagent_cards)
|
|
321
|
+
rescue StandardError
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Renders a one-line completion notice on the parent's CLI view, parallel
|
|
326
|
+
# to how a background shell's exit surfaces. DISPLAY-ONLY (a note on the
|
|
327
|
+
# parent UI) — the authoritative delivery to the MODEL is the InputQueue
|
|
328
|
+
# notice + the registry. No-op on Null/API (note is a quiet annotation).
|
|
329
|
+
# A terminal-state notice (id + status given) goes through the CLI's
|
|
330
|
+
# #subagent_finished so a completion landing at turn end folds into the
|
|
331
|
+
# turn footer instead of stacking a second `┄ ┄` rail (P4).
|
|
332
|
+
def surface_completion(parent_ui, line, id: nil, status: nil, report: nil)
|
|
333
|
+
return unless parent_ui.is_a?(UI::CLI)
|
|
334
|
+
|
|
335
|
+
if id && parent_ui.respond_to?(:subagent_finished)
|
|
336
|
+
parent_ui.subagent_finished(line, id: id, status: status || "done", report: report)
|
|
337
|
+
else
|
|
338
|
+
parent_ui.note(line)
|
|
339
|
+
end
|
|
340
|
+
rescue StandardError
|
|
341
|
+
# A UI hiccup must never wedge the worker's terminal-state bookkeeping.
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Parks the notice on the parent's InputQueue if one is wired — as a
|
|
345
|
+
# NOTICE, not a typed line: the parent loop folds it in at an iteration
|
|
346
|
+
# boundary of a live turn, or at the start of the NEXT real turn, never
|
|
347
|
+
# as a standalone synthetic user turn at the idle prompt (#13). When no
|
|
348
|
+
# sink exists (API/server, or the parent turn already ended) the result
|
|
349
|
+
# still lives in the registry and is reachable via `task_result`.
|
|
350
|
+
def notify(sink, text)
|
|
351
|
+
sink&.push_notice(text)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def completion_notice(entry, text, undelivered: [])
|
|
355
|
+
notice = "[background-task] Task #{entry.id} (subagent '#{entry.subagent}') completed.\n" \
|
|
356
|
+
"Result:\n#{Rubino::Util::Output.elide(text, 4000)}\n" \
|
|
357
|
+
"(full result via task_result(\"#{entry.id}\"))"
|
|
358
|
+
return notice if undelivered.empty?
|
|
359
|
+
|
|
360
|
+
notice + "\nNote: a steer note was NOT delivered (the task completed first): " \
|
|
361
|
+
"#{Rubino::Util::Output.elide(undelivered.join(" | "), 200)}"
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def failure_notice(entry, message)
|
|
365
|
+
"[background-task] Task #{entry.id} (subagent '#{entry.subagent}') failed: #{message}"
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# The stopped notice must carry the ground truth about PARTIAL progress:
|
|
369
|
+
# "no action needed" with zero detail led the parent model to assert that
|
|
370
|
+
# nothing was produced while completed side effects (an approved write,
|
|
371
|
+
# …) were already on disk (#150). Include the tool count + the activity
|
|
372
|
+
# tail from the registry entry so neither the model nor the human is misled.
|
|
373
|
+
def stopped_notice(entry)
|
|
374
|
+
base = "[background-task] Task #{entry.id} (subagent '#{entry.subagent}') was stopped " \
|
|
375
|
+
"at the user's request"
|
|
376
|
+
count = entry.tool_count.to_i
|
|
377
|
+
return "#{base} before it ran any tools — no action needed." if count.zero?
|
|
378
|
+
|
|
379
|
+
recent = Array(entry.activity_log).last(3).join("; ")
|
|
380
|
+
detail = recent.empty? ? "" : " (recent: #{recent})"
|
|
381
|
+
"#{base} after #{count} tool#{"s" if count != 1} had already run#{detail} — " \
|
|
382
|
+
"completed tools' side effects may exist."
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def spawn_handle(entry, definition)
|
|
386
|
+
"Started background subagent '#{definition.name}' as task #{entry.id}. " \
|
|
387
|
+
"It is running now — keep working on other things. You'll receive a " \
|
|
388
|
+
"`[background-task]` message when it finishes; or call " \
|
|
389
|
+
"task_result(\"#{entry.id}\") to check on it, task_stop(\"#{entry.id}\") to cancel."
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Turns a nil reserve into a clear, reason-specific model-facing string. The
|
|
393
|
+
# registry records WHY it refused (last_refusal_reason) so the three caps —
|
|
394
|
+
# max nesting depth, per-owner fan-out, global total — read distinctly
|
|
395
|
+
# instead of one undifferentiated "at capacity". The message must NOT
|
|
396
|
+
# recommend `background: false`: the sync path enforces the same ceilings
|
|
397
|
+
# (#196), so it is not an escape hatch.
|
|
398
|
+
def capacity_message(registry_bg)
|
|
399
|
+
case registry_bg.last_refusal_reason
|
|
400
|
+
when :depth
|
|
401
|
+
"Max nesting depth reached: subagents can only nest #{BackgroundTasks::MAX_DEPTH} " \
|
|
402
|
+
"levels deep. This subagent is too deep to delegate further — do the work " \
|
|
403
|
+
"directly, or report back so a shallower agent can split it up."
|
|
404
|
+
when :per_owner
|
|
405
|
+
"At capacity: this agent already has #{BackgroundTasks::MAX_CHILDREN_PER_NODE} " \
|
|
406
|
+
"subagents running. Wait for one to finish (you'll get a " \
|
|
407
|
+
"`[background-task]` message), check it with task_result, or do the work " \
|
|
408
|
+
"directly."
|
|
409
|
+
else # :global (or any future ceiling)
|
|
410
|
+
"At capacity: the maximum number of subagents " \
|
|
411
|
+
"(#{BackgroundTasks::MAX_CONCURRENT_TOTAL}) are already running across all " \
|
|
412
|
+
"agents. Wait for one to finish (you'll get a `[background-task]` message), " \
|
|
413
|
+
"check it with task_result, or do the work directly."
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Background children get their OWN fresh EventBus so their inner tool
|
|
418
|
+
# events stay off the parent recorder (the result-only isolation contract).
|
|
419
|
+
# Built directly here (not via @runner_factory, which tests use to inject a
|
|
420
|
+
# stub for the SYNC path) so the bus wiring is always honored.
|
|
421
|
+
def build_background_runner(definition, child_ui)
|
|
422
|
+
if @runner_factory
|
|
423
|
+
@runner_factory.call(definition)
|
|
424
|
+
else
|
|
425
|
+
Agent::Runner.new(
|
|
426
|
+
session_id: nil,
|
|
427
|
+
model_override: definition.resolved_model,
|
|
428
|
+
max_turns: definition.max_turns,
|
|
429
|
+
ui: child_ui,
|
|
430
|
+
agent_definition: definition,
|
|
431
|
+
event_bus: Interaction::EventBus.new
|
|
432
|
+
)
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Builds the child UI for a BACKGROUND run. In the interactive CLI it's a
|
|
437
|
+
# COLLAPSED-CARD SubagentView wired with this run's entry id (so its tool
|
|
438
|
+
# activity feeds the registry/card instead of flooding $stdout), the parent
|
|
439
|
+
# CLI (whose live region hosts the card), and the approval handler that
|
|
440
|
+
# surfaces a needed approval on the card + parks the child on a per-entry
|
|
441
|
+
# gate (Option 2). Off the CLI it's Null (headless/API stays silent and
|
|
442
|
+
# auto-approves as before).
|
|
443
|
+
def nested_ui_for(entry, parent_ui)
|
|
444
|
+
if parent_ui.is_a?(UI::CLI)
|
|
445
|
+
UI::SubagentView.new(
|
|
446
|
+
agent_name: entry.subagent,
|
|
447
|
+
entry_id: entry.id,
|
|
448
|
+
parent_ui: parent_ui,
|
|
449
|
+
approve: approval_handler_for(entry)
|
|
450
|
+
)
|
|
451
|
+
else
|
|
452
|
+
UI::Null.new
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# The approval handler the card-mode SubagentView calls when a background
|
|
457
|
+
# child's tool needs approval. It flips the entry to :needs_approval (the
|
|
458
|
+
# card now shows `● needs approval: <command>` + a parent note), registers a
|
|
459
|
+
# per-entry Run::ApprovalGate, and BLOCKS the child thread on the gate's
|
|
460
|
+
# bounded interruptible wait (15min → auto-deny; a /agents <id> --stop
|
|
461
|
+
# cancel wakes it to a deny). The user's /agents <id> decision resolves the
|
|
462
|
+
# gate; this returns the boolean to the child's tool. "Approve always" is
|
|
463
|
+
# persisted by the parent decision path (the existing allowlist), so here we
|
|
464
|
+
# only need the boolean.
|
|
465
|
+
def approval_handler_for(entry)
|
|
466
|
+
lambda do |question, scope: nil, command: nil, **_context|
|
|
467
|
+
gate = Run::ApprovalGate.new
|
|
468
|
+
approval_id = entry.id
|
|
469
|
+
gate.register(approval_id)
|
|
470
|
+
cmd = command && !command.to_s.empty? ? command.to_s : scope.to_s
|
|
471
|
+
BackgroundTasks.instance.begin_approval(
|
|
472
|
+
entry.id, gate: gate, approval_id: approval_id,
|
|
473
|
+
question: question, command: cmd
|
|
474
|
+
)
|
|
475
|
+
# The committed parent note shows a ONE-LINE elided preview. A
|
|
476
|
+
# multi-line command (ruby code, often starting with a blank line)
|
|
477
|
+
# truncated by raw character count committed its first code lines as
|
|
478
|
+
# bare unframed rows under the card — and an empty first line left
|
|
479
|
+
# "needs approval:" with no body at all (#141). Fall back to the
|
|
480
|
+
# question when the command has no usable line.
|
|
481
|
+
preview = approval_preview(cmd, question)
|
|
482
|
+
surface_completion(entry_parent_ui,
|
|
483
|
+
"● #{entry.id} · #{entry.subagent} · needs approval: #{preview} — /agents #{entry.id}")
|
|
484
|
+
repaint_parent_cards(entry_parent_ui)
|
|
485
|
+
ring_parent_attention(entry, preview)
|
|
486
|
+
begin
|
|
487
|
+
decision = gate.await(approval_id)
|
|
488
|
+
approved = decision_to_bool(decision)
|
|
489
|
+
rescue Rubino::Interrupted
|
|
490
|
+
approved = false # a stop/cancel while parked → deny and unwind
|
|
491
|
+
ensure
|
|
492
|
+
BackgroundTasks.instance.end_approval(entry.id)
|
|
493
|
+
repaint_parent_cards(entry_parent_ui)
|
|
494
|
+
end
|
|
495
|
+
approved
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# The parent CLI captured for repaints inside the approval handler. The
|
|
500
|
+
# handler runs on the CHILD thread, where Rubino.ui is the child's
|
|
501
|
+
# SubagentView (bound by with_ui); the real parent CLI is the process-global
|
|
502
|
+
# adapter, which is what hosts the live region.
|
|
503
|
+
def entry_parent_ui
|
|
504
|
+
Rubino.instance_variable_get(:@ui)
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Maps a gate decision to the boolean the child tool expects. EXPIRED (the
|
|
508
|
+
# 15-min bound elapsed with no answer) is a safe DENY, mirroring UI::API.
|
|
509
|
+
def decision_to_bool(decision)
|
|
510
|
+
return false if decision.equal?(Run::ApprovalGate::EXPIRED)
|
|
511
|
+
|
|
512
|
+
!!decision
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# One-line approval preview for the parent note (#141): the first
|
|
516
|
+
# NON-BLANK line of the command (elided), falling back to the question.
|
|
517
|
+
def approval_preview(cmd, question)
|
|
518
|
+
line = Rubino::Util::Output.first_nonblank_line(cmd)
|
|
519
|
+
line = Rubino::Util::Output.first_nonblank_line(question) if line.empty?
|
|
520
|
+
Rubino::Util::Output.elide(line, 80)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# Runs a FRESH nested agent turn for the given subagent definition and
|
|
524
|
+
# returns its final assistant message as the tool result string.
|
|
525
|
+
#
|
|
526
|
+
# The nested run uses a brand-new session (session_id: nil ⇒ created
|
|
527
|
+
# fresh) so the parent transcript never leaks. It runs synchronously —
|
|
528
|
+
# the parent waits — and is capped by the subagent's own `max_turns`.
|
|
529
|
+
# The nested loop's own tool events fire on the child's executor only;
|
|
530
|
+
# the parent recorder sees just this tool's start/complete boundary.
|
|
531
|
+
#
|
|
532
|
+
# GOVERNED LIKE THE BACKGROUND PATH (#196): a sync child goes through the
|
|
533
|
+
# SAME single enforcement point — BackgroundTasks#reserve — so all three
|
|
534
|
+
# nesting caps apply and it counts toward the live totals for the whole
|
|
535
|
+
# inline run; and it runs under with_current_subagent_id(entry.id) so
|
|
536
|
+
# anything IT spawns is stamped with the right owner/depth. Without this,
|
|
537
|
+
# `background: false` was an uncapped escape hatch that also corrupted
|
|
538
|
+
# ownership/depth stamping for its entire subtree.
|
|
539
|
+
def run_subagent(definition, prompt)
|
|
540
|
+
registry_bg = BackgroundTasks.instance
|
|
541
|
+
entry = registry_bg.reserve(
|
|
542
|
+
subagent: definition.name, prompt: prompt,
|
|
543
|
+
owner_subagent_id: Rubino.current_subagent_id, depth: 0
|
|
544
|
+
)
|
|
545
|
+
return capacity_message(registry_bg) unless entry
|
|
546
|
+
|
|
547
|
+
runner = build_runner(definition)
|
|
548
|
+
registry_bg.attach(entry, thread: Thread.current, runner: runner)
|
|
549
|
+
result = Rubino.with_current_subagent_id(entry.id) { runner.run!(prompt) }
|
|
550
|
+
text = result.to_s.strip
|
|
551
|
+
text = "(subagent '#{definition.name}' #{NOOP_RESULT_SUFFIX}" if text.empty?
|
|
552
|
+
registry_bg.complete(entry, status: :completed, result: text)
|
|
553
|
+
text
|
|
554
|
+
rescue StandardError => e
|
|
555
|
+
# Release the reserved slot on ANY failure so a raising sync child can
|
|
556
|
+
# never wedge a live-slot leak; #call's rescue phrases the message.
|
|
557
|
+
registry_bg.complete(entry, status: :failed, error: e.message) if entry
|
|
558
|
+
raise
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# Builds the nested Runner. Injectable via the constructor for tests
|
|
562
|
+
# (so a FakeLLMAdapter can drive the child loop); defaults to a real
|
|
563
|
+
# Runner wired with the subagent's resolved model / max_turns and a
|
|
564
|
+
# fresh ephemeral session. The child UI is chosen by #nested_ui: a
|
|
565
|
+
# live nested view in the interactive CLI, silent (Null) everywhere else.
|
|
566
|
+
def build_runner(definition)
|
|
567
|
+
if @runner_factory
|
|
568
|
+
@runner_factory.call(definition)
|
|
569
|
+
else
|
|
570
|
+
Agent::Runner.new(
|
|
571
|
+
session_id: nil,
|
|
572
|
+
model_override: definition.resolved_model,
|
|
573
|
+
max_turns: definition.max_turns,
|
|
574
|
+
ui: nested_ui(definition),
|
|
575
|
+
agent_definition: definition
|
|
576
|
+
)
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# The UI the child loop renders through.
|
|
581
|
+
#
|
|
582
|
+
# Interactive CLI → UI::SubagentView: the subagent's tool activity shows
|
|
583
|
+
# INLINE, nested + colored under the parent's "● delegated → X" row (the
|
|
584
|
+
# only "watch live" that fits our scroll-native + bottom-composer model).
|
|
585
|
+
# It is DISPLAY-ONLY — it writes to $stdout and never touches the parent
|
|
586
|
+
# loop's messages or recorder, so the result-only contract holds.
|
|
587
|
+
#
|
|
588
|
+
# API / headless / tests (UI::Null, UI::API, …) → UI::Null: the child
|
|
589
|
+
# stays silent so the boundary-only contract for SSE consumers and the
|
|
590
|
+
# non-interactive paths is unchanged (the web nested view is a separate
|
|
591
|
+
# follow-up).
|
|
592
|
+
def nested_ui(definition)
|
|
593
|
+
if Rubino.ui.is_a?(UI::CLI)
|
|
594
|
+
UI::SubagentView.new(agent_name: definition.name)
|
|
595
|
+
else
|
|
596
|
+
UI::Null.new
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# Optional injection point for tests — a callable taking the resolved
|
|
601
|
+
# Definition and returning something that responds to #run!(prompt).
|
|
602
|
+
def initialize(runner_factory: nil)
|
|
603
|
+
@runner_factory = runner_factory
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def registry
|
|
607
|
+
Rubino.agent_registry
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def available_subagent_names
|
|
611
|
+
registry.subagents.map(&:name)
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def available_subagents_description
|
|
615
|
+
registry.subagents.map do |a|
|
|
616
|
+
desc = a.description.to_s.strip
|
|
617
|
+
desc.empty? ? a.name : "#{a.name} (#{desc})"
|
|
618
|
+
end.join("; ")
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
end
|