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,510 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Rubino
|
|
7
|
+
module Commands
|
|
8
|
+
module Handlers
|
|
9
|
+
# The `/agents` (alias `/tasks`) drill-in surface and the `/reply` answer
|
|
10
|
+
# path, extracted from Commands::Executor (batch B).
|
|
11
|
+
#
|
|
12
|
+
# The "see what other agents do" surface. Lists background subagents from
|
|
13
|
+
# the BackgroundTasks registry (the async `task` substrate), drills into a
|
|
14
|
+
# single one's result/error, steers/probes/stops a running one, and routes
|
|
15
|
+
# a human /reply back down to a blocked child.
|
|
16
|
+
#
|
|
17
|
+
# /agents → list
|
|
18
|
+
# /agents <id> → drill-in (result / error / status)
|
|
19
|
+
# /agents <id> --stop → cancel a running subagent
|
|
20
|
+
# /agents <id> steer "…" → fire-and-forget note into the child's context
|
|
21
|
+
# /agents <id> probe "…" → ephemeral read-only peek
|
|
22
|
+
# /reply <id> <answer> → answer a child blocked on a human/parent ask
|
|
23
|
+
class Agents
|
|
24
|
+
include Rubino::UI::ProbeWaitIndicator
|
|
25
|
+
|
|
26
|
+
# How many times the parked-child approval prompt re-renders after an
|
|
27
|
+
# empty/aborted read (#144) before giving up and leaving the child parked.
|
|
28
|
+
APPROVAL_ASK_ATTEMPTS = 3
|
|
29
|
+
|
|
30
|
+
def initialize(ui:)
|
|
31
|
+
@ui = ui
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def handle_agents(arguments)
|
|
35
|
+
args = arguments.to_s.strip
|
|
36
|
+
|
|
37
|
+
if args.empty?
|
|
38
|
+
show_agents_list
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
tokens = args.split(/\s+/)
|
|
43
|
+
stop = tokens.delete("--stop") ? true : false
|
|
44
|
+
id = tokens.shift
|
|
45
|
+
|
|
46
|
+
if id.nil? || id.empty?
|
|
47
|
+
show_agents_list
|
|
48
|
+
elsif stop
|
|
49
|
+
stop_agent(id)
|
|
50
|
+
elsif tokens.first == "steer"
|
|
51
|
+
steer_agent(id, dequote(tokens[1..].join(" ")))
|
|
52
|
+
elsif tokens.first == "probe"
|
|
53
|
+
probe_agent(id, dequote(tokens[1..].join(" ")))
|
|
54
|
+
else
|
|
55
|
+
show_agent_detail(id)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# child->parent ASK_PARENT answer: /reply <id> <answer>. Resolves the
|
|
60
|
+
# child's ask gate (Run::ApprovalGate#decide) so a BLOCKING ask unwinds with
|
|
61
|
+
# the answer as its tool result, and ALSO pushes the answer onto the child's
|
|
62
|
+
# steer queue so a NON-BLOCKING ask folds it in at its next turn boundary.
|
|
63
|
+
# Either way the answer PERSISTS in the child's context. With no inline
|
|
64
|
+
# answer, falls back to an interactive prompt (the ◆ takeover, like the
|
|
65
|
+
# approval menu). Clears the blocked state and unblocks the tree.
|
|
66
|
+
def handle_reply(arguments)
|
|
67
|
+
tokens = arguments.to_s.strip.split(/\s+/)
|
|
68
|
+
id = tokens.shift
|
|
69
|
+
if id.nil? || id.empty?
|
|
70
|
+
show_blocked_agents
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# /reply is UNSCOPED: the human is the ultimate supervisor and may answer
|
|
75
|
+
# ANY blocked node — one waiting on the human (:blocked_on_human) OR one
|
|
76
|
+
# waiting on its agent-parent (:blocked_on_parent), if the human chooses
|
|
77
|
+
# to step in.
|
|
78
|
+
entry = Tools::BackgroundTasks.instance.find(id)
|
|
79
|
+
if entry.nil? || !%i[blocked_on_human blocked_on_parent].include?(entry.status)
|
|
80
|
+
@ui.error("#{id} is not waiting on you.")
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
answer = dequote(tokens.join(" "))
|
|
85
|
+
answer = prompt_reply_answer(entry) if answer.to_s.strip.empty?
|
|
86
|
+
if answer.to_s.strip.empty?
|
|
87
|
+
@ui.info("No answer given — #{id} is still waiting.")
|
|
88
|
+
return
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
deliver_reply(entry, answer)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# parent->child STEER: a fire-and-forget note that enters the child's
|
|
97
|
+
# context at its next turn boundary (Loop#inject_steered_input). Pushes onto
|
|
98
|
+
# the child's steering queue via BackgroundTasks#steer — the SAME wire the
|
|
99
|
+
# human uses to steer the parent. Echoed with the existing steer vocabulary
|
|
100
|
+
# (▸, "enters child context") + a card repaint so the parked note shows.
|
|
101
|
+
def steer_agent(id, text)
|
|
102
|
+
if text.to_s.strip.empty?
|
|
103
|
+
@ui.error(%(usage: /agents #{id} steer "your note"))
|
|
104
|
+
return
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if Tools::BackgroundTasks.instance.steer(id, text)
|
|
108
|
+
@ui.info("steer ▸ #{id} ← #{truncate(text, 80)} (parked · enters child context next turn)")
|
|
109
|
+
@ui.set_subagent_cards if @ui.respond_to?(:set_subagent_cards)
|
|
110
|
+
else
|
|
111
|
+
@ui.error("cannot steer #{id} — no such running subagent.")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# parent->child PROBE: an EPHEMERAL read-only peek. Snapshots the child's
|
|
116
|
+
# current messages, runs ONE side-inference ([child messages] + question) on
|
|
117
|
+
# the child's own model, prints the answer in a dashed "ephemeral · not
|
|
118
|
+
# saved" aside, and DISCARDS it — nothing is appended to the child's
|
|
119
|
+
# history, nothing enters the timeline. The absence of any saved/timeline
|
|
120
|
+
# entry is itself the signal that the peek changed nothing.
|
|
121
|
+
def probe_agent(id, question)
|
|
122
|
+
if question.to_s.strip.empty?
|
|
123
|
+
@ui.error(%(usage: /agents #{id} probe "your question"))
|
|
124
|
+
return
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
entry = Tools::BackgroundTasks.instance.find(id)
|
|
128
|
+
unless entry
|
|
129
|
+
@ui.error("cannot probe #{id} — no such subagent.")
|
|
130
|
+
return
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
@ui.info(pastel.dim("┄┄ probe → #{id} ┄┄ (ephemeral · not saved · child trajectory unchanged)"))
|
|
134
|
+
# A probe answers from the child's context AT THIS INSTANT; right after
|
|
135
|
+
# spawn that context is still empty and the child honestly says it isn't
|
|
136
|
+
# working on anything yet — hint so that doesn't read as broken (#112).
|
|
137
|
+
if entry.tool_count.to_i.zero?
|
|
138
|
+
@ui.info(pastel.dim(" (snapshot at this instant — the child just started and its " \
|
|
139
|
+
"context is still empty; probe again in a moment)"))
|
|
140
|
+
end
|
|
141
|
+
@ui.info("? #{question}")
|
|
142
|
+
# The peek is a synchronous side-inference (seconds of model wait) with
|
|
143
|
+
# nothing streaming — show the same thinking row /probe got in #58 so
|
|
144
|
+
# the gap before the ⟵ answer never looks frozen (#146). TTY only;
|
|
145
|
+
# Null/API adapters and pipes stay silent.
|
|
146
|
+
probe_thinking_started(@ui)
|
|
147
|
+
answer = begin
|
|
148
|
+
Tools::SubagentProbe.new.peek(entry: entry, question: question)
|
|
149
|
+
ensure
|
|
150
|
+
probe_thinking_finished(@ui)
|
|
151
|
+
end
|
|
152
|
+
@ui.info("⟵ #{answer}")
|
|
153
|
+
@ui.info(pastel.dim("┄┄ end probe (nothing was saved to #{id}) ┄┄"))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The interactive ◆ takeover for /reply with no inline answer — mirrors the
|
|
157
|
+
# approval menu (composer-suspend, ◆ glyph) so answering an ask_parent feels
|
|
158
|
+
# exactly like answering an approval, a pattern the user already knows.
|
|
159
|
+
def prompt_reply_answer(entry)
|
|
160
|
+
@ui.info("")
|
|
161
|
+
@ui.info("◆ #{entry.id} (#{entry.subagent}) asks — everything is waiting on this")
|
|
162
|
+
@ui.info(" ❓ #{entry.ask_question}")
|
|
163
|
+
@ui.ask("✎ your answer › ").to_s
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Routes the answer back DOWN to the child: decide the gate (unblocks a
|
|
167
|
+
# blocking ask with the answer as its tool result) and push it onto the
|
|
168
|
+
# steer queue (a non-blocking ask folds it in next turn). Then clear the
|
|
169
|
+
# blocked state and repaint so the ⛔ marker clears.
|
|
170
|
+
def deliver_reply(entry, answer)
|
|
171
|
+
# The ONE shared answer wire (also used by the model-callable
|
|
172
|
+
# answer_child tool): decide the gate + push the steer note + clear the
|
|
173
|
+
# blocked state, all in BackgroundTasks#deliver_answer.
|
|
174
|
+
Tools::BackgroundTasks.instance.deliver_answer(entry.id, answer)
|
|
175
|
+
@ui.info("↳ answered #{entry.id}: #{truncate(answer, 80)}")
|
|
176
|
+
@ui.info("✓ tree unblocked · #{entry.id} resumes at its next turn")
|
|
177
|
+
@ui.set_subagent_cards if @ui.respond_to?(:set_subagent_cards)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Lists the children currently blocked on the human (the /reply with no id
|
|
181
|
+
# case) so the user can see who is waiting and on what.
|
|
182
|
+
def show_blocked_agents
|
|
183
|
+
blocked = Tools::BackgroundTasks.instance.awaiting_human
|
|
184
|
+
if blocked.empty?
|
|
185
|
+
@ui.info("No subagent is waiting on you.")
|
|
186
|
+
return
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
@ui.info(pastel.red("⛔ #{blocked.size} subagent waiting on you:"))
|
|
190
|
+
blocked.each do |e|
|
|
191
|
+
@ui.info(" #{e.id} · #{e.subagent}: #{truncate(e.ask_question, 80)}")
|
|
192
|
+
end
|
|
193
|
+
@ui.info("/reply <id> <answer> to answer")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Strips a single pair of wrapping double/single quotes from a steer/probe
|
|
197
|
+
# argument so `steer "be terse"` lands as `be terse`, not `"be terse"`.
|
|
198
|
+
def dequote(text)
|
|
199
|
+
t = text.to_s.strip
|
|
200
|
+
if t.length >= 2 && ((t.start_with?(%(")) && t.end_with?(%("))) || (t.start_with?("'") && t.end_with?("'")))
|
|
201
|
+
return t[1..-2]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
t
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def show_agents_list
|
|
208
|
+
entries = Tools::BackgroundTasks.instance.list
|
|
209
|
+
if entries.empty?
|
|
210
|
+
@ui.info("No background subagents. The agent starts them with its `task` tool;")
|
|
211
|
+
@ui.info("they run while you keep working. They'll appear here when it does.")
|
|
212
|
+
return
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
rows = entries.map do |e|
|
|
216
|
+
[e.id, agent_status_icon(e.status), agent_label(e), agent_elapsed(e)]
|
|
217
|
+
end
|
|
218
|
+
@ui.table(headers: %w[ID Status Task Elapsed], rows: rows)
|
|
219
|
+
@ui.info("/agents <id> for output · /agents <id> --stop to cancel")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def show_agent_detail(id)
|
|
223
|
+
entry = Tools::BackgroundTasks.instance.find(id)
|
|
224
|
+
unless entry
|
|
225
|
+
@ui.error("no background subagent with id #{id}.")
|
|
226
|
+
return
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
case entry.status
|
|
230
|
+
when :needs_approval
|
|
231
|
+
# Option 2: a parked child is waiting on THIS human. Lead with the
|
|
232
|
+
# interactive approve/deny prompt that resolves its gate.
|
|
233
|
+
resolve_agent_approval(entry)
|
|
234
|
+
when :running
|
|
235
|
+
# #71 live drill-in: expand to the task summary + the recent-activity
|
|
236
|
+
# ring, tailing the registry live until the user stops watching.
|
|
237
|
+
watch_agent(entry)
|
|
238
|
+
else
|
|
239
|
+
show_agent_result(entry)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Static detail for a finished (done/failed) task — the full result/error,
|
|
244
|
+
# as before.
|
|
245
|
+
def show_agent_result(entry)
|
|
246
|
+
@ui.info("#{entry.id} #{agent_status_icon(entry.status)} · #{entry.subagent}")
|
|
247
|
+
@ui.info("task: #{truncate(entry.prompt, 200)}")
|
|
248
|
+
@ui.separator
|
|
249
|
+
case entry.status
|
|
250
|
+
when :failed
|
|
251
|
+
@ui.error(entry.error.to_s.empty? ? "(failed, no error message)" : entry.error.to_s)
|
|
252
|
+
when :stopped
|
|
253
|
+
show_stopped_summary(entry)
|
|
254
|
+
else
|
|
255
|
+
render_agent_report(entry.result.to_s)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# The child's final report is markdown (it is a model answer): render it
|
|
260
|
+
# through the SAME pipeline assistant answers use instead of dumping
|
|
261
|
+
# literal `##`/`**` into the transcript (#139). Adapters without the
|
|
262
|
+
# markdown seam (Null/API) keep the plain info fallback.
|
|
263
|
+
def render_agent_report(result)
|
|
264
|
+
return @ui.info("(no output)") if result.empty?
|
|
265
|
+
|
|
266
|
+
if @ui.respond_to?(:commit_markdown_block)
|
|
267
|
+
@ui.commit_markdown_block(result)
|
|
268
|
+
else
|
|
269
|
+
@ui.info(result)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# A stopped child may have COMPLETED side effects before the stop (#150):
|
|
274
|
+
# "no result" alone led the parent/human to assert nothing was produced
|
|
275
|
+
# while an approved write was already on disk. Surface the tool count and
|
|
276
|
+
# the registry's activity tail as ground truth.
|
|
277
|
+
def show_stopped_summary(entry)
|
|
278
|
+
count = entry.tool_count.to_i
|
|
279
|
+
if count.zero?
|
|
280
|
+
@ui.info("(stopped at your request before it ran any tools — no result)")
|
|
281
|
+
else
|
|
282
|
+
@ui.info("(stopped at your request after #{count} tool#{"s" if count != 1} had already run — " \
|
|
283
|
+
"completed tools' side effects may exist)")
|
|
284
|
+
Array(entry.activity_log).last(3).each { |line| @ui.info(" #{line}") }
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# #71 — LIVE drill-in for a running subagent. Renders the task summary and
|
|
289
|
+
# the recent-activity ring (read live from the registry, which the child's
|
|
290
|
+
# UI::SubagentView keeps fresh), refreshing in place until the user presses a
|
|
291
|
+
# key (Esc/Enter/q) or the task ends. Off an interactive terminal (#ask
|
|
292
|
+
# returns nil — Null/API/pipe) it degrades to a SINGLE snapshot so the
|
|
293
|
+
# non-interactive paths and unit tests never block on a redraw loop.
|
|
294
|
+
def watch_agent(entry)
|
|
295
|
+
render_agent_watch(entry)
|
|
296
|
+
return unless interactive_terminal?
|
|
297
|
+
|
|
298
|
+
@ui.info("(watching live — press Enter/Esc to stop, /agents #{entry.id} --stop to cancel)")
|
|
299
|
+
watch_loop(entry.id)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Renders ONE watch frame: header + task + the recent: ring. Public-ish
|
|
303
|
+
# snapshot shape reused per refresh tick. The recent ring is the registry's
|
|
304
|
+
# bounded activity_log, plus the live last_activity as the trailing ● line.
|
|
305
|
+
def render_agent_watch(entry)
|
|
306
|
+
@ui.info("#{entry.id} #{agent_status_icon(entry.status)} · #{entry.subagent} · #{agent_elapsed(entry)}")
|
|
307
|
+
@ui.info("task: #{truncate(entry.prompt, 120)}")
|
|
308
|
+
@ui.info("recent:")
|
|
309
|
+
Array(entry.activity_log).last(5).each { |line| @ui.info(" #{line}") }
|
|
310
|
+
last = entry.last_activity.to_s
|
|
311
|
+
@ui.info(" #{pastel.yellow("●")} #{last}") unless last.empty?
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# The live refresh loop for #watch_agent. Polls the registry and re-renders
|
|
315
|
+
# a frame each tick until the task leaves :running or the user hits a key.
|
|
316
|
+
# Kept deliberately simple (a periodic re-render of the snapshot, not a
|
|
317
|
+
# full-screen redraw) to stay scroll-native and avoid a second raw-mode
|
|
318
|
+
# rendering subsystem. Bounded so it can never hang the REPL.
|
|
319
|
+
def watch_loop(id, ticks: 600, interval: 0.5)
|
|
320
|
+
ticks.times do
|
|
321
|
+
break if key_pressed?(interval)
|
|
322
|
+
|
|
323
|
+
entry = Tools::BackgroundTasks.instance.find(id)
|
|
324
|
+
break if entry.nil? || entry.status != :running
|
|
325
|
+
|
|
326
|
+
@ui.separator
|
|
327
|
+
render_agent_watch(entry)
|
|
328
|
+
end
|
|
329
|
+
final = Tools::BackgroundTasks.instance.find(id)
|
|
330
|
+
@ui.info("(stopped watching #{id})") if final && final.status == :running
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Option 2 — resolve a parked child's approval. Shows the command and asks
|
|
334
|
+
# Approve once / Approve always / Deny; the answer resolves the child's
|
|
335
|
+
# gate (the child's #confirm returns it). "always" approves AND persists via
|
|
336
|
+
# the parent CLI's allowlist (the same path an inline approval uses), so the
|
|
337
|
+
# child — and future calls — proceed without re-prompting.
|
|
338
|
+
def resolve_agent_approval(entry)
|
|
339
|
+
gate = entry.approval_gate
|
|
340
|
+
unless gate
|
|
341
|
+
@ui.info("#{entry.id} is no longer waiting on approval.")
|
|
342
|
+
return
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
@ui.info("#{entry.id} #{agent_status_icon(entry.status)} · #{entry.subagent}")
|
|
346
|
+
@ui.info("needs approval to run:")
|
|
347
|
+
@ui.info(" #{entry.approval_command.to_s.empty? ? entry.approval_question : entry.approval_command}")
|
|
348
|
+
answer = ask_approval_answer(entry)
|
|
349
|
+
return if answer.nil?
|
|
350
|
+
|
|
351
|
+
decision =
|
|
352
|
+
case answer
|
|
353
|
+
when "a", "always" then persist_agent_always(entry)
|
|
354
|
+
true
|
|
355
|
+
when "o", "once", "y" then true
|
|
356
|
+
else false
|
|
357
|
+
end
|
|
358
|
+
gate.decide(entry.approval_id, decision)
|
|
359
|
+
@ui.info(decision ? "Approved #{entry.id}." : "Denied #{entry.id}.")
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Reads the approval answer, re-rendering the prompt on an EMPTY read.
|
|
363
|
+
# A background event (another child's completion fold-in) landing while
|
|
364
|
+
# the prompt is open can abort the underlying TTY read, which used to
|
|
365
|
+
# surface as an empty answer and silently resolve the gate to DENIED
|
|
366
|
+
# (#144). An empty/aborted read is therefore never an answer: re-ask,
|
|
367
|
+
# and after APPROVAL_ASK_ATTEMPTS empty reads return nil WITHOUT
|
|
368
|
+
# touching the gate — the child stays parked and `/agents <id>`
|
|
369
|
+
# re-opens the prompt. Denying requires an explicit keypress ("n", or
|
|
370
|
+
# any other non-approving answer).
|
|
371
|
+
def ask_approval_answer(entry)
|
|
372
|
+
APPROVAL_ASK_ATTEMPTS.times do
|
|
373
|
+
answer = @ui.ask("Approve? [o]nce / [a]lways / [n]o deny: ").to_s.strip.downcase
|
|
374
|
+
return answer unless answer.empty?
|
|
375
|
+
end
|
|
376
|
+
@ui.info("no answer read — #{entry.id} is still waiting; /agents #{entry.id} to decide.")
|
|
377
|
+
nil
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Persists an "approve always" for a parked subagent's command via the same
|
|
381
|
+
# session allowlist the inline CLI approval uses, so the decision survives
|
|
382
|
+
# and future identical calls (parent or child) skip the prompt.
|
|
383
|
+
def persist_agent_always(entry)
|
|
384
|
+
scope = "#{entry.subagent}:#{entry.approval_command}"
|
|
385
|
+
Run::SessionApprovalCache.instance.remember(@ui.respond_to?(:session_id) ? @ui.session_id : nil, scope,
|
|
386
|
+
"session")
|
|
387
|
+
rescue StandardError
|
|
388
|
+
nil
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# True when the REPL owns a real interactive terminal (so a live watch /
|
|
392
|
+
# keypress poll makes sense). Off a TTY we render a single snapshot.
|
|
393
|
+
def interactive_terminal?
|
|
394
|
+
$stdin.respond_to?(:tty?) && $stdin.tty? && $stdout.respond_to?(:tty?) && $stdout.tty?
|
|
395
|
+
rescue StandardError
|
|
396
|
+
false
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Non-blocking-ish single-key poll: waits up to +timeout+s for any key.
|
|
400
|
+
# Used to let the user stop the live watch with a keypress. Best-effort:
|
|
401
|
+
# returns false (keep watching) on any terminal hiccup so the bounded loop
|
|
402
|
+
# still terminates on its tick budget.
|
|
403
|
+
def key_pressed?(timeout)
|
|
404
|
+
return false unless interactive_terminal?
|
|
405
|
+
|
|
406
|
+
ready = $stdin.wait_readable(timeout)
|
|
407
|
+
return false unless ready
|
|
408
|
+
|
|
409
|
+
$stdin.read_nonblock(1)
|
|
410
|
+
true
|
|
411
|
+
rescue StandardError
|
|
412
|
+
false
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def stop_agent(id)
|
|
416
|
+
registry = Tools::BackgroundTasks.instance
|
|
417
|
+
entry = registry.find(id)
|
|
418
|
+
unless entry
|
|
419
|
+
@ui.error("no background subagent with id #{id}.")
|
|
420
|
+
return
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
unless %i[running needs_approval blocked_on_human blocked_on_parent].include?(entry.status)
|
|
424
|
+
@ui.info("#{id} already #{entry.status} — nothing to stop.")
|
|
425
|
+
return
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# A child parked on a human approval or an ask_parent is blocked in its
|
|
429
|
+
# gate's wait; cancel the gates so it wakes (Interrupted → deny/cancel) and
|
|
430
|
+
# unwinds instead of holding its thread until the bound. The stop-cascade
|
|
431
|
+
# then wakes every DESCENDANT parked on a blocking ask too, so the whole
|
|
432
|
+
# subtree unwinds at once (S5a — no orphaned blocked grandchild).
|
|
433
|
+
# Mark the stop FIRST so the very next /agents list shows ◌ stopping
|
|
434
|
+
# instead of a stale ● running (#108), and so the worker's terminal
|
|
435
|
+
# write records the unwind as :stopped, not ✗ failed (#13) — then wake
|
|
436
|
+
# the gates/runner.
|
|
437
|
+
registry.request_stop(id)
|
|
438
|
+
entry.approval_gate&.cancel!
|
|
439
|
+
entry.ask_gate&.cancel!
|
|
440
|
+
registry.cancel_descendant_ask_gates(id)
|
|
441
|
+
entry.runner&.cancel!
|
|
442
|
+
@ui.success("Stop requested for #{id} (#{entry.subagent}); it unwinds at its next checkpoint.")
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# `<glyph> <word>` for a subagent's state, with a SPACE between glyph and
|
|
446
|
+
# word and the glyph colored by state (#86): amber ● running, red ✗ failed,
|
|
447
|
+
# green ✓ done — instead of a same-color, glued "●running".
|
|
448
|
+
def agent_status_icon(status)
|
|
449
|
+
glyph, word, color =
|
|
450
|
+
case status
|
|
451
|
+
when :running then ["●", "running", :yellow]
|
|
452
|
+
when :stopping then ["◌", "stopping", :yellow]
|
|
453
|
+
when :stopped then ["⊘", "stopped", :yellow]
|
|
454
|
+
when :needs_approval then ["●", "approval", :yellow]
|
|
455
|
+
when :blocked_on_human then ["⛔", "waiting on you", :red]
|
|
456
|
+
when :blocked_on_parent then ["◷", "waiting on parent", :cyan]
|
|
457
|
+
when :failed then ["✗", "failed", :red]
|
|
458
|
+
else ["✓", "done", :green]
|
|
459
|
+
end
|
|
460
|
+
"#{pastel.public_send(color, glyph)} #{word}"
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# subagent name + the DISTINGUISHING detail for the list label (#127). For
|
|
464
|
+
# a running task the live last_activity is the most distinguishing field
|
|
465
|
+
# (two "explore: summarize lib/…" tasks differ by what they're doing NOW),
|
|
466
|
+
# so prefer it; otherwise a wider (80-char) slice of the prompt's first
|
|
467
|
+
# line so the tail — often the distinguishing path/arg — survives instead
|
|
468
|
+
# of being cut at 40.
|
|
469
|
+
def agent_label(entry)
|
|
470
|
+
if %i[running needs_approval stopping].include?(entry.status) && !entry.last_activity.to_s.empty?
|
|
471
|
+
return "#{entry.subagent}: #{truncate(entry.last_activity, 80)}"
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
prompt = truncate_middle(entry.prompt.to_s.lines.first.to_s.strip, 80)
|
|
475
|
+
prompt.empty? ? entry.subagent : "#{entry.subagent}: #{prompt}"
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# Middle truncation for the /agents Task label (#14): similarly-phrased
|
|
479
|
+
# delegations share their HEAD ("Summarize the contents of lib/…") while
|
|
480
|
+
# the distinguishing detail — the path/arg — sits at the TAIL, so a
|
|
481
|
+
# head-only cut renders concurrent tasks identical. Keep both ends,
|
|
482
|
+
# elide the middle.
|
|
483
|
+
def truncate_middle(text, max)
|
|
484
|
+
s = text.to_s.gsub(/\s+/, " ").strip
|
|
485
|
+
return s if s.length <= max
|
|
486
|
+
|
|
487
|
+
head = (max - 1) * 2 / 3
|
|
488
|
+
tail = max - 1 - head
|
|
489
|
+
"#{s[0, head]}…#{s[-tail, tail]}"
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def agent_elapsed(entry)
|
|
493
|
+
finish = entry.finished_at || Time.now
|
|
494
|
+
return "" unless entry.started_at
|
|
495
|
+
|
|
496
|
+
Rubino::Util::Duration.human_duration(finish - entry.started_at)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def pastel
|
|
500
|
+
@pastel ||= Pastel.new
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def truncate(text, max)
|
|
504
|
+
s = text.to_s.gsub(/\s+/, " ").strip
|
|
505
|
+
s.length > max ? "#{s[0, max - 1]}…" : s
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Commands
|
|
5
|
+
module Handlers
|
|
6
|
+
# The `/config` in-chat read/set surface, extracted from Commands::Executor
|
|
7
|
+
# (batch B) — over the SAME effective config (file merged over defaults)
|
|
8
|
+
# the `rubino config` CLI verbs use (#187), so checking `memory.backend` no
|
|
9
|
+
# longer means quitting the REPL. Rendering is shared with the CLI
|
|
10
|
+
# (CLI::ConfigCommand.render_get / .render_show), so secret-named keys are
|
|
11
|
+
# masked identically on both surfaces.
|
|
12
|
+
#
|
|
13
|
+
# /config → config file path + usage hint
|
|
14
|
+
# /config show → the full merged config, secrets masked
|
|
15
|
+
# /config path → the config file path
|
|
16
|
+
# /config <key> → get (dot-notation; `get <key>` also works)
|
|
17
|
+
# /config <key> <value> → set: the same Config::Writer write-through
|
|
18
|
+
# /reasoning uses (`set <key> <value>` too)
|
|
19
|
+
class Config
|
|
20
|
+
def initialize(ui:)
|
|
21
|
+
@ui = ui
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def handle_config(arguments)
|
|
25
|
+
tokens = arguments.to_s.strip.split(/\s+/)
|
|
26
|
+
case tokens.first
|
|
27
|
+
when nil then show_config_summary
|
|
28
|
+
when "show" then CLI::ConfigCommand.render_show(ui: @ui)
|
|
29
|
+
when "path" then @ui.info(Rubino::Config::Loader.new.config_path)
|
|
30
|
+
when "get" then config_get(tokens[1])
|
|
31
|
+
when "set" then config_set(tokens[1], tokens[2..])
|
|
32
|
+
else
|
|
33
|
+
tokens.length == 1 ? config_get(tokens.first) : config_set(tokens.first, tokens[1..])
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def show_config_summary
|
|
40
|
+
@ui.info("config #{Rubino::Config::Loader.new.config_path}")
|
|
41
|
+
@ui.info("/config show · /config <key> · /config <key> <value>")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def config_get(key)
|
|
45
|
+
if key.to_s.empty?
|
|
46
|
+
@ui.info("Usage: /config get <key> (dot-notation, e.g. memory.backend)")
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
CLI::ConfigCommand.render_get(key, ui: @ui)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Write-through + live update, the same pair /reasoning and /think run
|
|
54
|
+
# (#131): the file write makes the change survive the session; the
|
|
55
|
+
# in-memory set applies it to config reads from the next turn. The echo
|
|
56
|
+
# is masked like `config show` so a freshly-set api_key never lands in
|
|
57
|
+
# the scrollback. Consumers that memoize their config (e.g. the memory
|
|
58
|
+
# backend) still need a restart — same caveat as the CLI verb.
|
|
59
|
+
def config_set(key, value_tokens)
|
|
60
|
+
value = Array(value_tokens).join(" ")
|
|
61
|
+
if key.to_s.empty? || value.empty?
|
|
62
|
+
@ui.info("Usage: /config set <key> <value>")
|
|
63
|
+
return
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
writer = Rubino::Config::Writer.new(config_path: Rubino::Config::Loader.new.config_path)
|
|
67
|
+
writer.set(key, value)
|
|
68
|
+
coerced = writer.get(key)
|
|
69
|
+
apply_config_live(key, coerced)
|
|
70
|
+
@ui.success("#{key} = #{CLI::ConfigCommand.redact(coerced, key: key.split(".").last)} " \
|
|
71
|
+
"(persisted; applies from the next turn — memoizing consumers need a restart)")
|
|
72
|
+
rescue Rubino::ConfigurationError => e
|
|
73
|
+
@ui.error(e.message)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Mirrors the Writer's (already validated + coerced) value onto the live
|
|
77
|
+
# configuration. Best-effort: the merged in-memory tree can disagree
|
|
78
|
+
# with the file's shape (a default-valued scalar where the file grew a
|
|
79
|
+
# section), in which case the persisted value still applies on restart.
|
|
80
|
+
def apply_config_live(key, value)
|
|
81
|
+
Rubino.configuration.set(*key.split("."), value)
|
|
82
|
+
rescue StandardError
|
|
83
|
+
@ui.warning("#{key} persisted to config.yml but could not be applied live — restart to pick it up")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|