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,296 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Rubino
|
|
7
|
+
module UI
|
|
8
|
+
# Bridge between Agent::Runner and the HTTP API.
|
|
9
|
+
#
|
|
10
|
+
# Streaming output (info/success/stream/...) is appended to an in-memory
|
|
11
|
+
# event buffer that the API server drains over SSE.
|
|
12
|
+
#
|
|
13
|
+
# Interactive prompts cross threads through an ApprovalGate:
|
|
14
|
+
# - #confirm emits `approval.required` on the recorder and blocks on the
|
|
15
|
+
# gate until an HTTP client posts a decision.
|
|
16
|
+
# - #ask emits `clarify.required` and blocks the same way.
|
|
17
|
+
#
|
|
18
|
+
# When no gate/recorder is wired (CLI or test contexts), both calls fall
|
|
19
|
+
# back to auto-approve (#confirm -> true, #ask -> nil).
|
|
20
|
+
#
|
|
21
|
+
# APPROVE_DECISIONS lists the decision strings that count as approve;
|
|
22
|
+
# anything else yields a false from #confirm. The two deny forms differ
|
|
23
|
+
# only in persistence: "deny" denies this call ONCE (nothing remembered,
|
|
24
|
+
# re-prompts next session); "deny_always" additionally persists a
|
|
25
|
+
# permissions:deny rule so ApprovalPolicy#decide auto-denies the pattern
|
|
26
|
+
# across sessions. The set is kept in sync with Schemas::DecideApproval so
|
|
27
|
+
# every value the HTTP boundary accepts is either an approve or an explicit
|
|
28
|
+
# deny — no unreachable values, no silent denies from typos. `always` is a
|
|
29
|
+
# back-compat alias for `always_command` (existing web clients post it).
|
|
30
|
+
class API < Base
|
|
31
|
+
APPROVE_DECISIONS = %w[once session always always_prefix always_command].freeze
|
|
32
|
+
|
|
33
|
+
# `always` from older web clients means the narrow "always this command"
|
|
34
|
+
# form (== always_command); normalized away before decision handling.
|
|
35
|
+
ALWAYS_ALIAS = { "always" => "always_command" }.freeze
|
|
36
|
+
|
|
37
|
+
attr_reader :events
|
|
38
|
+
|
|
39
|
+
def initialize(gate: nil, recorder: nil, session_id: nil, approval_cache: nil)
|
|
40
|
+
@gate = gate
|
|
41
|
+
@recorder = recorder
|
|
42
|
+
@session_id = session_id
|
|
43
|
+
@approval_cache = approval_cache || Rubino::Run::SessionApprovalCache.instance
|
|
44
|
+
@events = []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The API adapter parks the run on the ApprovalGate for approvals
|
|
48
|
+
# (#confirm) and clarifications (#ask) — but only when a gate AND recorder
|
|
49
|
+
# are actually wired. Without them both calls auto-resolve and never block,
|
|
50
|
+
# so the loop can keep streaming. Drives Loop#interactive_turn?.
|
|
51
|
+
def blocking_human_input?
|
|
52
|
+
!@gate.nil? && !@recorder.nil?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def info(message) = emit_event(:info, message: message)
|
|
56
|
+
|
|
57
|
+
def success(message) = emit_event(:success, message: message)
|
|
58
|
+
def warning(message) = emit_event(:warning, message: message)
|
|
59
|
+
def error(message) = emit_event(:error, message: message)
|
|
60
|
+
def status(message) = emit_event(:status, message: message)
|
|
61
|
+
def note(text) = emit_event(:note, text: text)
|
|
62
|
+
def assistant_text(text) = emit_event(:assistant_text, text: text)
|
|
63
|
+
|
|
64
|
+
# The adapter no longer drops :thinking deltas in hidden mode (the CLI
|
|
65
|
+
# retains them unrendered for the Ctrl-O reveal, #76); the HTTP wire
|
|
66
|
+
# keeps the old contract — hidden means no reasoning deltas reach
|
|
67
|
+
# API consumers, so the gate lives here now.
|
|
68
|
+
def stream(chunk)
|
|
69
|
+
return if chunk.is_a?(Hash) && chunk[:type] == :thinking &&
|
|
70
|
+
Config::ReasoningPrefs.mode(Rubino.configuration) == :hidden
|
|
71
|
+
|
|
72
|
+
emit_event(:stream, chunk: chunk)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def stream_end = emit_event(:stream_end)
|
|
76
|
+
def thinking_started = emit_event(:thinking_started)
|
|
77
|
+
def table(headers:, rows:) = emit_event(:table, headers: headers, rows: rows)
|
|
78
|
+
|
|
79
|
+
def tool_started(name, arguments: nil, at: nil)
|
|
80
|
+
emit_event(:tool_started, name: name, arguments: arguments, at: at)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def tool_body(text, kind: :plain) = emit_event(:tool_body, text: text, kind: kind)
|
|
84
|
+
def tool_chunk(name, chunk) = emit_event(:tool_chunk, name: name, chunk: chunk)
|
|
85
|
+
def tool_finished(name, result: nil) = emit_event(:tool_finished, name: name)
|
|
86
|
+
def compression_started(at: nil) = emit_event(:compression_started, at: at)
|
|
87
|
+
|
|
88
|
+
def compression_finished(metadata, at: nil)
|
|
89
|
+
emit_event(:compression_finished, metadata: metadata, at: at)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def job_enqueued(type) = emit_event(:job_enqueued, type: type)
|
|
93
|
+
def job_started(type) = emit_event(:job_started, type: type)
|
|
94
|
+
def job_finished(type) = emit_event(:job_finished, type: type)
|
|
95
|
+
def separator = emit_event(:separator)
|
|
96
|
+
def blank_line = emit_event(:blank_line)
|
|
97
|
+
def mode_changed(name, previous: nil) = emit_event(:mode_changed, mode: name, previous: previous)
|
|
98
|
+
def reasoning_status(mode) = emit_event(:reasoning_status, mode: mode)
|
|
99
|
+
def reasoning_changed(mode, previous: nil) = emit_event(:reasoning_changed, mode: mode, previous: previous)
|
|
100
|
+
def think_status(effort) = emit_event(:think_status, effort: effort)
|
|
101
|
+
def think_changed(effort, previous: nil) = emit_event(:think_changed, effort: effort, previous: previous)
|
|
102
|
+
|
|
103
|
+
# Emits `approval.required` and blocks on the ApprovalGate until an
|
|
104
|
+
# HTTP client posts a decision for the generated approval_id.
|
|
105
|
+
# Auto-approves (returns true) when no gate/recorder is wired.
|
|
106
|
+
#
|
|
107
|
+
# @param question [String] human-readable approval prompt
|
|
108
|
+
# @param scope [String, nil] cache key for "session"/"always"
|
|
109
|
+
# decisions; pass `"<tool>:<args>"` so a second call with the
|
|
110
|
+
# same shape bypasses the user prompt entirely. Nil opts out.
|
|
111
|
+
# @param tool [String, nil] tool name, for the enriched event.
|
|
112
|
+
# @param command [String, nil] literal command/args, for the event +
|
|
113
|
+
# prefix derivation when a decision persists.
|
|
114
|
+
# @param pattern_key [String, nil] matched dangerous-pattern key, if any.
|
|
115
|
+
# @param description [String, nil] dangerous-pattern description, if any.
|
|
116
|
+
# @return [Boolean] true when the decision is in APPROVE_DECISIONS;
|
|
117
|
+
# false on an explicit deny OR when the gate's wait deadline elapses
|
|
118
|
+
# with no answer (abandoned run) — the safe auto-DENY default.
|
|
119
|
+
def confirm(question, scope: nil, tool: nil, command: nil, pattern_key: nil, description: nil)
|
|
120
|
+
return true unless @gate && @recorder
|
|
121
|
+
|
|
122
|
+
# Session-scope short-circuit: a prior "session" / "always_*"
|
|
123
|
+
# decision (or a persisted prefix) for this scope means we must NOT
|
|
124
|
+
# prompt again in the same session.
|
|
125
|
+
return true if scope && @session_id && @approval_cache.allowed?(@session_id, scope)
|
|
126
|
+
|
|
127
|
+
rule = derive_rule(tool, command, pattern_key)
|
|
128
|
+
|
|
129
|
+
approval_id = SecureRandom.uuid
|
|
130
|
+
# Register before emitting: a fast HTTP client could POST a decision
|
|
131
|
+
# the moment it sees approval.required, racing past #await; the gate
|
|
132
|
+
# must already know the id is valid by then.
|
|
133
|
+
@gate.register(approval_id, recorder: @recorder)
|
|
134
|
+
@recorder.emit(
|
|
135
|
+
"approval.required",
|
|
136
|
+
approval_payload(approval_id, question, tool: tool, command: command,
|
|
137
|
+
pattern_key: pattern_key, description: description, rule: rule)
|
|
138
|
+
)
|
|
139
|
+
decision = @gate.await(approval_id)
|
|
140
|
+
# Wait deadline elapsed with no human answer (abandoned run): the gate
|
|
141
|
+
# already emitted approval.expired. Resolve to a safe DENY — NEVER an
|
|
142
|
+
# auto-approve — so the gated command does not run.
|
|
143
|
+
return false if decision.equal?(Run::ApprovalGate::EXPIRED)
|
|
144
|
+
|
|
145
|
+
normalized = normalize_decision(decision)
|
|
146
|
+
approved = APPROVE_DECISIONS.include?(normalized)
|
|
147
|
+
|
|
148
|
+
if approved
|
|
149
|
+
apply_decision(normalized, scope: scope, command: command, rule: rule)
|
|
150
|
+
elsif normalized == "deny_always"
|
|
151
|
+
# Not an approve, but PERSIST the deny so ApprovalPolicy#decide
|
|
152
|
+
# auto-denies this pattern across sessions (it checks permissions:deny
|
|
153
|
+
# first). Plain "deny" stays a one-off — nothing persisted, re-prompts.
|
|
154
|
+
persist_deny(tool, command, rule)
|
|
155
|
+
end
|
|
156
|
+
approved
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Emits `clarify.required` and blocks on the ApprovalGate until an
|
|
160
|
+
# HTTP client posts a clarification response for the generated
|
|
161
|
+
# clarify_id. Returns nil when no gate/recorder is wired.
|
|
162
|
+
#
|
|
163
|
+
# @param prompt [String] question to ask the user
|
|
164
|
+
# @return [String, nil] the response text, or nil in non-API contexts
|
|
165
|
+
# or when the wait deadline elapsed with no answer (abandoned run)
|
|
166
|
+
def ask(prompt)
|
|
167
|
+
return nil unless @gate && @recorder
|
|
168
|
+
|
|
169
|
+
clarify_id = SecureRandom.uuid
|
|
170
|
+
@gate.register(clarify_id, recorder: @recorder)
|
|
171
|
+
@recorder.emit("clarify.required", { clarify_id: clarify_id, question: prompt.to_s })
|
|
172
|
+
answer = @gate.await(clarify_id)
|
|
173
|
+
# Deadline elapsed with no answer: the gate emitted approval.expired;
|
|
174
|
+
# treat an abandoned clarification as "no response".
|
|
175
|
+
return nil if answer.equal?(Run::ApprovalGate::EXPIRED)
|
|
176
|
+
|
|
177
|
+
answer
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
# Maps `always` (legacy web) to its canonical form; everything else
|
|
183
|
+
# passes through lowercased.
|
|
184
|
+
def normalize_decision(decision)
|
|
185
|
+
d = decision.to_s.downcase
|
|
186
|
+
ALWAYS_ALIAS.fetch(d, d)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The rule this approval would be remembered/persisted as, derived from
|
|
190
|
+
# the command. Nil when there is no command (tool-wide / structured-arg
|
|
191
|
+
# tools), in which case no prefix is offered and persistence is skipped.
|
|
192
|
+
def derive_rule(tool, command, pattern_key)
|
|
193
|
+
return nil if command.to_s.strip.empty?
|
|
194
|
+
|
|
195
|
+
Security::PrefixDeriver.rule_for(tool: tool.to_s, command: command.to_s, pattern_key: pattern_key)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The enriched approval.required payload. New fields are additive on top
|
|
199
|
+
# of the original {approval_id, question}; `hardline` is always false here
|
|
200
|
+
# (a hardline command is denied upstream and never reaches #confirm).
|
|
201
|
+
def approval_payload(approval_id, question, tool:, command:, pattern_key:, description:, rule:)
|
|
202
|
+
suggested = rule&.kind == :prefix ? rule.value : nil
|
|
203
|
+
{
|
|
204
|
+
approval_id: approval_id,
|
|
205
|
+
question: question.to_s,
|
|
206
|
+
command: command.to_s,
|
|
207
|
+
tool: tool.to_s,
|
|
208
|
+
description: description.to_s,
|
|
209
|
+
hardline: false,
|
|
210
|
+
suggested_prefix: suggested,
|
|
211
|
+
pattern_key: pattern_key,
|
|
212
|
+
choices: choices_for(suggested)
|
|
213
|
+
}
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The decisions the gem offers for THIS request. Always once/always_command/
|
|
217
|
+
# deny; session whenever in-memory caching applies (a gated run with a
|
|
218
|
+
# session id); always_prefix only when a :prefix rule is derivable. The web
|
|
219
|
+
# reads this list instead of synthesizing it.
|
|
220
|
+
def choices_for(suggested_prefix)
|
|
221
|
+
choices = %w[once]
|
|
222
|
+
choices << "session" if @session_id
|
|
223
|
+
choices << "always_prefix" if suggested_prefix
|
|
224
|
+
choices << "always_command"
|
|
225
|
+
choices << "deny"
|
|
226
|
+
choices << "deny_always"
|
|
227
|
+
choices
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Routes an approved decision to its cache/persister action:
|
|
231
|
+
# once -> nothing (this call only)
|
|
232
|
+
# session -> in-memory cache, dies with the process
|
|
233
|
+
# always_prefix -> persist the derived :prefix rule to command_allowlist
|
|
234
|
+
# always_command -> persist the NARROW rule (pattern key / exact command)
|
|
235
|
+
# `always` was already normalized to always_command upstream.
|
|
236
|
+
def apply_decision(decision, scope:, command:, rule:)
|
|
237
|
+
case decision
|
|
238
|
+
when "session"
|
|
239
|
+
remember_session(scope)
|
|
240
|
+
when "always_prefix"
|
|
241
|
+
remember_session(scope)
|
|
242
|
+
persist_rule(prefix_rule(rule, command))
|
|
243
|
+
when "always_command"
|
|
244
|
+
remember_session(scope)
|
|
245
|
+
persist_rule(narrow_rule(command))
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def remember_session(scope)
|
|
250
|
+
return unless scope && @session_id
|
|
251
|
+
|
|
252
|
+
@approval_cache.remember(@session_id, scope, "session")
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Persists a rule value to the on-disk command_allowlist so it pre-approves
|
|
256
|
+
# siblings across restarts (CommandAllowlist prefix start_with?). Skips when
|
|
257
|
+
# there is no value to persist.
|
|
258
|
+
def persist_rule(rule)
|
|
259
|
+
Security::AllowlistPersister.persist(rule.value) if rule
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Persists a permissions:deny rule for the "deny_always" decision, scoped
|
|
263
|
+
# the SAME way the allow side scopes (derived :prefix when available, else
|
|
264
|
+
# the exact command). ApprovalPolicy#decide checks permissions:deny first,
|
|
265
|
+
# so this auto-denies the pattern across sessions. No-op when there is no
|
|
266
|
+
# pattern to key on. Same DenyPersister path the CLI uses.
|
|
267
|
+
def persist_deny(tool, command, rule)
|
|
268
|
+
pattern = Security::DenyPersister.pattern_for(
|
|
269
|
+
tool: tool.to_s, rule: rule, command: command
|
|
270
|
+
)
|
|
271
|
+
Security::DenyPersister.persist(pattern) if pattern
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# The broad prefix rule for always_prefix. Falls back to deriving from the
|
|
275
|
+
# raw command when the caller didn't pass a tool-derived rule.
|
|
276
|
+
def prefix_rule(rule, command)
|
|
277
|
+
return rule if rule&.kind == :prefix
|
|
278
|
+
|
|
279
|
+
derived = Security::PrefixDeriver.rule_for(tool: "shell", command: command.to_s)
|
|
280
|
+
derived if derived&.kind == :prefix
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The narrow rule for always_command: exact command, or the dangerous
|
|
284
|
+
# pattern key when the command is dangerous (S3 semantics).
|
|
285
|
+
def narrow_rule(command)
|
|
286
|
+
return nil if command.to_s.strip.empty?
|
|
287
|
+
|
|
288
|
+
Security::PrefixDeriver.narrow_rule_for(tool: "shell", command: command.to_s)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def emit_event(type, **payload)
|
|
292
|
+
@events << { type: type, payload: payload, timestamp: Time.now.iso8601 }
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module UI
|
|
5
|
+
# Abstract base class for all UI adapters.
|
|
6
|
+
# Defines the interface that CLI, API, and Null must implement.
|
|
7
|
+
# No output method should be called directly from core logic;
|
|
8
|
+
# all output flows through one of these methods.
|
|
9
|
+
class Base
|
|
10
|
+
def info(message)
|
|
11
|
+
raise NotImplementedError, "#{self.class}#info not implemented"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def success(message)
|
|
15
|
+
raise NotImplementedError, "#{self.class}#success not implemented"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def warning(message)
|
|
19
|
+
raise NotImplementedError, "#{self.class}#warning not implemented"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def error(message)
|
|
23
|
+
raise NotImplementedError, "#{self.class}#error not implemented"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def status(message)
|
|
27
|
+
raise NotImplementedError, "#{self.class}#status not implemented"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Opens a box: `┌─ HH:MM · type · pieces ─────` filling the box width.
|
|
31
|
+
# Every visible scrollback block (user, thinking, tool, assistant, replay)
|
|
32
|
+
# is rendered as a box; body lines below get a `│ ` prefix and the box
|
|
33
|
+
# is closed with `box_close`. Pieces are joined with `·`. `at:` overrides
|
|
34
|
+
# the timestamp so replay preserves the original time of each historical
|
|
35
|
+
# step instead of showing "now" for the whole resumed session. `color:`
|
|
36
|
+
# overrides the auto-color (used by tool_finished to flip done to red).
|
|
37
|
+
def box_open(*pieces, at: nil, color: nil)
|
|
38
|
+
raise NotImplementedError, "#{self.class}#box_open not implemented"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Closes the currently-open box with `└─ pieces ─────`. When no pieces
|
|
42
|
+
# are given, emits a bare `└────` line — used for boxes that have no
|
|
43
|
+
# trailing metric (user, assistant, thinking). For tool boxes the
|
|
44
|
+
# caller passes `"done", name, metrics` so the bottom border carries
|
|
45
|
+
# the cost/scope of the call.
|
|
46
|
+
def box_close(*pieces, color: nil)
|
|
47
|
+
raise NotImplementedError, "#{self.class}#box_close not implemented"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Emits the payload that goes under a header (replay assistant body,
|
|
51
|
+
# one-shot text dumps, anything that isn't a stream chunk). Routing it
|
|
52
|
+
# through the UI rather than $stdout means the Null adapter still
|
|
53
|
+
# records it for tests and the CLI can later style/wrap it.
|
|
54
|
+
def body(text)
|
|
55
|
+
raise NotImplementedError, "#{self.class}#body not implemented"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A finished assistant message. The CLI renders it as markdown; other
|
|
59
|
+
# adapters fall back to plain body text. Part of the UI contract so the
|
|
60
|
+
# session-history replay (resume/continue) works on every adapter.
|
|
61
|
+
def assistant_text(text)
|
|
62
|
+
body(text)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A status-panel key/value row (` model minimax-m3`), optionally
|
|
66
|
+
# followed by an actionable pointer (`(use /mcp)`). The CLI styles it
|
|
67
|
+
# per the panel color diet (dim label, plain value, cyan pointer — P8);
|
|
68
|
+
# the default assembles one plain info line so recording adapters keep
|
|
69
|
+
# the full content.
|
|
70
|
+
def panel_line(label, value, pointer: nil)
|
|
71
|
+
info([" #{label.to_s.ljust(10)} #{value}", pointer].compact.join(" "))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A "try this" hint row: an actionable command plus its description
|
|
75
|
+
# (` /status what's going on right now`). The CLI renders the
|
|
76
|
+
# command cyan (the one accent color) and the description plain.
|
|
77
|
+
def hint_row(command, description)
|
|
78
|
+
info(" #{command.to_s.ljust(9)} #{description}")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Small metadata line, dim, no header. Used for the `turn · Xs · N
|
|
82
|
+
# tools · Y tok` summary after the final assistant message (on adapters
|
|
83
|
+
# without a dedicated #turn_footer), and any
|
|
84
|
+
# similar low-priority annotation that should sit close to the block
|
|
85
|
+
# it describes without competing visually.
|
|
86
|
+
def note(text)
|
|
87
|
+
raise NotImplementedError, "#{self.class}#note not implemented"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def stream(chunk)
|
|
91
|
+
raise NotImplementedError, "#{self.class}#stream not implemented"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def stream_end
|
|
95
|
+
raise NotImplementedError, "#{self.class}#stream_end not implemented"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Replays a user message from session history (resume / continue).
|
|
99
|
+
# Lets the CLI render past turns with a stable "you >" label so the
|
|
100
|
+
# scrolled-back transcript matches what the user typed at the time.
|
|
101
|
+
def replay_user_input(text)
|
|
102
|
+
raise NotImplementedError, "#{self.class}#replay_user_input not implemented"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Called when the model call starts but no chunk has arrived yet.
|
|
106
|
+
# Lets the UI show a transient "thinking…" affordance so the user
|
|
107
|
+
# sees something is happening during TTFB and when show_reasoning
|
|
108
|
+
# is disabled (otherwise the terminal sits silent until the first
|
|
109
|
+
# content chunk lands).
|
|
110
|
+
def thinking_started
|
|
111
|
+
raise NotImplementedError, "#{self.class}#thinking_started not implemented"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def table(headers:, rows:)
|
|
115
|
+
raise NotImplementedError, "#{self.class}#table not implemented"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ask(prompt)
|
|
119
|
+
raise NotImplementedError, "#{self.class}#ask not implemented"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Arrow-key single-select menu. +choices+ is an array of
|
|
123
|
+
# [label, value] pairs; returns the chosen value, or nil when no
|
|
124
|
+
# interactive selection is possible (non-TTY / Null adapter) so callers
|
|
125
|
+
# fall back to a non-interactive path.
|
|
126
|
+
def select(prompt, choices)
|
|
127
|
+
raise NotImplementedError, "#{self.class}#select not implemented"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# `scope:` is part of the contract for ALL adapters (not just API):
|
|
131
|
+
# ToolExecutor#request_approval always passes it. CLI/Null ignore it;
|
|
132
|
+
# API uses it as the session-approval cache key. Keeping the keyword in
|
|
133
|
+
# the shared signature is what stops UI::CLI from raising
|
|
134
|
+
# `ArgumentError: unknown keyword: :scope` on every interactive tool
|
|
135
|
+
# approval. `**context` absorbs the enriched approval fields (tool/
|
|
136
|
+
# command/pattern_key/description) that ToolExecutor passes for the /v1
|
|
137
|
+
# event — only UI::API consumes them; CLI/Null/SubagentView ignore them.
|
|
138
|
+
def confirm(question, scope: nil, **context)
|
|
139
|
+
raise NotImplementedError, "#{self.class}#confirm not implemented"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# A destructive yes/No confirm, default No — distinct from the tool-approval
|
|
143
|
+
# #confirm above (#218). Used for the in-chat/CLI destructive verbs (session
|
|
144
|
+
# delete, memory forget), so only the CLI and Null adapters implement it;
|
|
145
|
+
# both fail closed (decline) off a real terminal so a piped/EOF answer can
|
|
146
|
+
# never destroy. The API/subagent adapters don't host these verbs and raise.
|
|
147
|
+
def confirm_destructive(question)
|
|
148
|
+
raise NotImplementedError, "#{self.class}#confirm_destructive not implemented"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# `at:` overrides the timestamp on the tool box top — replay uses it so
|
|
152
|
+
# historical tool calls show when they actually happened, not "now".
|
|
153
|
+
# Live calls leave `at:` nil and get current time.
|
|
154
|
+
def tool_started(name, arguments: nil, at: nil)
|
|
155
|
+
raise NotImplementedError, "#{self.class}#tool_started not implemented"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def tool_finished(name, result: nil)
|
|
159
|
+
raise NotImplementedError, "#{self.class}#tool_finished not implemented"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Body block printed inside the open tool box, between the top and
|
|
163
|
+
# `done` rules. `kind:` controls coloring:
|
|
164
|
+
# :plain — every line dim (default; for shell/grep/glob/read
|
|
165
|
+
# previews where a leading `-` is `ls -la` permissions,
|
|
166
|
+
# not a diff removal)
|
|
167
|
+
# :diff — `+ ` lines green, `- ` lines red, rest dim (for edit)
|
|
168
|
+
# Caller is responsible for trimming the text first (Util::Output.preview).
|
|
169
|
+
def tool_body(text, kind: :plain)
|
|
170
|
+
raise NotImplementedError, "#{self.class}#tool_body not implemented"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# `at:` overrides the timestamp shown on the compaction free line.
|
|
174
|
+
# Live events leave it nil and pick up current time; replay (if
|
|
175
|
+
# compaction events ever become stored in history) can pin the
|
|
176
|
+
# original moment.
|
|
177
|
+
def compression_started(at: nil)
|
|
178
|
+
raise NotImplementedError, "#{self.class}#compression_started not implemented"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def compression_finished(metadata, at: nil)
|
|
182
|
+
raise NotImplementedError, "#{self.class}#compression_finished not implemented"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def job_enqueued(type)
|
|
186
|
+
raise NotImplementedError, "#{self.class}#job_enqueued not implemented"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def job_started(type)
|
|
190
|
+
raise NotImplementedError, "#{self.class}#job_started not implemented"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def job_finished(type)
|
|
194
|
+
raise NotImplementedError, "#{self.class}#job_finished not implemented"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def separator
|
|
198
|
+
raise NotImplementedError, "#{self.class}#separator not implemented"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def blank_line
|
|
202
|
+
raise NotImplementedError, "#{self.class}#blank_line not implemented"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Signals a Modes transition (e.g. user typed `/mode plan` or an API
|
|
206
|
+
# caller invoked Modes.set). CLI renders a `┄ HH:MM · mode → plan ┄`
|
|
207
|
+
# free line; API emits a `mode_changed` event the orchestrator can
|
|
208
|
+
# forward to the web client; Null records it for tests.
|
|
209
|
+
# `previous:` is the mode active *before* the transition, used to
|
|
210
|
+
# render the arrow ("default → plan").
|
|
211
|
+
def mode_changed(name, previous: nil)
|
|
212
|
+
raise NotImplementedError, "#{self.class}#mode_changed not implemented"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Echoes a message the user typed *during* a running turn — the steering
|
|
216
|
+
# / "talk while it works" affordance. The background reader captured the
|
|
217
|
+
# line and parked it for the next turn; this just confirms it visually so
|
|
218
|
+
# the keystrokes don't disappear into the streaming output. Concrete (not
|
|
219
|
+
# abstract) and a no-op by default: only the CLI shows the dim
|
|
220
|
+
# `queued ▸ …` echo; API/Null have nothing meaningful to render and
|
|
221
|
+
# inherit the no-op rather than each restating it.
|
|
222
|
+
def queued(text); end
|
|
223
|
+
|
|
224
|
+
# Echoes a message that was picked up MID-TURN at an agent-loop iteration
|
|
225
|
+
# boundary and injected as a user message into the current turn (the
|
|
226
|
+
# Phase-2 steering / "Enter injects into the current turn" affordance).
|
|
227
|
+
# Distinct from #queued, which parks text for the NEXT turn: this text is
|
|
228
|
+
# already part of the live turn, so the CLI renders a dim
|
|
229
|
+
# `↳ received while working: …` confirmation. Concrete no-op by default;
|
|
230
|
+
# only the CLI has something to render. API surfaces it via the
|
|
231
|
+
# INPUT_INJECTED bus event, not this echo.
|
|
232
|
+
def input_injected(text); end
|
|
233
|
+
|
|
234
|
+
# Commits the standardized `⎿ interrupted` marker right after the partial
|
|
235
|
+
# answer that's kept when a turn is cancelled (Ctrl+C, or the interrupt-by-
|
|
236
|
+
# default Enter on a type-ahead line). Concrete no-op by default; only the
|
|
237
|
+
# CLI renders the dim marker. API surfaces the cancel via its own events;
|
|
238
|
+
# Null records nothing.
|
|
239
|
+
def turn_interrupted; end
|
|
240
|
+
|
|
241
|
+
# True when this adapter parks the run on a cross-thread gate for human
|
|
242
|
+
# approvals/clarifications (the HTTP/API path) rather than prompting
|
|
243
|
+
# inline on a terminal. The agent loop uses this to run an interactive
|
|
244
|
+
# turn NON-STREAMING so no upstream LLM socket is held open during the
|
|
245
|
+
# wait. Default false: CLI/Null prompt inline (or auto-answer) and never
|
|
246
|
+
# park, so they keep streaming.
|
|
247
|
+
def blocking_human_input?
|
|
248
|
+
false
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|