swarm_memory 2.0.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.
Files changed (189) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/lib/claude_swarm/base_executor.rb +133 -0
  4. data/lib/claude_swarm/claude_code_executor.rb +349 -0
  5. data/lib/claude_swarm/claude_mcp_server.rb +77 -0
  6. data/lib/claude_swarm/cli.rb +712 -0
  7. data/lib/claude_swarm/commands/ps.rb +216 -0
  8. data/lib/claude_swarm/commands/show.rb +139 -0
  9. data/lib/claude_swarm/configuration.rb +363 -0
  10. data/lib/claude_swarm/hooks/session_start_hook.rb +42 -0
  11. data/lib/claude_swarm/json_handler.rb +91 -0
  12. data/lib/claude_swarm/mcp_generator.rb +248 -0
  13. data/lib/claude_swarm/openai/chat_completion.rb +264 -0
  14. data/lib/claude_swarm/openai/executor.rb +254 -0
  15. data/lib/claude_swarm/openai/responses.rb +338 -0
  16. data/lib/claude_swarm/orchestrator.rb +879 -0
  17. data/lib/claude_swarm/process_tracker.rb +78 -0
  18. data/lib/claude_swarm/session_cost_calculator.rb +209 -0
  19. data/lib/claude_swarm/session_path.rb +42 -0
  20. data/lib/claude_swarm/settings_generator.rb +77 -0
  21. data/lib/claude_swarm/system_utils.rb +46 -0
  22. data/lib/claude_swarm/templates/generation_prompt.md.erb +230 -0
  23. data/lib/claude_swarm/tools/reset_session_tool.rb +24 -0
  24. data/lib/claude_swarm/tools/session_info_tool.rb +24 -0
  25. data/lib/claude_swarm/tools/task_tool.rb +63 -0
  26. data/lib/claude_swarm/version.rb +5 -0
  27. data/lib/claude_swarm/worktree_manager.rb +475 -0
  28. data/lib/claude_swarm/yaml_loader.rb +22 -0
  29. data/lib/claude_swarm.rb +69 -0
  30. data/lib/swarm_cli/cli.rb +201 -0
  31. data/lib/swarm_cli/command_registry.rb +61 -0
  32. data/lib/swarm_cli/commands/mcp_serve.rb +130 -0
  33. data/lib/swarm_cli/commands/mcp_tools.rb +148 -0
  34. data/lib/swarm_cli/commands/migrate.rb +55 -0
  35. data/lib/swarm_cli/commands/run.rb +173 -0
  36. data/lib/swarm_cli/config_loader.rb +97 -0
  37. data/lib/swarm_cli/formatters/human_formatter.rb +711 -0
  38. data/lib/swarm_cli/formatters/json_formatter.rb +51 -0
  39. data/lib/swarm_cli/interactive_repl.rb +918 -0
  40. data/lib/swarm_cli/mcp_serve_options.rb +44 -0
  41. data/lib/swarm_cli/mcp_tools_options.rb +59 -0
  42. data/lib/swarm_cli/migrate_options.rb +54 -0
  43. data/lib/swarm_cli/migrator.rb +132 -0
  44. data/lib/swarm_cli/options.rb +151 -0
  45. data/lib/swarm_cli/ui/components/agent_badge.rb +33 -0
  46. data/lib/swarm_cli/ui/components/content_block.rb +120 -0
  47. data/lib/swarm_cli/ui/components/divider.rb +57 -0
  48. data/lib/swarm_cli/ui/components/panel.rb +62 -0
  49. data/lib/swarm_cli/ui/components/usage_stats.rb +70 -0
  50. data/lib/swarm_cli/ui/formatters/cost.rb +49 -0
  51. data/lib/swarm_cli/ui/formatters/number.rb +58 -0
  52. data/lib/swarm_cli/ui/formatters/text.rb +77 -0
  53. data/lib/swarm_cli/ui/formatters/time.rb +73 -0
  54. data/lib/swarm_cli/ui/icons.rb +59 -0
  55. data/lib/swarm_cli/ui/renderers/event_renderer.rb +188 -0
  56. data/lib/swarm_cli/ui/state/agent_color_cache.rb +45 -0
  57. data/lib/swarm_cli/ui/state/depth_tracker.rb +40 -0
  58. data/lib/swarm_cli/ui/state/spinner_manager.rb +170 -0
  59. data/lib/swarm_cli/ui/state/usage_tracker.rb +62 -0
  60. data/lib/swarm_cli/version.rb +5 -0
  61. data/lib/swarm_cli.rb +45 -0
  62. data/lib/swarm_memory/adapters/base.rb +140 -0
  63. data/lib/swarm_memory/adapters/filesystem_adapter.rb +789 -0
  64. data/lib/swarm_memory/chat_extension.rb +34 -0
  65. data/lib/swarm_memory/cli/commands.rb +306 -0
  66. data/lib/swarm_memory/core/entry.rb +37 -0
  67. data/lib/swarm_memory/core/frontmatter_parser.rb +108 -0
  68. data/lib/swarm_memory/core/metadata_extractor.rb +68 -0
  69. data/lib/swarm_memory/core/path_normalizer.rb +75 -0
  70. data/lib/swarm_memory/core/semantic_index.rb +244 -0
  71. data/lib/swarm_memory/core/storage.rb +286 -0
  72. data/lib/swarm_memory/core/storage_read_tracker.rb +63 -0
  73. data/lib/swarm_memory/dsl/builder_extension.rb +40 -0
  74. data/lib/swarm_memory/dsl/memory_config.rb +113 -0
  75. data/lib/swarm_memory/embeddings/embedder.rb +36 -0
  76. data/lib/swarm_memory/embeddings/informers_embedder.rb +152 -0
  77. data/lib/swarm_memory/errors.rb +21 -0
  78. data/lib/swarm_memory/integration/cli_registration.rb +30 -0
  79. data/lib/swarm_memory/integration/configuration.rb +43 -0
  80. data/lib/swarm_memory/integration/registration.rb +31 -0
  81. data/lib/swarm_memory/integration/sdk_plugin.rb +531 -0
  82. data/lib/swarm_memory/optimization/analyzer.rb +244 -0
  83. data/lib/swarm_memory/optimization/defragmenter.rb +863 -0
  84. data/lib/swarm_memory/prompts/memory.md.erb +109 -0
  85. data/lib/swarm_memory/prompts/memory_assistant.md.erb +139 -0
  86. data/lib/swarm_memory/prompts/memory_researcher.md.erb +201 -0
  87. data/lib/swarm_memory/prompts/memory_retrieval.md.erb +76 -0
  88. data/lib/swarm_memory/search/semantic_search.rb +112 -0
  89. data/lib/swarm_memory/search/text_search.rb +40 -0
  90. data/lib/swarm_memory/search/text_similarity.rb +80 -0
  91. data/lib/swarm_memory/skills/meta/deep-learning.md +101 -0
  92. data/lib/swarm_memory/skills/meta/deep-learning.yml +14 -0
  93. data/lib/swarm_memory/tools/load_skill.rb +313 -0
  94. data/lib/swarm_memory/tools/memory_defrag.rb +382 -0
  95. data/lib/swarm_memory/tools/memory_delete.rb +99 -0
  96. data/lib/swarm_memory/tools/memory_edit.rb +185 -0
  97. data/lib/swarm_memory/tools/memory_glob.rb +145 -0
  98. data/lib/swarm_memory/tools/memory_grep.rb +209 -0
  99. data/lib/swarm_memory/tools/memory_multi_edit.rb +281 -0
  100. data/lib/swarm_memory/tools/memory_read.rb +123 -0
  101. data/lib/swarm_memory/tools/memory_write.rb +215 -0
  102. data/lib/swarm_memory/utils.rb +50 -0
  103. data/lib/swarm_memory/version.rb +5 -0
  104. data/lib/swarm_memory.rb +166 -0
  105. data/lib/swarm_sdk/agent/RETRY_LOGIC.md +127 -0
  106. data/lib/swarm_sdk/agent/builder.rb +461 -0
  107. data/lib/swarm_sdk/agent/chat/context_tracker.rb +314 -0
  108. data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
  109. data/lib/swarm_sdk/agent/chat/logging_helpers.rb +116 -0
  110. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +152 -0
  111. data/lib/swarm_sdk/agent/chat.rb +1144 -0
  112. data/lib/swarm_sdk/agent/context.rb +112 -0
  113. data/lib/swarm_sdk/agent/context_manager.rb +309 -0
  114. data/lib/swarm_sdk/agent/definition.rb +556 -0
  115. data/lib/swarm_sdk/claude_code_agent_adapter.rb +205 -0
  116. data/lib/swarm_sdk/configuration.rb +296 -0
  117. data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
  118. data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
  119. data/lib/swarm_sdk/context_compactor.rb +340 -0
  120. data/lib/swarm_sdk/hooks/adapter.rb +359 -0
  121. data/lib/swarm_sdk/hooks/context.rb +197 -0
  122. data/lib/swarm_sdk/hooks/definition.rb +80 -0
  123. data/lib/swarm_sdk/hooks/error.rb +29 -0
  124. data/lib/swarm_sdk/hooks/executor.rb +146 -0
  125. data/lib/swarm_sdk/hooks/registry.rb +147 -0
  126. data/lib/swarm_sdk/hooks/result.rb +150 -0
  127. data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
  128. data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
  129. data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
  130. data/lib/swarm_sdk/log_collector.rb +51 -0
  131. data/lib/swarm_sdk/log_stream.rb +69 -0
  132. data/lib/swarm_sdk/markdown_parser.rb +75 -0
  133. data/lib/swarm_sdk/model_aliases.json +5 -0
  134. data/lib/swarm_sdk/models.json +1 -0
  135. data/lib/swarm_sdk/models.rb +120 -0
  136. data/lib/swarm_sdk/node/agent_config.rb +49 -0
  137. data/lib/swarm_sdk/node/builder.rb +439 -0
  138. data/lib/swarm_sdk/node/transformer_executor.rb +248 -0
  139. data/lib/swarm_sdk/node_context.rb +170 -0
  140. data/lib/swarm_sdk/node_orchestrator.rb +384 -0
  141. data/lib/swarm_sdk/permissions/config.rb +239 -0
  142. data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
  143. data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
  144. data/lib/swarm_sdk/permissions/validator.rb +173 -0
  145. data/lib/swarm_sdk/permissions_builder.rb +122 -0
  146. data/lib/swarm_sdk/plugin.rb +147 -0
  147. data/lib/swarm_sdk/plugin_registry.rb +101 -0
  148. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +243 -0
  149. data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
  150. data/lib/swarm_sdk/result.rb +97 -0
  151. data/lib/swarm_sdk/swarm/agent_initializer.rb +334 -0
  152. data/lib/swarm_sdk/swarm/all_agents_builder.rb +140 -0
  153. data/lib/swarm_sdk/swarm/builder.rb +586 -0
  154. data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
  155. data/lib/swarm_sdk/swarm/tool_configurator.rb +416 -0
  156. data/lib/swarm_sdk/swarm.rb +982 -0
  157. data/lib/swarm_sdk/tools/bash.rb +274 -0
  158. data/lib/swarm_sdk/tools/clock.rb +44 -0
  159. data/lib/swarm_sdk/tools/delegate.rb +164 -0
  160. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
  161. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
  162. data/lib/swarm_sdk/tools/document_converters/html_converter.rb +101 -0
  163. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
  164. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
  165. data/lib/swarm_sdk/tools/edit.rb +150 -0
  166. data/lib/swarm_sdk/tools/glob.rb +158 -0
  167. data/lib/swarm_sdk/tools/grep.rb +228 -0
  168. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
  169. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
  170. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
  171. data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
  172. data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
  173. data/lib/swarm_sdk/tools/read.rb +251 -0
  174. data/lib/swarm_sdk/tools/registry.rb +93 -0
  175. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +96 -0
  176. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +76 -0
  177. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +91 -0
  178. data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
  179. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +224 -0
  180. data/lib/swarm_sdk/tools/stores/storage.rb +148 -0
  181. data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
  182. data/lib/swarm_sdk/tools/think.rb +95 -0
  183. data/lib/swarm_sdk/tools/todo_write.rb +216 -0
  184. data/lib/swarm_sdk/tools/web_fetch.rb +261 -0
  185. data/lib/swarm_sdk/tools/write.rb +117 -0
  186. data/lib/swarm_sdk/utils.rb +50 -0
  187. data/lib/swarm_sdk/version.rb +5 -0
  188. data/lib/swarm_sdk.rb +167 -0
  189. metadata +313 -0
@@ -0,0 +1,1144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Agent
5
+ # Chat extends RubyLLM::Chat to enable parallel agent-to-agent tool calling
6
+ # with two-level rate limiting to prevent API quota exhaustion
7
+ #
8
+ # ## Rate Limiting Strategy
9
+ #
10
+ # In hierarchical agent trees, unlimited parallelism can cause exponential growth:
11
+ # Main → 10 agents → 100 agents → 1,000 agents = API meltdown!
12
+ #
13
+ # Solution: Two-level semaphore system
14
+ # 1. **Global semaphore** - Total concurrent LLM calls across entire swarm
15
+ # 2. **Local semaphore** - Max concurrent tool calls for this specific agent
16
+ #
17
+ # ## Architecture
18
+ #
19
+ # This class is now organized with clear separation of concerns:
20
+ # - Core (this file): Initialization, provider setup, rate limiting, parallel execution
21
+ # - SystemReminderInjector: First message reminders, TodoWrite reminders
22
+ # - LoggingHelpers: Tool call formatting, result serialization
23
+ # - ContextTracker: Logging callbacks, delegation tracking
24
+ # - HookIntegration: Hook system integration (wraps tool execution with hooks)
25
+ class Chat < RubyLLM::Chat
26
+ # Include logging helpers for tool call formatting
27
+ include LoggingHelpers
28
+
29
+ # Include hook integration for user_prompt hooks and hook trigger methods
30
+ # This module overrides ask() to inject user_prompt hooks
31
+ # and provides trigger methods for pre/post tool use hooks
32
+ include HookIntegration
33
+
34
+ # Register custom provider for responses API support
35
+ # This is done once at class load time
36
+ unless RubyLLM::Provider.providers.key?(:openai_with_responses)
37
+ RubyLLM::Provider.register(:openai_with_responses, SwarmSDK::Providers::OpenAIWithResponses)
38
+ end
39
+
40
+ # Initialize AgentChat with rate limiting
41
+ #
42
+ # @param definition [Hash] Agent definition containing all configuration
43
+ # @param agent_name [Symbol, nil] Agent identifier (for plugin callbacks)
44
+ # @param global_semaphore [Async::Semaphore, nil] Shared across all agents (not part of definition)
45
+ # @param options [Hash] Additional options to pass to RubyLLM::Chat
46
+ # @raise [ArgumentError] If provider doesn't support custom base_url or provider not specified with base_url
47
+ def initialize(definition:, agent_name: nil, global_semaphore: nil, **options)
48
+ # Extract configuration from definition
49
+ model = definition[:model]
50
+ provider = definition[:provider]
51
+ context_window = definition[:context_window]
52
+ max_concurrent_tools = definition[:max_concurrent_tools]
53
+ base_url = definition[:base_url]
54
+ api_version = definition[:api_version]
55
+ timeout = definition[:timeout] || Definition::DEFAULT_TIMEOUT
56
+ assume_model_exists = definition[:assume_model_exists]
57
+ system_prompt = definition[:system_prompt]
58
+ parameters = definition[:parameters]
59
+ headers = definition[:headers]
60
+
61
+ # Create isolated context if custom base_url or timeout specified
62
+ if base_url || timeout != Definition::DEFAULT_TIMEOUT
63
+ # Provider is required when using custom base_url
64
+ raise ArgumentError, "Provider must be specified when base_url is set" if base_url && !provider
65
+
66
+ # Determine actual provider to use
67
+ actual_provider = determine_provider(provider, base_url, api_version)
68
+ RubyLLM.logger.debug("SwarmSDK Agent::Chat: Using provider '#{actual_provider}' (requested='#{provider}', api_version='#{api_version}')")
69
+
70
+ context = build_custom_context(provider: provider, base_url: base_url, timeout: timeout)
71
+
72
+ # Use assume_model_exists to bypass model validation for custom endpoints
73
+ # Default to true when base_url is set, false otherwise (unless explicitly specified)
74
+ assume_model_exists = base_url ? true : false if assume_model_exists.nil?
75
+
76
+ super(model: model, provider: actual_provider, assume_model_exists: assume_model_exists, context: context, **options)
77
+
78
+ # Configure custom provider after creation (RubyLLM doesn't support custom init params)
79
+ if actual_provider == :openai_with_responses && api_version == "v1/responses"
80
+ configure_responses_api_provider
81
+ end
82
+ elsif provider
83
+ # No custom base_url or timeout: use RubyLLM's defaults (with optional provider override)
84
+ assume_model_exists = false if assume_model_exists.nil?
85
+ super(model: model, provider: provider, assume_model_exists: assume_model_exists, **options)
86
+ else
87
+ # No custom base_url, timeout, or provider: use RubyLLM's defaults
88
+ assume_model_exists = false if assume_model_exists.nil?
89
+ super(model: model, assume_model_exists: assume_model_exists, **options)
90
+ end
91
+
92
+ # Agent identifier (for plugin callbacks)
93
+ @agent_name = agent_name
94
+
95
+ # Context manager for ephemeral messages and future context optimization
96
+ @context_manager = ContextManager.new
97
+
98
+ # Rate limiting semaphores
99
+ @global_semaphore = global_semaphore
100
+ @local_semaphore = max_concurrent_tools ? Async::Semaphore.new(max_concurrent_tools) : nil
101
+ @explicit_context_window = context_window
102
+
103
+ # Track TodoWrite usage for periodic reminders
104
+ @last_todowrite_message_index = nil
105
+
106
+ # Agent context for logging (set via setup_context)
107
+ @agent_context = nil
108
+
109
+ # Context tracker (created after agent_context is set)
110
+ @context_tracker = nil
111
+
112
+ # Track which tools are immutable (cannot be removed by dynamic tool swapping)
113
+ # Default: Think, Clock, and TodoWrite are immutable utilities
114
+ # Plugins can mark additional tools as immutable via on_agent_initialized hook
115
+ @immutable_tool_names = Set.new(["Think", "Clock", "TodoWrite"])
116
+
117
+ # Track active skill (only used if memory enabled)
118
+ @active_skill_path = nil
119
+
120
+ # Try to fetch real model info for accurate context tracking
121
+ # This searches across ALL providers, so it works even when using proxies
122
+ # (e.g., Claude model through OpenAI-compatible proxy)
123
+ fetch_real_model_info(model)
124
+
125
+ # Configure system prompt, parameters, and headers after parent initialization
126
+ with_instructions(system_prompt) if system_prompt
127
+ configure_parameters(parameters)
128
+ configure_headers(headers)
129
+ end
130
+
131
+ # Setup agent context
132
+ #
133
+ # Sets the agent context for this chat, enabling delegation tracking.
134
+ # This is always called, regardless of whether logging is enabled.
135
+ #
136
+ # @param context [Agent::Context] Agent context for this chat
137
+ # @return [void]
138
+ def setup_context(context)
139
+ @agent_context = context
140
+ @context_tracker = ContextTracker.new(self, context)
141
+ end
142
+
143
+ # Setup logging callbacks
144
+ #
145
+ # This configures the chat to emit log events via LogStream.
146
+ # Should only be called when LogStream.emitter is set.
147
+ #
148
+ # @return [void]
149
+ def setup_logging
150
+ raise StateError, "Agent context not set. Call setup_context first." unless @agent_context
151
+
152
+ @context_tracker.setup_logging
153
+ end
154
+
155
+ # Emit model lookup warning if one occurred during initialization
156
+ #
157
+ # If a model wasn't found in the registry during initialization, this will
158
+ # emit a proper JSON log event through LogStream.
159
+ #
160
+ # @param agent_name [Symbol, String] The agent name for logging context
161
+ def emit_model_lookup_warning(agent_name)
162
+ return unless @model_lookup_error
163
+
164
+ LogStream.emit(
165
+ type: "model_lookup_warning",
166
+ agent: agent_name,
167
+ model: @model_lookup_error[:model],
168
+ error_message: @model_lookup_error[:error_message],
169
+ suggestions: @model_lookup_error[:suggestions].map { |s| { id: s.id, name: s.name, context_window: s.context_window } },
170
+ )
171
+ end
172
+
173
+ # Mark tools as immutable (cannot be removed by dynamic tool swapping)
174
+ #
175
+ # Called by plugins during on_agent_initialized lifecycle hook to mark
176
+ # their tools as immutable. This allows plugins to protect their core
177
+ # tools from being removed by dynamic tool swapping operations.
178
+ #
179
+ # @param tool_names [Array<String>] Tool names to mark as immutable
180
+ # @return [void]
181
+ def mark_tools_immutable(*tool_names)
182
+ @immutable_tool_names.merge(tool_names.flatten.map(&:to_s))
183
+ end
184
+
185
+ # Remove all mutable tools (keeps immutable tools)
186
+ #
187
+ # Used by LoadSkill to swap tools. Only works if called from a tool
188
+ # that has been given access to the chat instance.
189
+ #
190
+ # @return [void]
191
+ def remove_mutable_tools
192
+ @tools.select! { |tool| @immutable_tool_names.include?(tool.name) }
193
+ end
194
+
195
+ # Add a tool instance dynamically
196
+ #
197
+ # Used by LoadSkill to add skill-required tools after removing mutable tools.
198
+ # This is just a convenience wrapper around with_tool.
199
+ #
200
+ # @param tool_instance [RubyLLM::Tool] Tool to add
201
+ # @return [void]
202
+ def add_tool(tool_instance)
203
+ with_tool(tool_instance)
204
+ end
205
+
206
+ # Mark skill as loaded (tracking for debugging/logging)
207
+ #
208
+ # Called by LoadSkill after successfully swapping tools.
209
+ # This can be used for logging or debugging purposes.
210
+ #
211
+ # @param file_path [String] Path to loaded skill
212
+ # @return [void]
213
+ def mark_skill_loaded(file_path)
214
+ @active_skill_path = file_path
215
+ end
216
+
217
+ # Check if a skill is currently loaded
218
+ #
219
+ # @return [Boolean] True if a skill has been loaded
220
+ def skill_loaded?
221
+ !@active_skill_path.nil?
222
+ end
223
+
224
+ # Override ask to inject system reminders and periodic TodoWrite reminders
225
+ #
226
+ # Note: This is called BEFORE HookIntegration#ask (due to module include order),
227
+ # so HookIntegration will wrap this and inject user_prompt hooks.
228
+ #
229
+ # @param prompt [String] User prompt
230
+ # @param options [Hash] Additional options to pass to complete
231
+ # @return [RubyLLM::Message] LLM response
232
+ def ask(prompt, **options)
233
+ # Check if this is the first user message
234
+ is_first = SystemReminderInjector.first_message?(self)
235
+
236
+ if is_first
237
+ # Collect plugin reminders first
238
+ plugin_reminders = collect_plugin_reminders(prompt, is_first_message: true)
239
+
240
+ # Build full prompt with embedded plugin reminders
241
+ full_prompt = prompt
242
+ plugin_reminders.each do |reminder|
243
+ full_prompt = "#{full_prompt}\n\n#{reminder}"
244
+ end
245
+
246
+ # Inject first message reminders (includes system reminders + toolset + after)
247
+ # SystemReminderInjector will embed all reminders in the prompt via add_message
248
+ SystemReminderInjector.inject_first_message_reminders(self, full_prompt)
249
+
250
+ # Trigger user_prompt hook manually since we're bypassing the normal ask flow
251
+ if @hook_executor
252
+ hook_result = trigger_user_prompt(prompt)
253
+
254
+ # Check if hook halted execution
255
+ if hook_result[:halted]
256
+ # Return a halted message instead of calling LLM
257
+ return RubyLLM::Message.new(
258
+ role: :assistant,
259
+ content: hook_result[:halt_message],
260
+ model_id: model.id,
261
+ )
262
+ end
263
+
264
+ # NOTE: We ignore modified_prompt for first message since reminders already injected
265
+ end
266
+
267
+ # Call complete to get LLM response
268
+ complete(**options)
269
+ else
270
+ # Build prompt with embedded reminders (if needed)
271
+ full_prompt = prompt
272
+
273
+ # Add periodic TodoWrite reminder if needed
274
+ if SystemReminderInjector.should_inject_todowrite_reminder?(self, @last_todowrite_message_index)
275
+ full_prompt = "#{full_prompt}\n\n#{SystemReminderInjector::TODOWRITE_PERIODIC_REMINDER}"
276
+ # Update tracking
277
+ @last_todowrite_message_index = SystemReminderInjector.find_last_todowrite_index(self)
278
+ end
279
+
280
+ # Collect plugin reminders and embed them
281
+ plugin_reminders = collect_plugin_reminders(full_prompt, is_first_message: false)
282
+ plugin_reminders.each do |reminder|
283
+ full_prompt = "#{full_prompt}\n\n#{reminder}"
284
+ end
285
+
286
+ # Normal ask behavior for subsequent messages
287
+ # This calls super which goes to HookIntegration's ask override
288
+ # HookIntegration will call add_message, and we'll extract reminders there
289
+ super(full_prompt, **options)
290
+ end
291
+ end
292
+
293
+ # Override add_message to automatically extract and strip system reminders
294
+ #
295
+ # System reminders are extracted and tracked as ephemeral content (embedded
296
+ # when sent to LLM but not persisted in conversation history).
297
+ #
298
+ # @param message_or_attributes [RubyLLM::Message, Hash] Message object or attributes hash
299
+ # @return [RubyLLM::Message] The added message (with clean content)
300
+ def add_message(message_or_attributes)
301
+ # Handle both forms: add_message(message) and add_message({role: :user, content: "text"})
302
+ if message_or_attributes.is_a?(RubyLLM::Message)
303
+ # Message object provided
304
+ msg = message_or_attributes
305
+ content_str = msg.content.is_a?(RubyLLM::Content) ? msg.content.text : msg.content.to_s
306
+
307
+ # Extract system reminders
308
+ if @context_manager.has_system_reminders?(content_str)
309
+ reminders = @context_manager.extract_system_reminders(content_str)
310
+ clean_content_str = @context_manager.strip_system_reminders(content_str)
311
+
312
+ clean_content = if msg.content.is_a?(RubyLLM::Content)
313
+ RubyLLM::Content.new(clean_content_str, msg.content.attachments)
314
+ else
315
+ clean_content_str
316
+ end
317
+
318
+ clean_message = RubyLLM::Message.new(
319
+ role: msg.role,
320
+ content: clean_content,
321
+ tool_call_id: msg.tool_call_id,
322
+ )
323
+
324
+ result = super(clean_message)
325
+
326
+ # Track reminders as ephemeral
327
+ reminders.each do |reminder|
328
+ @context_manager.add_ephemeral_reminder(reminder, messages_array: @messages)
329
+ end
330
+
331
+ result
332
+ else
333
+ # No reminders - call parent normally
334
+ super(msg)
335
+ end
336
+ else
337
+ # Hash attributes provided
338
+ attrs = message_or_attributes
339
+ content_value = attrs[:content] || attrs["content"]
340
+ content_str = content_value.is_a?(RubyLLM::Content) ? content_value.text : content_value.to_s
341
+
342
+ # Extract system reminders
343
+ if @context_manager.has_system_reminders?(content_str)
344
+ reminders = @context_manager.extract_system_reminders(content_str)
345
+ clean_content_str = @context_manager.strip_system_reminders(content_str)
346
+
347
+ clean_content = if content_value.is_a?(RubyLLM::Content)
348
+ RubyLLM::Content.new(clean_content_str, content_value.attachments)
349
+ else
350
+ clean_content_str
351
+ end
352
+
353
+ clean_attrs = attrs.merge(content: clean_content)
354
+ result = super(clean_attrs)
355
+
356
+ # Track reminders as ephemeral
357
+ reminders.each do |reminder|
358
+ @context_manager.add_ephemeral_reminder(reminder, messages_array: @messages)
359
+ end
360
+
361
+ result
362
+ else
363
+ # No reminders - call parent normally
364
+ super(attrs)
365
+ end
366
+ end
367
+ end
368
+
369
+ # Collect reminders from all plugins
370
+ #
371
+ # Plugins can contribute system reminders based on the user's message.
372
+ # Returns array of reminder strings to be embedded in the user prompt.
373
+ #
374
+ # @param prompt [String] User's message
375
+ # @param is_first_message [Boolean] True if first message
376
+ # @return [Array<String>] Array of reminder strings
377
+ def collect_plugin_reminders(prompt, is_first_message:)
378
+ return [] unless @agent_name # Skip if agent_name not set
379
+
380
+ # Collect reminders from all plugins
381
+ PluginRegistry.all.flat_map do |plugin|
382
+ plugin.on_user_message(
383
+ agent_name: @agent_name,
384
+ prompt: prompt,
385
+ is_first_message: is_first_message,
386
+ )
387
+ end.compact
388
+ end
389
+
390
+ # Override complete() to inject ephemeral messages
391
+ #
392
+ # Ephemeral messages are sent to the LLM for the current turn only
393
+ # and are NOT stored in the conversation history. This prevents
394
+ # system reminders from accumulating and being resent every turn.
395
+ #
396
+ # @param options [Hash] Options to pass to provider
397
+ # @return [RubyLLM::Message] LLM response
398
+ def complete(**options, &block)
399
+ # Prepare messages: persistent + ephemeral for this turn
400
+ messages_for_llm = @context_manager.prepare_for_llm(@messages)
401
+
402
+ # Call provider with retry logic for transient failures
403
+ response = call_llm_with_retry do
404
+ @provider.complete(
405
+ messages_for_llm,
406
+ tools: @tools,
407
+ temperature: @temperature,
408
+ model: @model,
409
+ params: @params,
410
+ headers: @headers,
411
+ schema: @schema,
412
+ &wrap_streaming_block(&block)
413
+ )
414
+ end
415
+
416
+ @on[:new_message]&.call unless block
417
+
418
+ # Handle schema parsing if needed
419
+ if @schema && response.content.is_a?(String)
420
+ begin
421
+ response.content = JSON.parse(response.content)
422
+ rescue JSON::ParserError
423
+ # Keep as string if parsing fails
424
+ end
425
+ end
426
+
427
+ # Add response to persistent history
428
+ add_message(response)
429
+ @on[:end_message]&.call(response)
430
+
431
+ # Clear ephemeral messages after use
432
+ @context_manager.clear_ephemeral
433
+
434
+ # Handle tool calls if present
435
+ if response.tool_call?
436
+ handle_tool_calls(response, &block)
437
+ else
438
+ response
439
+ end
440
+ end
441
+
442
+ # Override handle_tool_calls to execute multiple tool calls in parallel with rate limiting.
443
+ #
444
+ # RubyLLM's default implementation executes tool calls one at a time. This
445
+ # override uses Async to execute all tool calls concurrently, with semaphores
446
+ # to prevent API quota exhaustion. Hooks are integrated via HookIntegration module.
447
+ #
448
+ # @param response [RubyLLM::Message] LLM response with tool calls
449
+ # @param block [Proc] Optional block passed through to complete
450
+ # @return [RubyLLM::Message] Final response when loop completes
451
+ def handle_tool_calls(response, &block)
452
+ # Single tool call: sequential execution with hooks
453
+ if response.tool_calls.size == 1
454
+ tool_call = response.tool_calls.values.first
455
+
456
+ # Handle pre_tool_use hook (skip for delegation tools)
457
+ unless delegation_tool_call?(tool_call)
458
+ # Trigger pre_tool_use hook (can block or provide custom result)
459
+ pre_result = trigger_pre_tool_use(tool_call)
460
+
461
+ # Handle finish_agent marker
462
+ if pre_result[:finish_agent]
463
+ message = RubyLLM::Message.new(
464
+ role: :assistant,
465
+ content: pre_result[:custom_result],
466
+ model_id: model.id,
467
+ )
468
+ # Set custom finish reason before triggering on_end_message
469
+ @context_tracker.finish_reason_override = "finish_agent" if @context_tracker
470
+ # Trigger on_end_message to ensure agent_stop event is emitted
471
+ @on[:end_message]&.call(message)
472
+ return message
473
+ end
474
+
475
+ # Handle finish_swarm marker
476
+ if pre_result[:finish_swarm]
477
+ return { __finish_swarm__: true, message: pre_result[:custom_result] }
478
+ end
479
+
480
+ # Handle blocked execution
481
+ unless pre_result[:proceed]
482
+ content = pre_result[:custom_result] || "Tool execution blocked by hook"
483
+ message = add_message(
484
+ role: :tool,
485
+ content: content,
486
+ tool_call_id: tool_call.id,
487
+ )
488
+ @on[:end_message]&.call(message)
489
+ return complete(&block)
490
+ end
491
+ end
492
+
493
+ # Execute tool
494
+ @on[:tool_call]&.call(tool_call)
495
+
496
+ result = execute_tool_with_error_handling(tool_call)
497
+
498
+ @on[:tool_result]&.call(result)
499
+
500
+ # Trigger post_tool_use hook (skip for delegation tools)
501
+ unless delegation_tool_call?(tool_call)
502
+ result = trigger_post_tool_use(result, tool_call: tool_call)
503
+ end
504
+
505
+ # Check for finish markers from hooks
506
+ if result.is_a?(Hash)
507
+ if result[:__finish_agent__]
508
+ # Finish this agent with the provided message
509
+ message = RubyLLM::Message.new(
510
+ role: :assistant,
511
+ content: result[:message],
512
+ model_id: model.id,
513
+ )
514
+ # Set custom finish reason before triggering on_end_message
515
+ @context_tracker.finish_reason_override = "finish_agent" if @context_tracker
516
+ # Trigger on_end_message to ensure agent_stop event is emitted
517
+ @on[:end_message]&.call(message)
518
+ return message
519
+ elsif result[:__finish_swarm__]
520
+ # Propagate finish_swarm marker up (don't add to conversation)
521
+ return result
522
+ end
523
+ end
524
+
525
+ # Check for halt result
526
+ return result if result.is_a?(RubyLLM::Tool::Halt)
527
+
528
+ # Add tool result to conversation
529
+ # add_message automatically extracts reminders and stores them as ephemeral
530
+ content = result.is_a?(RubyLLM::Content) ? result : result.to_s
531
+ message = add_message(
532
+ role: :tool,
533
+ content: content,
534
+ tool_call_id: tool_call.id,
535
+ )
536
+ @on[:end_message]&.call(message)
537
+
538
+ # Continue loop
539
+ return complete(&block)
540
+ end
541
+
542
+ # Multiple tool calls: execute in parallel with rate limiting and hooks
543
+ halt_result = nil
544
+
545
+ results = Async do
546
+ tasks = response.tool_calls.map do |_id, tool_call|
547
+ Async do
548
+ # Acquire semaphores (queues if limit reached)
549
+ acquire_semaphores do
550
+ @on[:tool_call]&.call(tool_call)
551
+
552
+ # Handle pre_tool_use hook (skip for delegation tools)
553
+ unless delegation_tool_call?(tool_call)
554
+ pre_result = trigger_pre_tool_use(tool_call)
555
+
556
+ # Handle finish markers first (early exit)
557
+ # Don't call on_tool_result for finish markers - they're not tool results
558
+ if pre_result[:finish_agent]
559
+ result = { __finish_agent__: true, message: pre_result[:custom_result] }
560
+ next { tool_call: tool_call, result: result, message: nil }
561
+ end
562
+
563
+ if pre_result[:finish_swarm]
564
+ result = { __finish_swarm__: true, message: pre_result[:custom_result] }
565
+ next { tool_call: tool_call, result: result, message: nil }
566
+ end
567
+
568
+ # Handle blocked execution
569
+ unless pre_result[:proceed]
570
+ result = pre_result[:custom_result] || "Tool execution blocked by hook"
571
+ @on[:tool_result]&.call(result)
572
+
573
+ # add_message automatically extracts reminders
574
+ content = result.is_a?(RubyLLM::Content) ? result : result.to_s
575
+ message = add_message(
576
+ role: :tool,
577
+ content: content,
578
+ tool_call_id: tool_call.id,
579
+ )
580
+ @on[:end_message]&.call(message)
581
+
582
+ next { tool_call: tool_call, result: result, message: message }
583
+ end
584
+ end
585
+
586
+ # Execute tool - Faraday yields during HTTP I/O
587
+ result = execute_tool_with_error_handling(tool_call)
588
+
589
+ @on[:tool_result]&.call(result)
590
+
591
+ # Trigger post_tool_use hook (skip for delegation tools)
592
+ unless delegation_tool_call?(tool_call)
593
+ result = trigger_post_tool_use(result, tool_call: tool_call)
594
+ end
595
+
596
+ # Check if result is a finish marker (don't add to conversation)
597
+ if result.is_a?(Hash) && (result[:__finish_agent__] || result[:__finish_swarm__])
598
+ # Finish markers will be detected after parallel execution completes
599
+ { tool_call: tool_call, result: result, message: nil }
600
+ else
601
+ # Add tool result to conversation
602
+ # add_message automatically extracts reminders and stores them as ephemeral
603
+ content = result.is_a?(RubyLLM::Content) ? result : result.to_s
604
+ message = add_message(
605
+ role: :tool,
606
+ content: content,
607
+ tool_call_id: tool_call.id,
608
+ )
609
+ @on[:end_message]&.call(message)
610
+
611
+ # Return result data for collection
612
+ { tool_call: tool_call, result: result, message: message }
613
+ end
614
+ end
615
+ end
616
+ end
617
+
618
+ # Wait for all tasks to complete
619
+ tasks.map(&:wait)
620
+ end.wait
621
+
622
+ # Check for halt and finish results
623
+ results.each do |data|
624
+ result = data[:result]
625
+
626
+ # Check for halt result (from tool execution errors)
627
+ if result.is_a?(RubyLLM::Tool::Halt)
628
+ halt_result = result
629
+ # Continue checking for finish markers below
630
+ end
631
+
632
+ # Check for finish markers (from hooks)
633
+ if result.is_a?(Hash)
634
+ if result[:__finish_agent__]
635
+ message = RubyLLM::Message.new(
636
+ role: :assistant,
637
+ content: result[:message],
638
+ model_id: model.id,
639
+ )
640
+ # Set custom finish reason before triggering on_end_message
641
+ @context_tracker.finish_reason_override = "finish_agent" if @context_tracker
642
+ # Trigger on_end_message to ensure agent_stop event is emitted
643
+ @on[:end_message]&.call(message)
644
+ return message
645
+ elsif result[:__finish_swarm__]
646
+ # Propagate finish_swarm marker up
647
+ return result
648
+ end
649
+ end
650
+ end
651
+
652
+ # Return halt result if we found one (but no finish markers)
653
+ halt_result = results.find { |data| data[:result].is_a?(RubyLLM::Tool::Halt) }&.dig(:result)
654
+
655
+ # Continue automatic loop (recursive call to complete)
656
+ halt_result || complete(&block)
657
+ end
658
+
659
+ # Get the provider instance
660
+ #
661
+ # Exposes the RubyLLM provider instance for configuration.
662
+ # This is needed for setting agent_name and other provider-specific settings.
663
+ #
664
+ # @return [RubyLLM::Provider::Base] Provider instance
665
+ attr_reader :provider, :global_semaphore, :local_semaphore, :real_model_info, :context_tracker, :context_manager
666
+
667
+ # Get context window limit for the current model
668
+ #
669
+ # Priority order:
670
+ # 1. Explicit context_window parameter (user override)
671
+ # 2. Real model info from RubyLLM registry (searched across all providers)
672
+ # 3. Model info from chat (may be nil if assume_model_exists was used)
673
+ #
674
+ # @return [Integer, nil] Maximum context tokens, or nil if not available
675
+ def context_limit
676
+ # Priority 1: Explicit override
677
+ return @explicit_context_window if @explicit_context_window
678
+
679
+ # Priority 2: Real model info from registry (searched across all providers)
680
+ return @real_model_info.context_window if @real_model_info&.context_window
681
+
682
+ # Priority 3: Fall back to model from chat
683
+ model.context_window
684
+ rescue StandardError
685
+ nil
686
+ end
687
+
688
+ # Calculate cumulative input tokens for the conversation
689
+ #
690
+ # The latest assistant message's input_tokens already includes the cumulative
691
+ # total for the entire conversation (all previous messages, system instructions,
692
+ # tool definitions, etc.). We don't sum across messages as that would double-count.
693
+ #
694
+ # @return [Integer] Total input tokens used in conversation
695
+ def cumulative_input_tokens
696
+ # Find the latest assistant message with input_tokens
697
+ messages.reverse.find { |msg| msg.role == :assistant && msg.input_tokens }&.input_tokens || 0
698
+ end
699
+
700
+ # Calculate cumulative output tokens across all assistant messages
701
+ #
702
+ # Unlike input tokens, output tokens are per-response and should be summed.
703
+ #
704
+ # @return [Integer] Total output tokens used in conversation
705
+ def cumulative_output_tokens
706
+ messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.output_tokens || 0 }
707
+ end
708
+
709
+ # Calculate total tokens used (input + output)
710
+ #
711
+ # @return [Integer] Total tokens used in conversation
712
+ def cumulative_total_tokens
713
+ cumulative_input_tokens + cumulative_output_tokens
714
+ end
715
+
716
+ # Calculate percentage of context window used
717
+ #
718
+ # @return [Float] Percentage (0.0 to 100.0), or 0.0 if limit unavailable
719
+ def context_usage_percentage
720
+ limit = context_limit
721
+ return 0.0 if limit.nil? || limit.zero?
722
+
723
+ (cumulative_total_tokens.to_f / limit * 100).round(2)
724
+ end
725
+
726
+ # Calculate remaining tokens in context window
727
+ #
728
+ # @return [Integer, nil] Tokens remaining, or nil if limit unavailable
729
+ def tokens_remaining
730
+ limit = context_limit
731
+ return if limit.nil?
732
+
733
+ limit - cumulative_total_tokens
734
+ end
735
+
736
+ # Compact the conversation history to reduce token usage
737
+ #
738
+ # Uses the Hybrid Production Strategy to intelligently compress the conversation:
739
+ # 1. Tool result pruning - Truncate tool outputs (they're 80%+ of tokens!)
740
+ # 2. Checkpoint creation - LLM-generated summary of conversation chunks
741
+ # 3. Sliding window - Keep recent messages in full detail
742
+ #
743
+ # This is a manual operation - call it when you need to free up context space.
744
+ # The method emits compression events via LogStream for monitoring.
745
+ #
746
+ # ## Usage
747
+ #
748
+ # # Use defaults
749
+ # metrics = agent.compact_context
750
+ # puts metrics.summary
751
+ #
752
+ # # With custom options
753
+ # metrics = agent.compact_context(
754
+ # tool_result_max_length: 300,
755
+ # checkpoint_threshold: 40,
756
+ # sliding_window_size: 15
757
+ # )
758
+ #
759
+ # @param options [Hash] Compression options (see ContextCompactor::DEFAULT_OPTIONS)
760
+ # @return [ContextCompactor::Metrics] Compression statistics
761
+ def compact_context(**options)
762
+ compactor = ContextCompactor.new(self, options)
763
+ compactor.compact
764
+ end
765
+
766
+ private
767
+
768
+ # Call LLM with retry logic for transient failures
769
+ #
770
+ # Retries up to 10 times with fixed 10-second delays for:
771
+ # - Network errors
772
+ # - Proxy failures
773
+ # - Transient API errors
774
+ #
775
+ # @yield Block that makes the LLM call
776
+ # @return [RubyLLM::Message] LLM response
777
+ # @raise [StandardError] If all retries exhausted
778
+ def call_llm_with_retry(max_retries: 10, delay: 10, &block)
779
+ attempts = 0
780
+
781
+ loop do
782
+ attempts += 1
783
+
784
+ begin
785
+ return yield
786
+ rescue StandardError => e
787
+ # Check if we should retry
788
+ if attempts >= max_retries
789
+ # Emit final failure log
790
+ LogStream.emit(
791
+ type: "llm_retry_exhausted",
792
+ agent: @agent_name,
793
+ model: @model&.id,
794
+ attempts: attempts,
795
+ error_class: e.class.name,
796
+ error_message: e.message,
797
+ )
798
+ raise
799
+ end
800
+
801
+ # Emit retry attempt log
802
+ LogStream.emit(
803
+ type: "llm_retry_attempt",
804
+ agent: @agent_name,
805
+ model: @model&.id,
806
+ attempt: attempts,
807
+ max_retries: max_retries,
808
+ error_class: e.class.name,
809
+ error_message: e.message,
810
+ retry_delay: delay,
811
+ )
812
+
813
+ # Wait before retry
814
+ sleep(delay)
815
+ end
816
+ end
817
+ end
818
+
819
+ # Build custom RubyLLM context for base_url/timeout overrides
820
+ #
821
+ # @param provider [String, Symbol] Provider name
822
+ # @param base_url [String, nil] Custom API base URL
823
+ # @param timeout [Integer] Request timeout in seconds
824
+ # @return [RubyLLM::Context] Configured context
825
+ def build_custom_context(provider:, base_url:, timeout:)
826
+ RubyLLM.context do |config|
827
+ # Set timeout for all providers
828
+ config.request_timeout = timeout
829
+
830
+ # Configure base_url if specified
831
+ next unless base_url
832
+
833
+ case provider.to_s
834
+ when "openai", "deepseek", "perplexity", "mistral", "openrouter"
835
+ config.openai_api_base = base_url
836
+ config.openai_api_key = ENV["OPENAI_API_KEY"] || "dummy-key-for-local"
837
+ when "ollama"
838
+ config.ollama_api_base = base_url
839
+ when "gpustack"
840
+ config.gpustack_api_base = base_url
841
+ config.gpustack_api_key = ENV["GPUSTACK_API_KEY"] || "dummy-key"
842
+ else
843
+ raise ArgumentError,
844
+ "Provider '#{provider}' doesn't support custom base_url. " \
845
+ "Only OpenAI-compatible providers (openai, deepseek, perplexity, mistral, openrouter), " \
846
+ "ollama, and gpustack support custom endpoints."
847
+ end
848
+ end
849
+ end
850
+
851
+ # Fetch real model info for accurate context tracking
852
+ #
853
+ # This searches across ALL providers, so it works even when using proxies
854
+ # (e.g., Claude model through OpenAI-compatible proxy).
855
+ #
856
+ # @param model [String] Model ID to lookup
857
+ # @return [void]
858
+ def fetch_real_model_info(model)
859
+ @model_lookup_error = nil
860
+ @real_model_info = begin
861
+ RubyLLM.models.find(model) # Searches all providers when no provider specified
862
+ rescue StandardError => e
863
+ # Store warning info to emit later through LogStream
864
+ suggestions = suggest_similar_models(model)
865
+ @model_lookup_error = {
866
+ model: model,
867
+ error_message: e.message,
868
+ suggestions: suggestions,
869
+ }
870
+ nil
871
+ end
872
+ end
873
+
874
+ # Determine which provider to use based on configuration
875
+ #
876
+ # When using base_url with OpenAI-compatible providers and api_version is set to
877
+ # 'v1/responses', use our custom provider that supports the responses API endpoint.
878
+ #
879
+ # @param provider [Symbol, String] The requested provider
880
+ # @param base_url [String, nil] Custom base URL
881
+ # @param api_version [String, nil] API endpoint version
882
+ # @return [Symbol] The provider to use
883
+ def determine_provider(provider, base_url, api_version)
884
+ return provider unless base_url
885
+
886
+ # Use custom provider for OpenAI-compatible providers when api_version is v1/responses
887
+ # The custom provider supports both chat/completions and responses endpoints
888
+ case provider.to_s
889
+ when "openai", "deepseek", "perplexity", "mistral", "openrouter"
890
+ if api_version == "v1/responses"
891
+ :openai_with_responses
892
+ else
893
+ provider
894
+ end
895
+ else
896
+ provider
897
+ end
898
+ end
899
+
900
+ # Configure the custom provider after creation to use responses API
901
+ #
902
+ # RubyLLM doesn't support passing custom parameters to provider initialization,
903
+ # so we configure the provider after the chat is created.
904
+ def configure_responses_api_provider
905
+ return unless provider.is_a?(SwarmSDK::Providers::OpenAIWithResponses)
906
+
907
+ provider.use_responses_api = true
908
+ RubyLLM.logger.debug("SwarmSDK: Configured provider to use responses API")
909
+ end
910
+
911
+ # Configure LLM parameters with proper temperature normalization
912
+ #
913
+ # Note: RubyLLM only normalizes temperature (for models that require specific values
914
+ # like gpt-5-mini which requires temperature=1.0) when using with_temperature().
915
+ # The with_params() method is designed for sending unparsed parameters directly to
916
+ # the LLM without provider-specific normalization. Therefore, we extract temperature
917
+ # and call with_temperature() separately to ensure proper normalization.
918
+ #
919
+ # @param params [Hash] Parameter hash (may include temperature and other params)
920
+ # @return [self] Returns self for method chaining
921
+ def configure_parameters(params)
922
+ return self if params.nil? || params.empty?
923
+
924
+ # Extract temperature for separate handling
925
+ if params[:temperature]
926
+ with_temperature(params[:temperature])
927
+ params = params.except(:temperature)
928
+ end
929
+
930
+ # Apply remaining parameters
931
+ with_params(**params) if params.any?
932
+
933
+ self
934
+ end
935
+
936
+ # Configure custom HTTP headers for LLM requests
937
+ #
938
+ # @param headers [Hash, nil] Custom HTTP headers
939
+ # @return [self] Returns self for method chaining
940
+ def configure_headers(headers)
941
+ return self if headers.nil? || headers.empty?
942
+
943
+ with_headers(**headers)
944
+
945
+ self
946
+ end
947
+
948
+ # Acquire both global and local semaphores (if configured).
949
+ #
950
+ # Semaphores queue requests when limits are reached, ensuring graceful
951
+ # degradation instead of API errors.
952
+ #
953
+ # Order matters: acquire global first (broader scope), then local
954
+ def acquire_semaphores(&block)
955
+ if @global_semaphore && @local_semaphore
956
+ # Both limits: acquire global first, then local
957
+ @global_semaphore.acquire do
958
+ @local_semaphore.acquire(&block)
959
+ end
960
+ elsif @global_semaphore
961
+ # Only global limit
962
+ @global_semaphore.acquire(&block)
963
+ elsif @local_semaphore
964
+ # Only local limit
965
+ @local_semaphore.acquire(&block)
966
+ else
967
+ # No limits: execute immediately
968
+ yield
969
+ end
970
+ end
971
+
972
+ # Suggest similar models when a model is not found
973
+ #
974
+ # @param query [String] Model name to search for
975
+ # @return [Array<RubyLLM::Model::Info>] Up to 3 similar models
976
+ def suggest_similar_models(query)
977
+ normalized_query = query.to_s.downcase.gsub(/[.\-_]/, "")
978
+
979
+ RubyLLM.models.all.select do |model|
980
+ normalized_id = model.id.downcase.gsub(/[.\-_]/, "")
981
+ normalized_id.include?(normalized_query) ||
982
+ model.name&.downcase&.gsub(/[.\-_]/, "")&.include?(normalized_query)
983
+ end.first(3)
984
+ rescue StandardError
985
+ []
986
+ end
987
+
988
+ # Execute a tool with error handling for common issues
989
+ #
990
+ # Handles:
991
+ # - Missing required parameters (validated before calling)
992
+ # - Tool doesn't exist (nil.call)
993
+ # - Other ArgumentErrors (from tool execution)
994
+ #
995
+ # Returns helpful messages with system reminders showing available tools
996
+ # or required parameters.
997
+ #
998
+ # @param tool_call [RubyLLM::ToolCall] Tool call from LLM
999
+ # @return [String, Object] Tool result or error message
1000
+ def execute_tool_with_error_handling(tool_call)
1001
+ tool_name = tool_call.name
1002
+ tool_instance = tools[tool_name.to_sym]
1003
+
1004
+ # Check if tool exists
1005
+ unless tool_instance
1006
+ return build_tool_not_found_error(tool_call)
1007
+ end
1008
+
1009
+ # Validate required parameters BEFORE calling the tool
1010
+ validation_error = validate_tool_parameters(tool_call, tool_instance)
1011
+ return validation_error if validation_error
1012
+
1013
+ # Execute the tool
1014
+ execute_tool(tool_call)
1015
+ rescue ArgumentError => e
1016
+ # This is an ArgumentError from INSIDE the tool execution (not missing params)
1017
+ # Still try to provide helpful error message
1018
+ build_argument_error(tool_call, e)
1019
+ end
1020
+
1021
+ # Validate that all required tool parameters are present
1022
+ #
1023
+ # @param tool_call [RubyLLM::ToolCall] Tool call from LLM
1024
+ # @param tool_instance [RubyLLM::Tool] Tool instance
1025
+ # @return [String, nil] Error message if validation fails, nil if valid
1026
+ def validate_tool_parameters(tool_call, tool_instance)
1027
+ return unless tool_instance.respond_to?(:parameters)
1028
+
1029
+ # Get required parameters from tool definition
1030
+ required_params = tool_instance.parameters.select { |_, param| param.required }
1031
+
1032
+ # Check which required parameters are missing from the tool call
1033
+ # ToolCall stores arguments in tool_call.arguments (not .parameters)
1034
+ missing_params = required_params.reject do |param_name, _param|
1035
+ tool_call.arguments.key?(param_name.to_s) || tool_call.arguments.key?(param_name.to_sym)
1036
+ end
1037
+
1038
+ return if missing_params.empty?
1039
+
1040
+ # Build missing parameter error
1041
+ build_missing_parameters_error(tool_call, tool_instance, missing_params.keys)
1042
+ end
1043
+
1044
+ # Build error message for missing required parameters
1045
+ #
1046
+ # @param tool_call [RubyLLM::ToolCall] Tool call that failed
1047
+ # @param tool_instance [RubyLLM::Tool] Tool instance
1048
+ # @param missing_param_names [Array<Symbol>] Names of missing parameters
1049
+ # @return [String] Formatted error message
1050
+ def build_missing_parameters_error(tool_call, tool_instance, missing_param_names)
1051
+ tool_name = tool_call.name
1052
+
1053
+ # Get all parameter information
1054
+ param_info = tool_instance.parameters.map do |_param_name, param_obj|
1055
+ {
1056
+ name: param_obj.name.to_s,
1057
+ type: param_obj.type,
1058
+ description: param_obj.description,
1059
+ required: param_obj.required,
1060
+ }
1061
+ end
1062
+
1063
+ # Format missing parameter names nicely
1064
+ missing_list = missing_param_names.map(&:to_s).join(", ")
1065
+
1066
+ error_message = "Error calling #{tool_name}: missing parameters: #{missing_list}\n\n"
1067
+ error_message += build_parameter_reminder(tool_name, param_info)
1068
+ error_message
1069
+ end
1070
+
1071
+ # Build a helpful error message for ArgumentErrors from tool execution
1072
+ #
1073
+ # This handles ArgumentErrors that come from INSIDE the tool (not our validation).
1074
+ # We still try to be helpful if it looks like a parameter issue.
1075
+ #
1076
+ # @param tool_call [RubyLLM::ToolCall] Tool call that failed
1077
+ # @param error [ArgumentError] The ArgumentError raised
1078
+ # @return [String] Formatted error message
1079
+ def build_argument_error(tool_call, error)
1080
+ tool_name = tool_call.name
1081
+
1082
+ # Just report the error - we already validated parameters, so this is an internal tool error
1083
+ "Error calling #{tool_name}: #{error.message}"
1084
+ end
1085
+
1086
+ # Build system reminder with parameter information
1087
+ #
1088
+ # @param tool_name [String] Tool name
1089
+ # @param param_info [Array<Hash>] Parameter information
1090
+ # @return [String] Formatted parameter reminder
1091
+ def build_parameter_reminder(tool_name, param_info)
1092
+ return "" if param_info.empty?
1093
+
1094
+ required_params = param_info.select { |p| p[:required] }
1095
+ optional_params = param_info.reject { |p| p[:required] }
1096
+
1097
+ reminder = "<system-reminder>\n"
1098
+ reminder += "CRITICAL: The #{tool_name} tool call failed due to missing parameters.\n\n"
1099
+ reminder += "ALL REQUIRED PARAMETERS for #{tool_name}:\n\n"
1100
+
1101
+ required_params.each do |param|
1102
+ reminder += "- #{param[:name]} (#{param[:type]}): #{param[:description]}\n"
1103
+ end
1104
+
1105
+ if optional_params.any?
1106
+ reminder += "\nOptional parameters:\n"
1107
+ optional_params.each do |param|
1108
+ reminder += "- #{param[:name]} (#{param[:type]}): #{param[:description]}\n"
1109
+ end
1110
+ end
1111
+
1112
+ reminder += "\nINSTRUCTIONS FOR RECOVERY:\n"
1113
+ reminder += "1. Use the Think tool to reason about what value EACH required parameter should have\n"
1114
+ reminder += "2. After thinking, retry the #{tool_name} tool call with ALL required parameters included\n"
1115
+ reminder += "3. Do NOT skip any required parameters - the tool will fail again if you do\n"
1116
+ reminder += "</system-reminder>"
1117
+
1118
+ reminder
1119
+ end
1120
+
1121
+ # Build a helpful error message when a tool doesn't exist
1122
+ #
1123
+ # @param tool_call [RubyLLM::ToolCall] Tool call that failed
1124
+ # @return [String] Formatted error message with available tools list
1125
+ def build_tool_not_found_error(tool_call)
1126
+ tool_name = tool_call.name
1127
+ available_tools = tools.keys.map(&:to_s).sort
1128
+
1129
+ error_message = "Error: Tool '#{tool_name}' is not available.\n\n"
1130
+ error_message += "You attempted to use '#{tool_name}', but this tool is not in your current toolset.\n\n"
1131
+
1132
+ error_message += "<system-reminder>\n"
1133
+ error_message += "Your available tools are:\n"
1134
+ available_tools.each do |name|
1135
+ error_message += " - #{name}\n"
1136
+ end
1137
+ error_message += "\nDo NOT attempt to use tools that are not in this list.\n"
1138
+ error_message += "</system-reminder>"
1139
+
1140
+ error_message
1141
+ end
1142
+ end
1143
+ end
1144
+ end