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,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# Encapsulates the result of a tool execution.
|
|
6
|
+
class Result
|
|
7
|
+
attr_reader :name, :call_id, :output, :status, :error, :session_id,
|
|
8
|
+
:metrics, :error_code, :artifact
|
|
9
|
+
|
|
10
|
+
# `error_code` is an optional Symbol surface for callers (UI badges,
|
|
11
|
+
# automation, future contract tests) that want to branch on the
|
|
12
|
+
# failure mode without parsing the human-facing error string. Today
|
|
13
|
+
# the canonical signal is still the output text — the symbol is a
|
|
14
|
+
# belt-and-suspenders next to it, not a replacement.
|
|
15
|
+
#
|
|
16
|
+
# `artifact` is an optional Hash carrying { path:, filename:,
|
|
17
|
+
# content_type:, byte_size: } when a tool produced a downloadable
|
|
18
|
+
# user-facing file. The agent loop reads this and emits an
|
|
19
|
+
# ARTIFACT_CREATED bus event so SSE consumers (the web UI, the CLI)
|
|
20
|
+
# can offer a download.
|
|
21
|
+
def initialize(name:, call_id:, output:, status:, error: nil,
|
|
22
|
+
metrics: nil, error_code: nil, artifact: nil)
|
|
23
|
+
@name = name
|
|
24
|
+
@call_id = call_id
|
|
25
|
+
@output = output
|
|
26
|
+
@status = status
|
|
27
|
+
@error = error
|
|
28
|
+
@metrics = metrics
|
|
29
|
+
@error_code = error_code
|
|
30
|
+
@artifact = artifact
|
|
31
|
+
@session_id = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def success?
|
|
35
|
+
@status == :success
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def failed?
|
|
39
|
+
@status == :error
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def denied?
|
|
43
|
+
@status == :denied
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True when this result represents a failure for DISPLAY purposes, even
|
|
47
|
+
# when the tool didn't raise. Many tools (read, edit, …) report a soft
|
|
48
|
+
# failure by RETURNING an "Error: …" string (status stays :success) or by
|
|
49
|
+
# setting an error_code, instead of raising. The CLI used to render those
|
|
50
|
+
# as a green "✓ done" because it only checked #success?. This is the
|
|
51
|
+
# single predicate the UI uses so an errored tool shows "✗" regardless of
|
|
52
|
+
# which failure convention the tool used.
|
|
53
|
+
def errorish?
|
|
54
|
+
return true unless success?
|
|
55
|
+
return true unless @error_code.nil?
|
|
56
|
+
|
|
57
|
+
@output.to_s.start_with?("Error:")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns a truncated preview for display
|
|
61
|
+
def truncated_preview(max_length: 80)
|
|
62
|
+
text = @output.to_s
|
|
63
|
+
text.length > max_length ? "#{text[0...max_length]}..." : text
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Substituted when a tool legitimately produces no output (e.g. `touch`).
|
|
67
|
+
# The string survives persistence and load_history, where nil/"" would
|
|
68
|
+
# be dropped and leave a tool_call orphaned — the provider then 400s
|
|
69
|
+
# the next turn for a tool_call with no matching tool_result.
|
|
70
|
+
EMPTY_OUTPUT_PLACEHOLDER = "(no output)"
|
|
71
|
+
|
|
72
|
+
# Factory methods
|
|
73
|
+
def self.success(name:, call_id:, output:, metrics: nil, error_code: nil, artifact: nil)
|
|
74
|
+
new(name: name, call_id: call_id, output: normalize_output(output),
|
|
75
|
+
status: :success, metrics: metrics, error_code: error_code, artifact: artifact)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.error(name:, call_id:, error:, error_code: nil)
|
|
79
|
+
msg = error.to_s
|
|
80
|
+
msg = "unknown error" if msg.empty?
|
|
81
|
+
new(name: name, call_id: call_id, output: "Error: #{msg}", status: :error,
|
|
82
|
+
error: error, error_code: error_code)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Model-facing text per denial reason (#143). Only a real human decision
|
|
86
|
+
# may read "denied by user" — an automatic denial must name the policy
|
|
87
|
+
# that fired, otherwise a child agent reports (and propagates upward)
|
|
88
|
+
# that "the user denied my tools" when no human ever decided anything.
|
|
89
|
+
DENIED_OUTPUTS = {
|
|
90
|
+
user: "Tool execution denied by user.",
|
|
91
|
+
policy: "Tool execution denied by policy (not by the user).",
|
|
92
|
+
hardline: "Tool execution blocked by policy (hardline safety floor, not by the user): " \
|
|
93
|
+
"this command is never allowed.",
|
|
94
|
+
permission_rule: "Tool execution blocked by policy (a configured permissions deny rule, " \
|
|
95
|
+
"not by the user).",
|
|
96
|
+
doom_loop: "Tool execution blocked by the doom-loop guard (policy, not by the user): " \
|
|
97
|
+
"this exact call was already made repeatedly. Change strategy instead of " \
|
|
98
|
+
"retrying it — e.g. wait for the background-task completion notice instead " \
|
|
99
|
+
"of polling."
|
|
100
|
+
}.freeze
|
|
101
|
+
|
|
102
|
+
def self.denied(name:, call_id:, reason: :user)
|
|
103
|
+
key = DENIED_OUTPUTS.key?(reason) ? reason : :policy
|
|
104
|
+
new(name: name, call_id: call_id, output: DENIED_OUTPUTS[key], status: :denied)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.normalize_output(output)
|
|
108
|
+
text = output.to_s
|
|
109
|
+
text.empty? ? EMPTY_OUTPUT_PLACEHOLDER : text
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
Binary file
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Tools
|
|
7
|
+
# Full-text search across the agent's own message history, backed by the
|
|
8
|
+
# `messages_fts` FTS5 index. Lets the model recall prior conversations
|
|
9
|
+
# without forcing the user to paste them back in.
|
|
10
|
+
#
|
|
11
|
+
# Returns a JSON array of match hits with a highlighted snippet so the
|
|
12
|
+
# model can decide whether to follow up with /v1/sessions/:id.
|
|
13
|
+
class SessionSearchTool < Base
|
|
14
|
+
DEFAULT_LIMIT = 20
|
|
15
|
+
MAX_LIMIT = 100
|
|
16
|
+
|
|
17
|
+
def name
|
|
18
|
+
"session_search"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def description
|
|
22
|
+
"Full-text search across past session messages. " \
|
|
23
|
+
"Returns matched messages with highlighted snippets and the owning session id. " \
|
|
24
|
+
"Use to recall earlier conversations or look up what a tool returned previously."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def input_schema
|
|
28
|
+
{
|
|
29
|
+
type: "object",
|
|
30
|
+
properties: {
|
|
31
|
+
query: {
|
|
32
|
+
type: "string",
|
|
33
|
+
description: "Free-text search query (FTS5 MATCH)."
|
|
34
|
+
},
|
|
35
|
+
since: {
|
|
36
|
+
type: "string",
|
|
37
|
+
description: "ISO8601 lower bound on message created_at."
|
|
38
|
+
},
|
|
39
|
+
until: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "ISO8601 upper bound on message created_at."
|
|
42
|
+
},
|
|
43
|
+
role: {
|
|
44
|
+
type: "string",
|
|
45
|
+
enum: %w[user assistant tool],
|
|
46
|
+
description: "Restrict to a single message role."
|
|
47
|
+
},
|
|
48
|
+
tool: {
|
|
49
|
+
type: "string",
|
|
50
|
+
description: "Restrict to a specific tool_name (when role=tool)."
|
|
51
|
+
},
|
|
52
|
+
limit: {
|
|
53
|
+
type: "integer",
|
|
54
|
+
description: "Max results to return (default 20, max 100)."
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
required: %w[query]
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def risk_level
|
|
62
|
+
:low
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def call(arguments)
|
|
66
|
+
query = arguments["query"] || arguments[:query]
|
|
67
|
+
return "Error: query is required" if query.nil? || query.to_s.strip.empty?
|
|
68
|
+
|
|
69
|
+
limit = (arguments["limit"] || arguments[:limit] || DEFAULT_LIMIT).to_i
|
|
70
|
+
limit = DEFAULT_LIMIT if limit <= 0
|
|
71
|
+
limit = MAX_LIMIT if limit > MAX_LIMIT
|
|
72
|
+
|
|
73
|
+
rows = store.search(
|
|
74
|
+
query: query,
|
|
75
|
+
since: arguments["since"] || arguments[:since],
|
|
76
|
+
until_: arguments["until"] || arguments[:until],
|
|
77
|
+
role: arguments["role"] || arguments[:role],
|
|
78
|
+
tool: arguments["tool"] || arguments[:tool],
|
|
79
|
+
limit: limit
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
results = rows.map do |row|
|
|
83
|
+
{
|
|
84
|
+
session_id: row[:session_id],
|
|
85
|
+
run_id: row[:run_id],
|
|
86
|
+
message_id: row[:message_id],
|
|
87
|
+
role: row[:role],
|
|
88
|
+
snippet: row[:snippet],
|
|
89
|
+
created_at: row[:created_at]
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
JSON.generate(results)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def store
|
|
99
|
+
@store ||= Rubino::Session::Store.new
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# Feeds input to a background shell's stdin (registered by ShellTool when
|
|
6
|
+
# run_in_background: true). This is how the agent answers an interactive
|
|
7
|
+
# prompt a running command emits — Y/N confirmations, "select region"
|
|
8
|
+
# menus, apt-style questions — without having to pre-bake the answer at
|
|
9
|
+
# spawn time (`echo y | cmd`, `-y`, heredoc).
|
|
10
|
+
#
|
|
11
|
+
# Typical loop: shell(run_in_background: true) → shell_output (see the
|
|
12
|
+
# prompt) → shell_input(run_id:, text: "y") → shell_output (see the result).
|
|
13
|
+
#
|
|
14
|
+
# By default a newline is appended (like pressing Enter). Pass
|
|
15
|
+
# `enter: false` to send raw bytes without a newline. Pass `eof: true` to
|
|
16
|
+
# close stdin (send EOF) after writing — for commands that read until EOF.
|
|
17
|
+
#
|
|
18
|
+
# Works for line-oriented prompts. Full-screen TTY programs (vim, REPLs
|
|
19
|
+
# that require a real terminal) are out of scope: the background shell uses
|
|
20
|
+
# a plain pipe, not a pseudo-terminal.
|
|
21
|
+
class ShellInputTool < Base
|
|
22
|
+
def name
|
|
23
|
+
"shell_input"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def description
|
|
27
|
+
"Send input to a background shell started via `shell` with " \
|
|
28
|
+
"run_in_background: true — answer an interactive prompt (Y/N, menu " \
|
|
29
|
+
"selection, password) of a running command. A newline is appended by " \
|
|
30
|
+
"default (like pressing Enter); pass enter: false for raw bytes, or " \
|
|
31
|
+
"eof: true to close stdin (EOF). Read the prompt and the result with " \
|
|
32
|
+
"`shell_output`."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def input_schema
|
|
36
|
+
{
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
run_id: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "The run_id returned by `shell` when launched in background"
|
|
42
|
+
},
|
|
43
|
+
text: {
|
|
44
|
+
type: "string",
|
|
45
|
+
description: "The text to write to the process's stdin (e.g. \"y\", \"2\")"
|
|
46
|
+
},
|
|
47
|
+
enter: {
|
|
48
|
+
type: "boolean",
|
|
49
|
+
description: "Append a newline like pressing Enter (default true)"
|
|
50
|
+
},
|
|
51
|
+
eof: {
|
|
52
|
+
type: "boolean",
|
|
53
|
+
description: "Close stdin / send EOF after writing (default false)"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
required: %w[run_id]
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def risk_level
|
|
61
|
+
:medium
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def call(arguments)
|
|
65
|
+
run_id = arguments["run_id"] || arguments[:run_id]
|
|
66
|
+
text = arguments["text"] || arguments[:text] || ""
|
|
67
|
+
enter = arguments.fetch("enter", arguments.fetch(:enter, true))
|
|
68
|
+
eof = arguments["eof"] || arguments[:eof] || false
|
|
69
|
+
|
|
70
|
+
return "Error: run_id is required" if run_id.nil? || run_id.to_s.empty?
|
|
71
|
+
|
|
72
|
+
registry = ShellRegistry.instance
|
|
73
|
+
entry = registry.find(run_id)
|
|
74
|
+
return "Error: no background shell with run_id=#{run_id}" unless entry
|
|
75
|
+
|
|
76
|
+
unless entry.wait_thr.alive?
|
|
77
|
+
return "Error: [#{run_id}] already exited (exit=#{registry.exit_code(entry)}) — cannot send input"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
written =
|
|
81
|
+
begin
|
|
82
|
+
registry.write_input(entry, text, enter: enter)
|
|
83
|
+
rescue IOError, Errno::EPIPE => e
|
|
84
|
+
return "Error: [#{run_id}] stdin is closed (#{e.message})"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
registry.close_stdin(entry) if eof
|
|
88
|
+
|
|
89
|
+
msg = "[#{run_id}] wrote #{written} byte#{"s" unless written == 1} to stdin"
|
|
90
|
+
msg << " (EOF sent)" if eof
|
|
91
|
+
msg << "\nRead the result: shell_output run_id=#{run_id}"
|
|
92
|
+
msg
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# Terminates a background shell. Sends SIGTERM to the whole process
|
|
6
|
+
# group first; if the process is still alive after a 2s grace period,
|
|
7
|
+
# follows up with SIGKILL.
|
|
8
|
+
class ShellKillTool < Base
|
|
9
|
+
GRACE_SECONDS = 2
|
|
10
|
+
|
|
11
|
+
def name
|
|
12
|
+
"shell_kill"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def description
|
|
16
|
+
"Terminate a background shell started via `shell` with run_in_background: true. " \
|
|
17
|
+
"Sends SIGTERM to the process group, waits #{GRACE_SECONDS}s, then SIGKILL if " \
|
|
18
|
+
"the process is still alive."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def input_schema
|
|
22
|
+
{
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
run_id: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "The run_id returned by `shell` when launched in background"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
required: %w[run_id]
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def risk_level
|
|
35
|
+
:medium
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call(arguments)
|
|
39
|
+
run_id = arguments["run_id"] || arguments[:run_id]
|
|
40
|
+
return "Error: run_id is required" if run_id.nil? || run_id.to_s.empty?
|
|
41
|
+
|
|
42
|
+
registry = ShellRegistry.instance
|
|
43
|
+
entry = registry.find(run_id)
|
|
44
|
+
return "Error: no background shell with run_id=#{run_id}" unless entry
|
|
45
|
+
|
|
46
|
+
unless entry.wait_thr.alive?
|
|
47
|
+
registry.remove(run_id)
|
|
48
|
+
return "[#{run_id}] already exited (exit=#{registry.exit_code(entry)})"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
send_signal(entry.pgid, "TERM")
|
|
52
|
+
GRACE_SECONDS.times do
|
|
53
|
+
break unless entry.wait_thr.alive?
|
|
54
|
+
|
|
55
|
+
sleep 1
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if entry.wait_thr.alive?
|
|
59
|
+
send_signal(entry.pgid, "KILL")
|
|
60
|
+
sleep 0.1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
registry.remove(run_id)
|
|
64
|
+
"[#{run_id}] terminated (SIGTERM" + (entry.wait_thr.alive? ? "+SIGKILL" : "") + ")"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def send_signal(pgid, signal)
|
|
70
|
+
Process.kill(signal, -pgid)
|
|
71
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
72
|
+
# Already dead or not ours.
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# Reads stdout/stderr accumulated by a background shell (registered by
|
|
6
|
+
# ShellTool when run_in_background: true).
|
|
7
|
+
#
|
|
8
|
+
# By default returns only the bytes produced since the last call —
|
|
9
|
+
# repeated polling shows incremental progress like `tail -F`. Pass
|
|
10
|
+
# `mode: "all"` for the full buffer (bounded by ShellRegistry::RING_BYTES).
|
|
11
|
+
class ShellOutputTool < Base
|
|
12
|
+
def name
|
|
13
|
+
"shell_output"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def description
|
|
17
|
+
"Read output from a background shell started via `shell` with " \
|
|
18
|
+
"run_in_background: true. By default returns only new bytes since " \
|
|
19
|
+
"the previous read. Pass mode: 'all' for the full buffered output."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def input_schema
|
|
23
|
+
{
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
run_id: {
|
|
27
|
+
type: "string",
|
|
28
|
+
description: "The run_id returned by `shell` when launched in background"
|
|
29
|
+
},
|
|
30
|
+
mode: {
|
|
31
|
+
type: "string",
|
|
32
|
+
enum: %w[new all],
|
|
33
|
+
description: "'new' (default) = bytes since last read; 'all' = full buffer"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
required: %w[run_id]
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def risk_level
|
|
41
|
+
:low
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def call(arguments)
|
|
45
|
+
run_id = arguments["run_id"] || arguments[:run_id]
|
|
46
|
+
mode = (arguments["mode"] || arguments[:mode] || "new").to_s
|
|
47
|
+
|
|
48
|
+
return "Error: run_id is required" if run_id.nil? || run_id.to_s.empty?
|
|
49
|
+
|
|
50
|
+
registry = ShellRegistry.instance
|
|
51
|
+
entry = registry.find(run_id)
|
|
52
|
+
return "Error: no background shell with run_id=#{run_id}" unless entry
|
|
53
|
+
|
|
54
|
+
body = mode == "all" ? registry.read_all(entry) : registry.read_new(entry)
|
|
55
|
+
status = registry.status(entry)
|
|
56
|
+
exit_code = registry.exit_code(entry)
|
|
57
|
+
|
|
58
|
+
header = "[#{run_id}] status=#{status}"
|
|
59
|
+
header << " exit=#{exit_code}" if exit_code
|
|
60
|
+
header << " (#{body.bytesize} bytes #{mode == "all" ? "total" : "new"})"
|
|
61
|
+
|
|
62
|
+
registry.remove(run_id) unless status == :running
|
|
63
|
+
|
|
64
|
+
if body.empty?
|
|
65
|
+
status == :running ? "#{header}\n(no new output)" : header
|
|
66
|
+
else
|
|
67
|
+
"#{header}\n#{body}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Rubino
|
|
7
|
+
module Tools
|
|
8
|
+
# Process-wide registry for shell commands started with `run_in_background`.
|
|
9
|
+
# Each entry owns a pgid (process group), a reader thread that drains
|
|
10
|
+
# stdout+stderr into an in-memory ring buffer, and the wait_thr for exit.
|
|
11
|
+
#
|
|
12
|
+
# The registry survives a single CLI/server process — it is intentionally
|
|
13
|
+
# NOT persisted to disk. Background shells die with the agent process.
|
|
14
|
+
class ShellRegistry
|
|
15
|
+
RING_BYTES = 256 * 1024 # cap per run; older bytes are dropped
|
|
16
|
+
|
|
17
|
+
Entry = Struct.new(
|
|
18
|
+
:id, :command, :cwd, :pid, :pgid, :wait_thr, :reader_thr,
|
|
19
|
+
:buffer, :mutex, :started_at, :read_offset, :stdin,
|
|
20
|
+
keyword_init: true
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
def instance
|
|
25
|
+
@instance ||= new
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@entries = {}
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Spawns `command` detached in its own process group so a single kill
|
|
35
|
+
# takes out the whole subtree. Returns the new entry.
|
|
36
|
+
def spawn(command:, cwd:)
|
|
37
|
+
rd, wr = IO.pipe
|
|
38
|
+
# Writable stdin pipe: the agent feeds answers to interactive prompts
|
|
39
|
+
# (Y/N, "select region", apt-style) via the `shell_input` tool, which
|
|
40
|
+
# writes to `in_wr`. Line-oriented `read`/prompt commands consume this
|
|
41
|
+
# fine; full-screen TTY programs (vim, REPLs that require [ -t 0 ]) are
|
|
42
|
+
# out of scope for a plain pipe.
|
|
43
|
+
in_rd, in_wr = IO.pipe
|
|
44
|
+
# pgroup: true → child becomes leader of a new process group whose
|
|
45
|
+
# pgid == child pid. Lets shell_kill send SIGTERM to the whole tree.
|
|
46
|
+
# bash -o pipefail keeps this path consistent with the foreground
|
|
47
|
+
# shell: a mid-pipeline crash surfaces as the exit status (#156).
|
|
48
|
+
pid = Process.spawn("bash", "-o", "pipefail", "-c", command,
|
|
49
|
+
chdir: cwd, pgroup: true, in: in_rd, out: wr, err: wr)
|
|
50
|
+
wr.close
|
|
51
|
+
in_rd.close
|
|
52
|
+
|
|
53
|
+
entry = Entry.new(
|
|
54
|
+
id: new_id,
|
|
55
|
+
command: command,
|
|
56
|
+
cwd: cwd,
|
|
57
|
+
pid: pid,
|
|
58
|
+
pgid: pid,
|
|
59
|
+
wait_thr: Process.detach(pid),
|
|
60
|
+
buffer: +"",
|
|
61
|
+
mutex: Mutex.new,
|
|
62
|
+
started_at: Time.now,
|
|
63
|
+
read_offset: 0,
|
|
64
|
+
stdin: in_wr
|
|
65
|
+
)
|
|
66
|
+
entry.reader_thr = Thread.new { drain_into(entry, rd) }
|
|
67
|
+
|
|
68
|
+
@mutex.synchronize { @entries[entry.id] = entry }
|
|
69
|
+
entry
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def find(id)
|
|
73
|
+
@mutex.synchronize { @entries[id] }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def remove(id)
|
|
77
|
+
entry = @mutex.synchronize { @entries.delete(id) }
|
|
78
|
+
close_stdin(entry) if entry
|
|
79
|
+
entry
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Writes `text` to the background process's stdin (with a trailing
|
|
83
|
+
# newline unless `enter: false`) — the "press Enter to answer a prompt"
|
|
84
|
+
# path. Returns the number of bytes written, or raises if stdin is gone.
|
|
85
|
+
def write_input(entry, text, enter: true)
|
|
86
|
+
io = entry.stdin
|
|
87
|
+
raise IOError, "stdin already closed" if io.nil? || io.closed?
|
|
88
|
+
|
|
89
|
+
payload = enter ? "#{text}\n" : text.to_s
|
|
90
|
+
io.write(payload)
|
|
91
|
+
io.flush
|
|
92
|
+
payload.bytesize
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Closes the write end of the child's stdin (sends EOF). Idempotent.
|
|
96
|
+
def close_stdin(entry)
|
|
97
|
+
io = entry&.stdin
|
|
98
|
+
io.close if io && !io.closed?
|
|
99
|
+
rescue IOError
|
|
100
|
+
# already closed
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Reads accumulated bytes since the last `read_new` call. Returns the
|
|
104
|
+
# full snapshot if `since` is nil. Thread-safe.
|
|
105
|
+
def read_new(entry)
|
|
106
|
+
entry.mutex.synchronize do
|
|
107
|
+
snapshot = entry.buffer.byteslice(entry.read_offset..) || ""
|
|
108
|
+
entry.read_offset = entry.buffer.bytesize
|
|
109
|
+
snapshot
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def read_all(entry)
|
|
114
|
+
entry.mutex.synchronize { entry.buffer.dup }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def status(entry)
|
|
118
|
+
return :running if entry.wait_thr.alive?
|
|
119
|
+
|
|
120
|
+
code = entry.wait_thr.value.exitstatus
|
|
121
|
+
code && ShellTool.success_exit?(code) ? :completed : :failed
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def exit_code(entry)
|
|
125
|
+
return nil if entry.wait_thr.alive?
|
|
126
|
+
|
|
127
|
+
entry.wait_thr.value.exitstatus
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def new_id
|
|
133
|
+
"bg_#{SecureRandom.hex(4)}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Single-reader pattern: only this thread writes to entry.buffer, the
|
|
137
|
+
# mutex protects only against concurrent reads from shell_output_tool.
|
|
138
|
+
def drain_into(entry, rd)
|
|
139
|
+
rd.each_line do |chunk|
|
|
140
|
+
entry.mutex.synchronize do
|
|
141
|
+
entry.buffer << chunk
|
|
142
|
+
overflow = entry.buffer.bytesize - RING_BYTES
|
|
143
|
+
if overflow.positive?
|
|
144
|
+
entry.buffer = entry.buffer.byteslice(overflow..) || +""
|
|
145
|
+
# Reset read_offset proportionally so the next read still sees
|
|
146
|
+
# only fresh bytes, not whatever survived the trim.
|
|
147
|
+
entry.read_offset = [entry.read_offset - overflow, 0].max
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
rescue IOError, Errno::EBADF
|
|
152
|
+
# pipe closed — process exited
|
|
153
|
+
ensure
|
|
154
|
+
rd.close unless rd.closed?
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|