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,617 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Agent
|
|
5
|
+
# The core agent loop that handles LLM calls and tool execution cycles.
|
|
6
|
+
# Runs until the LLM produces a final text response or budget is exhausted.
|
|
7
|
+
class Loop
|
|
8
|
+
# Nudge issued on the final, toolless model call when the iteration/budget
|
|
9
|
+
# ceiling is hit. Mirrors the reference handle_max_iterations summary request
|
|
10
|
+
# — ask the model to wrap up in prose
|
|
11
|
+
# instead of ending the turn with nothing.
|
|
12
|
+
MAX_ITERATIONS_SUMMARY_NUDGE =
|
|
13
|
+
"You've reached the maximum number of tool-calling iterations allowed. " \
|
|
14
|
+
"Please provide a final response summarizing what you've found and " \
|
|
15
|
+
"accomplished so far, without calling any more tools."
|
|
16
|
+
|
|
17
|
+
# Framing for turn-start background notices (#148): tells the model the
|
|
18
|
+
# notices are secondary to the user message that follows them.
|
|
19
|
+
NOTICES_PREAMBLE =
|
|
20
|
+
"[background notices — acknowledge briefly; the user's message AFTER " \
|
|
21
|
+
"these notices is the instruction to act on]"
|
|
22
|
+
|
|
23
|
+
def initialize(session:, llm_adapter:, tool_executor:, message_store:,
|
|
24
|
+
budget:, ui:, event_bus:, config:, cancel_token: nil,
|
|
25
|
+
initial_image_paths: [], input_queue: nil)
|
|
26
|
+
@session = session
|
|
27
|
+
@llm = llm_adapter
|
|
28
|
+
@tool_executor = tool_executor
|
|
29
|
+
@message_store = message_store
|
|
30
|
+
@budget = budget
|
|
31
|
+
@ui = ui
|
|
32
|
+
@event_bus = event_bus
|
|
33
|
+
@config = config
|
|
34
|
+
@cancel_token = cancel_token
|
|
35
|
+
# Optional steering hand-off (Interaction::InputQueue). When present,
|
|
36
|
+
# text the user typed mid-turn is drained at the top of each loop
|
|
37
|
+
# iteration and injected as a user message. Nil for the API/server path
|
|
38
|
+
# and nested subagent runs — they get no injection and behave exactly
|
|
39
|
+
# as before.
|
|
40
|
+
@input_queue = input_queue
|
|
41
|
+
# Consumed once on the first iteration. After the first model call
|
|
42
|
+
# subsequent iterations are tool-result follow-ups — no user input,
|
|
43
|
+
# nothing to re-attach.
|
|
44
|
+
@pending_image_paths = Array(initial_image_paths)
|
|
45
|
+
# Provider/model fallback chain (Slice 7). Primary at index 0; rotates to
|
|
46
|
+
# the next configured backend when the primary keeps failing, and is
|
|
47
|
+
# restored at the top of each turn (#run). With no agent.fallback_models
|
|
48
|
+
# configured the chain holds only the primary and is an inert pass-through,
|
|
49
|
+
# so single-provider setups behave exactly as before.
|
|
50
|
+
@fallback_chain = FallbackChain.new(
|
|
51
|
+
primary_adapter: llm_adapter,
|
|
52
|
+
config: config,
|
|
53
|
+
ui: ui,
|
|
54
|
+
event_bus: event_bus,
|
|
55
|
+
tool_executor: tool_executor,
|
|
56
|
+
cancel_token: cancel_token
|
|
57
|
+
)
|
|
58
|
+
# Owns the inner retry loop (call → validate → classify → backoff →
|
|
59
|
+
# return/raise). The Loop builds each LLM::Request and hands it to the
|
|
60
|
+
# runner, which returns a validated response or raises (empty-exhausted →
|
|
61
|
+
# EmptyModelResponseError; transient-exhausted/permanent → the classified
|
|
62
|
+
# error). The error-classification + backoff retries that used to live in
|
|
63
|
+
# the adapter's with_retries now live here — single owner, no double-retry.
|
|
64
|
+
# The runner issues calls against the chain's CURRENT adapter and can
|
|
65
|
+
# rotate it via the chain on a fallback-worthy failure.
|
|
66
|
+
@model_call_runner = ModelCallRunner.new(
|
|
67
|
+
llm: llm_adapter,
|
|
68
|
+
fallback_chain: @fallback_chain,
|
|
69
|
+
config: config,
|
|
70
|
+
ui: ui,
|
|
71
|
+
event_bus: event_bus,
|
|
72
|
+
cancel_token: cancel_token
|
|
73
|
+
)
|
|
74
|
+
# Single count + persist sink for tool results. The executor invokes it
|
|
75
|
+
# for every tool on BOTH paths: the streaming path (ruby_llm runs the
|
|
76
|
+
# tool mid-stream via ToolBridge → ToolExecutor#execute, never returning
|
|
77
|
+
# through #execute_tool_calls) and the non-streaming path. Registered
|
|
78
|
+
# here rather than passed at construction because the executor is built
|
|
79
|
+
# before the Loop (the adapter/ToolBridge share the same executor).
|
|
80
|
+
@tool_executor.on_result = method(:handle_tool_result) if @tool_executor.respond_to?(:on_result=)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Runs the agent loop, returning the final assistant response content.
|
|
84
|
+
def run(messages:, tools:)
|
|
85
|
+
# Stash the resolved toolset so #streaming? can decide, per run, whether
|
|
86
|
+
# this turn might block on a human (clarify/approval). When it might, we
|
|
87
|
+
# run NON-STREAMING so the LLM HTTP request completes and CLOSES before
|
|
88
|
+
# any tool fires — leaving no upstream socket held open during the gate
|
|
89
|
+
# wait (the wait can now be effectively unbounded; see ApprovalGate).
|
|
90
|
+
@turn_tools = Array(tools)
|
|
91
|
+
iteration = 0
|
|
92
|
+
turn_started_at = monotonic_now
|
|
93
|
+
|
|
94
|
+
# If a previous turn rotated to a fallback, restore the primary backend
|
|
95
|
+
# so this turn gets a fresh attempt with the preferred model
|
|
96
|
+
# (conversation_loop.py:427). No-op when we never left the primary.
|
|
97
|
+
@fallback_chain.restore_primary!
|
|
98
|
+
|
|
99
|
+
# Mutated by the ToolExecutor's on_result sink (see #handle_tool_result),
|
|
100
|
+
# which fires for EVERY tool regardless of streaming mode — including the
|
|
101
|
+
# streaming path where ruby_llm runs the tool mid-stream via ToolBridge
|
|
102
|
+
# and never returns through #execute_tool_calls below. Instance vars (not
|
|
103
|
+
# locals) so the sink closure can update them.
|
|
104
|
+
@tool_count = 0
|
|
105
|
+
@denied_count = 0
|
|
106
|
+
token_total = 0
|
|
107
|
+
|
|
108
|
+
loop do
|
|
109
|
+
iteration += 1
|
|
110
|
+
@cancel_token&.check!
|
|
111
|
+
|
|
112
|
+
# Mid-turn steering boundary. SAFE point: the cancel check has passed
|
|
113
|
+
# and any prior assistant(tool_use) + tool(result) messages from the
|
|
114
|
+
# previous iteration are already appended, so adding a USER message
|
|
115
|
+
# here can never split a tool_use from its results (no orphan pair on
|
|
116
|
+
# strict providers). On iteration 1 the initial user input is already
|
|
117
|
+
# the user turn, so only parked background NOTICES fold in (#13);
|
|
118
|
+
# typed lines stay queued for their own turns.
|
|
119
|
+
inject_steered_input(messages, iteration)
|
|
120
|
+
|
|
121
|
+
unless @budget.can_continue?(iteration)
|
|
122
|
+
@ui.warning("Iteration budget exhausted (#{iteration} turns)")
|
|
123
|
+
return summarize_on_budget_exhausted(messages, iteration,
|
|
124
|
+
turn_started_at, token_total)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
@event_bus.emit(Interaction::Events::MODEL_CALL_STARTED, iteration: iteration)
|
|
128
|
+
# Show a transient "thinking…" indicator during TTFB. The UI erases
|
|
129
|
+
# it the moment the first chunk lands (any type). Skipped in
|
|
130
|
+
# non-streaming mode — the response arrives in one shot, indicator
|
|
131
|
+
# would flash uselessly.
|
|
132
|
+
@ui.thinking_started if streaming?
|
|
133
|
+
begin
|
|
134
|
+
response = call_model(messages, tools, iteration)
|
|
135
|
+
rescue Rubino::Interrupted
|
|
136
|
+
# The streaming callback (or the per-iteration check above)
|
|
137
|
+
# observed cancellation. Close any open stream box on the UI
|
|
138
|
+
# (commits the partial answer streamed so far) and bail out — the
|
|
139
|
+
# standardized `⎿ interrupted` marker is appended once by the Runner's
|
|
140
|
+
# rescue, right after this kept partial. Lifecycle will not persist a
|
|
141
|
+
# turn that never completed, but the user already saw the partial.
|
|
142
|
+
@ui.stream_end if streaming?
|
|
143
|
+
raise
|
|
144
|
+
end
|
|
145
|
+
@event_bus.emit(Interaction::Events::MODEL_CALL_FINISHED,
|
|
146
|
+
tokens: response.total_tokens,
|
|
147
|
+
has_tool_calls: response.has_tool_calls?)
|
|
148
|
+
|
|
149
|
+
token_total += response.total_tokens.to_i
|
|
150
|
+
|
|
151
|
+
if response.interrupted?
|
|
152
|
+
# The upstream stream was cut before a clean completion (no
|
|
153
|
+
# finish_reason / [DONE]); `response` carries only a buffered partial
|
|
154
|
+
# with no tool call. Returning it would end the run as "completed"
|
|
155
|
+
# with truncated/empty output — the silent-completion bug. Persist
|
|
156
|
+
# whatever streamed so the transcript keeps it, close the stream box,
|
|
157
|
+
# then raise: Lifecycle maps this to INTERACTION_FAILED → run.failed,
|
|
158
|
+
# the same path every other turn error already takes.
|
|
159
|
+
persist_assistant_message(response) unless response.content.to_s.empty?
|
|
160
|
+
finalize_stream(response)
|
|
161
|
+
emit_turn_summary(turn_started_at, token_total)
|
|
162
|
+
raise Rubino::StreamInterruptedError,
|
|
163
|
+
"stream ended before completion after " \
|
|
164
|
+
"#{response.content.to_s.bytesize} buffered byte(s) with no finish signal — " \
|
|
165
|
+
"the model did not finish (run marked failed, not completed). " \
|
|
166
|
+
"Often caused by a very large context pushing time-to-first-token past the " \
|
|
167
|
+
"provider's stream idle timeout."
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
if response.text_only?
|
|
171
|
+
persist_assistant_message(response)
|
|
172
|
+
finalize_stream(response)
|
|
173
|
+
emit_turn_summary(turn_started_at, token_total)
|
|
174
|
+
return response.content
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
if response.has_tool_calls?
|
|
178
|
+
persist_assistant_message(response)
|
|
179
|
+
close_intermediate_stream(response)
|
|
180
|
+
|
|
181
|
+
# Bedrock (and other providers) require the assistant turn with the
|
|
182
|
+
# toolUse block to appear in the conversation history before the
|
|
183
|
+
# toolResult turn. Append it now so the next LLM call sees the
|
|
184
|
+
# correct sequence: user → assistant(toolUse) → user(toolResult).
|
|
185
|
+
messages << build_assistant_tool_use_message(response)
|
|
186
|
+
|
|
187
|
+
# NOTE: counting and `tool` message persistence happen in the
|
|
188
|
+
# ToolExecutor's on_result sink (#handle_tool_result), which fires
|
|
189
|
+
# for BOTH this non-streaming path and the streaming path (where
|
|
190
|
+
# ruby_llm runs tools mid-stream and never returns here). We only
|
|
191
|
+
# build the conversation-history messages for the next iteration.
|
|
192
|
+
execute_tool_calls(response.tool_calls).each { |result| messages << result }
|
|
193
|
+
else
|
|
194
|
+
# Unreachable in practice: the ModelCallRunner either returns a
|
|
195
|
+
# response with text or tool calls, or raises EmptyModelResponseError.
|
|
196
|
+
# Kept as a defensive backstop so a future response shape can never
|
|
197
|
+
# silently complete an empty turn.
|
|
198
|
+
emit_turn_summary(turn_started_at, token_total)
|
|
199
|
+
raise Rubino::EmptyModelResponseError
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
# Mid-turn steering (Phase 2): drains anything the user typed while the
|
|
207
|
+
# agent was working and folds it into the live turn as a single USER
|
|
208
|
+
# message. Called at the top of each iteration (after the cancel check,
|
|
209
|
+
# before the model call) where appending a user message is always valid
|
|
210
|
+
# ordering — never between an assistant tool_use and its tool results.
|
|
211
|
+
#
|
|
212
|
+
# No-op when no queue is wired (API/server, subagents) or when nothing
|
|
213
|
+
# was typed. Multiple drained lines are coalesced (newline-joined) into
|
|
214
|
+
# ONE user message so a burst of keystrokes reads as one interjection.
|
|
215
|
+
# The drain is atomic, so the between-turns #next_input fallback in the
|
|
216
|
+
# CLI never double-consumes the same text.
|
|
217
|
+
def inject_steered_input(messages, iteration)
|
|
218
|
+
return unless @input_queue&.pending?
|
|
219
|
+
|
|
220
|
+
# Iteration 1's user input IS the turn: only parked background notices
|
|
221
|
+
# ([background-task] completion lines) fold in at turn start, so a
|
|
222
|
+
# notice never spends a standalone model turn restating itself (#13).
|
|
223
|
+
# Later iterations drain everything (typed steering + notices).
|
|
224
|
+
lines = iteration > 1 ? @input_queue.drain : @input_queue.drain_notices
|
|
225
|
+
return if lines.empty?
|
|
226
|
+
|
|
227
|
+
text = lines.join("\n")
|
|
228
|
+
# Turn-start fold-in: the notices are CONTEXT, the user's just-sent
|
|
229
|
+
# message is the INSTRUCTION. Appended after the user message, screens
|
|
230
|
+
# of completion reports drowned the prompt and the model answered the
|
|
231
|
+
# notices, ignoring the request (#148). Frame the notices and insert
|
|
232
|
+
# them BEFORE the user message so it stays last (most salient).
|
|
233
|
+
if iteration == 1
|
|
234
|
+
text = "#{NOTICES_PREAMBLE}\n#{text}"
|
|
235
|
+
persist_user_message(text)
|
|
236
|
+
insert_before_trailing_user(messages, text)
|
|
237
|
+
else
|
|
238
|
+
persist_user_message(text)
|
|
239
|
+
messages << { role: "user", content: text }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
@event_bus.emit(Interaction::Events::INPUT_INJECTED,
|
|
243
|
+
text: text, iteration: iteration)
|
|
244
|
+
@ui.input_injected(text)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Inserts the framed notice message just before the trailing user message
|
|
248
|
+
# (the turn's instruction, #148); appends defensively when the last
|
|
249
|
+
# message isn't a user one (should not happen at iteration 1).
|
|
250
|
+
def insert_before_trailing_user(messages, text)
|
|
251
|
+
notice = { role: "user", content: text }
|
|
252
|
+
if messages.last&.[](:role) == "user"
|
|
253
|
+
messages.insert(-2, notice)
|
|
254
|
+
else
|
|
255
|
+
messages << notice
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# True when the model is configured to stream and the UI should display it
|
|
260
|
+
# AND this turn cannot block on a human. An interactive turn (one that may
|
|
261
|
+
# raise an approval/clarify gate that parks the run on a human answer) runs
|
|
262
|
+
# NON-STREAMING so the LLM request closes before the wait — otherwise the
|
|
263
|
+
# upstream socket sits open mid-response and the provider drops it.
|
|
264
|
+
def streaming?
|
|
265
|
+
return false if interactive_turn?
|
|
266
|
+
|
|
267
|
+
@config.streaming_enabled? && @config.display_streaming?
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# A turn "may block on a human" when the UI bridges human input across
|
|
271
|
+
# threads (the HTTP/API path with a gate; CLI prompts inline and never
|
|
272
|
+
# parks) AND the toolset contains a tool that can trigger the gate:
|
|
273
|
+
# - `question` → @ui.ask (clarify) — always blocks when called.
|
|
274
|
+
# - any risky tool under manual approvals → @ui.confirm — blocks.
|
|
275
|
+
# - `shell` when require_confirmation_for_shell is on → confirm.
|
|
276
|
+
# Memoised per run; the toolset is fixed for the turn.
|
|
277
|
+
def interactive_turn?
|
|
278
|
+
return @interactive_turn unless @interactive_turn.nil?
|
|
279
|
+
|
|
280
|
+
@interactive_turn = gate_backed_ui? && toolset_can_block?
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The UI parks the run on a cross-thread gate (UI::API) rather than
|
|
284
|
+
# prompting inline (UI::CLI). Adapters opt in via #blocking_human_input?;
|
|
285
|
+
# anything that doesn't respond is treated as non-blocking (CLI/Null/test).
|
|
286
|
+
def gate_backed_ui?
|
|
287
|
+
@ui.respond_to?(:blocking_human_input?) && @ui.blocking_human_input?
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def toolset_can_block?
|
|
291
|
+
names = @turn_tools.map { |t| tool_name_of(t) }
|
|
292
|
+
return true if names.include?("question")
|
|
293
|
+
|
|
294
|
+
manual = @config.approvals_mode == "manual"
|
|
295
|
+
# shell can park on the gate under EITHER confirm_policy: confirm_all
|
|
296
|
+
# always prompts; dangerous_only still prompts on a DangerousPattern.
|
|
297
|
+
# We don't have the concrete command here, so treat a present shell tool
|
|
298
|
+
# as potentially-blocking unless approvals are skipped entirely.
|
|
299
|
+
confirm_shell = @config.approvals_mode != "skip"
|
|
300
|
+
return true if confirm_shell && names.include?("shell")
|
|
301
|
+
return true if manual && @turn_tools.any? { |t| t.respond_to?(:risky?) && t.risky? }
|
|
302
|
+
|
|
303
|
+
false
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def tool_name_of(tool)
|
|
307
|
+
tool.respond_to?(:name) ? tool.name.to_s : tool.to_s
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Budget exhausted: instead of ending the turn with nothing, issue ONE
|
|
311
|
+
# final model call with the tools stripped, nudging the model to summarise
|
|
312
|
+
# what it did and what remains. The summary still runs through the normal
|
|
313
|
+
# model-call path (validation + recovery via ModelCallRunner) and its text
|
|
314
|
+
# becomes the turn's final assistant content. Because tools are empty AND
|
|
315
|
+
# this is the loop's terminal action, the summary can never re-enter the
|
|
316
|
+
# tool loop. Ports conversation_loop.py:4296 / handle_max_iterations.
|
|
317
|
+
def summarize_on_budget_exhausted(messages, iteration, turn_started_at, token_total)
|
|
318
|
+
persist_user_message(MAX_ITERATIONS_SUMMARY_NUDGE)
|
|
319
|
+
messages << { role: "user", content: MAX_ITERATIONS_SUMMARY_NUDGE }
|
|
320
|
+
|
|
321
|
+
@event_bus.emit(Interaction::Events::MODEL_CALL_STARTED, iteration: iteration)
|
|
322
|
+
@ui.thinking_started if streaming?
|
|
323
|
+
response = call_model(messages, [], iteration)
|
|
324
|
+
@event_bus.emit(Interaction::Events::MODEL_CALL_FINISHED,
|
|
325
|
+
tokens: response.total_tokens,
|
|
326
|
+
has_tool_calls: response.has_tool_calls?)
|
|
327
|
+
token_total += response.total_tokens.to_i
|
|
328
|
+
|
|
329
|
+
persist_assistant_message(response)
|
|
330
|
+
finalize_stream(response)
|
|
331
|
+
emit_turn_summary(turn_started_at, token_total)
|
|
332
|
+
response.content
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Builds the per-call LLM::Request and runs it through the ModelCallRunner,
|
|
336
|
+
# which owns the inner retry loop (call → validate → classify → backoff).
|
|
337
|
+
# Returns a validated AdapterResponse or raises (EmptyModelResponseError on
|
|
338
|
+
# an exhausted empty turn; the classified error on an exhausted/permanent
|
|
339
|
+
# API failure). interrupted? / text / tool-call dispatch stays in #run.
|
|
340
|
+
def call_model(messages, tools, iteration)
|
|
341
|
+
# Pop the staged native-attachments slot — they only ride on the
|
|
342
|
+
# first model call of this turn (the one that sees the user's input).
|
|
343
|
+
image_paths = @pending_image_paths
|
|
344
|
+
@pending_image_paths = []
|
|
345
|
+
|
|
346
|
+
request = LLM::Request.new(
|
|
347
|
+
messages: messages,
|
|
348
|
+
tools: tools,
|
|
349
|
+
image_paths: image_paths,
|
|
350
|
+
stream: streaming?
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Single boundary entry (normalize_response seam).
|
|
354
|
+
# The adapter dispatches stream-vs-chat off request.stream internally;
|
|
355
|
+
# streaming yields chunks to the block, non-streaming returns in one shot.
|
|
356
|
+
# The runner forwards this block straight through on each attempt.
|
|
357
|
+
stream_chunk = lambda do |chunk|
|
|
358
|
+
@ui.stream(chunk)
|
|
359
|
+
@event_bus.emit(Interaction::Events::MODEL_STREAM, chunk: chunk)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
response = @model_call_runner.call!(request, iteration: iteration, &stream_chunk)
|
|
363
|
+
|
|
364
|
+
# Truncation continuation (Slice 9 / conversation_loop.py:1560-1714,3382).
|
|
365
|
+
# When the model hit max_tokens (stop_reason==:length) we stitch the
|
|
366
|
+
# answer back together over ≤3 boosted re-issues. This is a no-op unless
|
|
367
|
+
# stop_reason==:length reaches us — which it does only on the NON-STREAMING
|
|
368
|
+
# path today (the adapter surfaces stop_reason from the raw body on #chat;
|
|
369
|
+
# the streaming path leaves it nil — see RubyLLMAdapter#extract_stop_reason
|
|
370
|
+
# see the boundary spike). On the streaming path #applicable? is
|
|
371
|
+
# therefore false and #continue returns the response untouched.
|
|
372
|
+
# TODO: once ruby_llm surfaces a stream finish_reason, this activates for
|
|
373
|
+
# streaming too with no change here.
|
|
374
|
+
truncation_continuation(iteration).continue(request, response, &stream_chunk)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Each continuation re-issue still flows through the ModelCallRunner, so a
|
|
378
|
+
# boosted-budget retry gets the same validation/recovery/backoff as the
|
|
379
|
+
# first call. The boundary is a thin lambda matching #call(request, &block).
|
|
380
|
+
def truncation_continuation(iteration)
|
|
381
|
+
boundary = lambda do |req, &blk|
|
|
382
|
+
@model_call_runner.call!(req, iteration: iteration, &blk)
|
|
383
|
+
end
|
|
384
|
+
TruncationContinuation.new(
|
|
385
|
+
boundary: boundary,
|
|
386
|
+
base_tokens: @config.dig("model", "max_tokens"),
|
|
387
|
+
ui: @ui
|
|
388
|
+
)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def finalize_stream(response)
|
|
392
|
+
if streaming?
|
|
393
|
+
@ui.stream_end
|
|
394
|
+
else
|
|
395
|
+
# Non-streaming finalize: wrap the buffered content in the same chunk
|
|
396
|
+
# shape the streaming path yields so the UI never has to branch on
|
|
397
|
+
# String-vs-Hash. Single block ⇒ message_id 0.
|
|
398
|
+
@ui.stream({ type: :content, text: response.content.to_s, message_id: 0 })
|
|
399
|
+
@ui.stream_end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Called when the model returned tool calls. If streaming was active,
|
|
404
|
+
# close the open stream so the UI can finalize the thinking/preamble text
|
|
405
|
+
# the model emitted before the tool call.
|
|
406
|
+
def close_intermediate_stream(response)
|
|
407
|
+
return unless streaming?
|
|
408
|
+
return if response.content.nil? || response.content.empty?
|
|
409
|
+
|
|
410
|
+
@ui.stream_end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Build an assistant message that includes the tool use blocks.
|
|
414
|
+
# Providers like Bedrock require this message to appear in the conversation
|
|
415
|
+
# history between the user prompt and the tool result(s).
|
|
416
|
+
def build_assistant_tool_use_message(response)
|
|
417
|
+
{
|
|
418
|
+
role: "assistant",
|
|
419
|
+
content: response.content || "",
|
|
420
|
+
tool_calls: response.tool_calls
|
|
421
|
+
}
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Called once per executed tool by the ToolExecutor's on_result sink, on
|
|
425
|
+
# BOTH the streaming and non-streaming paths. Bumps the turn's tool count
|
|
426
|
+
# (B2 — the streaming path used to bypass the only counter) and persists
|
|
427
|
+
# the result as a `tool` message (B3 — streaming tool results never hit
|
|
428
|
+
# the message store, leaving `tool_calls`/role='tool' rows empty and
|
|
429
|
+
# breaking --resume + audit). Idempotency is structural: the executor
|
|
430
|
+
# calls #finish exactly once per tool call.
|
|
431
|
+
def handle_tool_result(name:, arguments:, call_id:, result:)
|
|
432
|
+
# A denied tool never ran, so it shouldn't inflate the "N tools" run
|
|
433
|
+
# count in the footer — track it separately and surface it as
|
|
434
|
+
# "0 run · 1 denied" so the deny outcome is unambiguous (#83).
|
|
435
|
+
if result.respond_to?(:denied?) && result.denied?
|
|
436
|
+
@denied_count += 1
|
|
437
|
+
else
|
|
438
|
+
@tool_count += 1
|
|
439
|
+
end
|
|
440
|
+
persist_tool_result(
|
|
441
|
+
role: "tool",
|
|
442
|
+
content: result.output,
|
|
443
|
+
tool_call_id: call_id,
|
|
444
|
+
name: name,
|
|
445
|
+
arguments: arguments
|
|
446
|
+
)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def execute_tool_calls(tool_calls)
|
|
450
|
+
tool_calls.map do |tc|
|
|
451
|
+
# TOOL_STARTED / TOOL_FINISHED + ui.tool_started/tool_finished are
|
|
452
|
+
# emitted from Agent::ToolExecutor#execute itself — the executor is
|
|
453
|
+
# the single source of truth so the streaming path (ruby_llm calls
|
|
454
|
+
# the tool mid-stream via ToolBridge → never lands here) and the
|
|
455
|
+
# non-streaming path (this branch) both emit exactly once.
|
|
456
|
+
result = @tool_executor.execute(
|
|
457
|
+
name: tc[:name],
|
|
458
|
+
arguments: tc[:arguments],
|
|
459
|
+
call_id: tc[:id]
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
{
|
|
463
|
+
role: "tool",
|
|
464
|
+
content: result.output,
|
|
465
|
+
tool_call_id: tc[:id],
|
|
466
|
+
name: tc[:name],
|
|
467
|
+
arguments: tc[:arguments]
|
|
468
|
+
}
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Persists a mid-turn injected user message the same way Lifecycle
|
|
473
|
+
# persists the initial user turn: one "user" row plus a session
|
|
474
|
+
# message-count bump, so session history and counts stay correct. Wrapped
|
|
475
|
+
# in the same DB-lock retry as the assistant/tool writes.
|
|
476
|
+
def persist_user_message(text)
|
|
477
|
+
with_db_retries do
|
|
478
|
+
@message_store.create(
|
|
479
|
+
session_id: @session[:id],
|
|
480
|
+
role: "user",
|
|
481
|
+
content: text
|
|
482
|
+
)
|
|
483
|
+
end
|
|
484
|
+
session_repo.increment_message_count!(@session[:id])
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def session_repo
|
|
488
|
+
@session_repo ||= Session::Repository.new
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def persist_assistant_message(response)
|
|
492
|
+
# Stash tool_calls under metadata so --resume can rebuild the
|
|
493
|
+
# assistant(toolUse) → tool(result) pair the provider expects. Without
|
|
494
|
+
# this, strict providers (Anthropic, Bedrock) 400 the next turn because
|
|
495
|
+
# they see tool result messages with no matching toolUse upstream.
|
|
496
|
+
metadata = response.has_tool_calls? ? { tool_calls: response.tool_calls } : {}
|
|
497
|
+
|
|
498
|
+
# Record the REAL context size the provider saw for this response:
|
|
499
|
+
# input_tokens covers the whole assembled prompt (system prompt +
|
|
500
|
+
# history + tools), which no local chars/4 estimate can reproduce
|
|
501
|
+
# without re-assembling. The status bar under the chat input prefers
|
|
502
|
+
# this over the estimate when present. Omitted when the provider
|
|
503
|
+
# reports no usage (same rule as the `↳ turn` footer, #86).
|
|
504
|
+
metadata[:input_tokens] = response.input_tokens if response.input_tokens.to_i.positive?
|
|
505
|
+
|
|
506
|
+
with_db_retries do
|
|
507
|
+
@message_store.create(
|
|
508
|
+
session_id: @session[:id],
|
|
509
|
+
role: "assistant",
|
|
510
|
+
content: response.content,
|
|
511
|
+
token_count: response.output_tokens,
|
|
512
|
+
metadata: metadata
|
|
513
|
+
)
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def persist_tool_result(result)
|
|
518
|
+
# Persist arguments alongside the tool message so --resume replay can
|
|
519
|
+
# render the same "⏺ name · args" line the live session showed.
|
|
520
|
+
# Old rows that pre-date this field hydrate with empty metadata; the
|
|
521
|
+
# replay path falls back to printing just the name.
|
|
522
|
+
metadata = result[:arguments] ? { arguments: result[:arguments] } : {}
|
|
523
|
+
|
|
524
|
+
with_db_retries do
|
|
525
|
+
@message_store.create(
|
|
526
|
+
session_id: @session[:id],
|
|
527
|
+
role: "tool",
|
|
528
|
+
content: result[:content],
|
|
529
|
+
tool_name: result[:name],
|
|
530
|
+
tool_call_id: result[:tool_call_id],
|
|
531
|
+
metadata: metadata
|
|
532
|
+
)
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Closes the turn with a one-line dim summary: how long it took, how
|
|
537
|
+
# many tools the model called across all iterations, and the rough
|
|
538
|
+
# token spend. The cost stays visible without having to scroll back
|
|
539
|
+
# or run a stats command, and the user can spot a runaway turn
|
|
540
|
+
# (15 tools, 30s) at a glance.
|
|
541
|
+
def emit_turn_summary(started_at, token_total)
|
|
542
|
+
duration = monotonic_now - started_at
|
|
543
|
+
# Drop the token field entirely when usage is unknown/zero rather than
|
|
544
|
+
# printing a permanent "0 tok" that reads as broken (#86). Providers
|
|
545
|
+
# that don't report usage simply omit the segment.
|
|
546
|
+
# No "◆ " prefix: the static footer is all dim — red is the error
|
|
547
|
+
# color, and the only red ◆ left is the ANIMATED status row (P4).
|
|
548
|
+
parts = ["turn", format_duration(duration), tool_count_label]
|
|
549
|
+
parts << format_tokens(token_total) if token_total.to_i.positive?
|
|
550
|
+
summary = parts.join(" · ")
|
|
551
|
+
# The CLI renders the footer attached directly under the answer (no
|
|
552
|
+
# blank, P3) and folds pending subagent completions into its grammar
|
|
553
|
+
# (P4); other adapters keep the plain note path.
|
|
554
|
+
if @ui.respond_to?(:turn_footer)
|
|
555
|
+
@ui.turn_footer(summary)
|
|
556
|
+
else
|
|
557
|
+
@ui.note(summary)
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# "1 tool" normally; "2 tools · 1 denied" when something was denied; and
|
|
562
|
+
# "0 run · 1 denied" when the only tool call(s) were denied — so a denied
|
|
563
|
+
# tool is never silently counted as if it ran (#83).
|
|
564
|
+
def tool_count_label
|
|
565
|
+
denied = @denied_count.to_i
|
|
566
|
+
return "#{@tool_count} tool#{"s" if @tool_count != 1}" if denied.zero?
|
|
567
|
+
|
|
568
|
+
ran = @tool_count.zero? ? "0 run" : "#{@tool_count} tool#{"s" if @tool_count != 1}"
|
|
569
|
+
"#{ran} · #{denied} denied"
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def monotonic_now
|
|
573
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def format_duration(seconds)
|
|
577
|
+
if seconds < 1
|
|
578
|
+
"#{(seconds * 1000).round}ms"
|
|
579
|
+
elsif seconds < 60
|
|
580
|
+
"#{seconds.round(1)}s"
|
|
581
|
+
else
|
|
582
|
+
mins, secs = seconds.divmod(60)
|
|
583
|
+
"#{mins.to_i}m#{secs.round}s"
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Only called for a positive count (see #emit_turn_summary); a zero total
|
|
588
|
+
# is omitted upstream rather than rendered as "0 tok".
|
|
589
|
+
def format_tokens(n)
|
|
590
|
+
n >= 1000 ? "#{(n / 1000.0).round(1)}k tok" : "#{n} tok"
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# SQLite serialises writes; a backup tool, another session, or a
|
|
594
|
+
# mid-flight migration can hold the database busy for up to a second.
|
|
595
|
+
# Without retry the persist propagates a Sequel::DatabaseError up to
|
|
596
|
+
# Runner#run, which prints a generic error and discards the turn — we
|
|
597
|
+
# lose a completed assistant response over a transient lock. Three
|
|
598
|
+
# attempts with 100/200/400ms backoff cover the common case; if the
|
|
599
|
+
# lock outlives that, we re-raise and the turn does drop, but at
|
|
600
|
+
# least we tried instead of folding on the first hiccup.
|
|
601
|
+
def with_db_retries(max_attempts: 3)
|
|
602
|
+
attempt = 0
|
|
603
|
+
begin
|
|
604
|
+
yield
|
|
605
|
+
rescue Sequel::DatabaseError => e
|
|
606
|
+
raise unless e.message.to_s.match?(/locked|busy/i)
|
|
607
|
+
|
|
608
|
+
attempt += 1
|
|
609
|
+
raise if attempt >= max_attempts
|
|
610
|
+
|
|
611
|
+
sleep(0.1 * (2**(attempt - 1)))
|
|
612
|
+
retry
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
end
|