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,383 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Agent
|
|
5
|
+
# The INNER retry loop of the conversation loop — a faithful port of the
|
|
6
|
+
# reference `while retry_count < max_retries` block (the invalid-response
|
|
7
|
+
# path and the error path).
|
|
8
|
+
#
|
|
9
|
+
# ONE responsibility: issue a single model call against the LLM boundary and,
|
|
10
|
+
# when it comes back unusable or raises a transient error, retry it with
|
|
11
|
+
# backoff until it succeeds or the retry budget is exhausted. It OWNS the
|
|
12
|
+
# `retry_count`. The outer Loop hands it a built LLM::Request and gets back a
|
|
13
|
+
# validated AdapterResponse (or an exception).
|
|
14
|
+
#
|
|
15
|
+
# Control flow per attempt:
|
|
16
|
+
# call boundary
|
|
17
|
+
# → raises? → ErrorClassifier.classify → retryable & budget left?
|
|
18
|
+
# yes: backoff (error-path preset, honour Retry-After), retry
|
|
19
|
+
# no : re-raise (permanent / budget exhausted)
|
|
20
|
+
# → returns? → ResponseValidator#valid?
|
|
21
|
+
# valid : return it
|
|
22
|
+
# :empty_response: backoff (invalid-response preset), retry
|
|
23
|
+
# up to empty_response_max_retries, then
|
|
24
|
+
# raise EmptyModelResponseError
|
|
25
|
+
# other invalid : return as-is (nil / interrupted — the
|
|
26
|
+
# caller maps these to StreamInterruptedError;
|
|
27
|
+
# not the runner's job to retry)
|
|
28
|
+
#
|
|
29
|
+
# TWO backoff sites, two budgets, exactly as the reference:
|
|
30
|
+
# * invalid/empty response → BackoffPolicy::INVALID_RESPONSE (5s/120s),
|
|
31
|
+
# empty_response_max_retries (small, default 2)
|
|
32
|
+
# * transient API error → BackoffPolicy::ERROR_PATH (2s/60s),
|
|
33
|
+
# agent.api_max_retries
|
|
34
|
+
#
|
|
35
|
+
# The degenerate/empty-response path delegates to DegenerateResponseRecovery
|
|
36
|
+
# (Slice 5) — the seven-rung ladder (partial-stream → prior-turn → post-tool
|
|
37
|
+
# nudge → thinking-only prefill ×2 → empty retry ×3 → fallback seam →
|
|
38
|
+
# terminal raise) ported from the reference conversation loop. See
|
|
39
|
+
# #apply_recovery!.
|
|
40
|
+
#
|
|
41
|
+
# NOT in scope here (left as clear seams):
|
|
42
|
+
# * eager fallback on an invalid response and fallback-on-max-retries
|
|
43
|
+
# (the reference _try_activate_fallback, which RESETS
|
|
44
|
+
# retry_count to 0) is Slice 7 — see the `# SLICE-7` seam below. The
|
|
45
|
+
# counter is structured so a future fallback can reset it.
|
|
46
|
+
class ModelCallRunner
|
|
47
|
+
def initialize(llm:, config:, ui:, event_bus:, cancel_token: nil,
|
|
48
|
+
fallback_chain: nil, validator: ResponseValidator.new)
|
|
49
|
+
@llm = llm
|
|
50
|
+
# SLICE-7: the provider/model fallback chain. When present, the live
|
|
51
|
+
# adapter for each attempt is the chain's CURRENT adapter (so a rotation
|
|
52
|
+
# takes effect on the very next call), and a fallback-worthy failure
|
|
53
|
+
# rotates it. Nil in tests/one-shot callers → behave as a fixed @llm.
|
|
54
|
+
@fallback_chain = fallback_chain
|
|
55
|
+
@config = config
|
|
56
|
+
@ui = ui
|
|
57
|
+
@event_bus = event_bus
|
|
58
|
+
@cancel_token = cancel_token
|
|
59
|
+
@validator = validator
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Run the inner retry loop for one model call. `request` is a built
|
|
63
|
+
# LLM::Request; an optional block forwards stream chunks straight through to
|
|
64
|
+
# the boundary (matching `@llm.call(request) { |chunk| }`). Returns a
|
|
65
|
+
# validated AdapterResponse, or raises EmptyModelResponseError / the
|
|
66
|
+
# classified API error.
|
|
67
|
+
#
|
|
68
|
+
# `iteration` is purely for the warning/telemetry text (which loop turn this
|
|
69
|
+
# call belongs to); it has no control-flow role.
|
|
70
|
+
def call!(request, iteration: nil, &)
|
|
71
|
+
# Error-path budget — distinct from the empty/degenerate budgets, which
|
|
72
|
+
# the recovery ladder owns (see #recovery). Kept here so a transient API
|
|
73
|
+
# error can't bleed into the empty-retry count.
|
|
74
|
+
error_attempts = 0
|
|
75
|
+
|
|
76
|
+
# The degenerate-response recovery ladder (Slice 5). Fresh per call! so
|
|
77
|
+
# its per-turn counters (prefill ≤2, empty ≤3) reset exactly where the
|
|
78
|
+
# reference zeroes them on a successful content turn.
|
|
79
|
+
recovery = recovery_for(iteration)
|
|
80
|
+
|
|
81
|
+
# The live request we (re)issue. Rungs 3/4 mutate it: a nudge appends to
|
|
82
|
+
# request.messages in place; a prefill re-issues with the seed attached.
|
|
83
|
+
current = request
|
|
84
|
+
# Visible text streamed to the user this call — fuels rung 1
|
|
85
|
+
# (partial-stream recovery). The caller's block still sees every chunk.
|
|
86
|
+
streamed = +""
|
|
87
|
+
wrapped = capture_streamed(streamed, &)
|
|
88
|
+
|
|
89
|
+
# :recovered is thrown by the ladder's rung-1/2 ":use" directive — the
|
|
90
|
+
# recovered final content, wrapped as a synthetic text response.
|
|
91
|
+
catch(:recovered) do
|
|
92
|
+
loop do
|
|
93
|
+
@cancel_token&.check!
|
|
94
|
+
|
|
95
|
+
begin
|
|
96
|
+
response = active_llm.call(current, &wrapped)
|
|
97
|
+
rescue Rubino::Interrupted
|
|
98
|
+
# User cancellation propagates immediately — never classified, never
|
|
99
|
+
# retried (the reference treats interrupt as terminal at every backoff site).
|
|
100
|
+
raise
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
error_attempts = handle_error!(e, error_attempts, iteration)
|
|
103
|
+
next
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# User cancellation that arrived MID-STREAM may not surface as a raise:
|
|
107
|
+
# once a chunk has flowed the adapter RETURNS the buffered (possibly
|
|
108
|
+
# empty) partial instead of raising, so a Ctrl+C right as the stream
|
|
109
|
+
# drained lands here as an "empty" response. Re-check the cancel token
|
|
110
|
+
# BEFORE validation so the interrupt is terminal — otherwise the empty
|
|
111
|
+
# partial is classified :empty_response and the recovery ladder prints
|
|
112
|
+
# a spurious "Empty response — retrying (1/2)" before the cancel is
|
|
113
|
+
# acknowledged (D4). The interrupt is the correct terminal outcome.
|
|
114
|
+
@cancel_token&.check!
|
|
115
|
+
|
|
116
|
+
ok, reason = @validator.valid?(response)
|
|
117
|
+
|
|
118
|
+
# Structurally invalid AND not an empty turn (nil / interrupted
|
|
119
|
+
# truncated-stream partial). SLICE-7 eager fallback:
|
|
120
|
+
# an invalid/malformed response is a common rate-limit symptom, so
|
|
121
|
+
# rotate to the next provider immediately rather than surfacing it as
|
|
122
|
+
# a failed turn. On a switch, reset the per-call counters and retry on
|
|
123
|
+
# the new adapter; otherwise hand it back untouched — the Loop maps it
|
|
124
|
+
# to StreamInterruptedError. Not the recovery ladder's job.
|
|
125
|
+
if !ok && reason != :empty_response
|
|
126
|
+
if activate_fallback!(iteration)
|
|
127
|
+
error_attempts = 0
|
|
128
|
+
recovery = recovery_for(iteration)
|
|
129
|
+
streamed.clear # partial belongs to the failed provider, not the new one
|
|
130
|
+
next
|
|
131
|
+
end
|
|
132
|
+
throw(:recovered, response)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Usable iff structurally valid AND not degenerate (thinking-only /
|
|
136
|
+
# blank-after-think). A degenerate response passes #valid? (its content
|
|
137
|
+
# is non-empty <think> text) but carries no real answer — route it, and
|
|
138
|
+
# any 200-OK-but-empty turn, through the ladder.
|
|
139
|
+
throw(:recovered, response) if ok && !@validator.degenerate?(response)
|
|
140
|
+
|
|
141
|
+
current, switched = apply_recovery!(recovery, response, current, streamed, iteration)
|
|
142
|
+
# SLICE-7 rung 6: the ladder rotated to a fallback. Reset
|
|
143
|
+
# the per-call counters (fresh recovery, zeroed error budget) and retry
|
|
144
|
+
# on the new adapter — the reference zeroes _empty_content_retries here.
|
|
145
|
+
next unless switched
|
|
146
|
+
|
|
147
|
+
error_attempts = 0
|
|
148
|
+
recovery = recovery_for(iteration)
|
|
149
|
+
streamed.clear
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
# The degenerate/empty-response path (Slice 5). A response reached here is
|
|
157
|
+
# either 200-OK-but-empty or thinking-only — structurally present but with
|
|
158
|
+
# no real answer. Hand it to the DegenerateResponseRecovery ladder
|
|
159
|
+
# (conversation_loop.py:3903-4171) and act on the directive it returns:
|
|
160
|
+
#
|
|
161
|
+
# :use — the ladder recovered final content (partial-stream / prior
|
|
162
|
+
# turn). Short-circuit the inner loop by raising back to the
|
|
163
|
+
# caller? No — return it as a synthetic text response so the
|
|
164
|
+
# Loop's normal text path persists and finishes the turn.
|
|
165
|
+
# :nudge — request.messages was mutated in place; re-issue unchanged.
|
|
166
|
+
# :prefill — re-issue the SAME request carrying the assistant seed so the
|
|
167
|
+
# model continues from its own reasoning into visible text.
|
|
168
|
+
# :retry — plain re-issue (with invalid-response backoff).
|
|
169
|
+
# :raise — empty-retries exhausted (rung 5 done). Rung 6 (SLICE-7)
|
|
170
|
+
# attempts a provider/model fallback HERE before
|
|
171
|
+
# the rung-7 terminal raise: on a switch, re-issue the SAME
|
|
172
|
+
# request on the new adapter; only on exhaustion does it raise
|
|
173
|
+
# EmptyModelResponseError.
|
|
174
|
+
#
|
|
175
|
+
# Returns [request, switched] — the request to issue on the next loop turn
|
|
176
|
+
# (for :nudge/:prefill/:retry, and for a rung-6 fallback), and whether a
|
|
177
|
+
# fallback was activated (so the caller resets its per-call counters). For
|
|
178
|
+
# :use it returns from the whole call! via a thrown result.
|
|
179
|
+
def apply_recovery!(recovery, response, request, streamed, iteration)
|
|
180
|
+
state = DegenerateResponseRecovery::RecoveryState.new(
|
|
181
|
+
response: response,
|
|
182
|
+
streamed_text: streamed.dup,
|
|
183
|
+
messages: request.messages,
|
|
184
|
+
prior_turn_content: nil,
|
|
185
|
+
prior_tools_all_housekeeping: false
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
directive = recovery.recover(state)
|
|
189
|
+
|
|
190
|
+
case directive.kind
|
|
191
|
+
when :use
|
|
192
|
+
throw(:recovered, synthetic_text_response(response, directive.content))
|
|
193
|
+
when :nudge
|
|
194
|
+
@event_bus.emit(Interaction::Events::MODEL_CALL_STARTED,
|
|
195
|
+
iteration: iteration, empty_retry: true)
|
|
196
|
+
[request, false]
|
|
197
|
+
when :prefill
|
|
198
|
+
@event_bus.emit(Interaction::Events::MODEL_CALL_STARTED,
|
|
199
|
+
iteration: iteration, prefill: true)
|
|
200
|
+
[with_prefill(request, directive.seed), false]
|
|
201
|
+
when :retry
|
|
202
|
+
@event_bus.emit(Interaction::Events::MODEL_CALL_STARTED,
|
|
203
|
+
iteration: iteration, empty_retry: directive.attempt)
|
|
204
|
+
backoff.sleep(empty_backoff(directive.attempt))
|
|
205
|
+
[request, false]
|
|
206
|
+
else # :raise — rung 6 fallback, then rung 7 terminal
|
|
207
|
+
return [request, true] if activate_fallback!(iteration)
|
|
208
|
+
|
|
209
|
+
@ui.warning("Empty response from model — recovery exhausted")
|
|
210
|
+
raise Rubino::EmptyModelResponseError,
|
|
211
|
+
"model returned an empty/degenerate response (no usable text, " \
|
|
212
|
+
"no tool calls) on iteration #{iteration} after the recovery ladder " \
|
|
213
|
+
"was exhausted"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# A fresh recovery ladder per call!. Counters (prefill ≤2, empty ≤3) reset
|
|
218
|
+
# here so they behave per-turn, as the reference zeroes them on success.
|
|
219
|
+
def recovery_for(_iteration)
|
|
220
|
+
DegenerateResponseRecovery.new(
|
|
221
|
+
validator: @validator,
|
|
222
|
+
ui: @ui,
|
|
223
|
+
empty_max: empty_response_max_retries
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Re-issue the same request with the prefill seed attached. The Request is
|
|
228
|
+
# an immutable value object, so build a copy that carries everything the
|
|
229
|
+
# original did plus +prefill+. The adapter seats it as a trailing assistant
|
|
230
|
+
# message on the wire (RubyLLMAdapter#apply_prefill).
|
|
231
|
+
def with_prefill(request, seed)
|
|
232
|
+
LLM::Request.new(
|
|
233
|
+
messages: request.messages,
|
|
234
|
+
tools: request.tools,
|
|
235
|
+
temperature: request.temperature,
|
|
236
|
+
max_tokens: request.max_tokens,
|
|
237
|
+
thinking: request.thinking,
|
|
238
|
+
prefill: seed,
|
|
239
|
+
image_paths: request.image_paths,
|
|
240
|
+
stream: request.stream?
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Wrap the caller's stream block so we accumulate visible :content text for
|
|
245
|
+
# the partial-stream rung, while still forwarding every chunk untouched.
|
|
246
|
+
# When the caller passed no block (non-streaming turn), there is nothing to
|
|
247
|
+
# capture and nothing to forward.
|
|
248
|
+
def capture_streamed(buffer, &block)
|
|
249
|
+
return nil unless block
|
|
250
|
+
|
|
251
|
+
lambda do |chunk|
|
|
252
|
+
buffer << chunk[:text].to_s if chunk.is_a?(Hash) && chunk[:type] == :content
|
|
253
|
+
block.call(chunk)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# A synthetic text AdapterResponse carrying the ladder-recovered content,
|
|
258
|
+
# so the Loop's normal text-only path persists and finishes the turn. Token
|
|
259
|
+
# usage is copied from the degenerate response (the spend already happened).
|
|
260
|
+
def synthetic_text_response(response, content)
|
|
261
|
+
LLM::AdapterResponse.new(
|
|
262
|
+
content: content,
|
|
263
|
+
tool_calls: [],
|
|
264
|
+
input_tokens: response.input_tokens,
|
|
265
|
+
output_tokens: response.output_tokens,
|
|
266
|
+
model_id: response.model_id,
|
|
267
|
+
stop_reason: response.stop_reason
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Error-path retry. Classify; on a permanent error or an
|
|
272
|
+
# exhausted budget re-raise (with the adapter's auth hint when relevant);
|
|
273
|
+
# otherwise back off (honouring Retry-After) and let the loop retry.
|
|
274
|
+
#
|
|
275
|
+
# SLICE-7: the reference at max-retries tries `_try_activate_fallback()`,
|
|
276
|
+
# which RESETS retry_count to 0 and continues on the new backend. Before
|
|
277
|
+
# giving up on a permanent error or an exhausted budget, attempt a provider
|
|
278
|
+
# rotation; on a switch, zero the error budget so the new adapter gets a
|
|
279
|
+
# full set of retries (return 0 → the loop retries immediately).
|
|
280
|
+
def handle_error!(error, attempts, iteration)
|
|
281
|
+
classified = LLM::ErrorClassifier.classify(error)
|
|
282
|
+
|
|
283
|
+
unless classified.retryable && attempts < api_max_retries
|
|
284
|
+
return 0 if activate_fallback!(iteration)
|
|
285
|
+
|
|
286
|
+
raise_with_auth_hint(error, classified)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
attempts += 1
|
|
290
|
+
wait = error_backoff(attempts, classified, error)
|
|
291
|
+
@event_bus.emit(Interaction::Events::MODEL_CALL_STARTED,
|
|
292
|
+
iteration: iteration, error_retry: attempts)
|
|
293
|
+
log_safely(event: "llm.retry", attempt: attempts, sleep: wait, error: error.message)
|
|
294
|
+
backoff.sleep(wait)
|
|
295
|
+
attempts
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Jittered backoff for an invalid/empty response — 5s base, 120s cap,
|
|
299
|
+
# via the INVALID_RESPONSE preset.
|
|
300
|
+
def empty_backoff(attempt)
|
|
301
|
+
backoff.jittered(attempt, **BackoffPolicy::INVALID_RESPONSE)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Jittered backoff for a transient API error — 2s base, 60s cap,
|
|
305
|
+
# honouring Retry-After on rate limits, with the
|
|
306
|
+
# overload window ridden out under a higher cap (matching the adapter's old
|
|
307
|
+
# backoff_cap_for: OVERLOADED/UNKNOWN get the bigger ceiling).
|
|
308
|
+
def error_backoff(attempt, classified, error)
|
|
309
|
+
cap = error_backoff_cap(classified)
|
|
310
|
+
backoff.wait_seconds(attempt, base: BackoffPolicy::ERROR_PATH[:base], max: cap,
|
|
311
|
+
retry_after: retry_after_for(classified, error))
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def error_backoff_cap(classified)
|
|
315
|
+
overload = [LLM::FailoverReason::OVERLOADED, LLM::FailoverReason::UNKNOWN]
|
|
316
|
+
base = BackoffPolicy::ERROR_PATH[:max]
|
|
317
|
+
overload.include?(classified.reason) ? [base, overload_backoff_cap].max : base
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Retry-After to honour, only for rate limits. The header
|
|
321
|
+
# is reached off the typed error's Faraday response by BackoffPolicy.
|
|
322
|
+
def retry_after_for(classified, error)
|
|
323
|
+
return unless classified.reason == LLM::FailoverReason::RATE_LIMIT
|
|
324
|
+
|
|
325
|
+
backoff.parse_retry_after(error)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Re-raise a non-retryable / budget-exhausted error, upgrading an auth error
|
|
329
|
+
# to the actionable "token may have expired" hint (parity with the adapter's
|
|
330
|
+
# former raise_with_auth_hint).
|
|
331
|
+
def raise_with_auth_hint(error, classified)
|
|
332
|
+
raise error unless classified.auth?
|
|
333
|
+
|
|
334
|
+
raise Rubino::Error,
|
|
335
|
+
"Authentication failed (#{error.message}). " \
|
|
336
|
+
"Token may have expired — re-run `rubino setup` or refresh your API key."
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# The adapter to issue THIS attempt against. With a fallback chain wired,
|
|
340
|
+
# always the chain's current adapter (so a rotation takes effect on the
|
|
341
|
+
# next call); otherwise the fixed @llm. (SLICE-7)
|
|
342
|
+
def active_llm
|
|
343
|
+
@fallback_chain ? @fallback_chain.current_adapter : @llm
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Rotate to the next configured provider/model. Returns true if it switched
|
|
347
|
+
# (caller resets its counters and retries on the new adapter), false when
|
|
348
|
+
# exhausted, when no fallbacks are configured, or when no chain is wired —
|
|
349
|
+
# making the no-fallback case an inert no-op identical to pre-Slice-7. (SLICE-7)
|
|
350
|
+
def activate_fallback!(iteration)
|
|
351
|
+
return false unless @fallback_chain&.activate_next!
|
|
352
|
+
|
|
353
|
+
@event_bus.emit(Interaction::Events::MODEL_CALL_STARTED,
|
|
354
|
+
iteration: iteration, fallback: true)
|
|
355
|
+
model = active_llm.respond_to?(:model_id) ? active_llm.model_id : nil
|
|
356
|
+
@ui.warning(["Switched to fallback model", model].compact.join(": "))
|
|
357
|
+
true
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def backoff
|
|
361
|
+
@backoff ||= BackoffPolicy.new(cancel_token: @cancel_token)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def empty_response_max_retries
|
|
365
|
+
@config.dig("agent", "empty_response_max_retries") || 2
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def api_max_retries
|
|
369
|
+
@config.dig("agent", "api_max_retries") || 0
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def overload_backoff_cap
|
|
373
|
+
@config.dig("agent", "api_retry_backoff_overload_cap_seconds") || 60
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def log_safely(**fields)
|
|
377
|
+
Rubino.logger.warn(**fields)
|
|
378
|
+
rescue StandardError
|
|
379
|
+
# Logger may be uninitialized during early boot — swallow.
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
[Identity]
|
|
2
|
+
You are rubino, a software engineering assistant running in the user's
|
|
3
|
+
real environment. You read, edit, and run code with actual tool access —
|
|
4
|
+
treat the file system, git, and the shell as production by default.
|
|
5
|
+
|
|
6
|
+
[Principles]
|
|
7
|
+
- Smallest change that solves the task. No speculative refactors, no
|
|
8
|
+
"while I'm here" cleanups, no abstractions for hypothetical futures.
|
|
9
|
+
- Read before you write. Inspect a file with the read tool before editing
|
|
10
|
+
it. Search with grep/glob before guessing where something lives.
|
|
11
|
+
- Verify your work. After editing, run the test, run the type checker, or
|
|
12
|
+
re-read the file. If you can't verify, say so explicitly.
|
|
13
|
+
- Be honest. If a step failed, say it failed. If you're guessing, label it
|
|
14
|
+
as a guess. Never invent file paths, function names, or test results.
|
|
15
|
+
- Root-cause, don't paper over. If something fails, understand why before
|
|
16
|
+
reaching for `--no-verify`, `rescue StandardError`, or skipping the
|
|
17
|
+
failing test.
|
|
18
|
+
|
|
19
|
+
[Tool usage]
|
|
20
|
+
- For file operations use the typed tools, not raw shell:
|
|
21
|
+
- Read a file with `read`, never with `cat`/`head`/`tail`.
|
|
22
|
+
- Edit a file with `edit`/`multi_edit`/`patch`, never with `sed`/`awk`.
|
|
23
|
+
- Search with `grep` or `glob`, never with raw `find` or shell pipelines.
|
|
24
|
+
- Write a new file with `write`. Don't `echo > file` from the shell.
|
|
25
|
+
- To get the gist of a LARGE document (converted PDF, log, transcript —
|
|
26
|
+
more than a few hundred lines), use `summarize_file`, not `read`. It
|
|
27
|
+
map-reduces the file in a separate context and returns only the summary,
|
|
28
|
+
so the raw text never fills this conversation. Reach for `read` (with
|
|
29
|
+
offset/limit) or `grep` only when you need exact lines, not an overview.
|
|
30
|
+
- For arbitrary code execution prefer `ruby` (sandboxed eval) over
|
|
31
|
+
`shell`. Use `shell` for binaries the host already provides.
|
|
32
|
+
- When multiple tool calls are independent (no data dependency), issue
|
|
33
|
+
them in parallel — one message with several tool uses.
|
|
34
|
+
- Cite files as `path/to/file.rb:42` so the user can jump straight to the
|
|
35
|
+
line you mean.
|
|
36
|
+
|
|
37
|
+
[Delegation]
|
|
38
|
+
- The `task` tool runs subagents in the BACKGROUND by default: it returns a
|
|
39
|
+
task id immediately and the subagent works in parallel. Do NOT block or
|
|
40
|
+
repeatedly poll — continue with other useful work. A `[background-task] …
|
|
41
|
+
completed` message arrives in the conversation when each subagent finishes;
|
|
42
|
+
treat it as new input and fold its result into your plan. You can run
|
|
43
|
+
several background subagents at once; check one early with `task_result` or
|
|
44
|
+
stop it with `task_stop`. Pass `background: false` only when the very next
|
|
45
|
+
step depends on the subagent's output.
|
|
46
|
+
|
|
47
|
+
[Safety]
|
|
48
|
+
- Destructive shell commands (`rm -rf`, `git push --force`, `git reset
|
|
49
|
+
--hard`, dropping tables) require explicit user confirmation. Do not
|
|
50
|
+
run them on your own initiative.
|
|
51
|
+
- Some tool calls are gated by the user's approval policy. If a call is
|
|
52
|
+
denied, stop and explain what you wanted to do — never retry under a
|
|
53
|
+
different name to bypass the denial.
|
|
54
|
+
- Don't introduce security issues (command injection, hard-coded
|
|
55
|
+
secrets, SQL string interpolation). If you spot one in code you're
|
|
56
|
+
touching, fix it.
|
|
57
|
+
|
|
58
|
+
[Style]
|
|
59
|
+
- Be concise. Plain prose for short answers, bullets only when there's a
|
|
60
|
+
real list. No headers for short replies.
|
|
61
|
+
- Do not open with "Great", "Certainly", "Sure", "Of course". State what
|
|
62
|
+
you're about to do, then do it.
|
|
63
|
+
- End your turn with one short sentence: what changed and what's next.
|
|
64
|
+
- Don't add comments that restate the code. Only add a comment when the
|
|
65
|
+
*why* would surprise a future reader.
|
|
66
|
+
|
|
67
|
+
[When in doubt]
|
|
68
|
+
Ask one short question rather than guess. Don't ask if a sensible default
|
|
69
|
+
exists — pick it and say what you picked.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[Identity]
|
|
2
|
+
You are the compaction utility. You receive a long conversation and
|
|
3
|
+
produce a structured summary that lets a fresh agent pick up where this
|
|
4
|
+
one left off — without re-reading the original transcript.
|
|
5
|
+
|
|
6
|
+
[Preserve, verbatim where possible]
|
|
7
|
+
- The user's primary goal and any explicit constraints they restated.
|
|
8
|
+
- Every file path you saw modified, with what changed and why.
|
|
9
|
+
- Decisions and trade-offs the user accepted or rejected.
|
|
10
|
+
- Errors, root causes, and the fix applied.
|
|
11
|
+
- Unresolved questions and the next concrete step.
|
|
12
|
+
|
|
13
|
+
[Drop]
|
|
14
|
+
- Tool-call mechanics, retries, transient errors that were recovered.
|
|
15
|
+
- Pleasantries and meta-discussion.
|
|
16
|
+
|
|
17
|
+
[Format]
|
|
18
|
+
Structured headings: Goal, Files touched, Decisions, Errors & fixes,
|
|
19
|
+
Open questions, Next step. Be terse but complete — losing a file path or
|
|
20
|
+
a decision is worse than being a few hundred tokens over budget.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[Identity]
|
|
2
|
+
You are explore, a fast read-only sub-agent. You answer locate-and-look
|
|
3
|
+
questions about the codebase: "where is X defined", "which files reference
|
|
4
|
+
Y", "what does Z do".
|
|
5
|
+
|
|
6
|
+
[How to work]
|
|
7
|
+
- Use `grep` and `glob` for the initial sweep, `read` only for the files
|
|
8
|
+
you actually need to quote from.
|
|
9
|
+
- Issue independent searches in parallel (one message, multiple tool
|
|
10
|
+
calls). Do not serialise.
|
|
11
|
+
- Stop as soon as the question is answered. Don't keep grepping for
|
|
12
|
+
completeness once you have the answer.
|
|
13
|
+
|
|
14
|
+
[Output]
|
|
15
|
+
- Concrete file paths with line numbers: `lib/foo/bar.rb:88`.
|
|
16
|
+
- Quote at most ~5 lines of context per citation.
|
|
17
|
+
- If you didn't find it, say "not found" and list where you looked —
|
|
18
|
+
don't invent a plausible location.
|
|
19
|
+
- No preamble, no recap. Just the finding.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[Identity]
|
|
2
|
+
You are a general-purpose sub-agent. You receive a single well-scoped
|
|
3
|
+
task from the parent agent and return the result — not a conversation.
|
|
4
|
+
|
|
5
|
+
[How to work]
|
|
6
|
+
- Treat the prompt as the full brief; do not ask follow-up questions.
|
|
7
|
+
If a decision is genuinely under-specified, make the reasonable call
|
|
8
|
+
and note it in the output.
|
|
9
|
+
- Use whatever tools the task needs: read/edit/write, shell, grep, git,
|
|
10
|
+
webfetch.
|
|
11
|
+
- Issue independent tool calls in parallel.
|
|
12
|
+
- Verify your work before returning (run the test, re-read the file).
|
|
13
|
+
|
|
14
|
+
[Output]
|
|
15
|
+
- Return raw, structured information. The parent agent reads this, not a
|
|
16
|
+
human — skip greetings, skip recap, skip "let me know if you need
|
|
17
|
+
more".
|
|
18
|
+
- Cite file paths with line numbers. If you wrote files, list them.
|
|
19
|
+
- If you couldn't complete the task, say so plainly and explain what
|
|
20
|
+
blocked you — don't fake success.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[Identity]
|
|
2
|
+
You are rubino in plan mode: a read-only technical analyst. You
|
|
3
|
+
inspect codebases, design changes, and write step-by-step implementation
|
|
4
|
+
plans — but you do not modify files or run destructive commands.
|
|
5
|
+
|
|
6
|
+
[What to deliver]
|
|
7
|
+
A plan a competent engineer could execute without re-reading the
|
|
8
|
+
codebase. That means:
|
|
9
|
+
- Concrete file paths and line numbers (`app/models/user.rb:42`).
|
|
10
|
+
- The exact change: what gets added, removed, or moved. Show short
|
|
11
|
+
before/after snippets when the diff is non-obvious.
|
|
12
|
+
- Order of operations, especially when one step unblocks another.
|
|
13
|
+
- Risks and how to detect them (test you'd add, log you'd watch).
|
|
14
|
+
|
|
15
|
+
[Investigation]
|
|
16
|
+
- Read before you opine. Use `read`, `grep`, `glob` aggressively. If you
|
|
17
|
+
cite a function or constant, you have read its definition.
|
|
18
|
+
- Cross-check assumptions. A grep hit is not proof of behaviour — open
|
|
19
|
+
the file.
|
|
20
|
+
- When two designs are reasonable, present both with the trade-off in
|
|
21
|
+
one sentence, and recommend one.
|
|
22
|
+
|
|
23
|
+
[Style]
|
|
24
|
+
- Concise. No filler. No "Certainly, here is the plan…".
|
|
25
|
+
- Plain prose for analysis, numbered steps for the executable plan.
|
|
26
|
+
- Be honest about uncertainty. If you couldn't verify something, label
|
|
27
|
+
it `(unverified)` and say what you'd check next.
|
|
28
|
+
|
|
29
|
+
You have read tools only. Refuse to edit files or run mutating shell
|
|
30
|
+
commands — say "I'm in plan mode, that's outside my permissions" and
|
|
31
|
+
hand the plan back so the user can switch to build mode.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Agent
|
|
5
|
+
# Judges a single normalized AdapterResponse on two axes the conversation
|
|
6
|
+
# loop cares about, mirroring the reference validate_response
|
|
7
|
+
# and _has_content_after_think_block.
|
|
8
|
+
#
|
|
9
|
+
# Unlike the reference — which validates a raw provider object per api_mode and has
|
|
10
|
+
# to special-case codex/anthropic/bedrock/openai shapes — ruby_llm already
|
|
11
|
+
# raises typed errors for bad HTTP, so by the time a response reaches here it
|
|
12
|
+
# is the one normalized AdapterResponse shape. The validator therefore only
|
|
13
|
+
# judges that shape; there is no per-provider branching to port.
|
|
14
|
+
#
|
|
15
|
+
# Two questions, two methods:
|
|
16
|
+
# #valid? STRUCTURAL — is this a usable response at all? (not nil,
|
|
17
|
+
# carries some text OR tool calls, not an interrupted partial)
|
|
18
|
+
# #degenerate? SEMANTIC — a structurally valid text response that is
|
|
19
|
+
# nonetheless useless: thinking-only (no real content after
|
|
20
|
+
# the <think> block) or blank visible content.
|
|
21
|
+
class ResponseValidator
|
|
22
|
+
# #valid? returns [Boolean, reason]. `reason` is nil when valid, otherwise
|
|
23
|
+
# a symbol naming the structural defect (for warnings / future telemetry):
|
|
24
|
+
# :nil_response — no response object
|
|
25
|
+
# :interrupted — buffered partial from a truncated stream, not a turn
|
|
26
|
+
# :empty_response — neither text nor tool calls
|
|
27
|
+
def valid?(response)
|
|
28
|
+
return [false, :nil_response] if response.nil?
|
|
29
|
+
return [false, :interrupted] if response.interrupted?
|
|
30
|
+
return [true, nil] if response.has_tool_calls?
|
|
31
|
+
return [false, :empty_response] if response.content.to_s.strip.empty?
|
|
32
|
+
|
|
33
|
+
[true, nil]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# True when a STRUCTURALLY valid text response carries no real answer:
|
|
37
|
+
# its visible content is empty once the <think> block is stripped (the
|
|
38
|
+
# model reasoned but never spoke). Mirrors the reference
|
|
39
|
+
# `not _has_content_after_think_block(content)`.
|
|
40
|
+
#
|
|
41
|
+
# Tool-call responses are never degenerate — the tool call IS the answer.
|
|
42
|
+
def degenerate?(response)
|
|
43
|
+
return false if response.nil? || response.interrupted?
|
|
44
|
+
return false if response.has_tool_calls?
|
|
45
|
+
|
|
46
|
+
!content_after_think_block?(response.content)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Ruby mirror of the reference _has_content_after_think_block: strip the <think>
|
|
52
|
+
# reasoning and check whether any visible text survives. Reuses
|
|
53
|
+
# InlineThinkFilter (the same sentinel recogniser the stream path uses) by
|
|
54
|
+
# feeding the whole string once and flushing — we keep only the :content
|
|
55
|
+
# side, discarding :thinking. Scoped to <think> per Slice 2; the wider tag
|
|
56
|
+
# zoo (<reasoning>, tool-call XML, …) is not in play for this gem's models.
|
|
57
|
+
def content_after_think_block?(content)
|
|
58
|
+
return false if content.to_s.empty?
|
|
59
|
+
|
|
60
|
+
visible = +""
|
|
61
|
+
filter = LLM::InlineThinkFilter.new
|
|
62
|
+
emit = ->(type, text) { visible << text if type == :content }
|
|
63
|
+
filter.feed(content.to_s, &emit)
|
|
64
|
+
filter.flush(&emit)
|
|
65
|
+
|
|
66
|
+
!visible.strip.empty?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|