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,382 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Context
|
|
5
|
+
# Assembles the complete prompt from all context sources.
|
|
6
|
+
# Returns the message array (system + summary + history) for LLM submission.
|
|
7
|
+
class PromptAssembler
|
|
8
|
+
# Process-wide cache of the memory snapshot keyed by session id.
|
|
9
|
+
# Captured the first time build_system_prompt runs for a session and
|
|
10
|
+
# reused on every subsequent assembly in that session — even if the
|
|
11
|
+
# agent calls Tools::MemoryTool mid-session. Rationale: without
|
|
12
|
+
# freezing, an injected memory written this turn would land in the
|
|
13
|
+
# *next* prompt and effectively self-elevate. The agent must wait
|
|
14
|
+
# for the next session (or call reset_snapshot!) for new writes to
|
|
15
|
+
# appear in the system prompt.
|
|
16
|
+
@snapshots = {}
|
|
17
|
+
@snapshots_mutex = Mutex.new
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Returns the cached snapshot for a session, computing it via the
|
|
21
|
+
# supplied block on first access. The block receives no args and
|
|
22
|
+
# must return the memory-context hash to freeze.
|
|
23
|
+
def snapshot_for(session_id)
|
|
24
|
+
@snapshots_mutex.synchronize do
|
|
25
|
+
@snapshots[session_id] ||= yield
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Drops the cached snapshot for a session so the next assembly
|
|
30
|
+
# captures fresh memory state. Use this when a tool call must
|
|
31
|
+
# influence the very next turn (trade-off: the freeze stops
|
|
32
|
+
# protecting against same-turn poisoning).
|
|
33
|
+
def reset_snapshot!(session_id)
|
|
34
|
+
@snapshots_mutex.synchronize { @snapshots.delete(session_id) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Test/teardown hook. Not part of the public API.
|
|
38
|
+
def reset_all_snapshots!
|
|
39
|
+
@snapshots_mutex.synchronize { @snapshots.clear }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(session:, memory_context:, config:, agent_definition: nil,
|
|
44
|
+
ignore_rules: false)
|
|
45
|
+
@session = session
|
|
46
|
+
@memory_context = memory_context
|
|
47
|
+
@config = config
|
|
48
|
+
@agent_definition = agent_definition
|
|
49
|
+
# --ignore-rules suppresses project-context discovery
|
|
50
|
+
# (AGENTS.md/CLAUDE.md/.rubino.md/.cursorrules). The flag is threaded
|
|
51
|
+
# from Lifecycle so the CLI option genuinely skips discovery (#47), not
|
|
52
|
+
# just the trust gate.
|
|
53
|
+
@ignore_rules = ignore_rules
|
|
54
|
+
@message_store = Session::Store.new
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Builds and returns the full message array for LLM submission
|
|
58
|
+
def build
|
|
59
|
+
messages = []
|
|
60
|
+
|
|
61
|
+
# System prompt (always first)
|
|
62
|
+
messages << { role: "system", content: build_system_prompt }
|
|
63
|
+
|
|
64
|
+
# Session summary (if compacted)
|
|
65
|
+
summary = load_summary
|
|
66
|
+
messages << { role: "system", content: "[Session Summary]\n#{summary}" } if summary
|
|
67
|
+
|
|
68
|
+
# Conversation history. Repair tool pairing across the FULL list before
|
|
69
|
+
# mapping to wire format — this is the defensive "net" that recovers
|
|
70
|
+
# sessions already corrupted by the historical metadata-dropping bug in
|
|
71
|
+
# compaction/fork (those rows exist in prod). Mirrors Claude Code's
|
|
72
|
+
# pre-call sanitization: never emit an orphan tool block that 400s a
|
|
73
|
+
# strict provider. Conservative by design — when in doubt, keep.
|
|
74
|
+
history = repair_tool_pairs(@message_store.for_session(@session[:id]))
|
|
75
|
+
history.each do |msg|
|
|
76
|
+
messages << msg.to_context
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
messages
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# Final pairing repair over the full history (a list of Message objects).
|
|
85
|
+
# Two orphan shapes 400 strict providers; we fix both, conservatively:
|
|
86
|
+
#
|
|
87
|
+
# 1. tool RESULT with no matching assistant tool_call upstream → drop it.
|
|
88
|
+
# 2. assistant tool_call whose results are ENTIRELY absent downstream →
|
|
89
|
+
# strip its tool_calls (keep the message if it still has content,
|
|
90
|
+
# otherwise drop it). Partially-answered calls are LEFT ALONE: pruning
|
|
91
|
+
# a still-referenced id would itself create an orphan.
|
|
92
|
+
#
|
|
93
|
+
# Reuses ToolPairSanitizer's id predicates so the matching logic lives in
|
|
94
|
+
# one place. Returns a list of Message objects safe to map via to_context.
|
|
95
|
+
def repair_tool_pairs(history)
|
|
96
|
+
sanitizer = ToolPairSanitizer.new
|
|
97
|
+
|
|
98
|
+
# All tool_call ids declared by assistant messages anywhere in history.
|
|
99
|
+
declared_ids = history
|
|
100
|
+
.select { |m| sanitizer.assistant_tool_call?(m) }
|
|
101
|
+
.flat_map { |m| sanitizer.tool_call_ids(m) }
|
|
102
|
+
.to_set
|
|
103
|
+
|
|
104
|
+
# All ids actually answered by a tool result anywhere in history.
|
|
105
|
+
answered_ids = history
|
|
106
|
+
.select { |m| m.role == "tool" && m.tool_call_id }
|
|
107
|
+
.map(&:tool_call_id)
|
|
108
|
+
.to_set
|
|
109
|
+
|
|
110
|
+
repaired = []
|
|
111
|
+
history.each do |msg|
|
|
112
|
+
if msg.role == "tool" && msg.tool_call_id
|
|
113
|
+
# Drop a result whose triggering assistant call is gone.
|
|
114
|
+
next unless declared_ids.include?(msg.tool_call_id)
|
|
115
|
+
|
|
116
|
+
repaired << msg
|
|
117
|
+
elsif sanitizer.assistant_tool_call?(msg)
|
|
118
|
+
ids = sanitizer.tool_call_ids(msg)
|
|
119
|
+
if ids.any? { |id| answered_ids.include?(id) }
|
|
120
|
+
# At least one result present → keep the call intact. Partial
|
|
121
|
+
# answers stay as-is (pruning would re-orphan the kept result).
|
|
122
|
+
repaired << msg
|
|
123
|
+
else
|
|
124
|
+
# No results at all → strip tool_calls so we don't emit a toolUse
|
|
125
|
+
# with no following toolResult. Keep the surrounding prose if any.
|
|
126
|
+
stripped = strip_tool_calls(msg)
|
|
127
|
+
repaired << stripped if stripped
|
|
128
|
+
end
|
|
129
|
+
else
|
|
130
|
+
repaired << msg
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
repaired
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Returns a copy of an assistant message with tool_calls removed, or nil
|
|
138
|
+
# when the message would be empty afterwards (nothing left to send).
|
|
139
|
+
def strip_tool_calls(msg)
|
|
140
|
+
return nil if msg.content.nil? || msg.content.to_s.strip.empty?
|
|
141
|
+
|
|
142
|
+
metadata = msg.metadata.is_a?(Hash) ? msg.metadata.dup : {}
|
|
143
|
+
metadata.delete(:tool_calls)
|
|
144
|
+
metadata.delete("tool_calls")
|
|
145
|
+
|
|
146
|
+
Session::Message.new(
|
|
147
|
+
id: msg.id,
|
|
148
|
+
session_id: msg.session_id,
|
|
149
|
+
role: msg.role,
|
|
150
|
+
content: msg.content,
|
|
151
|
+
tool_name: msg.tool_name,
|
|
152
|
+
tool_call_id: msg.tool_call_id,
|
|
153
|
+
token_count: msg.token_count,
|
|
154
|
+
metadata: metadata,
|
|
155
|
+
created_at: msg.created_at
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Assembles the system prompt as a stack of labelled blocks:
|
|
160
|
+
# 1. Identity — role-specific built-in prompt (or override)
|
|
161
|
+
# 2. Product preamble— config.prompts.preamble, customer-side
|
|
162
|
+
# 3. Environment — date/OS/cwd/git/runtimes/PATH utilities
|
|
163
|
+
# 4. User profile — from memory
|
|
164
|
+
# 5. Relevant memories
|
|
165
|
+
# 6. Skills index — "## Skills (mandatory)" catalogue (auto-trigger)
|
|
166
|
+
# 7. Project context — AGENTS.md / CLAUDE.md walk
|
|
167
|
+
# Each block is independent: if a section is empty/disabled it just
|
|
168
|
+
# drops out without leaving a stray header.
|
|
169
|
+
def build_system_prompt
|
|
170
|
+
# Memory snapshot is frozen for the lifetime of the session — see
|
|
171
|
+
# the class-level @snapshots cache for why. The first assembly in
|
|
172
|
+
# a session captures @memory_context; later assemblies reuse it
|
|
173
|
+
# even if Memory::Store has been mutated in the meantime.
|
|
174
|
+
snapshot = self.class.snapshot_for(@session[:id]) { @memory_context }
|
|
175
|
+
|
|
176
|
+
parts = []
|
|
177
|
+
parts << agent_identity
|
|
178
|
+
product = product_preamble
|
|
179
|
+
parts << "[Product]\n#{product}" if product
|
|
180
|
+
env = environment_block
|
|
181
|
+
parts << env if env
|
|
182
|
+
|
|
183
|
+
if snapshot[:user_profile] && !snapshot[:user_profile].empty?
|
|
184
|
+
parts << "[User Profile]\n#{snapshot[:user_profile]}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
if snapshot[:relevant_memories]&.any?
|
|
188
|
+
memories_text = snapshot[:relevant_memories].map { |m| "- #{m[:content]}" }.join("\n")
|
|
189
|
+
parts << "[Relevant Memories]\n#{memories_text}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
skills_index = skills_index_block
|
|
193
|
+
parts << skills_index if skills_index
|
|
194
|
+
|
|
195
|
+
# The user-PINNED active skill (the `/skills <name>` picker): force-load
|
|
196
|
+
# its full SKILL.md into the prompt EACH turn so the model actually uses
|
|
197
|
+
# it, not just shows a chip. Sits after the skills index so the pinned
|
|
198
|
+
# skill is the most concrete, last-read instruction in the skills region.
|
|
199
|
+
active_skill = active_skill_block
|
|
200
|
+
parts << active_skill if active_skill
|
|
201
|
+
|
|
202
|
+
project_ctx = load_project_context
|
|
203
|
+
parts << "[Project Context]\n#{project_ctx}" if project_ctx
|
|
204
|
+
|
|
205
|
+
parts.join("\n\n")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The "## Skills (mandatory)" catalogue. This is the load-bearing trigger
|
|
209
|
+
# for skill auto-activation — surfacing skills in the system prompt (not
|
|
210
|
+
# just the `skill` tool description) is what makes the model proactively
|
|
211
|
+
# scan and load a relevant skill before replying.
|
|
212
|
+
#
|
|
213
|
+
# Gated, mirroring the reference (which gates on the skills
|
|
214
|
+
# toolset being present), on both holding:
|
|
215
|
+
# - the skills feature is enabled (config skills.enabled), and
|
|
216
|
+
# - the `skill` tool is actually available this turn.
|
|
217
|
+
# When either fails we inject nothing. When both hold we always inject the
|
|
218
|
+
# block, even with zero skills discovered: the catalogue half drops out but
|
|
219
|
+
# the proactive-creation nudge remains, so a fresh install still gets told
|
|
220
|
+
# to distill repeatable work into a skill (PromptIndex#render handles the
|
|
221
|
+
# empty-catalogue case and never returns nil).
|
|
222
|
+
def skills_index_block
|
|
223
|
+
return nil unless skills_feature_enabled?
|
|
224
|
+
return nil unless skill_tool_available?
|
|
225
|
+
|
|
226
|
+
Skills::PromptIndex.new(
|
|
227
|
+
registry: Skills::Registry.new(
|
|
228
|
+
config: @config,
|
|
229
|
+
include_project_local: project_local_trusted?
|
|
230
|
+
)
|
|
231
|
+
).render
|
|
232
|
+
rescue StandardError
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# The user-PINNED active skill block. When the user has activated a skill
|
|
237
|
+
# via the `/skills <name>` picker (Rubino::ActiveSkill), we force-load its
|
|
238
|
+
# FULL SKILL.md content into the system prompt every turn and prepend a
|
|
239
|
+
# strong directive naming it — so the model treats it as active and follows
|
|
240
|
+
# it without having to discover/load it via the `skill` tool. This is the
|
|
241
|
+
# load-bearing half of the picker: the chip is cosmetic, THIS is what makes
|
|
242
|
+
# the skill actually take effect.
|
|
243
|
+
#
|
|
244
|
+
# Gated on the skills feature being enabled (same gate as the index). A
|
|
245
|
+
# pinned-but-now-missing/disabled skill (deleted on disk, or toggled off)
|
|
246
|
+
# silently drops out rather than injecting an empty block. Never raises —
|
|
247
|
+
# a load failure must not take down prompt assembly — but it LOGS, so a
|
|
248
|
+
# logic error here (e.g. a signature drift) is visible instead of the
|
|
249
|
+
# pinned skill silently vanishing from the prompt (#62).
|
|
250
|
+
def active_skill_block
|
|
251
|
+
return nil unless skills_feature_enabled?
|
|
252
|
+
|
|
253
|
+
name = Rubino::ActiveSkill.current
|
|
254
|
+
return nil unless name
|
|
255
|
+
|
|
256
|
+
registry = Skills::Registry.new(
|
|
257
|
+
config: @config,
|
|
258
|
+
include_project_local: project_local_trusted?
|
|
259
|
+
)
|
|
260
|
+
return nil unless registry.enabled?(name)
|
|
261
|
+
|
|
262
|
+
content = registry.load_skill(name)
|
|
263
|
+
return nil if content.nil? || content.to_s.strip.empty?
|
|
264
|
+
|
|
265
|
+
<<~PROMPT.strip
|
|
266
|
+
## Active skill (pinned): #{name}
|
|
267
|
+
The user has PINNED the "#{name}" skill active for this session. You MUST follow its instructions for this and every subsequent turn until it is changed. Its full content is loaded below — treat it as authoritative; you do not need to load it again with the `skill` tool.
|
|
268
|
+
|
|
269
|
+
<active_skill name="#{name}">
|
|
270
|
+
#{content.to_s.strip}
|
|
271
|
+
</active_skill>
|
|
272
|
+
PROMPT
|
|
273
|
+
rescue StandardError => e
|
|
274
|
+
Rubino.logger.debug(event: "prompt.active_skill_block_failed",
|
|
275
|
+
error: "#{e.class}: #{e.message}")
|
|
276
|
+
nil
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def skills_feature_enabled?
|
|
280
|
+
value = @config.dig("skills", "enabled")
|
|
281
|
+
value.nil? || value == true
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# True when the `skill` tool is exposed to the model this turn. Honors the
|
|
285
|
+
# agent definition's tool restrictions when present, else falls back to the
|
|
286
|
+
# globally enabled tools — the same source the loop uses to pick tools.
|
|
287
|
+
def skill_tool_available?
|
|
288
|
+
tools =
|
|
289
|
+
if @agent_definition
|
|
290
|
+
@agent_definition.resolved_tools
|
|
291
|
+
else
|
|
292
|
+
Tools::Registry.instance.enabled_tools
|
|
293
|
+
end
|
|
294
|
+
tools.any? { |t| t.respond_to?(:name) && t.name == "skill" }
|
|
295
|
+
rescue StandardError
|
|
296
|
+
false
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def agent_identity
|
|
300
|
+
return @agent_definition.system_prompt if @agent_definition&.system_prompt
|
|
301
|
+
|
|
302
|
+
load_builtin_prompt("build") || <<~FALLBACK.strip
|
|
303
|
+
You are a helpful AI assistant powered by rubino.
|
|
304
|
+
You can use tools to help accomplish tasks.
|
|
305
|
+
Be concise and accurate in your responses.
|
|
306
|
+
FALLBACK
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def product_preamble
|
|
310
|
+
return nil unless @config.respond_to?(:prompts_preamble)
|
|
311
|
+
|
|
312
|
+
@config.prompts_preamble
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def environment_block
|
|
316
|
+
return nil unless environment_enabled?
|
|
317
|
+
|
|
318
|
+
EnvironmentInspector.new(
|
|
319
|
+
extra_utilities: environment_extra_utilities
|
|
320
|
+
).render
|
|
321
|
+
rescue StandardError
|
|
322
|
+
# The env block is a convenience; never let a probe failure
|
|
323
|
+
# (read-only filesystem, missing `git`, weird PATH) take down the
|
|
324
|
+
# whole interaction.
|
|
325
|
+
nil
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def environment_enabled?
|
|
329
|
+
return true unless @config.respond_to?(:prompts_environment_enabled?)
|
|
330
|
+
|
|
331
|
+
@config.prompts_environment_enabled?
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def environment_extra_utilities
|
|
335
|
+
return [] unless @config.respond_to?(:prompts_environment_extra_utilities)
|
|
336
|
+
|
|
337
|
+
@config.prompts_environment_extra_utilities
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def load_builtin_prompt(name)
|
|
341
|
+
path = File.expand_path("../agent/prompts/#{name}.txt", __dir__)
|
|
342
|
+
File.exist?(path) ? File.read(path).strip : nil
|
|
343
|
+
rescue StandardError
|
|
344
|
+
nil
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def load_summary
|
|
348
|
+
Session::SummaryStore.new.latest_content(@session[:id])
|
|
349
|
+
rescue StandardError
|
|
350
|
+
nil
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def load_project_context
|
|
354
|
+
return nil if @ignore_rules
|
|
355
|
+
return nil unless @config.dig("memory", "project_context_enabled")
|
|
356
|
+
return nil unless project_local_trusted?
|
|
357
|
+
|
|
358
|
+
# Discover from the PRIMARY workspace root (not just Dir.pwd) so project
|
|
359
|
+
# context tracks terminal.cwd and the dir the trust gate vouched for.
|
|
360
|
+
discovery = Context::FileDiscovery.new(base_path: Rubino::Workspace.primary_root)
|
|
361
|
+
discovery.load_project_context
|
|
362
|
+
rescue StandardError
|
|
363
|
+
nil
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Folder-trust gate (proportionate; see Rubino::Trust). The cwd's
|
|
367
|
+
# AGENTS.md/etc. and its .rubino/skills are auto-injected into the system
|
|
368
|
+
# prompt, so a hostile repo could STEER the agent the moment you start
|
|
369
|
+
# there. We withhold that project-local context until the primary root is
|
|
370
|
+
# trusted — the CLI prompts once at boot / on /add-dir and remembers the
|
|
371
|
+
# answer. An already-trusted dir (or one the user never gated, e.g. a bare
|
|
372
|
+
# scratch dir with no context files) loads normally.
|
|
373
|
+
def project_local_trusted?
|
|
374
|
+
Rubino::Trust.trusted?(Rubino::Workspace.primary_root)
|
|
375
|
+
rescue StandardError
|
|
376
|
+
# Never let the trust check itself drop context on a real error; the
|
|
377
|
+
# boot-time prompt is the authoritative gate, this is defence-in-depth.
|
|
378
|
+
true
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Context
|
|
5
|
+
# Builds structured summaries from compressible message segments.
|
|
6
|
+
# Uses the LLM to generate a comprehensive summary following the template.
|
|
7
|
+
class SummaryBuilder
|
|
8
|
+
SUMMARY_TEMPLATE = <<~TEMPLATE
|
|
9
|
+
## Goal
|
|
10
|
+
Current user objective.
|
|
11
|
+
|
|
12
|
+
## Constraints & Preferences
|
|
13
|
+
Technical constraints, preferences, conventions.
|
|
14
|
+
|
|
15
|
+
## Progress
|
|
16
|
+
|
|
17
|
+
### Done
|
|
18
|
+
Completed items.
|
|
19
|
+
|
|
20
|
+
### In Progress
|
|
21
|
+
Work in progress.
|
|
22
|
+
|
|
23
|
+
### Blocked
|
|
24
|
+
Open blockers or errors.
|
|
25
|
+
|
|
26
|
+
## Key Decisions
|
|
27
|
+
Technical decisions made and their rationale.
|
|
28
|
+
|
|
29
|
+
## Relevant Files
|
|
30
|
+
Files read, modified, or created.
|
|
31
|
+
|
|
32
|
+
## Tool Results
|
|
33
|
+
Important tool execution results.
|
|
34
|
+
|
|
35
|
+
## Current State
|
|
36
|
+
Current session state.
|
|
37
|
+
|
|
38
|
+
## Next Steps
|
|
39
|
+
Planned next actions.
|
|
40
|
+
|
|
41
|
+
## Critical Context
|
|
42
|
+
Information that must not be lost.
|
|
43
|
+
TEMPLATE
|
|
44
|
+
|
|
45
|
+
def initialize(session_id:, config: nil)
|
|
46
|
+
@session_id = session_id
|
|
47
|
+
@config = config || Rubino.configuration
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Builds a summary from messages, optionally incorporating a previous summary
|
|
51
|
+
def build(messages:, previous_summary: nil)
|
|
52
|
+
content = format_messages_for_summary(messages)
|
|
53
|
+
|
|
54
|
+
prompt = build_summary_prompt(content, previous_summary)
|
|
55
|
+
@config.compression_max_summary_tokens
|
|
56
|
+
|
|
57
|
+
# Use the auxiliary compression model if configured
|
|
58
|
+
model = compression_model
|
|
59
|
+
adapter = LLM::RubyLLMAdapter.new(model_id: model)
|
|
60
|
+
|
|
61
|
+
response = adapter.chat(messages: [
|
|
62
|
+
{ role: "system", content: summary_system_prompt },
|
|
63
|
+
{ role: "user", content: prompt }
|
|
64
|
+
])
|
|
65
|
+
|
|
66
|
+
response&.content || fallback_summary(messages, previous_summary)
|
|
67
|
+
rescue StandardError
|
|
68
|
+
# If LLM fails, produce a basic extractive summary
|
|
69
|
+
fallback_summary(messages, previous_summary)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Builds and saves the summary to the database
|
|
73
|
+
def build_and_save!
|
|
74
|
+
message_store = Session::Store.new
|
|
75
|
+
messages = message_store.for_session(@session_id)
|
|
76
|
+
return if messages.size < 10
|
|
77
|
+
|
|
78
|
+
summary = build(messages: messages, previous_summary: load_previous_summary)
|
|
79
|
+
save!(summary)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def summary_system_prompt
|
|
85
|
+
<<~PROMPT
|
|
86
|
+
You are a context summarizer. Your job is to create a structured summary
|
|
87
|
+
of a conversation segment that preserves all important information.
|
|
88
|
+
|
|
89
|
+
Follow this template structure:
|
|
90
|
+
#{SUMMARY_TEMPLATE}
|
|
91
|
+
|
|
92
|
+
Be concise but comprehensive. Do not lose critical technical details,
|
|
93
|
+
file paths, decisions, or error states.
|
|
94
|
+
PROMPT
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def build_summary_prompt(content, previous_summary)
|
|
98
|
+
parts = []
|
|
99
|
+
|
|
100
|
+
parts << "Previous summary to incorporate:\n#{previous_summary}\n\n---\n" if previous_summary
|
|
101
|
+
|
|
102
|
+
parts << "New conversation segment to summarize:\n#{content}"
|
|
103
|
+
parts.join("\n")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def format_messages_for_summary(messages)
|
|
107
|
+
messages.map do |msg|
|
|
108
|
+
role = msg.respond_to?(:role) ? msg.role : msg[:role]
|
|
109
|
+
content = msg.respond_to?(:content) ? msg.content : msg[:content]
|
|
110
|
+
"[#{role}] #{content}"
|
|
111
|
+
end.join("\n\n")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def compression_model
|
|
115
|
+
aux_config = @config.auxiliary_compression_config
|
|
116
|
+
model = aux_config["model"]
|
|
117
|
+
|
|
118
|
+
if model && !model.empty?
|
|
119
|
+
model
|
|
120
|
+
else
|
|
121
|
+
@config.model_default
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def summary_store
|
|
126
|
+
@summary_store ||= Session::SummaryStore.new
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def load_previous_summary
|
|
130
|
+
summary_store.latest_content(@session_id)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def save!(content)
|
|
134
|
+
summary_store.insert(session_id: @session_id, content: content)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def fallback_summary(messages, previous_summary)
|
|
138
|
+
parts = []
|
|
139
|
+
parts << "## Previous Context\n#{previous_summary}" if previous_summary
|
|
140
|
+
|
|
141
|
+
# Extract key information heuristically
|
|
142
|
+
parts << "## Conversation Summary"
|
|
143
|
+
parts << "Messages in segment: #{messages.size}"
|
|
144
|
+
|
|
145
|
+
# Get user messages as goal indicators
|
|
146
|
+
user_msgs = messages.select { |m| (m.respond_to?(:role) ? m.role : m[:role]) == "user" }
|
|
147
|
+
unless user_msgs.empty?
|
|
148
|
+
parts << "\n### User Requests"
|
|
149
|
+
user_msgs.last(3).each do |m|
|
|
150
|
+
content = m.respond_to?(:content) ? m.content : m[:content]
|
|
151
|
+
parts << "- #{content&.slice(0, 200)}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
parts.join("\n")
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Context
|
|
5
|
+
# Manages token budget calculations and determines when compaction is needed.
|
|
6
|
+
class TokenBudget
|
|
7
|
+
CHARS_PER_TOKEN = 4 # Rough approximation
|
|
8
|
+
# Fallback when the user hasn't pinned `model.context_length` in config.
|
|
9
|
+
# Generous-but-safe; truncation kicks in via `needs_compaction?` long
|
|
10
|
+
# before the real provider limit would be hit.
|
|
11
|
+
DEFAULT_CONTEXT_WINDOW = 128_000
|
|
12
|
+
|
|
13
|
+
def initialize(model_id:, config:)
|
|
14
|
+
@model_id = model_id
|
|
15
|
+
@config = config
|
|
16
|
+
@context_window = determine_context_window
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :context_window
|
|
20
|
+
|
|
21
|
+
# Returns the max tokens available for conversation
|
|
22
|
+
def available_tokens
|
|
23
|
+
override = @config.dig("context", "max_tokens")
|
|
24
|
+
override || @context_window
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Estimates token count for a set of messages
|
|
28
|
+
def estimate_tokens(messages)
|
|
29
|
+
total_chars = messages.sum { |m| (m[:content] || "").length }
|
|
30
|
+
(total_chars.to_f / CHARS_PER_TOKEN).ceil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns true if the messages exceed the compaction threshold
|
|
34
|
+
def needs_compaction?(messages)
|
|
35
|
+
return false unless @config.compression_enabled?
|
|
36
|
+
|
|
37
|
+
estimated = estimate_tokens(messages)
|
|
38
|
+
threshold = (available_tokens * @config.compression_threshold).to_i
|
|
39
|
+
estimated > threshold
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns true if critically close to context limit
|
|
43
|
+
def critical?(messages)
|
|
44
|
+
return false unless @config.compression_enabled?
|
|
45
|
+
|
|
46
|
+
estimated = estimate_tokens(messages)
|
|
47
|
+
gateway = (available_tokens * @config.compression_gateway_threshold).to_i
|
|
48
|
+
estimated > gateway
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns the target token count after compaction
|
|
52
|
+
def compaction_target
|
|
53
|
+
(available_tokens * @config.compression_target_ratio).to_i
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Single source of truth: the user's `model.context_length` config
|
|
59
|
+
# value if set, else the default. We deliberately do NOT maintain a
|
|
60
|
+
# per-model lookup table — `assume_model_exists: true` already lets
|
|
61
|
+
# any provider-compatible model id work; if its real window differs
|
|
62
|
+
# from the default, the user pins it in config.
|
|
63
|
+
def determine_context_window
|
|
64
|
+
@config.model_context_length || DEFAULT_CONTEXT_WINDOW
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Context
|
|
5
|
+
# Ensures tool_call and tool_result pairs are not split during compaction.
|
|
6
|
+
# If a tool_call is in the compressible section, its result must be too (and
|
|
7
|
+
# vice versa).
|
|
8
|
+
#
|
|
9
|
+
# WIRE FORMAT: an assistant tool call lives in metadata[:tool_calls] (a list
|
|
10
|
+
# of { id:, name:, arguments: }), NOT in tool_call_id — tool_call_id is only
|
|
11
|
+
# set on role:"tool" RESULT rows. The original predicate keyed off
|
|
12
|
+
# `tool_call_id && role=="assistant"`, a contradiction that never matched, so
|
|
13
|
+
# the trailing-orphan trim was inert. The methods below read metadata, and
|
|
14
|
+
# are also reused by PromptAssembler's pre-send repair pass.
|
|
15
|
+
class ToolPairSanitizer
|
|
16
|
+
# Adjusts a slice to ensure tool pairs remain intact at its boundaries.
|
|
17
|
+
def sanitize(middle_messages)
|
|
18
|
+
adjusted = middle_messages.dup
|
|
19
|
+
|
|
20
|
+
# Leading orphan: a tool RESULT whose call lives in the head section.
|
|
21
|
+
adjusted.shift while adjusted.first&.role == "tool"
|
|
22
|
+
|
|
23
|
+
# Trailing orphan: an assistant tool call whose results are NOT all
|
|
24
|
+
# present after it in this slice (e.g. interrupted turn, or results
|
|
25
|
+
# landed in the tail section). A fully-PAIRED trailing call is kept.
|
|
26
|
+
adjusted.pop while adjusted.last && trailing_unanswered_tool_call?(adjusted)
|
|
27
|
+
|
|
28
|
+
adjusted
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# True when the message is an assistant turn carrying tool calls.
|
|
32
|
+
def assistant_tool_call?(message)
|
|
33
|
+
message.role == "assistant" &&
|
|
34
|
+
message.respond_to?(:metadata) && message.metadata.is_a?(Hash) &&
|
|
35
|
+
!Array(message.metadata[:tool_calls]).empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The tool_call ids declared by an assistant message. Handles both
|
|
39
|
+
# symbol and string keys — metadata is hydrated with symbolize_names but
|
|
40
|
+
# in-memory messages (pre-persist) may carry string keys.
|
|
41
|
+
def tool_call_ids(message)
|
|
42
|
+
Array(message.metadata[:tool_calls]).map { |tc| tc[:id] || tc["id"] }.compact
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# The last message is an unanswered assistant tool call: it declares ids
|
|
48
|
+
# that are not all satisfied by role:"tool" results appearing after it.
|
|
49
|
+
def trailing_unanswered_tool_call?(messages)
|
|
50
|
+
last = messages.last
|
|
51
|
+
return false unless assistant_tool_call?(last)
|
|
52
|
+
|
|
53
|
+
ids = tool_call_ids(last)
|
|
54
|
+
return true if ids.empty? # malformed call with no id → cannot be paired
|
|
55
|
+
|
|
56
|
+
answered = answered_ids(messages, after_index: messages.length - 1)
|
|
57
|
+
!ids.all? { |id| answered.include?(id) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Set of tool_call_ids answered by role:"tool" messages positioned after
|
|
61
|
+
# the given index in the slice.
|
|
62
|
+
def answered_ids(messages, after_index:)
|
|
63
|
+
messages[(after_index + 1)..]
|
|
64
|
+
&.select { |m| m.role == "tool" && m.tool_call_id }
|
|
65
|
+
&.map(&:tool_call_id)
|
|
66
|
+
&.to_set || Set.new
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|