swarm_memory 2.1.3 → 2.1.4

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/lib/claude_swarm/claude_mcp_server.rb +1 -0
  3. data/lib/claude_swarm/cli.rb +5 -18
  4. data/lib/claude_swarm/configuration.rb +2 -15
  5. data/lib/claude_swarm/mcp_generator.rb +1 -0
  6. data/lib/claude_swarm/openai/chat_completion.rb +4 -12
  7. data/lib/claude_swarm/openai/executor.rb +3 -1
  8. data/lib/claude_swarm/openai/responses.rb +13 -32
  9. data/lib/claude_swarm/version.rb +1 -1
  10. data/lib/swarm_cli/commands/run.rb +2 -2
  11. data/lib/swarm_cli/config_loader.rb +11 -11
  12. data/lib/swarm_cli/formatters/human_formatter.rb +70 -0
  13. data/lib/swarm_cli/interactive_repl.rb +11 -5
  14. data/lib/swarm_cli/ui/icons.rb +0 -23
  15. data/lib/swarm_cli/version.rb +1 -1
  16. data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
  17. data/lib/swarm_memory/integration/sdk_plugin.rb +87 -7
  18. data/lib/swarm_memory/version.rb +1 -1
  19. data/lib/swarm_memory.rb +1 -1
  20. data/lib/swarm_sdk/agent/builder.rb +58 -0
  21. data/lib/swarm_sdk/agent/chat.rb +527 -1059
  22. data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +9 -88
  23. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
  24. data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +111 -44
  25. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
  26. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
  27. data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
  28. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
  29. data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +12 -12
  30. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
  31. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
  32. data/lib/swarm_sdk/agent/context.rb +2 -2
  33. data/lib/swarm_sdk/agent/definition.rb +66 -154
  34. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +4 -2
  35. data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
  36. data/lib/swarm_sdk/builders/base_builder.rb +409 -0
  37. data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
  38. data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
  39. data/lib/swarm_sdk/concerns/validatable.rb +55 -0
  40. data/lib/swarm_sdk/configuration/parser.rb +353 -0
  41. data/lib/swarm_sdk/configuration/translator.rb +255 -0
  42. data/lib/swarm_sdk/configuration.rb +65 -543
  43. data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
  44. data/lib/swarm_sdk/context_compactor.rb +6 -11
  45. data/lib/swarm_sdk/context_management/builder.rb +128 -0
  46. data/lib/swarm_sdk/context_management/context.rb +328 -0
  47. data/lib/swarm_sdk/defaults.rb +196 -0
  48. data/lib/swarm_sdk/events_to_messages.rb +18 -0
  49. data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
  50. data/lib/swarm_sdk/log_collector.rb +179 -29
  51. data/lib/swarm_sdk/log_stream.rb +29 -0
  52. data/lib/swarm_sdk/node_context.rb +1 -1
  53. data/lib/swarm_sdk/observer/builder.rb +81 -0
  54. data/lib/swarm_sdk/observer/config.rb +45 -0
  55. data/lib/swarm_sdk/observer/manager.rb +236 -0
  56. data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
  57. data/lib/swarm_sdk/plugin.rb +93 -3
  58. data/lib/swarm_sdk/snapshot.rb +6 -6
  59. data/lib/swarm_sdk/snapshot_from_events.rb +13 -2
  60. data/lib/swarm_sdk/state_restorer.rb +136 -151
  61. data/lib/swarm_sdk/state_snapshot.rb +65 -100
  62. data/lib/swarm_sdk/swarm/agent_initializer.rb +180 -136
  63. data/lib/swarm_sdk/swarm/builder.rb +44 -578
  64. data/lib/swarm_sdk/swarm/executor.rb +213 -0
  65. data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
  66. data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
  67. data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
  68. data/lib/swarm_sdk/swarm/tool_configurator.rb +42 -138
  69. data/lib/swarm_sdk/swarm.rb +137 -679
  70. data/lib/swarm_sdk/tools/bash.rb +11 -3
  71. data/lib/swarm_sdk/tools/delegate.rb +61 -43
  72. data/lib/swarm_sdk/tools/edit.rb +8 -13
  73. data/lib/swarm_sdk/tools/glob.rb +9 -1
  74. data/lib/swarm_sdk/tools/grep.rb +7 -0
  75. data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
  76. data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
  77. data/lib/swarm_sdk/tools/read.rb +11 -13
  78. data/lib/swarm_sdk/tools/registry.rb +122 -10
  79. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +8 -5
  80. data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
  81. data/lib/swarm_sdk/tools/todo_write.rb +7 -0
  82. data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
  83. data/lib/swarm_sdk/tools/write.rb +8 -13
  84. data/lib/swarm_sdk/version.rb +1 -1
  85. data/lib/swarm_sdk/{node → workflow}/agent_config.rb +1 -1
  86. data/lib/swarm_sdk/workflow/builder.rb +143 -0
  87. data/lib/swarm_sdk/workflow/executor.rb +497 -0
  88. data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +3 -3
  89. data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
  90. data/lib/swarm_sdk/{node_orchestrator.rb → workflow.rb} +152 -456
  91. data/lib/swarm_sdk.rb +33 -3
  92. metadata +37 -14
  93. data/lib/swarm_memory/chat_extension.rb +0 -34
  94. data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -589
@@ -2,104 +2,145 @@
2
2
 
3
3
  module SwarmSDK
4
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
5
+ # Chat wraps RubyLLM::Chat to provide SwarmSDK orchestration capabilities
6
+ #
7
+ # ## Architecture
8
+ #
9
+ # This class uses **composition** with RubyLLM::Chat:
10
+ # - RubyLLM::Chat handles: LLM API, messages, tools, concurrent execution
11
+ # - SwarmSDK::Agent::Chat adds: hooks, reminders, semaphores, event enrichment
12
+ #
13
+ # ## ChatHelpers Module Architecture
14
+ #
15
+ # Chat is decomposed into 8 focused helper modules to manage complexity:
16
+ #
17
+ # ### Core Functionality
18
+ # - **EventEmitter**: Multi-subscriber event callbacks for tool/lifecycle events.
19
+ # Provides `subscribe`, `emit_event`, `clear_subscribers` for observable behavior.
20
+ # - **LoggingHelpers**: Formatting tool call information for structured JSON logs.
21
+ # Converts tool calls/results to loggable hashes with sanitization.
22
+ # - **LlmConfiguration**: Model selection, provider setup, and API configuration.
23
+ # Resolves provider from model, handles model aliases, builds connection config.
24
+ # - **SystemReminders**: Dynamic system message injection based on agent state.
25
+ # Collects reminders from plugins, context trackers, and other sources.
26
+ #
27
+ # ### Cross-Cutting Concerns
28
+ # - **Instrumentation**: LLM API request/response logging via Faraday middleware.
29
+ # Wraps HTTP calls to capture timing, tokens, and error information.
30
+ # - **HookIntegration**: Pre/post tool execution callbacks and delegation hooks.
31
+ # Integrates with SwarmSDK Hooks::Registry for lifecycle events.
32
+ # - **TokenTracking**: Usage statistics and cost calculation per conversation.
33
+ # Accumulates input/output tokens across all LLM calls.
34
+ #
35
+ # ### State Management
36
+ # - **Serialization**: Snapshot/restore for session persistence.
37
+ # Saves/restores message history, tool states, and agent context.
38
+ #
39
+ # ## Module Dependencies
40
+ #
41
+ # EventEmitter <-- HookIntegration (event emission for hooks)
42
+ # TokenTracking <-- Instrumentation (usage data collection)
43
+ # SystemReminders <-- uses ContextTracker instance (not a module)
44
+ # LoggingHelpers <-- EventEmitter (log event formatting)
45
+ #
46
+ # ## Design Rationale
47
+ #
48
+ # This decomposition follows Single Responsibility Principle. Each module
49
+ # handles one concern. They access shared Chat internals (@llm_chat,
50
+ # @messages, etc.) which makes them tightly coupled to Chat, but this keeps
51
+ # the main Chat class focused on orchestration rather than implementation
52
+ # details. The modules are intentionally NOT standalone - they augment
53
+ # Chat with specific capabilities.
7
54
  #
8
55
  # ## Rate Limiting Strategy
9
56
  #
10
- # In hierarchical agent trees, unlimited parallelism can cause exponential growth:
11
- # Main 10 agents 100 agents 1,000 agents = API meltdown!
57
+ # Two-level semaphore system prevents API quota exhaustion in hierarchical agent trees:
58
+ # 1. **Global semaphore** - Serializes ask() calls across entire swarm
59
+ # 2. **Local semaphore** - Limits concurrent tool calls per agent (via RubyLLM)
12
60
  #
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
61
+ # ## Event Flow
16
62
  #
17
- # ## Architecture
63
+ # RubyLLM events → SwarmSDK subscribes → enriches with context → emits SwarmSDK events
64
+ # This allows hooks to fire on SwarmSDK events with full agent context.
18
65
  #
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
66
+ # @see ChatHelpers::EventEmitter Event subscription and emission
67
+ # @see ChatHelpers::Instrumentation API logging via Faraday middleware
68
+ # @see ChatHelpers::Serialization State persistence (snapshot/restore)
69
+ # @see ChatHelpers::HookIntegration Pre/post tool execution callbacks
70
+ class Chat
71
+ # Include event emitter for multi-subscriber callbacks
72
+ include ChatHelpers::EventEmitter
73
+
26
74
  # Include logging helpers for tool call formatting
27
- include LoggingHelpers
75
+ include ChatHelpers::LoggingHelpers
28
76
 
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
77
+ # Include hook integration for pre/post tool hooks
78
+ include ChatHelpers::HookIntegration
33
79
 
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
80
+ # Include LLM configuration helpers
81
+ include ChatHelpers::LlmConfiguration
82
+
83
+ # Include system reminder collection
84
+ include ChatHelpers::SystemReminders
85
+
86
+ # Include token tracking methods
87
+ include ChatHelpers::TokenTracking
88
+
89
+ # Include message serialization
90
+ include ChatHelpers::Serialization
39
91
 
40
- # Initialize AgentChat with rate limiting
92
+ # Include LLM instrumentation
93
+ include ChatHelpers::Instrumentation
94
+
95
+ # SwarmSDK-specific accessors
96
+ attr_reader :global_semaphore,
97
+ :real_model_info,
98
+ :context_tracker,
99
+ :context_manager,
100
+ :agent_context,
101
+ :last_todowrite_message_index,
102
+ :active_skill_path,
103
+ :provider # Extracted from RubyLLM::Chat for instrumentation (not publicly accessible)
104
+
105
+ # Setters for snapshot/restore
106
+ attr_writer :last_todowrite_message_index, :active_skill_path
107
+
108
+ # Initialize AgentChat with RubyLLM::Chat wrapper
41
109
  #
42
110
  # @param definition [Hash] Agent definition containing all configuration
43
111
  # @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
112
+ # @param global_semaphore [Async::Semaphore, nil] Shared across all agents
113
+ # @param options [Hash] Additional options
47
114
  def initialize(definition:, agent_name: nil, global_semaphore: nil, **options)
115
+ # Initialize event emitter system
116
+ initialize_event_emitter
117
+
48
118
  # Extract configuration from definition
49
- model = definition[:model]
50
- provider = definition[:provider]
119
+ model_id = definition[:model]
120
+ provider_name = definition[:provider]
51
121
  context_window = definition[:context_window]
52
122
  max_concurrent_tools = definition[:max_concurrent_tools]
53
123
  base_url = definition[:base_url]
54
124
  api_version = definition[:api_version]
55
- timeout = definition[:timeout] || Definition::DEFAULT_TIMEOUT
125
+ timeout = definition[:timeout] || Defaults::Timeouts::AGENT_REQUEST_SECONDS
56
126
  assume_model_exists = definition[:assume_model_exists]
57
127
  system_prompt = definition[:system_prompt]
58
128
  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
129
+ custom_headers = definition[:headers]
91
130
 
92
131
  # Agent identifier (for plugin callbacks)
93
132
  @agent_name = agent_name
94
133
 
95
- # Context manager for ephemeral messages and future context optimization
134
+ # Context manager for ephemeral messages
96
135
  @context_manager = ContextManager.new
97
136
 
98
- # Rate limiting semaphores
137
+ # Rate limiting
99
138
  @global_semaphore = global_semaphore
100
- @local_semaphore = max_concurrent_tools ? Async::Semaphore.new(max_concurrent_tools) : nil
101
139
  @explicit_context_window = context_window
102
140
 
141
+ # Serialize ask() calls to prevent message corruption
142
+ @ask_semaphore = Async::Semaphore.new(1)
143
+
103
144
  # Track TodoWrite usage for periodic reminders
104
145
  @last_todowrite_message_index = nil
105
146
 
@@ -109,42 +150,186 @@ module SwarmSDK
109
150
  # Context tracker (created after agent_context is set)
110
151
  @context_tracker = nil
111
152
 
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
153
+ # Track immutable tools
115
154
  @immutable_tool_names = Set.new(["Think", "Clock", "TodoWrite"])
116
155
 
117
156
  # Track active skill (only used if memory enabled)
118
157
  @active_skill_path = nil
119
158
 
159
+ # Create internal RubyLLM::Chat instance
160
+ @llm_chat = create_llm_chat(
161
+ model_id: model_id,
162
+ provider_name: provider_name,
163
+ base_url: base_url,
164
+ api_version: api_version,
165
+ timeout: timeout,
166
+ assume_model_exists: assume_model_exists,
167
+ max_concurrent_tools: max_concurrent_tools,
168
+ )
169
+
170
+ # Extract provider from RubyLLM::Chat for instrumentation
171
+ # Must be done after create_llm_chat since with_responses_api() may swap provider
172
+ # NOTE: RubyLLM doesn't expose provider publicly, but we need it for Faraday middleware
173
+ # rubocop:disable Security/NoReflectionMethods
174
+ @provider = @llm_chat.instance_variable_get(:@provider)
175
+ # rubocop:enable Security/NoReflectionMethods
176
+
120
177
  # 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)
178
+ fetch_real_model_info(model_id)
124
179
 
125
- # Configure system prompt, parameters, and headers after parent initialization
126
- with_instructions(system_prompt) if system_prompt
180
+ # Configure system prompt, parameters, and headers
181
+ configure_system_prompt(system_prompt) if system_prompt
127
182
  configure_parameters(parameters)
128
- configure_headers(headers)
183
+ configure_headers(custom_headers)
184
+
185
+ # Setup around_tool_execution hook for SwarmSDK orchestration
186
+ setup_tool_execution_hook
187
+
188
+ # Setup around_llm_request hook for ephemeral message injection
189
+ setup_llm_request_hook
190
+
191
+ # Setup event bridging from RubyLLM to SwarmSDK
192
+ setup_event_bridging
129
193
  end
130
194
 
131
- # Setup agent context
195
+ # --- SwarmSDK Abstraction API ---
196
+ # These methods provide SwarmSDK-specific semantics without exposing RubyLLM internals
197
+
198
+ # Model information
199
+ def model_id
200
+ @llm_chat.model.id
201
+ end
202
+
203
+ def model_provider
204
+ @llm_chat.model.provider
205
+ end
206
+
207
+ def model_context_window
208
+ @real_model_info&.context_window || @llm_chat.model.context_window
209
+ end
210
+
211
+ # Tool introspection
212
+ def has_tool?(name)
213
+ @llm_chat.tools.key?(name.to_s) || @llm_chat.tools.key?(name.to_sym)
214
+ end
215
+
216
+ def tool_names
217
+ @llm_chat.tools.values.map(&:name).sort
218
+ end
219
+
220
+ def tool_count
221
+ @llm_chat.tools.size
222
+ end
223
+
224
+ def remove_tool(name)
225
+ @llm_chat.tools.delete(name.to_s) || @llm_chat.tools.delete(name.to_sym)
226
+ end
227
+
228
+ # Direct access to tools hash for advanced operations
132
229
  #
133
- # Sets the agent context for this chat, enabling delegation tracking.
134
- # This is always called, regardless of whether logging is enabled.
230
+ # Use with caution - prefer has_tool?, tool_names, remove_tool for most cases.
231
+ # This is provided for:
232
+ # - Direct tool execution in tests
233
+ # - Advanced tool manipulation (remove_mutable_tools)
135
234
  #
136
- # @param context [Agent::Context] Agent context for this chat
235
+ # @return [Hash] Tool name to tool instance mapping
236
+ def tools
237
+ @llm_chat.tools
238
+ end
239
+
240
+ # Message introspection
241
+ def message_count
242
+ @llm_chat.messages.size
243
+ end
244
+
245
+ def has_user_message?
246
+ @llm_chat.messages.any? { |msg| msg.role == :user }
247
+ end
248
+
249
+ def last_assistant_message
250
+ @llm_chat.messages.reverse.find { |msg| msg.role == :assistant }
251
+ end
252
+
253
+ # Read-only access to conversation messages
254
+ #
255
+ # Returns a copy of the message array for safe enumeration.
256
+ # External code should use this instead of internal_messages.
257
+ #
258
+ # @return [Array<RubyLLM::Message>] Copy of message array
259
+ def messages
260
+ @llm_chat.messages.dup
261
+ end
262
+
263
+ # Atomically replace all conversation messages
264
+ #
265
+ # Used for context compaction and state restoration.
266
+ # This is the safe way to manipulate messages from external code.
267
+ #
268
+ # @param new_messages [Array<RubyLLM::Message>] New message array
269
+ # @return [self] for chaining
270
+ def replace_messages(new_messages)
271
+ @llm_chat.messages.clear
272
+ new_messages.each { |msg| @llm_chat.messages << msg }
273
+ self
274
+ end
275
+
276
+ # Get all assistant messages
277
+ #
278
+ # @return [Array<RubyLLM::Message>] All assistant messages
279
+ def assistant_messages
280
+ @llm_chat.messages.select { |msg| msg.role == :assistant }
281
+ end
282
+
283
+ # Find the last message matching a condition
284
+ #
285
+ # @yield [msg] Block to test each message
286
+ # @return [RubyLLM::Message, nil] Last matching message or nil
287
+ def find_last_message(&block)
288
+ @llm_chat.messages.reverse.find(&block)
289
+ end
290
+
291
+ # Find the index of last message matching a condition
292
+ #
293
+ # @yield [msg] Block to test each message
294
+ # @return [Integer, nil] Index of last matching message or nil
295
+ def find_last_message_index(&block)
296
+ @llm_chat.messages.rindex(&block)
297
+ end
298
+
299
+ # Get tool names that are NOT delegation tools
300
+ #
301
+ # @return [Array<String>] Non-delegation tool names
302
+ def non_delegation_tool_names
303
+ if @agent_context
304
+ @llm_chat.tools.keys.reject { |name| @agent_context.delegation_tool?(name.to_s) }
305
+ else
306
+ @llm_chat.tools.keys
307
+ end
308
+ end
309
+
310
+ # Add an ephemeral reminder to the most recent message
311
+ #
312
+ # The reminder will be sent to the LLM but not persisted in message history.
313
+ # This encapsulates the internal message array access.
314
+ #
315
+ # @param reminder [String] Reminder content to add
137
316
  # @return [void]
317
+ def add_ephemeral_reminder(reminder)
318
+ @context_manager&.add_ephemeral_reminder(reminder, messages_array: @llm_chat.messages)
319
+ end
320
+
321
+ # --- Setup Methods ---
322
+
323
+ # Setup agent context
324
+ #
325
+ # @param context [Agent::Context] Agent context for this chat
138
326
  def setup_context(context)
139
327
  @agent_context = context
140
- @context_tracker = ContextTracker.new(self, context)
328
+ @context_tracker = ChatHelpers::ContextTracker.new(self, context)
141
329
  end
142
330
 
143
331
  # Setup logging callbacks
144
332
  #
145
- # This configures the chat to emit log events via LogStream.
146
- # Should only be called when LogStream.emitter is set.
147
- #
148
333
  # @return [void]
149
334
  def setup_logging
150
335
  raise StateError, "Agent context not set. Call setup_context first." unless @agent_context
@@ -155,9 +340,6 @@ module SwarmSDK
155
340
 
156
341
  # Emit model lookup warning if one occurred during initialization
157
342
  #
158
- # If a model wasn't found in the registry during initialization, this will
159
- # emit a proper JSON log event through LogStream.
160
- #
161
343
  # @param agent_name [Symbol, String] The agent name for logging context
162
344
  def emit_model_lookup_warning(agent_name)
163
345
  return unless @model_lookup_error
@@ -173,46 +355,76 @@ module SwarmSDK
173
355
  )
174
356
  end
175
357
 
176
- # Mark tools as immutable (cannot be removed by dynamic tool swapping)
358
+ # --- Adapter API (SwarmSDK-stable interface) ---
359
+
360
+ # Configure system prompt for the conversation
177
361
  #
178
- # Called by plugins during on_agent_initialized lifecycle hook to mark
179
- # their tools as immutable. This allows plugins to protect their core
180
- # tools from being removed by dynamic tool swapping operations.
362
+ # @param prompt [String] System prompt
363
+ # @param replace [Boolean] Replace existing system messages if true
364
+ # @return [self] for chaining
365
+ def configure_system_prompt(prompt, replace: false)
366
+ @llm_chat.with_instructions(prompt, replace: replace)
367
+ self
368
+ end
369
+
370
+ # Add a tool to this chat
181
371
  #
182
- # @param tool_names [Array<String>] Tool names to mark as immutable
183
- # @return [void]
184
- def mark_tools_immutable(*tool_names)
185
- @immutable_tool_names.merge(tool_names.flatten.map(&:to_s))
372
+ # @param tool [Class, RubyLLM::Tool] Tool class or instance
373
+ # @return [self] for chaining
374
+ def add_tool(tool)
375
+ @llm_chat.with_tool(tool)
376
+ self
186
377
  end
187
378
 
188
- # Remove all mutable tools (keeps immutable tools)
379
+ # Complete the current conversation (no additional prompt)
189
380
  #
190
- # Used by LoadSkill to swap tools. Only works if called from a tool
191
- # that has been given access to the chat instance.
381
+ # Delegates to RubyLLM::Chat#complete() which handles:
382
+ # - LLM API calls (with around_llm_request hook for ephemeral injection)
383
+ # - Tool execution (with around_tool_execution hook for SwarmSDK hooks)
384
+ # - Automatic tool loop (continues until no more tool calls)
192
385
  #
193
- # @return [void]
194
- def remove_mutable_tools
195
- @tools.select! { |tool| @immutable_tool_names.include?(tool.name) }
386
+ # SwarmSDK adds:
387
+ # - Semaphore rate limiting (ask + global)
388
+ # - Finish marker handling (finish_agent, finish_swarm)
389
+ #
390
+ # @param options [Hash] Additional options (currently unused, for future compatibility)
391
+ # @param block [Proc] Optional streaming block
392
+ # @return [RubyLLM::Message] LLM response
393
+ def complete(**_options, &block)
394
+ @ask_semaphore.acquire do
395
+ execute_with_global_semaphore do
396
+ result = catch(:finish_agent) do
397
+ catch(:finish_swarm) do
398
+ # Delegate to RubyLLM::Chat#complete()
399
+ # Hooks handle ephemeral injection and tool orchestration
400
+ @llm_chat.complete(&block)
401
+ end
402
+ end
403
+
404
+ # Handle finish markers thrown by hooks
405
+ handle_finish_marker(result)
406
+ end
407
+ end
196
408
  end
197
409
 
198
- # Add a tool instance dynamically
410
+ # Mark tools as immutable (cannot be removed by dynamic tool swapping)
199
411
  #
200
- # Used by LoadSkill to add skill-required tools after removing mutable tools.
201
- # This is just a convenience wrapper around with_tool.
412
+ # @param tool_names [Array<String>] Tool names to mark as immutable
413
+ def mark_tools_immutable(*tool_names)
414
+ @immutable_tool_names.merge(tool_names.flatten.map(&:to_s))
415
+ end
416
+
417
+ # Remove all mutable tools (keeps immutable tools)
202
418
  #
203
- # @param tool_instance [RubyLLM::Tool] Tool to add
204
419
  # @return [void]
205
- def add_tool(tool_instance)
206
- with_tool(tool_instance)
420
+ def remove_mutable_tools
421
+ mutable_tool_names = tools.keys.reject { |name| @immutable_tool_names.include?(name.to_s) }
422
+ mutable_tool_names.each { |name| tools.delete(name) }
207
423
  end
208
424
 
209
425
  # Mark skill as loaded (tracking for debugging/logging)
210
426
  #
211
- # Called by LoadSkill after successfully swapping tools.
212
- # This can be used for logging or debugging purposes.
213
- #
214
427
  # @param file_path [String] Path to loaded skill
215
- # @return [void]
216
428
  def mark_skill_loaded(file_path)
217
429
  @active_skill_path = file_path
218
430
  end
@@ -226,708 +438,285 @@ module SwarmSDK
226
438
 
227
439
  # Clear conversation history
228
440
  #
229
- # Removes all messages from the conversation history and clears tool executions.
230
- # Used by composable swarms when keep_context: false is specified.
231
- #
232
441
  # @return [void]
233
442
  def clear_conversation
234
- @messages.clear if @messages.respond_to?(:clear)
443
+ @llm_chat.reset_messages!
235
444
  @context_manager&.clear_ephemeral
236
445
  end
237
446
 
238
- # Override ask to inject system reminders and periodic TodoWrite reminders
447
+ # --- Core Conversation Methods ---
448
+
449
+ # Send a message to the LLM and get a response
239
450
  #
240
- # Note: This is called BEFORE HookIntegration#ask (due to module include order),
241
- # so HookIntegration will wrap this and inject user_prompt hooks.
451
+ # This method:
452
+ # 1. Serializes concurrent asks via @ask_semaphore
453
+ # 2. Adds CLEAN user message to history (no reminders)
454
+ # 3. Injects system reminders as ephemeral content (sent to LLM but not stored)
455
+ # 4. Triggers user_prompt hooks
456
+ # 5. Acquires global semaphore for LLM call
457
+ # 6. Delegates to RubyLLM::Chat for actual execution
242
458
  #
243
459
  # @param prompt [String] User prompt
244
- # @param options [Hash] Additional options to pass to complete
460
+ # @param options [Hash] Additional options (source: for hooks)
245
461
  # @return [RubyLLM::Message] LLM response
246
462
  def ask(prompt, **options)
247
- # Serialize ask() calls to prevent message corruption from concurrent fibers
248
- # Uses Async::Semaphore (not Mutex) because SwarmSDK runs in fiber context
249
- # This protects against parallel delegation scenarios where multiple delegation
250
- # instances call the same underlying primary agent (e.g., tester@frontend and
251
- # tester@backend both calling database in parallel).
252
- @ask_semaphore ||= Async::Semaphore.new(1)
253
-
254
463
  @ask_semaphore.acquire do
255
- # Check if this is the first user message
256
- is_first = SystemReminderInjector.first_message?(self)
464
+ is_first = first_message?
257
465
 
258
- if is_first
259
- # Collect plugin reminders first
260
- plugin_reminders = collect_plugin_reminders(prompt, is_first_message: true)
466
+ # Collect system reminders to inject as ephemeral content
467
+ reminders = collect_system_reminders(prompt, is_first)
261
468
 
262
- # Build full prompt with embedded plugin reminders
263
- full_prompt = prompt
264
- plugin_reminders.each do |reminder|
265
- full_prompt = "#{full_prompt}\n\n#{reminder}"
469
+ # Trigger user_prompt hook (with clean prompt, not reminders)
470
+ source = options.delete(:source) || "user"
471
+ final_prompt = prompt
472
+ if @hook_executor
473
+ hook_result = trigger_user_prompt(prompt, source: source)
474
+
475
+ if hook_result[:halted]
476
+ return RubyLLM::Message.new(
477
+ role: :assistant,
478
+ content: hook_result[:halt_message],
479
+ model_id: model_id,
480
+ )
266
481
  end
267
482
 
268
- # Inject first message reminders (includes system reminders + toolset + after)
269
- # SystemReminderInjector will embed all reminders in the prompt via add_message
270
- SystemReminderInjector.inject_first_message_reminders(self, full_prompt)
271
-
272
- # Trigger user_prompt hook manually since we're bypassing the normal ask flow
273
- if @hook_executor
274
- hook_result = trigger_user_prompt(prompt)
275
-
276
- # Check if hook halted execution
277
- if hook_result[:halted]
278
- # Return a halted message instead of calling LLM
279
- return RubyLLM::Message.new(
280
- role: :assistant,
281
- content: hook_result[:halt_message],
282
- model_id: model.id,
283
- )
284
- end
483
+ final_prompt = hook_result[:modified_prompt] if hook_result[:modified_prompt]
484
+ end
285
485
 
286
- # NOTE: We ignore modified_prompt for first message since reminders already injected
287
- end
486
+ # Add CLEAN user message to history (no reminders embedded)
487
+ @llm_chat.add_message(role: :user, content: final_prompt)
288
488
 
289
- # Call complete to get LLM response
290
- complete(**options)
291
- else
292
- # Build prompt with embedded reminders (if needed)
293
- full_prompt = prompt
294
-
295
- # Add periodic TodoWrite reminder if needed (only if agent has TodoWrite tool)
296
- if tools.key?("TodoWrite") && SystemReminderInjector.should_inject_todowrite_reminder?(self, @last_todowrite_message_index)
297
- full_prompt = "#{full_prompt}\n\n#{SystemReminderInjector::TODOWRITE_PERIODIC_REMINDER}"
298
- # Update tracking
299
- @last_todowrite_message_index = SystemReminderInjector.find_last_todowrite_index(self)
300
- end
489
+ # Track reminders as ephemeral content for this LLM call only
490
+ # They'll be injected by around_llm_request hook but not stored
491
+ reminders.each do |reminder|
492
+ @context_manager.add_ephemeral_reminder(reminder, messages_array: @llm_chat.messages)
493
+ end
301
494
 
302
- # Collect plugin reminders and embed them
303
- plugin_reminders = collect_plugin_reminders(full_prompt, is_first_message: false)
304
- plugin_reminders.each do |reminder|
305
- full_prompt = "#{full_prompt}\n\n#{reminder}"
495
+ # Execute complete() which handles tool loop and ephemeral injection
496
+ response = execute_with_global_semaphore do
497
+ catch(:finish_agent) do
498
+ catch(:finish_swarm) do
499
+ @llm_chat.complete(**options)
500
+ end
306
501
  end
307
-
308
- # Normal ask behavior for subsequent messages
309
- # This calls super which goes to HookIntegration's ask override
310
- # HookIntegration will call add_message, and we'll extract reminders there
311
- super(full_prompt, **options)
312
502
  end
503
+
504
+ # Handle finish markers from hooks
505
+ handle_finish_marker(response)
313
506
  end
314
507
  end
315
508
 
316
- # Override add_message to automatically extract and strip system reminders
509
+ # Add a message to the conversation history
317
510
  #
318
- # System reminders are extracted and tracked as ephemeral content (embedded
319
- # when sent to LLM but not persisted in conversation history).
511
+ # Automatically extracts and strips system reminders, tracking them as ephemeral.
320
512
  #
321
513
  # @param message_or_attributes [RubyLLM::Message, Hash] Message object or attributes hash
322
- # @return [RubyLLM::Message] The added message (with clean content)
514
+ # @return [RubyLLM::Message] The added message
323
515
  def add_message(message_or_attributes)
324
- # Handle both forms: add_message(message) and add_message({role: :user, content: "text"})
325
- if message_or_attributes.is_a?(RubyLLM::Message)
326
- # Message object provided
327
- msg = message_or_attributes
328
- content_str = msg.content.is_a?(RubyLLM::Content) ? msg.content.text : msg.content.to_s
329
-
330
- # Extract system reminders
331
- if @context_manager.has_system_reminders?(content_str)
332
- reminders = @context_manager.extract_system_reminders(content_str)
333
- clean_content_str = @context_manager.strip_system_reminders(content_str)
334
-
335
- clean_content = if msg.content.is_a?(RubyLLM::Content)
336
- RubyLLM::Content.new(clean_content_str, msg.content.attachments)
337
- else
338
- clean_content_str
339
- end
340
-
341
- clean_message = RubyLLM::Message.new(
342
- role: msg.role,
343
- content: clean_content,
344
- tool_call_id: msg.tool_call_id,
345
- )
346
-
347
- result = super(clean_message)
348
-
349
- # Track reminders as ephemeral
350
- reminders.each do |reminder|
351
- @context_manager.add_ephemeral_reminder(reminder, messages_array: @messages)
352
- end
353
-
354
- result
355
- else
356
- # No reminders - call parent normally
357
- super(msg)
358
- end
516
+ message = if message_or_attributes.is_a?(RubyLLM::Message)
517
+ message_or_attributes
359
518
  else
360
- # Hash attributes provided
361
- attrs = message_or_attributes
362
- content_value = attrs[:content] || attrs["content"]
363
- content_str = content_value.is_a?(RubyLLM::Content) ? content_value.text : content_value.to_s
364
-
365
- # Extract system reminders
366
- if @context_manager.has_system_reminders?(content_str)
367
- reminders = @context_manager.extract_system_reminders(content_str)
368
- clean_content_str = @context_manager.strip_system_reminders(content_str)
369
-
370
- clean_content = if content_value.is_a?(RubyLLM::Content)
371
- RubyLLM::Content.new(clean_content_str, content_value.attachments)
372
- else
373
- clean_content_str
374
- end
519
+ RubyLLM::Message.new(message_or_attributes)
520
+ end
375
521
 
376
- clean_attrs = attrs.merge(content: clean_content)
377
- result = super(clean_attrs)
522
+ # Extract system reminders if present
523
+ content_str = message.content.is_a?(RubyLLM::Content) ? message.content.text : message.content.to_s
378
524
 
379
- # Track reminders as ephemeral
380
- reminders.each do |reminder|
381
- @context_manager.add_ephemeral_reminder(reminder, messages_array: @messages)
382
- end
525
+ if @context_manager.has_system_reminders?(content_str)
526
+ reminders = @context_manager.extract_system_reminders(content_str)
527
+ clean_content_str = @context_manager.strip_system_reminders(content_str)
383
528
 
384
- result
529
+ clean_content = if message.content.is_a?(RubyLLM::Content)
530
+ RubyLLM::Content.new(clean_content_str, message.content.attachments)
385
531
  else
386
- # No reminders - call parent normally
387
- super(attrs)
532
+ clean_content_str
388
533
  end
389
- end
390
- end
391
534
 
392
- # Collect reminders from all plugins
393
- #
394
- # Plugins can contribute system reminders based on the user's message.
395
- # Returns array of reminder strings to be embedded in the user prompt.
396
- #
397
- # @param prompt [String] User's message
398
- # @param is_first_message [Boolean] True if first message
399
- # @return [Array<String>] Array of reminder strings
400
- def collect_plugin_reminders(prompt, is_first_message:)
401
- return [] unless @agent_name # Skip if agent_name not set
402
-
403
- # Collect reminders from all plugins
404
- PluginRegistry.all.flat_map do |plugin|
405
- plugin.on_user_message(
406
- agent_name: @agent_name,
407
- prompt: prompt,
408
- is_first_message: is_first_message,
535
+ clean_message = RubyLLM::Message.new(
536
+ role: message.role,
537
+ content: clean_content,
538
+ tool_call_id: message.tool_call_id,
539
+ tool_calls: message.tool_calls,
540
+ model_id: message.model_id,
541
+ input_tokens: message.input_tokens,
542
+ output_tokens: message.output_tokens,
543
+ cached_tokens: message.cached_tokens,
544
+ cache_creation_tokens: message.cache_creation_tokens,
409
545
  )
410
- end.compact
411
- end
412
546
 
413
- # Override complete() to inject ephemeral messages
414
- #
415
- # Ephemeral messages are sent to the LLM for the current turn only
416
- # and are NOT stored in the conversation history. This prevents
417
- # system reminders from accumulating and being resent every turn.
418
- #
419
- # @param options [Hash] Options to pass to provider
420
- # @return [RubyLLM::Message] LLM response
421
- def complete(**options, &block)
422
- # Prepare messages: persistent + ephemeral for this turn
423
- messages_for_llm = @context_manager.prepare_for_llm(@messages)
424
-
425
- # Call provider with retry logic for transient failures
426
- response = call_llm_with_retry do
427
- @provider.complete(
428
- messages_for_llm,
429
- tools: @tools,
430
- temperature: @temperature,
431
- model: @model,
432
- params: @params,
433
- headers: @headers,
434
- schema: @schema,
435
- &wrap_streaming_block(&block)
436
- )
437
- end
438
-
439
- # Handle nil response from provider (malformed API response)
440
- if response.nil?
441
- raise StandardError, "Provider returned nil response. This usually indicates a malformed API response " \
442
- "that couldn't be parsed.\n\n" \
443
- "Provider: #{@provider.class.name}\n" \
444
- "API Base: #{@provider.api_base}\n" \
445
- "Model: #{@model.id}\n" \
446
- "Response: #{response.inspect}\n\n" \
447
- "The API endpoint returned a response that couldn't be parsed into a valid Message object. " \
448
- "Enable RubyLLM debug logging (RubyLLM.logger.level = Logger::DEBUG) to see the raw API response."
449
- end
450
-
451
- @on[:new_message]&.call unless block
547
+ @llm_chat.add_message(clean_message)
452
548
 
453
- # Handle schema parsing if needed
454
- if @schema && response.content.is_a?(String)
455
- begin
456
- response.content = JSON.parse(response.content)
457
- rescue JSON::ParserError
458
- # Keep as string if parsing fails
549
+ # Track reminders as ephemeral
550
+ reminders.each do |reminder|
551
+ @context_manager.add_ephemeral_reminder(reminder, messages_array: messages)
459
552
  end
460
- end
461
-
462
- # Add response to persistent history
463
- add_message(response)
464
- @on[:end_message]&.call(response)
465
-
466
- # Clear ephemeral messages after use
467
- @context_manager.clear_ephemeral
468
553
 
469
- # Handle tool calls if present
470
- if response.tool_call?
471
- handle_tool_calls(response, &block)
554
+ clean_message
472
555
  else
473
- response
556
+ @llm_chat.add_message(message)
474
557
  end
475
558
  end
476
559
 
477
- # Override handle_tool_calls to execute multiple tool calls in parallel with rate limiting.
478
- #
479
- # RubyLLM's default implementation executes tool calls one at a time. This
480
- # override uses Async to execute all tool calls concurrently, with semaphores
481
- # to prevent API quota exhaustion. Hooks are integrated via HookIntegration module.
482
- #
483
- # @param response [RubyLLM::Message] LLM response with tool calls
484
- # @param block [Proc] Optional block passed through to complete
485
- # @return [RubyLLM::Message] Final response when loop completes
486
- def handle_tool_calls(response, &block)
487
- # Single tool call: sequential execution with hooks
488
- if response.tool_calls.size == 1
489
- tool_call = response.tool_calls.values.first
490
-
491
- # Handle pre_tool_use hook (skip for delegation tools)
492
- unless delegation_tool_call?(tool_call)
493
- # Trigger pre_tool_use hook (can block or provide custom result)
494
- pre_result = trigger_pre_tool_use(tool_call)
495
-
496
- # Handle finish_agent marker
497
- if pre_result[:finish_agent]
498
- message = RubyLLM::Message.new(
499
- role: :assistant,
500
- content: pre_result[:custom_result],
501
- model_id: model.id,
502
- )
503
- # Set custom finish reason before triggering on_end_message
504
- @context_tracker.finish_reason_override = "finish_agent" if @context_tracker
505
- # Trigger on_end_message to ensure agent_stop event is emitted
506
- @on[:end_message]&.call(message)
507
- return message
508
- end
509
-
510
- # Handle finish_swarm marker
511
- if pre_result[:finish_swarm]
512
- return { __finish_swarm__: true, message: pre_result[:custom_result] }
513
- end
514
-
515
- # Handle blocked execution
516
- unless pre_result[:proceed]
517
- content = pre_result[:custom_result] || "Tool execution blocked by hook"
518
- message = add_message(
519
- role: :tool,
520
- content: content,
521
- tool_call_id: tool_call.id,
522
- )
523
- @on[:end_message]&.call(message)
524
- return complete(&block)
525
- end
526
- end
527
-
528
- # Execute tool
529
- @on[:tool_call]&.call(tool_call)
530
-
531
- result = execute_tool_with_error_handling(tool_call)
560
+ private
532
561
 
533
- @on[:tool_result]&.call(result)
562
+ # --- Tool Execution Hook ---
534
563
 
535
- # Trigger post_tool_use hook (skip for delegation tools)
536
- unless delegation_tool_call?(tool_call)
537
- result = trigger_post_tool_use(result, tool_call: tool_call)
538
- end
564
+ # Setup around_tool_execution hook for SwarmSDK orchestration
565
+ #
566
+ # This hook intercepts all tool executions to:
567
+ # - Trigger pre_tool_use hooks (can block, replace, or finish)
568
+ # - Trigger post_tool_use hooks (can transform results)
569
+ # - Handle finish markers
570
+ def setup_tool_execution_hook
571
+ @llm_chat.around_tool_execution do |tool_call, _tool_instance, execute|
572
+ # Skip hooks for delegation tools (they have their own events)
573
+ if delegation_tool_call?(tool_call)
574
+ execute.call
575
+ else
576
+ # PRE-HOOK
577
+ pre_result = trigger_pre_tool_use(tool_call)
539
578
 
540
- # Check for finish markers from hooks
541
- if result.is_a?(Hash)
542
- if result[:__finish_agent__]
543
- # Finish this agent with the provided message
544
- message = RubyLLM::Message.new(
545
- role: :assistant,
546
- content: result[:message],
547
- model_id: model.id,
548
- )
549
- # Set custom finish reason before triggering on_end_message
550
- @context_tracker.finish_reason_override = "finish_agent" if @context_tracker
551
- # Trigger on_end_message to ensure agent_stop event is emitted
552
- @on[:end_message]&.call(message)
553
- return message
554
- elsif result[:__finish_swarm__]
555
- # Propagate finish_swarm marker up (don't add to conversation)
556
- return result
579
+ case pre_result
580
+ when Hash
581
+ if pre_result[:finish_agent]
582
+ throw(:finish_agent, { __finish_agent__: true, message: pre_result[:custom_result] })
583
+ elsif pre_result[:finish_swarm]
584
+ throw(:finish_swarm, { __finish_swarm__: true, message: pre_result[:custom_result] })
585
+ elsif !pre_result[:proceed]
586
+ # Blocked - return custom result without executing
587
+ next pre_result[:custom_result] || "Tool execution blocked by hook"
588
+ end
557
589
  end
558
- end
559
-
560
- # Check for halt result
561
- return result if result.is_a?(RubyLLM::Tool::Halt)
562
590
 
563
- # Add tool result to conversation
564
- # add_message automatically extracts reminders and stores them as ephemeral
565
- content = result.is_a?(RubyLLM::Content) ? result : result.to_s
566
- message = add_message(
567
- role: :tool,
568
- content: content,
569
- tool_call_id: tool_call.id,
570
- )
571
- @on[:end_message]&.call(message)
591
+ # EXECUTE tool (no retry - failures are returned to LLM)
592
+ result = execute.call
572
593
 
573
- # Continue loop
574
- return complete(&block)
575
- end
594
+ # POST-HOOK
595
+ post_result = trigger_post_tool_use(result, tool_call: tool_call)
576
596
 
577
- # Multiple tool calls: execute in parallel with rate limiting and hooks
578
- halt_result = nil
579
-
580
- results = Async do
581
- tasks = response.tool_calls.map do |_id, tool_call|
582
- Async do
583
- # Acquire semaphores (queues if limit reached)
584
- acquire_semaphores do
585
- @on[:tool_call]&.call(tool_call)
586
-
587
- # Handle pre_tool_use hook (skip for delegation tools)
588
- unless delegation_tool_call?(tool_call)
589
- pre_result = trigger_pre_tool_use(tool_call)
590
-
591
- # Handle finish markers first (early exit)
592
- # Don't call on_tool_result for finish markers - they're not tool results
593
- if pre_result[:finish_agent]
594
- result = { __finish_agent__: true, message: pre_result[:custom_result] }
595
- next { tool_call: tool_call, result: result, message: nil }
596
- end
597
-
598
- if pre_result[:finish_swarm]
599
- result = { __finish_swarm__: true, message: pre_result[:custom_result] }
600
- next { tool_call: tool_call, result: result, message: nil }
601
- end
602
-
603
- # Handle blocked execution
604
- unless pre_result[:proceed]
605
- result = pre_result[:custom_result] || "Tool execution blocked by hook"
606
- @on[:tool_result]&.call(result)
607
-
608
- # add_message automatically extracts reminders
609
- content = result.is_a?(RubyLLM::Content) ? result : result.to_s
610
- message = add_message(
611
- role: :tool,
612
- content: content,
613
- tool_call_id: tool_call.id,
614
- )
615
- @on[:end_message]&.call(message)
616
-
617
- next { tool_call: tool_call, result: result, message: message }
618
- end
619
- end
620
-
621
- # Execute tool - Faraday yields during HTTP I/O
622
- result = execute_tool_with_error_handling(tool_call)
623
-
624
- @on[:tool_result]&.call(result)
625
-
626
- # Trigger post_tool_use hook (skip for delegation tools)
627
- unless delegation_tool_call?(tool_call)
628
- result = trigger_post_tool_use(result, tool_call: tool_call)
629
- end
630
-
631
- # Check if result is a finish marker (don't add to conversation)
632
- if result.is_a?(Hash) && (result[:__finish_agent__] || result[:__finish_swarm__])
633
- # Finish markers will be detected after parallel execution completes
634
- { tool_call: tool_call, result: result, message: nil }
635
- else
636
- # Add tool result to conversation
637
- # add_message automatically extracts reminders and stores them as ephemeral
638
- content = result.is_a?(RubyLLM::Content) ? result : result.to_s
639
- message = add_message(
640
- role: :tool,
641
- content: content,
642
- tool_call_id: tool_call.id,
643
- )
644
- @on[:end_message]&.call(message)
645
-
646
- # Return result data for collection
647
- { tool_call: tool_call, result: result, message: message }
648
- end
597
+ # Check for finish markers from post-hook
598
+ if post_result.is_a?(Hash)
599
+ if post_result[:__finish_agent__]
600
+ throw(:finish_agent, post_result)
601
+ elsif post_result[:__finish_swarm__]
602
+ throw(:finish_swarm, post_result)
649
603
  end
650
604
  end
651
- end
652
-
653
- # Wait for all tasks to complete
654
- tasks.map(&:wait)
655
- end.wait
656
-
657
- # Check for halt and finish results
658
- results.each do |data|
659
- result = data[:result]
660
-
661
- # Check for halt result (from tool execution errors)
662
- if result.is_a?(RubyLLM::Tool::Halt)
663
- halt_result = result
664
- # Continue checking for finish markers below
665
- end
666
605
 
667
- # Check for finish markers (from hooks)
668
- if result.is_a?(Hash)
669
- if result[:__finish_agent__]
670
- message = RubyLLM::Message.new(
671
- role: :assistant,
672
- content: result[:message],
673
- model_id: model.id,
674
- )
675
- # Set custom finish reason before triggering on_end_message
676
- @context_tracker.finish_reason_override = "finish_agent" if @context_tracker
677
- # Trigger on_end_message to ensure agent_stop event is emitted
678
- @on[:end_message]&.call(message)
679
- return message
680
- elsif result[:__finish_swarm__]
681
- # Propagate finish_swarm marker up
682
- return result
683
- end
606
+ post_result
684
607
  end
685
608
  end
686
-
687
- # Return halt result if we found one (but no finish markers)
688
- halt_result = results.find { |data| data[:result].is_a?(RubyLLM::Tool::Halt) }&.dig(:result)
689
-
690
- # Continue automatic loop (recursive call to complete)
691
- halt_result || complete(&block)
692
609
  end
693
610
 
694
- # Get the provider instance
695
- #
696
- # Exposes the RubyLLM provider instance for configuration.
697
- # This is needed for setting agent_name and other provider-specific settings.
698
- #
699
- # @return [RubyLLM::Provider::Base] Provider instance
700
- attr_reader :provider, :global_semaphore, :local_semaphore, :real_model_info, :context_tracker, :context_manager, :agent_context, :last_todowrite_message_index, :active_skill_path
701
-
702
- # Setters for snapshot/restore
703
- attr_writer :last_todowrite_message_index, :active_skill_path
704
-
705
- # Expose messages array (inherited from RubyLLM::Chat but not publicly accessible)
706
- #
707
- # @return [Array<RubyLLM::Message>] Conversation messages
708
- attr_reader :messages
611
+ # --- Event Bridging ---
709
612
 
710
- # Get context window limit for the current model
711
- #
712
- # Priority order:
713
- # 1. Explicit context_window parameter (user override)
714
- # 2. Real model info from RubyLLM registry (searched across all providers)
715
- # 3. Model info from chat (may be nil if assume_model_exists was used)
613
+ # Setup event bridging from RubyLLM to SwarmSDK
716
614
  #
717
- # @return [Integer, nil] Maximum context tokens, or nil if not available
718
- def context_limit
719
- # Priority 1: Explicit override
720
- return @explicit_context_window if @explicit_context_window
721
-
722
- # Priority 2: Real model info from registry (searched across all providers)
723
- return @real_model_info.context_window if @real_model_info&.context_window
724
-
725
- # Priority 3: Fall back to model from chat
726
- model.context_window
727
- rescue StandardError
728
- nil
729
- end
615
+ # Subscribes to RubyLLM events and emits enriched SwarmSDK events.
616
+ def setup_event_bridging
617
+ # Bridge tool_call events
618
+ @llm_chat.on_tool_call do |tool_call|
619
+ emit(:tool_call, tool_call)
620
+ end
730
621
 
731
- # Calculate cumulative input tokens for the conversation
732
- #
733
- # The latest assistant message's input_tokens already includes the cumulative
734
- # total for the entire conversation (all previous messages, system instructions,
735
- # tool definitions, etc.). We don't sum across messages as that would double-count.
736
- #
737
- # @return [Integer] Total input tokens used in conversation
738
- def cumulative_input_tokens
739
- # Find the latest assistant message with input_tokens
740
- messages.reverse.find { |msg| msg.role == :assistant && msg.input_tokens }&.input_tokens || 0
741
- end
622
+ # Bridge tool_result events
623
+ @llm_chat.on_tool_result do |_tool_call, result|
624
+ emit(:tool_result, result)
625
+ end
742
626
 
743
- # Calculate cumulative output tokens across all assistant messages
744
- #
745
- # Unlike input tokens, output tokens are per-response and should be summed.
746
- #
747
- # @return [Integer] Total output tokens used in conversation
748
- def cumulative_output_tokens
749
- messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.output_tokens || 0 }
750
- end
627
+ # Bridge new_message events
628
+ @llm_chat.on_new_message do
629
+ emit(:new_message)
630
+ end
751
631
 
752
- # Calculate cumulative cached tokens across all assistant messages
753
- #
754
- # Cached tokens are portions of prompts served from the provider's cache.
755
- # OpenAI reports this automatically for prompts >1024 tokens.
756
- # Anthropic/Bedrock expose cache control via Content::Raw blocks.
757
- #
758
- # @return [Integer] Total cached tokens used in conversation
759
- def cumulative_cached_tokens
760
- messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.cached_tokens || 0 }
632
+ # Bridge end_message events (used for agent_step/agent_stop)
633
+ @llm_chat.on_end_message do |message|
634
+ emit(:end_message, message)
635
+ end
761
636
  end
762
637
 
763
- # Calculate cumulative cache creation tokens
764
- #
765
- # Cache creation tokens are written to the cache (Anthropic/Bedrock only).
766
- # These are charged at the normal input rate when first created.
767
- #
768
- # @return [Integer] Total tokens written to cache
769
- def cumulative_cache_creation_tokens
770
- messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.cache_creation_tokens || 0 }
771
- end
638
+ # --- LLM Request Hook ---
772
639
 
773
- # Calculate effective input tokens (excluding cache hits)
774
- #
775
- # This represents the actual tokens charged for input, excluding cached portions.
776
- # Useful for accurate cost tracking when using prompt caching.
640
+ # Setup around_llm_request hook for ephemeral message injection
777
641
  #
778
- # @return [Integer] Actual input tokens charged (input minus cached)
779
- def effective_input_tokens
780
- cumulative_input_tokens - cumulative_cached_tokens
781
- end
642
+ # This hook intercepts all LLM API calls to:
643
+ # - Inject ephemeral content (system reminders) that shouldn't be persisted
644
+ # - Clear ephemeral content after each LLM call
645
+ # - Add retry logic for transient failures
646
+ def setup_llm_request_hook
647
+ @llm_chat.around_llm_request do |messages, &send_request|
648
+ # Inject ephemeral content (system reminders, etc.)
649
+ # These are sent to LLM but NOT persisted in message history
650
+ prepared_messages = @context_manager.prepare_for_llm(messages)
782
651
 
783
- # Calculate total tokens used (input + output)
784
- #
785
- # @return [Integer] Total tokens used in conversation
786
- def cumulative_total_tokens
787
- cumulative_input_tokens + cumulative_output_tokens
788
- end
652
+ # Make the actual LLM API call with retry logic
653
+ response = call_llm_with_retry do
654
+ send_request.call(prepared_messages)
655
+ end
789
656
 
790
- # Calculate percentage of context window used
791
- #
792
- # @return [Float] Percentage (0.0 to 100.0), or 0.0 if limit unavailable
793
- def context_usage_percentage
794
- limit = context_limit
795
- return 0.0 if limit.nil? || limit.zero?
657
+ # Clear ephemeral content after successful call
658
+ @context_manager.clear_ephemeral
796
659
 
797
- (cumulative_total_tokens.to_f / limit * 100).round(2)
660
+ response
661
+ end
798
662
  end
799
663
 
800
- # Calculate remaining tokens in context window
801
- #
802
- # @return [Integer, nil] Tokens remaining, or nil if limit unavailable
803
- def tokens_remaining
804
- limit = context_limit
805
- return if limit.nil?
806
-
807
- limit - cumulative_total_tokens
808
- end
664
+ # --- Semaphore and Reminder Management ---
809
665
 
810
- # Compact the conversation history to reduce token usage
666
+ # Execute block with global semaphore
811
667
  #
812
- # Uses the Hybrid Production Strategy to intelligently compress the conversation:
813
- # 1. Tool result pruning - Truncate tool outputs (they're 80%+ of tokens!)
814
- # 2. Checkpoint creation - LLM-generated summary of conversation chunks
815
- # 3. Sliding window - Keep recent messages in full detail
816
- #
817
- # This is a manual operation - call it when you need to free up context space.
818
- # The method emits compression events via LogStream for monitoring.
819
- #
820
- # ## Usage
821
- #
822
- # # Use defaults
823
- # metrics = agent.compact_context
824
- # puts metrics.summary
825
- #
826
- # # With custom options
827
- # metrics = agent.compact_context(
828
- # tool_result_max_length: 300,
829
- # checkpoint_threshold: 40,
830
- # sliding_window_size: 15
831
- # )
832
- #
833
- # @param options [Hash] Compression options (see ContextCompactor::DEFAULT_OPTIONS)
834
- # @return [ContextCompactor::Metrics] Compression statistics
835
- def compact_context(**options)
836
- compactor = ContextCompactor.new(self, options)
837
- compactor.compact
668
+ # @yield Block to execute
669
+ # @return [Object] Result from block
670
+ def execute_with_global_semaphore(&block)
671
+ if @global_semaphore
672
+ @global_semaphore.acquire(&block)
673
+ else
674
+ yield
675
+ end
838
676
  end
839
677
 
840
- private
841
-
842
- # Inject LLM instrumentation middleware for API request/response logging
843
- #
844
- # This middleware captures HTTP requests/responses to LLM providers and
845
- # emits structured events via LogStream. Only injected when logging is enabled.
678
+ # Check if this is the first user message
846
679
  #
847
- # @return [void]
848
- def inject_llm_instrumentation
849
- # Safety checks
850
- return unless @provider
851
-
852
- faraday_conn = @provider.connection&.connection
853
- return unless faraday_conn
854
-
855
- # Check if middleware is already present to prevent duplicates
856
- return if @llm_instrumentation_injected
857
-
858
- # Get provider name for logging
859
- provider_name = @provider.class.name.split("::").last.downcase
860
-
861
- # Inject middleware at beginning of stack (position 0)
862
- # This ensures we capture raw requests before any transformations
863
- # Use fully qualified name to ensure Zeitwerk loads it
864
- faraday_conn.builder.insert(
865
- 0,
866
- SwarmSDK::Agent::LLMInstrumentationMiddleware,
867
- on_request: method(:handle_llm_api_request),
868
- on_response: method(:handle_llm_api_response),
869
- provider_name: provider_name,
870
- )
871
-
872
- # Mark as injected to prevent duplicates
873
- @llm_instrumentation_injected = true
874
-
875
- RubyLLM.logger.debug("SwarmSDK: Injected LLM instrumentation middleware for agent #{@agent_name}")
876
- rescue StandardError => e
877
- # Don't fail initialization if instrumentation fails
878
- RubyLLM.logger.error("SwarmSDK: Failed to inject LLM instrumentation: #{e.message}")
680
+ # @return [Boolean] true if no user messages exist yet
681
+ def first_message?
682
+ !has_user_message?
879
683
  end
880
684
 
881
- # Handle LLM API request event
882
- #
883
- # Emits llm_api_request event via LogStream with request details.
685
+ # Handle finish markers from hooks
884
686
  #
885
- # @param data [Hash] Request data from middleware
886
- # @return [void]
887
- def handle_llm_api_request(data)
888
- return unless LogStream.emitter
889
-
890
- LogStream.emit(
891
- type: "llm_api_request",
892
- agent: @agent_name,
893
- swarm_id: @agent_context&.swarm_id,
894
- parent_swarm_id: @agent_context&.parent_swarm_id,
895
- **data,
896
- )
897
- rescue StandardError => e
898
- RubyLLM.logger.error("SwarmSDK: Error emitting llm_api_request event: #{e.message}")
687
+ # @param response [Object] Response from ask (may be a finish marker hash)
688
+ # @return [RubyLLM::Message] Final message
689
+ def handle_finish_marker(response)
690
+ if response.is_a?(Hash)
691
+ if response[:__finish_agent__]
692
+ message = RubyLLM::Message.new(
693
+ role: :assistant,
694
+ content: response[:message],
695
+ model_id: model_id,
696
+ )
697
+ @context_tracker.finish_reason_override = "finish_agent" if @context_tracker
698
+ emit(:end_message, message)
699
+ message
700
+ elsif response[:__finish_swarm__]
701
+ # Propagate finish_swarm marker up
702
+ response
703
+ else
704
+ # Regular response
705
+ response
706
+ end
707
+ else
708
+ response
709
+ end
899
710
  end
900
711
 
901
- # Handle LLM API response event
902
- #
903
- # Emits llm_api_response event via LogStream with response details.
904
- #
905
- # @param data [Hash] Response data from middleware
906
- # @return [void]
907
- def handle_llm_api_response(data)
908
- return unless LogStream.emitter
712
+ # --- LLM Call Retry Logic ---
909
713
 
910
- LogStream.emit(
911
- type: "llm_api_response",
912
- agent: @agent_name,
913
- swarm_id: @agent_context&.swarm_id,
914
- parent_swarm_id: @agent_context&.parent_swarm_id,
915
- **data,
916
- )
917
- rescue StandardError => e
918
- RubyLLM.logger.error("SwarmSDK: Error emitting llm_api_response event: #{e.message}")
919
- end
920
-
921
- # Call LLM with retry logic for transient failures
922
- #
923
- # Retries up to 10 times with fixed 10-second delays for:
924
- # - Network errors
925
- # - Proxy failures
926
- # - Transient API errors
714
+ # Call LLM provider with retry logic for transient failures
927
715
  #
928
- # @yield Block that makes the LLM call
929
- # @return [RubyLLM::Message] LLM response
930
- # @raise [StandardError] If all retries exhausted
716
+ # @param max_retries [Integer] Maximum retry attempts
717
+ # @param delay [Integer] Delay between retries in seconds
718
+ # @yield Block that performs the LLM call
719
+ # @return [Object] Result from block
931
720
  def call_llm_with_retry(max_retries: 10, delay: 10, &block)
932
721
  attempts = 0
933
722
 
@@ -937,15 +726,13 @@ module SwarmSDK
937
726
  begin
938
727
  return yield
939
728
  rescue StandardError => e
940
- # Check if we should retry
941
729
  if attempts >= max_retries
942
- # Emit final failure log
943
730
  LogStream.emit(
944
731
  type: "llm_retry_exhausted",
945
732
  agent: @agent_name,
946
733
  swarm_id: @agent_context&.swarm_id,
947
734
  parent_swarm_id: @agent_context&.parent_swarm_id,
948
- model: @model&.id,
735
+ model: model_id,
949
736
  attempts: attempts,
950
737
  error_class: e.class.name,
951
738
  error_message: e.message,
@@ -954,13 +741,12 @@ module SwarmSDK
954
741
  raise
955
742
  end
956
743
 
957
- # Emit retry attempt log
958
744
  LogStream.emit(
959
745
  type: "llm_retry_attempt",
960
746
  agent: @agent_name,
961
747
  swarm_id: @agent_context&.swarm_id,
962
748
  parent_swarm_id: @agent_context&.parent_swarm_id,
963
- model: @model&.id,
749
+ model: model_id,
964
750
  attempt: attempts,
965
751
  max_retries: max_retries,
966
752
  error_class: e.class.name,
@@ -969,337 +755,19 @@ module SwarmSDK
969
755
  retry_delay: delay,
970
756
  )
971
757
 
972
- # Wait before retry
973
758
  sleep(delay)
974
759
  end
975
760
  end
976
761
  end
977
762
 
978
- # Build custom RubyLLM context for base_url/timeout overrides
979
- #
980
- # @param provider [String, Symbol] Provider name
981
- # @param base_url [String, nil] Custom API base URL
982
- # @param timeout [Integer] Request timeout in seconds
983
- # @return [RubyLLM::Context] Configured context
984
- def build_custom_context(provider:, base_url:, timeout:)
985
- RubyLLM.context do |config|
986
- # Set timeout for all providers
987
- config.request_timeout = timeout
988
-
989
- # Configure base_url if specified
990
- next unless base_url
991
-
992
- case provider.to_s
993
- when "openai", "deepseek", "perplexity", "mistral", "openrouter"
994
- config.openai_api_base = base_url
995
- config.openai_api_key = ENV["OPENAI_API_KEY"] || "dummy-key-for-local"
996
- # Use standard 'system' role instead of 'developer' for OpenAI-compatible proxies
997
- # Most proxies don't support OpenAI's newer 'developer' role convention
998
- config.openai_use_system_role = true
999
- when "ollama"
1000
- config.ollama_api_base = base_url
1001
- when "gpustack"
1002
- config.gpustack_api_base = base_url
1003
- config.gpustack_api_key = ENV["GPUSTACK_API_KEY"] || "dummy-key"
1004
- else
1005
- raise ArgumentError,
1006
- "Provider '#{provider}' doesn't support custom base_url. " \
1007
- "Only OpenAI-compatible providers (openai, deepseek, perplexity, mistral, openrouter), " \
1008
- "ollama, and gpustack support custom endpoints."
1009
- end
1010
- end
1011
- end
1012
-
1013
- # Fetch real model info for accurate context tracking
763
+ # Check if a tool call is a delegation tool
1014
764
  #
1015
- # This searches across ALL providers, so it works even when using proxies
1016
- # (e.g., Claude model through OpenAI-compatible proxy).
1017
- #
1018
- # @param model [String] Model ID to lookup
1019
- # @return [void]
1020
- def fetch_real_model_info(model)
1021
- @model_lookup_error = nil
1022
- @real_model_info = begin
1023
- RubyLLM.models.find(model) # Searches all providers when no provider specified
1024
- rescue StandardError => e
1025
- # Store warning info to emit later through LogStream
1026
- suggestions = suggest_similar_models(model)
1027
- @model_lookup_error = {
1028
- model: model,
1029
- error_message: e.message,
1030
- suggestions: suggestions,
1031
- }
1032
- nil
1033
- end
1034
- end
1035
-
1036
- # Determine which provider to use based on configuration
1037
- #
1038
- # When using base_url with OpenAI-compatible providers and api_version is set to
1039
- # 'v1/responses', use our custom provider that supports the responses API endpoint.
1040
- #
1041
- # @param provider [Symbol, String] The requested provider
1042
- # @param base_url [String, nil] Custom base URL
1043
- # @param api_version [String, nil] API endpoint version
1044
- # @return [Symbol] The provider to use
1045
- def determine_provider(provider, base_url, api_version)
1046
- return provider unless base_url
1047
-
1048
- # Use custom provider for OpenAI-compatible providers when api_version is v1/responses
1049
- # The custom provider supports both chat/completions and responses endpoints
1050
- case provider.to_s
1051
- when "openai", "deepseek", "perplexity", "mistral", "openrouter"
1052
- if api_version == "v1/responses"
1053
- :openai_with_responses
1054
- else
1055
- provider
1056
- end
1057
- else
1058
- provider
1059
- end
1060
- end
1061
-
1062
- # Configure the custom provider after creation to use responses API
1063
- #
1064
- # RubyLLM doesn't support passing custom parameters to provider initialization,
1065
- # so we configure the provider after the chat is created.
1066
- def configure_responses_api_provider
1067
- return unless provider.is_a?(SwarmSDK::Providers::OpenAIWithResponses)
1068
-
1069
- provider.use_responses_api = true
1070
- RubyLLM.logger.debug("SwarmSDK: Configured provider to use responses API")
1071
- end
1072
-
1073
- # Configure LLM parameters with proper temperature normalization
1074
- #
1075
- # Note: RubyLLM only normalizes temperature (for models that require specific values
1076
- # like gpt-5-mini which requires temperature=1.0) when using with_temperature().
1077
- # The with_params() method is designed for sending unparsed parameters directly to
1078
- # the LLM without provider-specific normalization. Therefore, we extract temperature
1079
- # and call with_temperature() separately to ensure proper normalization.
1080
- #
1081
- # @param params [Hash] Parameter hash (may include temperature and other params)
1082
- # @return [self] Returns self for method chaining
1083
- def configure_parameters(params)
1084
- return self if params.nil? || params.empty?
1085
-
1086
- # Extract temperature for separate handling
1087
- if params[:temperature]
1088
- with_temperature(params[:temperature])
1089
- params = params.except(:temperature)
1090
- end
1091
-
1092
- # Apply remaining parameters
1093
- with_params(**params) if params.any?
1094
-
1095
- self
1096
- end
1097
-
1098
- # Configure custom HTTP headers for LLM requests
1099
- #
1100
- # @param headers [Hash, nil] Custom HTTP headers
1101
- # @return [self] Returns self for method chaining
1102
- def configure_headers(headers)
1103
- return self if headers.nil? || headers.empty?
1104
-
1105
- with_headers(**headers)
1106
-
1107
- self
1108
- end
1109
-
1110
- # Acquire both global and local semaphores (if configured).
1111
- #
1112
- # Semaphores queue requests when limits are reached, ensuring graceful
1113
- # degradation instead of API errors.
1114
- #
1115
- # Order matters: acquire global first (broader scope), then local
1116
- def acquire_semaphores(&block)
1117
- if @global_semaphore && @local_semaphore
1118
- # Both limits: acquire global first, then local
1119
- @global_semaphore.acquire do
1120
- @local_semaphore.acquire(&block)
1121
- end
1122
- elsif @global_semaphore
1123
- # Only global limit
1124
- @global_semaphore.acquire(&block)
1125
- elsif @local_semaphore
1126
- # Only local limit
1127
- @local_semaphore.acquire(&block)
1128
- else
1129
- # No limits: execute immediately
1130
- yield
1131
- end
1132
- end
1133
-
1134
- # Suggest similar models when a model is not found
1135
- #
1136
- # @param query [String] Model name to search for
1137
- # @return [Array<RubyLLM::Model::Info>] Up to 3 similar models
1138
- def suggest_similar_models(query)
1139
- normalized_query = query.to_s.downcase.gsub(/[.\-_]/, "")
1140
-
1141
- RubyLLM.models.all.select do |model|
1142
- normalized_id = model.id.downcase.gsub(/[.\-_]/, "")
1143
- normalized_id.include?(normalized_query) ||
1144
- model.name&.downcase&.gsub(/[.\-_]/, "")&.include?(normalized_query)
1145
- end.first(3)
1146
- rescue StandardError
1147
- []
1148
- end
1149
-
1150
- # Execute a tool with error handling for common issues
1151
- #
1152
- # Handles:
1153
- # - Missing required parameters (validated before calling)
1154
- # - Tool doesn't exist (nil.call)
1155
- # - Other ArgumentErrors (from tool execution)
1156
- #
1157
- # Returns helpful messages with system reminders showing available tools
1158
- # or required parameters.
1159
- #
1160
- # @param tool_call [RubyLLM::ToolCall] Tool call from LLM
1161
- # @return [String, Object] Tool result or error message
1162
- def execute_tool_with_error_handling(tool_call)
1163
- tool_name = tool_call.name
1164
- tool_instance = tools[tool_name.to_sym]
1165
-
1166
- # Check if tool exists
1167
- unless tool_instance
1168
- return build_tool_not_found_error(tool_call)
1169
- end
1170
-
1171
- # Validate required parameters BEFORE calling the tool
1172
- validation_error = validate_tool_parameters(tool_call, tool_instance)
1173
- return validation_error if validation_error
1174
-
1175
- # Execute the tool
1176
- execute_tool(tool_call)
1177
- rescue ArgumentError => e
1178
- # This is an ArgumentError from INSIDE the tool execution (not missing params)
1179
- # Still try to provide helpful error message
1180
- build_argument_error(tool_call, e)
1181
- end
1182
-
1183
- # Validate that all required tool parameters are present
1184
- #
1185
- # @param tool_call [RubyLLM::ToolCall] Tool call from LLM
1186
- # @param tool_instance [RubyLLM::Tool] Tool instance
1187
- # @return [String, nil] Error message if validation fails, nil if valid
1188
- def validate_tool_parameters(tool_call, tool_instance)
1189
- return unless tool_instance.respond_to?(:parameters)
1190
-
1191
- # Get required parameters from tool definition
1192
- required_params = tool_instance.parameters.select { |_, param| param.required }
1193
-
1194
- # Check which required parameters are missing from the tool call
1195
- # ToolCall stores arguments in tool_call.arguments (not .parameters)
1196
- missing_params = required_params.reject do |param_name, _param|
1197
- tool_call.arguments.key?(param_name.to_s) || tool_call.arguments.key?(param_name.to_sym)
1198
- end
1199
-
1200
- return if missing_params.empty?
1201
-
1202
- # Build missing parameter error
1203
- build_missing_parameters_error(tool_call, tool_instance, missing_params.keys)
1204
- end
1205
-
1206
- # Build error message for missing required parameters
1207
- #
1208
- # @param tool_call [RubyLLM::ToolCall] Tool call that failed
1209
- # @param tool_instance [RubyLLM::Tool] Tool instance
1210
- # @param missing_param_names [Array<Symbol>] Names of missing parameters
1211
- # @return [String] Formatted error message
1212
- def build_missing_parameters_error(tool_call, tool_instance, missing_param_names)
1213
- tool_name = tool_call.name
1214
-
1215
- # Get all parameter information
1216
- param_info = tool_instance.parameters.map do |_param_name, param_obj|
1217
- {
1218
- name: param_obj.name.to_s,
1219
- type: param_obj.type,
1220
- description: param_obj.description,
1221
- required: param_obj.required,
1222
- }
1223
- end
1224
-
1225
- # Format missing parameter names nicely
1226
- missing_list = missing_param_names.map(&:to_s).join(", ")
1227
-
1228
- error_message = "Error calling #{tool_name}: missing parameters: #{missing_list}\n\n"
1229
- error_message += build_parameter_reminder(tool_name, param_info)
1230
- error_message
1231
- end
1232
-
1233
- # Build a helpful error message for ArgumentErrors from tool execution
1234
- #
1235
- # This handles ArgumentErrors that come from INSIDE the tool (not our validation).
1236
- # We still try to be helpful if it looks like a parameter issue.
1237
- #
1238
- # @param tool_call [RubyLLM::ToolCall] Tool call that failed
1239
- # @param error [ArgumentError] The ArgumentError raised
1240
- # @return [String] Formatted error message
1241
- def build_argument_error(tool_call, error)
1242
- tool_name = tool_call.name
1243
-
1244
- # Just report the error - we already validated parameters, so this is an internal tool error
1245
- "Error calling #{tool_name}: #{error.message}"
1246
- end
1247
-
1248
- # Build system reminder with parameter information
1249
- #
1250
- # @param tool_name [String] Tool name
1251
- # @param param_info [Array<Hash>] Parameter information
1252
- # @return [String] Formatted parameter reminder
1253
- def build_parameter_reminder(tool_name, param_info)
1254
- return "" if param_info.empty?
1255
-
1256
- required_params = param_info.select { |p| p[:required] }
1257
- optional_params = param_info.reject { |p| p[:required] }
1258
-
1259
- reminder = "<system-reminder>\n"
1260
- reminder += "CRITICAL: The #{tool_name} tool call failed due to missing parameters.\n\n"
1261
- reminder += "ALL REQUIRED PARAMETERS for #{tool_name}:\n\n"
1262
-
1263
- required_params.each do |param|
1264
- reminder += "- #{param[:name]} (#{param[:type]}): #{param[:description]}\n"
1265
- end
1266
-
1267
- if optional_params.any?
1268
- reminder += "\nOptional parameters:\n"
1269
- optional_params.each do |param|
1270
- reminder += "- #{param[:name]} (#{param[:type]}): #{param[:description]}\n"
1271
- end
1272
- end
1273
-
1274
- reminder += "\nINSTRUCTIONS FOR RECOVERY:\n"
1275
- reminder += "1. Use the Think tool to reason about what value EACH required parameter should have\n"
1276
- reminder += "2. After thinking, retry the #{tool_name} tool call with ALL required parameters included\n"
1277
- reminder += "3. Do NOT skip any required parameters - the tool will fail again if you do\n"
1278
- reminder += "</system-reminder>"
1279
-
1280
- reminder
1281
- end
1282
-
1283
- # Build a helpful error message when a tool doesn't exist
1284
- #
1285
- # @param tool_call [RubyLLM::ToolCall] Tool call that failed
1286
- # @return [String] Formatted error message with available tools list
1287
- def build_tool_not_found_error(tool_call)
1288
- tool_name = tool_call.name
1289
- available_tools = tools.keys.map(&:to_s).sort
1290
-
1291
- error_message = "Error: Tool '#{tool_name}' is not available.\n\n"
1292
- error_message += "You attempted to use '#{tool_name}', but this tool is not in your current toolset.\n\n"
1293
-
1294
- error_message += "<system-reminder>\n"
1295
- error_message += "Your available tools are:\n"
1296
- available_tools.each do |name|
1297
- error_message += " - #{name}\n"
1298
- end
1299
- error_message += "\nDo NOT attempt to use tools that are not in this list.\n"
1300
- error_message += "</system-reminder>"
765
+ # @param tool_call [RubyLLM::ToolCall] Tool call to check
766
+ # @return [Boolean] true if this is a delegation tool
767
+ def delegation_tool_call?(tool_call)
768
+ return false unless @agent_context
1301
769
 
1302
- error_message
770
+ @agent_context.delegation_tool?(tool_call.name)
1303
771
  end
1304
772
  end
1305
773
  end