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,520 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Tools
|
|
7
|
+
# Process-wide registry for subagents started by the `task` tool in the
|
|
8
|
+
# BACKGROUND (the default). Mirrors ShellRegistry — the in-repo precedent
|
|
9
|
+
# for "fire-and-forget + poll later + kill" — but the unit of work is a
|
|
10
|
+
# nested Agent::Runner thread instead of a detached OS process.
|
|
11
|
+
#
|
|
12
|
+
# Each entry owns:
|
|
13
|
+
# - the worker Thread running the child Runner#run!,
|
|
14
|
+
# - the child Runner (so #cancel can flip its CancelToken — exactly the
|
|
15
|
+
# mechanism Run::Executor's stop-watcher uses for top-level runs),
|
|
16
|
+
# - the terminal status/result/error captured in the worker's `ensure`.
|
|
17
|
+
#
|
|
18
|
+
# The registry survives a single CLI/server process — like ShellRegistry it
|
|
19
|
+
# is intentionally NOT persisted. Background subagents die with the process.
|
|
20
|
+
#
|
|
21
|
+
# Concurrency cap (mirrors the reference _DEFAULT_MAX_CONCURRENT_CHILDREN = 3): a
|
|
22
|
+
# background subagent is a full LLM run = real cost, so #spawn refuses past
|
|
23
|
+
# MAX_CONCURRENT live children rather than fanning out unbounded threads.
|
|
24
|
+
class BackgroundTasks
|
|
25
|
+
MAX_CONCURRENT = 3
|
|
26
|
+
|
|
27
|
+
# Fallback caps for the nested-subagent tree, used when config is absent
|
|
28
|
+
# (e.g. a bare registry in a unit test with no Configuration wired). The
|
|
29
|
+
# live values come from config (tasks.max_depth / max_children_per_node /
|
|
30
|
+
# max_concurrent_total); these constants are the built-in defaults the
|
|
31
|
+
# config keys themselves default to. All three are enforced in #reserve.
|
|
32
|
+
MAX_DEPTH = 2
|
|
33
|
+
MAX_CHILDREN_PER_NODE = 3
|
|
34
|
+
MAX_CONCURRENT_TOTAL = 8
|
|
35
|
+
|
|
36
|
+
# last_activity / tool_count / activity_log — live-progress fields written
|
|
37
|
+
# by UI::SubagentView#tool_started / #tool_finished (via
|
|
38
|
+
# #record_tool_started / #record_tool_finished) under the registry mutex
|
|
39
|
+
# and read by the parent renderer (UI::SubagentCards) and
|
|
40
|
+
# the /agents drill-in. activity_log is a bounded ring of the last few
|
|
41
|
+
# `✓ verb · hint` lines for the live drill-in; nothing is persisted (it
|
|
42
|
+
# dies with the process, like the rest of the registry).
|
|
43
|
+
#
|
|
44
|
+
# approval_gate / approval_question / approval_command are the
|
|
45
|
+
# Option-2 approval-surfacing state: when a background child's tool needs
|
|
46
|
+
# approval the child thread parks on `approval_gate` (a Run::ApprovalGate)
|
|
47
|
+
# and the entry flips to status :needs_approval with the question/command
|
|
48
|
+
# shown on the card; the user resolves it via /agents <id>.
|
|
49
|
+
Entry = Struct.new(
|
|
50
|
+
:id, :subagent, :prompt, :status, :result, :error,
|
|
51
|
+
:thread, :runner, :started_at, :finished_at,
|
|
52
|
+
:last_activity, :tool_count, :activity_log,
|
|
53
|
+
:approval_gate, :approval_id, :approval_question, :approval_command,
|
|
54
|
+
# Parent->child steer (the `/agents <id> steer "..."` note). Wired into
|
|
55
|
+
# the child Loop as its Interaction::InputQueue (the SAME turn-boundary
|
|
56
|
+
# steering channel the human uses on the parent); the parent pushes a
|
|
57
|
+
# note, the child folds it in at its next iteration via
|
|
58
|
+
# Loop#inject_steered_input. nil ⇒ no steer wire (sync/foreground path).
|
|
59
|
+
:steer_queue,
|
|
60
|
+
# child->parent ask_parent escalation (Run::ApprovalGate handoff). When a
|
|
61
|
+
# subagent calls ask_parent and it escalates to the HUMAN, the child
|
|
62
|
+
# parks on `ask_gate` keyed by `ask_id`, the entry flips to
|
|
63
|
+
# :blocked_on_human, and the card/banner surface `ask_question`. A
|
|
64
|
+
# blocking ask holds the child's worker thread on the gate (bounded only
|
|
65
|
+
# by an explicit /reply or stop — see ask_parent_tool.rb); a non-blocking
|
|
66
|
+
# ask returns immediately and the answer is delivered later via
|
|
67
|
+
# `steer_queue`. The human answers via /reply <id>, which decides the gate.
|
|
68
|
+
:ask_gate, :ask_id, :ask_question, :ask_blocking,
|
|
69
|
+
# Ownership link (S1 — foundation for model-driven steer/probe/ask_parent).
|
|
70
|
+
# owner_subagent_id is the `sa_*` id of the subagent that spawned this
|
|
71
|
+
# child, or nil when the spawner is the human / top-level agent. depth is
|
|
72
|
+
# 0 for a human-spawned child and owner.depth + 1 otherwise. The registry
|
|
73
|
+
# stays a FLAT map keyed by id; the parent/child tree is computed over
|
|
74
|
+
# owner_subagent_id (see #children_of / #descendants_of / #ancestors_of).
|
|
75
|
+
:owner_subagent_id, :depth,
|
|
76
|
+
# Model-driven LIVE-probe budget (S3). probe_count is how many BILLED
|
|
77
|
+
# `probe(live:true)` peeks the owner has run against this child;
|
|
78
|
+
# last_probe_at is when the last one ran (for an optional min-interval).
|
|
79
|
+
# Free snapshot probes (live:false) never touch these. Per-process, dies
|
|
80
|
+
# with the registry like the rest of the live-progress state.
|
|
81
|
+
:probe_count, :last_probe_at,
|
|
82
|
+
# The SPAWNING side's input queue, captured on the PARENT thread at
|
|
83
|
+
# spawn time (TaskTool#run_background) — the same spawn-captured sink
|
|
84
|
+
# the [background-task] completion notice rides. ask_parent's
|
|
85
|
+
# [subagent-question] notice for a top-level-owned child MUST use this:
|
|
86
|
+
# reading the thread-local Rubino.background_sink on the CHILD's thread
|
|
87
|
+
# resolves to the child's OWN steer_queue and misroutes the question
|
|
88
|
+
# back into the asking child (#195). nil ⇒ no queue was wired
|
|
89
|
+
# (sync/foreground spawn, headless).
|
|
90
|
+
:parent_sink,
|
|
91
|
+
keyword_init: true
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# How many recent activity lines the drill-in shows (the live `recent:` ring).
|
|
95
|
+
ACTIVITY_LOG_MAX = 6
|
|
96
|
+
|
|
97
|
+
class << self
|
|
98
|
+
def instance
|
|
99
|
+
@instance ||= new
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Test seam: drop all state between examples.
|
|
103
|
+
def reset!
|
|
104
|
+
@instance = nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def initialize
|
|
109
|
+
@entries = {}
|
|
110
|
+
@mutex = Mutex.new
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Reserves a slot and registers a `running` entry, returning it. The
|
|
114
|
+
# caller then attaches the worker thread + runner via #attach.
|
|
115
|
+
#
|
|
116
|
+
# owner_subagent_id is the `sa_*` id of the SPAWNING subagent (nil ⇒ the
|
|
117
|
+
# human / top-level agent spawned this child). depth is the caller's hint
|
|
118
|
+
# for a human-spawned child (0); for an owner-spawned child the depth is
|
|
119
|
+
# recomputed here from the owner entry (owner.depth + 1) so a stale hint
|
|
120
|
+
# can't smuggle a child past the depth cap.
|
|
121
|
+
#
|
|
122
|
+
# Returns nil — so TaskTool can surface a clear message instead of spawning
|
|
123
|
+
# unbounded work — when ANY of the three nesting caps is hit. The reason is
|
|
124
|
+
# available via #last_refusal_reason for the caller to phrase the message:
|
|
125
|
+
# :depth — depth >= max_depth (no deeper nesting allowed)
|
|
126
|
+
# :per_owner — this owner already has max_children_per_node live kids
|
|
127
|
+
# :global — total live subagents across the tree >= max total
|
|
128
|
+
# This is the SINGLE enforcement point for every nesting limit.
|
|
129
|
+
def reserve(subagent:, prompt:, owner_subagent_id: nil, depth: 0)
|
|
130
|
+
@mutex.synchronize do
|
|
131
|
+
owner = owner_subagent_id ? @entries[owner_subagent_id] : nil
|
|
132
|
+
effective_depth = owner ? owner.depth.to_i + 1 : depth.to_i
|
|
133
|
+
|
|
134
|
+
@last_refusal_reason = refusal_reason(owner_subagent_id, effective_depth)
|
|
135
|
+
return nil if @last_refusal_reason
|
|
136
|
+
|
|
137
|
+
entry = Entry.new(
|
|
138
|
+
id: new_id,
|
|
139
|
+
subagent: subagent.to_s,
|
|
140
|
+
prompt: prompt.to_s,
|
|
141
|
+
status: :running,
|
|
142
|
+
started_at: Time.now,
|
|
143
|
+
tool_count: 0,
|
|
144
|
+
activity_log: [],
|
|
145
|
+
# Every background child gets its OWN steering queue at reserve time
|
|
146
|
+
# so the parent can `/agents <id> steer "..."` it the instant it is
|
|
147
|
+
# listed — no separate wiring step, no nil window.
|
|
148
|
+
steer_queue: Interaction::InputQueue.new,
|
|
149
|
+
owner_subagent_id: owner_subagent_id,
|
|
150
|
+
depth: effective_depth
|
|
151
|
+
)
|
|
152
|
+
@entries[entry.id] = entry
|
|
153
|
+
entry
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Why the most recent #reserve returned nil (one of :depth / :per_owner /
|
|
158
|
+
# :global), or nil when the last reserve succeeded. Read by TaskTool to
|
|
159
|
+
# phrase a reason-specific at-capacity message.
|
|
160
|
+
attr_reader :last_refusal_reason
|
|
161
|
+
|
|
162
|
+
# Binds the live worker thread + child runner to a reserved entry so the
|
|
163
|
+
# registry can later cancel it. Done after reserve so the entry exists in
|
|
164
|
+
# the map before the thread starts (no race on completion writing back).
|
|
165
|
+
def attach(entry, thread:, runner:)
|
|
166
|
+
@mutex.synchronize do
|
|
167
|
+
entry.thread = thread
|
|
168
|
+
entry.runner = runner
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Marks a stop REQUEST (the /agents <id> --stop / task_stop path) on a
|
|
173
|
+
# live entry so the list/cards immediately show ◌ stopping instead of a
|
|
174
|
+
# stale ● running while the child unwinds at its next checkpoint (#108).
|
|
175
|
+
# Returns true when the entry flipped. #complete then maps a failure on
|
|
176
|
+
# a :stopping entry to the terminal :stopped, so a deliberate stop never
|
|
177
|
+
# reads as ✗ failed (#13).
|
|
178
|
+
def request_stop(id)
|
|
179
|
+
@mutex.synchronize do
|
|
180
|
+
entry = @entries[id]
|
|
181
|
+
return false unless entry && live_status?(entry.status)
|
|
182
|
+
|
|
183
|
+
entry.status = :stopping
|
|
184
|
+
true
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Records terminal state when the worker finishes (called from its
|
|
189
|
+
# `ensure`). Single writer per entry, but guarded so #find/#list readers
|
|
190
|
+
# see a consistent snapshot. A failure landing on a :stopping entry is a
|
|
191
|
+
# USER-REQUESTED stop unwinding (Interrupted at the next checkpoint), so
|
|
192
|
+
# it is recorded as :stopped — distinct from a genuine :failed (#108/#13).
|
|
193
|
+
def complete(entry, status:, result: nil, error: nil)
|
|
194
|
+
@mutex.synchronize do
|
|
195
|
+
status = :stopped if entry.status == :stopping && status == :failed
|
|
196
|
+
entry.status = status
|
|
197
|
+
entry.result = result
|
|
198
|
+
entry.error = error
|
|
199
|
+
entry.finished_at = Time.now
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Records a child tool STARTING: bumps the tool counter and sets the
|
|
204
|
+
# last-activity string the card/list show so concurrent tasks stay
|
|
205
|
+
# distinguishable (#124/#127). Called from UI::SubagentView#tool_started,
|
|
206
|
+
# which runs on the CHILD thread, so it MUST take the mutex (the parent
|
|
207
|
+
# renderer reads these fields concurrently). No-op for an unknown id (a late event
|
|
208
|
+
# after #remove).
|
|
209
|
+
def record_tool_started(id, activity)
|
|
210
|
+
@mutex.synchronize do
|
|
211
|
+
entry = @entries[id]
|
|
212
|
+
return unless entry
|
|
213
|
+
|
|
214
|
+
entry.tool_count = entry.tool_count.to_i + 1
|
|
215
|
+
entry.last_activity = activity.to_s
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Records a child tool FINISHING: appends a terse line to the bounded
|
|
220
|
+
# activity ring the live drill-in (#71) tails. Keeps the last
|
|
221
|
+
# ACTIVITY_LOG_MAX entries so the ring never grows unbounded for a
|
|
222
|
+
# read-heavy child.
|
|
223
|
+
def record_tool_finished(id, line)
|
|
224
|
+
@mutex.synchronize do
|
|
225
|
+
entry = @entries[id]
|
|
226
|
+
return unless entry
|
|
227
|
+
|
|
228
|
+
log = (entry.activity_log ||= [])
|
|
229
|
+
log << line.to_s
|
|
230
|
+
log.shift while log.size > ACTIVITY_LOG_MAX
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Flips an entry into the :needs_approval state and stores the gate +
|
|
235
|
+
# question/command the card surfaces (Option 2). The child thread then
|
|
236
|
+
# parks on `gate.await(approval_id)`; the user resolves it via
|
|
237
|
+
# /agents <id>. Returns the previous status so the child can restore it.
|
|
238
|
+
def begin_approval(id, gate:, approval_id:, question:, command:)
|
|
239
|
+
@mutex.synchronize do
|
|
240
|
+
entry = @entries[id]
|
|
241
|
+
return unless entry
|
|
242
|
+
|
|
243
|
+
entry.approval_gate = gate
|
|
244
|
+
entry.approval_id = approval_id
|
|
245
|
+
entry.approval_question = question.to_s
|
|
246
|
+
entry.approval_command = command.to_s
|
|
247
|
+
entry.status = :needs_approval
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Clears the approval state and returns the entry to :running once a
|
|
252
|
+
# decision has been delivered (or the child unwinds).
|
|
253
|
+
def end_approval(id)
|
|
254
|
+
@mutex.synchronize do
|
|
255
|
+
entry = @entries[id]
|
|
256
|
+
return unless entry
|
|
257
|
+
|
|
258
|
+
entry.approval_gate = nil
|
|
259
|
+
entry.approval_id = nil
|
|
260
|
+
entry.approval_question = nil
|
|
261
|
+
entry.approval_command = nil
|
|
262
|
+
entry.status = :running if entry.status == :needs_approval
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Records a parent->child steer note (the `/agents <id> steer \"...\"`
|
|
267
|
+
# affordance). Pushes the text onto the child's steering queue, which the
|
|
268
|
+
# child Loop drains at its next iteration boundary (Loop#inject_steered_input)
|
|
269
|
+
# — between turns, never between a tool_use and its results. Best-effort:
|
|
270
|
+
# returns false (and pushes nothing) when the entry is gone or has no queue
|
|
271
|
+
# (e.g. a finished child), true when the note was queued.
|
|
272
|
+
def steer(id, text)
|
|
273
|
+
queue = @mutex.synchronize do
|
|
274
|
+
entry = @entries[id]
|
|
275
|
+
entry&.steer_queue
|
|
276
|
+
end
|
|
277
|
+
return false unless queue
|
|
278
|
+
|
|
279
|
+
queue.push(text)
|
|
280
|
+
true
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Records a BILLED live probe against a child (S3): bumps probe_count and
|
|
284
|
+
# stamps last_probe_at, under the mutex (the owner runs this on its own
|
|
285
|
+
# thread while the parent renderer may read the entry). Returns the new
|
|
286
|
+
# count, or nil for an unknown id. Free snapshot probes (live:false) never
|
|
287
|
+
# call this — only `probe(live:true)` does, after the budget check passes.
|
|
288
|
+
def record_live_probe(id)
|
|
289
|
+
@mutex.synchronize do
|
|
290
|
+
entry = @entries[id]
|
|
291
|
+
return nil unless entry
|
|
292
|
+
|
|
293
|
+
entry.probe_count = entry.probe_count.to_i + 1
|
|
294
|
+
entry.last_probe_at = Time.now
|
|
295
|
+
entry.probe_count
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Flips an entry into the :blocked_on_human state for an escalated
|
|
300
|
+
# ask_parent: stores the gate + question + blocking flag the card/banner
|
|
301
|
+
# surface (mirror of #begin_approval, but for a child->parent question that
|
|
302
|
+
# the parent couldn't answer and escalated to the human). The child thread
|
|
303
|
+
# then parks on `ask_gate.await(ask_id)` (blocking ask) until /reply <id>
|
|
304
|
+
# decides the gate, or keeps working (non-blocking ask) with the answer
|
|
305
|
+
# delivered later via the steer queue. A child in this state still holds a
|
|
306
|
+
# concurrency slot (its thread is alive, or it is awaiting the human), so it
|
|
307
|
+
# counts as live.
|
|
308
|
+
# The status depends on WHO owns the asking child (S4): owner_id present (an
|
|
309
|
+
# agent-parent) → :blocked_on_parent (the parent MODEL answers via
|
|
310
|
+
# answer_child; the question was pushed onto the owner's steer_queue, NOT
|
|
311
|
+
# the human's job); owner_id nil (the human / top-level) → :blocked_on_human
|
|
312
|
+
# (the human answers via /reply <id>).
|
|
313
|
+
def begin_ask(id, gate:, ask_id:, question:, blocking:, owner_id: nil)
|
|
314
|
+
@mutex.synchronize do
|
|
315
|
+
entry = @entries[id]
|
|
316
|
+
return unless entry
|
|
317
|
+
|
|
318
|
+
entry.ask_gate = gate
|
|
319
|
+
entry.ask_id = ask_id
|
|
320
|
+
entry.ask_question = question.to_s
|
|
321
|
+
entry.ask_blocking = blocking ? true : false
|
|
322
|
+
entry.status = owner_id ? :blocked_on_parent : :blocked_on_human
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Clears the ask state and returns the entry to :running once the question
|
|
327
|
+
# has been answered (by the human via /reply, or the agent-parent via
|
|
328
|
+
# answer_child), or the child unwinds / is stopped.
|
|
329
|
+
def end_ask(id)
|
|
330
|
+
@mutex.synchronize do
|
|
331
|
+
entry = @entries[id]
|
|
332
|
+
return unless entry
|
|
333
|
+
|
|
334
|
+
entry.ask_gate = nil
|
|
335
|
+
entry.ask_id = nil
|
|
336
|
+
entry.ask_question = nil
|
|
337
|
+
entry.ask_blocking = nil
|
|
338
|
+
entry.status = :running if %i[blocked_on_human blocked_on_parent].include?(entry.status)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# The ONE shared answer wire for an escalated ask_parent, used by BOTH the
|
|
343
|
+
# human /reply path (Commands::Executor#deliver_reply) and the model-callable
|
|
344
|
+
# `answer_child` tool: route the answer back DOWN to the asking child by
|
|
345
|
+
# (1) deciding its ask gate — unblocks a BLOCKING ask with the answer as its
|
|
346
|
+
# tool result — and (2) pushing the answer onto its steer queue so a
|
|
347
|
+
# NON-BLOCKING ask folds it in at its next turn boundary; then clear the
|
|
348
|
+
# blocked state (#end_ask). Either way the answer PERSISTS in the child's
|
|
349
|
+
# context. No-op (returns false) for an unknown id or one not awaiting an
|
|
350
|
+
# answer (no ask_gate); true when the answer was routed.
|
|
351
|
+
def deliver_answer(id, answer)
|
|
352
|
+
entry = find(id)
|
|
353
|
+
return false unless entry&.ask_gate
|
|
354
|
+
|
|
355
|
+
entry.ask_gate.decide(entry.ask_id, answer)
|
|
356
|
+
steer(entry.id, "[parent answer] #{answer}")
|
|
357
|
+
end_ask(entry.id)
|
|
358
|
+
true
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Entries parked on an escalated ask_parent, waiting on THE HUMAN — the
|
|
362
|
+
# source of the persistent \"\u26d4 N subagent waiting on you\" marker and
|
|
363
|
+
# answerable via /reply <id>. Counts ONLY :blocked_on_human: a
|
|
364
|
+
# :blocked_on_parent child is its agent-parent's job (answer_child), not the
|
|
365
|
+
# human's, so it must NOT inflate the human's "waiting on you" count.
|
|
366
|
+
def awaiting_human
|
|
367
|
+
@mutex.synchronize { @entries.values.select { |e| e.status == :blocked_on_human } }
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Entries currently parked on a human approval — surfaced on their card
|
|
371
|
+
# and answerable via /agents <id>.
|
|
372
|
+
def awaiting_approval
|
|
373
|
+
@mutex.synchronize { @entries.values.select { |e| e.status == :needs_approval } }
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def find(id)
|
|
377
|
+
@mutex.synchronize { @entries[id] }
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# All entries, newest first — for a `task` listing (the /tasks analogue).
|
|
381
|
+
def list
|
|
382
|
+
@mutex.synchronize { @entries.values.sort_by(&:started_at).reverse }
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Live (still-running) children — used by the parent stop path to cancel
|
|
386
|
+
# orphans, and to enforce the concurrency cap. A child parked on a human
|
|
387
|
+
# approval (:needs_approval) is STILL live (its thread is alive, holding a
|
|
388
|
+
# slot), so it counts as running here.
|
|
389
|
+
def running
|
|
390
|
+
@mutex.synchronize { @entries.values.select { |e| live_status?(e.status) } }
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def remove(id)
|
|
394
|
+
@mutex.synchronize { @entries.delete(id) }
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# --- Tree over owner_subagent_id (the registry stays a flat map) ---------
|
|
398
|
+
|
|
399
|
+
# Direct children of `id`: entries whose owner_subagent_id == id. Pass nil
|
|
400
|
+
# for the human/top-level node's direct children.
|
|
401
|
+
def children_of(id)
|
|
402
|
+
@mutex.synchronize { @entries.values.select { |e| e.owner_subagent_id == id } }
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# All transitive descendants of `id` (BFS over owner_subagent_id), in
|
|
406
|
+
# breadth order. Cycle-safe (an id is visited at most once).
|
|
407
|
+
def descendants_of(id)
|
|
408
|
+
@mutex.synchronize do
|
|
409
|
+
out = []
|
|
410
|
+
seen = {}
|
|
411
|
+
frontier = @entries.values.select { |e| e.owner_subagent_id == id }
|
|
412
|
+
until frontier.empty?
|
|
413
|
+
nxt = []
|
|
414
|
+
frontier.each do |e|
|
|
415
|
+
next if seen[e.id]
|
|
416
|
+
|
|
417
|
+
seen[e.id] = true
|
|
418
|
+
out << e
|
|
419
|
+
nxt.concat(@entries.values.select { |c| c.owner_subagent_id == e.id })
|
|
420
|
+
end
|
|
421
|
+
frontier = nxt
|
|
422
|
+
end
|
|
423
|
+
out
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# The chain of ancestors of `id`, nearest parent first, walking
|
|
428
|
+
# owner_subagent_id up to the human/top-level root. Cycle-safe.
|
|
429
|
+
def ancestors_of(id)
|
|
430
|
+
@mutex.synchronize do
|
|
431
|
+
out = []
|
|
432
|
+
seen = { id => true }
|
|
433
|
+
cur = @entries[id]&.owner_subagent_id
|
|
434
|
+
while cur && (entry = @entries[cur]) && !seen[cur]
|
|
435
|
+
seen[cur] = true
|
|
436
|
+
out << entry
|
|
437
|
+
cur = entry.owner_subagent_id
|
|
438
|
+
end
|
|
439
|
+
out
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Stop-cascade (S5a): when a node is stopped, cancel the ask-gates of ALL
|
|
444
|
+
# its descendants so a blocking ask anywhere in the subtree unwinds at once
|
|
445
|
+
# (Run::ApprovalGate#cancel! wakes the parked child thread with Interrupted)
|
|
446
|
+
# instead of leaving an orphaned grandchild parked until its bound elapses.
|
|
447
|
+
# The descendant runners' CancelTokens are flipped by the caller's cancel!
|
|
448
|
+
# of the node; this just makes the gate-parked ones wake immediately. Safe
|
|
449
|
+
# to call on a node with no descendants or no blocked descendants.
|
|
450
|
+
def cancel_descendant_ask_gates(id)
|
|
451
|
+
descendants_of(id).each { |e| e.ask_gate&.cancel! }
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# True iff `child_id`'s direct owner is `parent_id` (the ownership predicate
|
|
455
|
+
# later slices' steer/probe/answer_child AUTHORIZATION checks will build on).
|
|
456
|
+
def owned_by?(parent_id, child_id)
|
|
457
|
+
@mutex.synchronize do
|
|
458
|
+
child = @entries[child_id]
|
|
459
|
+
!child.nil? && child.owner_subagent_id == parent_id
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
private
|
|
464
|
+
|
|
465
|
+
# The reason (if any) a reserve at this owner/depth must be refused, checked
|
|
466
|
+
# in the documented order. nil ⇒ allowed. Runs UNDER the mutex (callers hold
|
|
467
|
+
# it), reading the live entry map for the per-owner and global live counts.
|
|
468
|
+
def refusal_reason(owner_subagent_id, effective_depth)
|
|
469
|
+
return :depth if effective_depth >= max_depth
|
|
470
|
+
return :global if running_count >= max_concurrent_total
|
|
471
|
+
|
|
472
|
+
live_children = @entries.values.count do |e|
|
|
473
|
+
e.owner_subagent_id == owner_subagent_id && live_status?(e.status)
|
|
474
|
+
end
|
|
475
|
+
return :per_owner if live_children >= max_children_per_node
|
|
476
|
+
|
|
477
|
+
nil
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Live cap values, from config when wired, else the built-in constants (so a
|
|
481
|
+
# bare registry in a unit test with no Configuration still has sane caps).
|
|
482
|
+
def max_depth
|
|
483
|
+
config_int(:tasks_max_depth, MAX_DEPTH)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def max_children_per_node
|
|
487
|
+
config_int(:tasks_max_children_per_node, MAX_CHILDREN_PER_NODE)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def max_concurrent_total
|
|
491
|
+
config_int(:tasks_max_concurrent_total, MAX_CONCURRENT_TOTAL)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def config_int(accessor, fallback)
|
|
495
|
+
cfg = Rubino.configuration if Rubino.respond_to?(:configuration)
|
|
496
|
+
val = cfg&.respond_to?(accessor) ? cfg.public_send(accessor) : nil
|
|
497
|
+
Integer(val)
|
|
498
|
+
rescue StandardError, TypeError, ArgumentError
|
|
499
|
+
fallback
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# A child holds a concurrency slot while its thread is alive — whether
|
|
503
|
+
# actively running, parked on a human approval, parked on an escalated
|
|
504
|
+
# ask_parent question (waiting on the human OR on its agent-parent), or
|
|
505
|
+
# unwinding after a stop request (:stopping). All of these hold a live
|
|
506
|
+
# thread, so all count as live.
|
|
507
|
+
def live_status?(status)
|
|
508
|
+
%i[running needs_approval blocked_on_human blocked_on_parent stopping].include?(status)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def running_count
|
|
512
|
+
@entries.values.count { |e| live_status?(e.status) }
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def new_id
|
|
516
|
+
"sa_#{SecureRandom.hex(4)}"
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
end
|