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,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# answer_child — the MODEL-callable answer to a child's ask_parent (S4). The
|
|
6
|
+
# agent-parent counterpart of the human `/reply <id> <answer>`: when a
|
|
7
|
+
# subagent calls ask_parent and it is OWNED by an agent (not the human), the
|
|
8
|
+
# question lands on this parent's steer_queue as a [subagent-question] note;
|
|
9
|
+
# the parent model reads it at its next turn and answers it with this tool.
|
|
10
|
+
#
|
|
11
|
+
# SCOPED AT CALL (like steer/probe, the S1 correction): registered for ALL
|
|
12
|
+
# agents and AUTHORIZED by OWNERSHIP at call time. The caller is the
|
|
13
|
+
# thread-local Rubino.current_subagent_id (nil ⇒ the human / top-level agent).
|
|
14
|
+
# The target must be the caller's OWN DIRECT child (BackgroundTasks.owned_by?)
|
|
15
|
+
# AND it must actually be waiting on an ask (it has an ask_gate). NOT on any
|
|
16
|
+
# strip list.
|
|
17
|
+
#
|
|
18
|
+
# Mechanism reuse: it wraps BackgroundTasks#deliver_answer verbatim — the SAME
|
|
19
|
+
# ONE answer wire the human /reply path uses (decide the gate + push the
|
|
20
|
+
# [parent answer] steer note + clear the blocked state). No new transport.
|
|
21
|
+
#
|
|
22
|
+
# An agent-parent that CANNOT answer from its own context does NOT use this
|
|
23
|
+
# tool: it escalates by calling its OWN ask_parent (recursion up the tree).
|
|
24
|
+
class AnswerChildTool < Base
|
|
25
|
+
def name
|
|
26
|
+
"answer_child"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Gated by the same `tools.task` delegation key — answering a child is
|
|
30
|
+
# meaningless without the delegation substrate. Disabling delegation
|
|
31
|
+
# disables answer_child too.
|
|
32
|
+
def config_key
|
|
33
|
+
"task"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def description
|
|
37
|
+
"Answer one of YOUR OWN subagents that asked you a question via " \
|
|
38
|
+
"ask_parent (you will have received it as a [subagent-question] note). " \
|
|
39
|
+
"The answer is delivered into that child's context: it unblocks a child " \
|
|
40
|
+
"that paused for it and folds into a child that kept working. You can " \
|
|
41
|
+
"ONLY answer a subagent you started (your direct child) that is actually " \
|
|
42
|
+
"waiting on you. If you CANNOT answer from your own context, do NOT guess " \
|
|
43
|
+
"— escalate by calling ask_parent yourself."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def input_schema
|
|
47
|
+
{
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: {
|
|
50
|
+
task_id: { type: "string", description: "The id (sa_…) of YOUR subagent that asked you." },
|
|
51
|
+
answer: { type: "string",
|
|
52
|
+
description: "Your answer. Be specific and self-contained — it enters the child's context." }
|
|
53
|
+
},
|
|
54
|
+
required: %w[task_id answer]
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Answering a child is a low-risk, non-destructive hand-off (the child
|
|
59
|
+
# carries its own approval/risk gates for whatever it does with the answer).
|
|
60
|
+
def risk_level
|
|
61
|
+
:low
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def call(arguments)
|
|
65
|
+
task_id = (arguments["task_id"] || arguments[:task_id]).to_s.strip
|
|
66
|
+
answer = (arguments["answer"] || arguments[:answer]).to_s.strip
|
|
67
|
+
return "Error: answer is required" if answer.empty?
|
|
68
|
+
|
|
69
|
+
caller_id = Rubino.current_subagent_id
|
|
70
|
+
registry = BackgroundTasks.instance
|
|
71
|
+
|
|
72
|
+
# Ownership: only a DIRECT child of the caller may be answered.
|
|
73
|
+
return "Error: #{task_id} is not one of your subagents." unless registry.owned_by?(caller_id, task_id)
|
|
74
|
+
|
|
75
|
+
# It must actually be waiting on an ask (deliver_answer no-ops without a
|
|
76
|
+
# live ask_gate). Covers a missing/finished/not-blocked child uniformly.
|
|
77
|
+
return "#{task_id} is not waiting on you." unless registry.deliver_answer(task_id, answer)
|
|
78
|
+
|
|
79
|
+
"↳ answered #{task_id}: #{Rubino::Util::Output.elide(answer, 80)}\n✓ #{task_id} resumes at its next turn"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# ask_parent — the child->parent escalation channel (the third mechanism of
|
|
6
|
+
# the parent<->subagent comm design). A subagent calls this when it hits a
|
|
7
|
+
# fork it cannot resolve from its sealed prompt ("sqlite or postgres?").
|
|
8
|
+
#
|
|
9
|
+
# Answerer is MIXED: the parent answers from its own context if it can, else
|
|
10
|
+
# it escalates to the HUMAN. This tool implements the wire; the escalation
|
|
11
|
+
# itself reuses Run::ApprovalGate verbatim (the SAME blocking cross-thread
|
|
12
|
+
# hand-off the Option-2 background-approval path already uses):
|
|
13
|
+
#
|
|
14
|
+
# 1. The tool finds the child\'s own BackgroundTasks entry (via the
|
|
15
|
+
# thread-local Rubino.current_subagent_id set by TaskTool around the
|
|
16
|
+
# child run). No entry ⇒ this run has no parent (top-level / foreground
|
|
17
|
+
# sync) and the tool refuses gracefully — never hangs.
|
|
18
|
+
# 2. It registers a Run::ApprovalGate on the entry (BackgroundTasks#begin_ask),
|
|
19
|
+
# flipping the entry to :blocked_on_human so the parent CLI surfaces the
|
|
20
|
+
# ⛔ blocked banner + the persistent "N subagent waiting on you" marker,
|
|
21
|
+
# and informs the parent loop by pushing a note onto the parent\'s
|
|
22
|
+
# InputQueue (so the parent MODEL sees the question at its next turn and
|
|
23
|
+
# MAY answer it — the "parent answers if it can" half; the parent\'s
|
|
24
|
+
# answer routes back through the SAME gate via /reply or a parent path).
|
|
25
|
+
# 3. blocking:true → the tool BLOCKS on gate.await(timeout: nil) — wait
|
|
26
|
+
# INDEFINITELY, no auto-default (the owner constraint). The human answers
|
|
27
|
+
# via /reply <id>, which decides the gate; the answer is the tool result
|
|
28
|
+
# and enters the child\'s context as the tool message.
|
|
29
|
+
# blocking:false → the tool returns IMMEDIATELY ("asked, keep working");
|
|
30
|
+
# the answer is delivered later as a steer note on the child\'s queue
|
|
31
|
+
# (Loop#inject_steered_input), so the child keeps making progress.
|
|
32
|
+
#
|
|
33
|
+
# SUSPEND/RESUME (the W1/#54 lesson): on the CLI a background subagent runs on
|
|
34
|
+
# its OWN dedicated Thread — NOT a pooled Puma/Solid-Queue worker. Parking
|
|
35
|
+
# that dedicated thread on the gate (blocking:true) therefore holds only the
|
|
36
|
+
# child\'s own thread, never a shared pool, so it cannot freeze the REPL the
|
|
37
|
+
# way a parked Puma worker froze the server (W1). This is exactly how the
|
|
38
|
+
# existing Option-2 approval handler parks the child thread today. A full
|
|
39
|
+
# persist-and-resume suspend (free the thread entirely, rehydrate on answer)
|
|
40
|
+
# is only required for the POOLED web path, which is OUT OF SCOPE here and
|
|
41
|
+
# tracked as a follow-up. A stop (/agents <id> --stop) cancels the gate so a
|
|
42
|
+
# blocking ask unwinds at once instead of waiting forever.
|
|
43
|
+
class AskParentTool < Base
|
|
44
|
+
# Sentinel head used when a non-blocking ask returns to the child: the
|
|
45
|
+
# child keeps working and the real answer arrives later as a steer note.
|
|
46
|
+
NONBLOCKING_ACK = "Question sent to your parent. Keep working with your best " + "judgement; the answer will be delivered to you as a note " + "at your next turn if/when it arrives."
|
|
47
|
+
|
|
48
|
+
# Fallback bound (seconds) for a blocking ask when no configuration is
|
|
49
|
+
# reachable (a bare tool in a unit test). The live value comes from
|
|
50
|
+
# tasks.ask_parent_timeout; this matches the approvals wait-timeout default.
|
|
51
|
+
DEFAULT_ASK_TIMEOUT = 900
|
|
52
|
+
|
|
53
|
+
def name
|
|
54
|
+
"ask_parent"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Gated by the same `tools.task` delegation key — it is meaningless without
|
|
58
|
+
# the delegation substrate (BackgroundTasks/registry). Disabling delegation
|
|
59
|
+
# disables ask_parent too.
|
|
60
|
+
def config_key
|
|
61
|
+
"task"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def description
|
|
65
|
+
"Ask YOUR PARENT agent a question when you hit a decision you cannot " + "resolve from the task you were given (e.g. a missing preference, an " + "ambiguous requirement, sqlite-vs-postgres). Your parent answers from " + "its own context if it can, otherwise it asks the human. Use " + "blocking:true when you CANNOT proceed without the answer (you will " + "pause until it arrives); blocking:false (default) when you can keep " + "working and fold the answer in later. Only available to subagents."
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def input_schema
|
|
69
|
+
{
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
question: { type: "string", description: "The question for your parent. Be specific and self-contained." },
|
|
73
|
+
blocking: {
|
|
74
|
+
type: "boolean",
|
|
75
|
+
description: "true = pause until answered (you cannot proceed without it). " + "false (default) = keep working; the answer is delivered later as a note."
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
required: %w[question]
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def risk_level
|
|
83
|
+
:low
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def call(arguments)
|
|
87
|
+
question = (arguments["question"] || arguments[:question]).to_s.strip
|
|
88
|
+
blocking = blocking_arg(arguments)
|
|
89
|
+
return "Error: question is required" if question.empty?
|
|
90
|
+
|
|
91
|
+
id = Rubino.current_subagent_id
|
|
92
|
+
entry = id && BackgroundTasks.instance.find(id)
|
|
93
|
+
unless entry
|
|
94
|
+
return "Error: ask_parent is only available to a background subagent " + "(no parent to ask). Resolve this from your task instead."
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
escalate(entry, question, blocking)
|
|
98
|
+
rescue Rubino::Interrupted
|
|
99
|
+
# A /agents <id> --stop (or teardown) cancelled the gate while we were
|
|
100
|
+
# parked. Unwind cleanly: report it as denied/cancelled so the child can
|
|
101
|
+
# finish rather than hang.
|
|
102
|
+
BackgroundTasks.instance.end_ask(entry&.id) if defined?(entry) && entry
|
|
103
|
+
"Your parent question was cancelled (the run is being stopped)."
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# blocking defaults to FALSE (the cheap, non-freezing default): the child
|
|
109
|
+
# keeps working and the answer is injected later. Only an explicit true
|
|
110
|
+
# opts into the indefinite blocking wait.
|
|
111
|
+
def blocking_arg(arguments)
|
|
112
|
+
raw = arguments.key?("blocking") ? arguments["blocking"] : arguments[:blocking]
|
|
113
|
+
[true, "true", 1, "1"].include?(raw)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def escalate(entry, question, blocking)
|
|
117
|
+
gate = Run::ApprovalGate.new
|
|
118
|
+
ask_id = "ask_#{entry.id}"
|
|
119
|
+
gate.register(ask_id)
|
|
120
|
+
# Route by OWNER (S4): a child with an agent-parent blocks on that PARENT
|
|
121
|
+
# (:blocked_on_parent, answered by the parent model's `answer_child`); a
|
|
122
|
+
# human/top-level-owned child blocks on the HUMAN (:blocked_on_human,
|
|
123
|
+
# answered via /reply). begin_ask records the right status from the owner.
|
|
124
|
+
owner_id = entry.owner_subagent_id
|
|
125
|
+
BackgroundTasks.instance.begin_ask(
|
|
126
|
+
entry.id, gate: gate, ask_id: ask_id, question: question,
|
|
127
|
+
blocking: blocking, owner_id: owner_id
|
|
128
|
+
)
|
|
129
|
+
if owner_id
|
|
130
|
+
notify_agent_parent(owner_id, entry, question)
|
|
131
|
+
else
|
|
132
|
+
surface_and_notify(entry, question)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
if blocking
|
|
136
|
+
await_human(entry, gate, ask_id)
|
|
137
|
+
else
|
|
138
|
+
# Non-blocking: the child keeps working. The answer arrives later as a
|
|
139
|
+
# steer note via the gate-watcher the CLI installs at /reply time
|
|
140
|
+
# (BackgroundTasks#steer pushes onto the child\'s queue). We do NOT
|
|
141
|
+
# clear the ask state here — the entry stays :blocked_on_human on the
|
|
142
|
+
# card until the human answers, so a non-blocking ask is still visible
|
|
143
|
+
# and answerable; the child simply does not pause for it.
|
|
144
|
+
NONBLOCKING_ACK
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Parks the child\'s OWN thread on the gate, BOUNDED by
|
|
149
|
+
# tasks.ask_parent_timeout (S5a — default 900s). On EXPIRED (no answer
|
|
150
|
+
# within the bound) the child self-heals: it stops waiting and proceeds with
|
|
151
|
+
# its best judgement instead of hanging forever. The answer (from /reply or
|
|
152
|
+
# answer_child, both via gate.decide) returns as the tool result so it
|
|
153
|
+
# enters the child\'s context. A cancel (/agents <id> --stop or a
|
|
154
|
+
# stop-cascade from an ancestor) raises Interrupted, handled in #call.
|
|
155
|
+
def await_human(entry, gate, ask_id)
|
|
156
|
+
decision = gate.await(ask_id, timeout: ask_timeout)
|
|
157
|
+
answer = decision.equal?(Run::ApprovalGate::EXPIRED) ? nil : decision.to_s
|
|
158
|
+
BackgroundTasks.instance.end_ask(entry.id)
|
|
159
|
+
if answer.nil? || answer.empty?
|
|
160
|
+
"Your parent did not provide an answer. Proceed with your best judgement."
|
|
161
|
+
else
|
|
162
|
+
"Your parent answered: #{answer}"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The bounded wait for a blocking ask, from config (tasks.ask_parent_timeout)
|
|
167
|
+
# when wired, else the built-in default. Reuses the approval-gate timeout
|
|
168
|
+
# convention (a sane upper bound, never "forever") so an abandoned ask
|
|
169
|
+
# self-heals rather than parking the child\'s thread indefinitely.
|
|
170
|
+
def ask_timeout
|
|
171
|
+
cfg = Rubino.configuration if defined?(Rubino) && Rubino.respond_to?(:configuration)
|
|
172
|
+
val = cfg&.respond_to?(:tasks_ask_parent_timeout) ? cfg.tasks_ask_parent_timeout : nil
|
|
173
|
+
Integer(val)
|
|
174
|
+
rescue StandardError, TypeError, ArgumentError
|
|
175
|
+
DEFAULT_ASK_TIMEOUT
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Notifies the AGENT-parent (owner) of a child question by pushing the
|
|
179
|
+
# [subagent-question] note onto the OWNER\'s steer_queue — the same
|
|
180
|
+
# turn-boundary channel a steer rides — so the parent MODEL sees it at its
|
|
181
|
+
# next iteration and can answer with `answer_child` (or escalate up via its
|
|
182
|
+
# own ask_parent). No human surfacing here: a :blocked_on_parent ask is the
|
|
183
|
+
# agent-parent\'s job, not the human\'s. Best-effort.
|
|
184
|
+
def notify_agent_parent(owner_id, entry, question)
|
|
185
|
+
BackgroundTasks.instance.steer(owner_id, agent_parent_notice(entry, question))
|
|
186
|
+
rescue StandardError
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def agent_parent_notice(entry, question)
|
|
191
|
+
"[subagent-question] Your subagent #{entry.id} ('#{entry.subagent}') is asking you:\n" \
|
|
192
|
+
"#{question}\n" \
|
|
193
|
+
"Answer it with answer_child(task_id: \"#{entry.id}\", answer: \"…\") if you can. " \
|
|
194
|
+
"If you cannot answer from your own context, escalate by calling ask_parent yourself."
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Surfaces the blocked state on the parent CLI (a committed banner in
|
|
198
|
+
# scrollback + a card repaint so the persistent ⛔ marker shows) and pushes
|
|
199
|
+
# a note onto the PARENT\'s InputQueue so the parent MODEL learns of the
|
|
200
|
+
# question at its next turn and may answer it (answer_child). The sink is
|
|
201
|
+
# the one TaskTool captured on the PARENT thread at spawn time
|
|
202
|
+
# (entry.parent_sink) — NEVER the thread-local Rubino.background_sink,
|
|
203
|
+
# which on this (the child\'s) thread is the child\'s own steer_queue and
|
|
204
|
+
# would misroute the question back into the asking child (#195). Pushed as
|
|
205
|
+
# a NOTICE so it rides the parent\'s next real turn instead of firing a
|
|
206
|
+
# standalone synthetic user turn at the idle prompt (#13). DISPLAY/notify
|
|
207
|
+
# only — the authoritative answer delivery is the gate decision
|
|
208
|
+
# (/reply or answer_child).
|
|
209
|
+
def surface_and_notify(entry, question)
|
|
210
|
+
entry.parent_sink&.push_notice(parent_notice(entry, question))
|
|
211
|
+
parent_ui = Rubino.instance_variable_get(:@ui)
|
|
212
|
+
return unless parent_ui.is_a?(UI::CLI)
|
|
213
|
+
|
|
214
|
+
parent_ui.subagent_ask_banner(entry.id, entry.subagent, question) if parent_ui.respond_to?(:subagent_ask_banner)
|
|
215
|
+
parent_ui.set_subagent_cards if parent_ui.respond_to?(:set_subagent_cards)
|
|
216
|
+
rescue StandardError
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The top-level agent has answer_child registered too — the notice must
|
|
221
|
+
# name the MODEL-callable tool, not the human-only /reply command the
|
|
222
|
+
# model cannot invoke (#195).
|
|
223
|
+
def parent_notice(entry, question)
|
|
224
|
+
"[subagent-question] Task #{entry.id} (subagent '#{entry.subagent}') is asking you:\n" \
|
|
225
|
+
"#{question}\n" \
|
|
226
|
+
"Answer it with answer_child(task_id: \"#{entry.id}\", answer: \"…\") if you can. " \
|
|
227
|
+
"If you cannot answer from your own context, tell the user — the human can also " \
|
|
228
|
+
"answer via /reply #{entry.id}."
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Tools
|
|
7
|
+
# Hands a previously-written file to the surrounding UI as a downloadable
|
|
8
|
+
# artifact. The tool itself does not move bytes — it validates the path
|
|
9
|
+
# against the workspace and the file's existence, then surfaces a
|
|
10
|
+
# structured artifact payload that the agent loop turns into an
|
|
11
|
+
# ARTIFACT_CREATED event. Downstream consumers (the web UI's
|
|
12
|
+
# run job, the CLI) fetch the file separately via GET /v1/files.
|
|
13
|
+
#
|
|
14
|
+
# Why a dedicated tool rather than inferring artifacts from write/edit
|
|
15
|
+
# tool calls: the model writes lots of intermediate files (helper
|
|
16
|
+
# scripts, scratch JSON, downloaded fixtures) that should NOT show up
|
|
17
|
+
# as user-facing downloads. An explicit attach_file call makes that
|
|
18
|
+
# decision intentional and reviewable.
|
|
19
|
+
class AttachFileTool < Base
|
|
20
|
+
DEFAULT_CONTENT_TYPE = "application/octet-stream"
|
|
21
|
+
|
|
22
|
+
# Minimal extension → MIME map. Anything not listed falls back to
|
|
23
|
+
# application/octet-stream; the browser will then decide based on
|
|
24
|
+
# filename. Add entries here only when a real run needs a specific
|
|
25
|
+
# type signalled (e.g. inline PDF preview).
|
|
26
|
+
CONTENT_TYPES = {
|
|
27
|
+
"pdf" => "application/pdf",
|
|
28
|
+
"csv" => "text/csv",
|
|
29
|
+
"txt" => "text/plain",
|
|
30
|
+
"md" => "text/markdown",
|
|
31
|
+
"json" => "application/json",
|
|
32
|
+
"html" => "text/html",
|
|
33
|
+
"htm" => "text/html",
|
|
34
|
+
"xml" => "application/xml",
|
|
35
|
+
"png" => "image/png",
|
|
36
|
+
"jpg" => "image/jpeg",
|
|
37
|
+
"jpeg" => "image/jpeg",
|
|
38
|
+
"gif" => "image/gif",
|
|
39
|
+
"svg" => "image/svg+xml",
|
|
40
|
+
"zip" => "application/zip",
|
|
41
|
+
"tar" => "application/x-tar",
|
|
42
|
+
"gz" => "application/gzip",
|
|
43
|
+
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
44
|
+
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
45
|
+
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
def name
|
|
49
|
+
"attach_file"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def description
|
|
53
|
+
"Attach a previously-written file to the current turn as a downloadable artifact " \
|
|
54
|
+
"for the user. Call this AFTER you have already created the file with write/edit/shell. " \
|
|
55
|
+
"Pass the absolute or workspace-relative path. The tool does not copy or move the file — " \
|
|
56
|
+
"it just registers it as a deliverable. Use for final user-facing outputs " \
|
|
57
|
+
"(PDF, CSV, ZIP, reports) and not for intermediate helper scripts."
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def input_schema
|
|
61
|
+
{
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
file_path: {
|
|
65
|
+
type: "string",
|
|
66
|
+
description: "Path to the file to attach. Must exist and live inside the workspace."
|
|
67
|
+
},
|
|
68
|
+
filename: {
|
|
69
|
+
type: "string",
|
|
70
|
+
description: "Optional display name; defaults to the basename of file_path."
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
required: %w[file_path]
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def risk_level
|
|
78
|
+
:low
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def call(arguments)
|
|
82
|
+
file_path = (arguments["file_path"] || arguments[:file_path]).to_s
|
|
83
|
+
return error("file_path is required") if file_path.empty?
|
|
84
|
+
|
|
85
|
+
expanded = File.expand_path(file_path)
|
|
86
|
+
return error("File not found: #{file_path}") unless File.exist?(expanded)
|
|
87
|
+
return error("Not a regular file: #{file_path}") unless File.file?(expanded)
|
|
88
|
+
return error("Path escapes the workspace: #{file_path}") unless within_workspace?(expanded)
|
|
89
|
+
|
|
90
|
+
display = (arguments["filename"] || arguments[:filename]).to_s
|
|
91
|
+
display = File.basename(expanded) if display.empty?
|
|
92
|
+
|
|
93
|
+
size = File.size(expanded)
|
|
94
|
+
artifact = {
|
|
95
|
+
path: expanded,
|
|
96
|
+
filename: display,
|
|
97
|
+
content_type: content_type_for(expanded),
|
|
98
|
+
byte_size: size
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
output: "Attached #{display} (#{size} bytes) as a downloadable artifact.",
|
|
103
|
+
metrics: "#{size} bytes",
|
|
104
|
+
artifact: artifact
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def error(message)
|
|
111
|
+
{ output: "Error: #{message}", error_code: :attach_failed }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def content_type_for(path)
|
|
115
|
+
ext = File.extname(path).to_s.sub(/\A\./, "").downcase
|
|
116
|
+
CONTENT_TYPES[ext] || DEFAULT_CONTENT_TYPE
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|