swarm_sdk 2.0.6 → 2.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/swarm_sdk/agent/RETRY_LOGIC.md +127 -0
- data/lib/swarm_sdk/agent/builder.rb +16 -42
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +43 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +41 -3
- data/lib/swarm_sdk/agent/chat.rb +426 -61
- data/lib/swarm_sdk/agent/context.rb +5 -1
- data/lib/swarm_sdk/agent/context_manager.rb +309 -0
- data/lib/swarm_sdk/agent/definition.rb +57 -24
- data/lib/swarm_sdk/plugin.rb +147 -0
- data/lib/swarm_sdk/plugin_registry.rb +101 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +7 -1
- data/lib/swarm_sdk/swarm/agent_initializer.rb +80 -12
- data/lib/swarm_sdk/swarm/tool_configurator.rb +116 -44
- data/lib/swarm_sdk/swarm.rb +44 -8
- data/lib/swarm_sdk/tools/clock.rb +44 -0
- data/lib/swarm_sdk/tools/grep.rb +16 -19
- data/lib/swarm_sdk/tools/registry.rb +23 -12
- data/lib/swarm_sdk/tools/todo_write.rb +1 -1
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk.rb +4 -0
- metadata +7 -12
- data/lib/swarm_sdk/prompts/memory.md.erb +0 -480
- data/lib/swarm_sdk/tools/memory/memory_delete.rb +0 -64
- data/lib/swarm_sdk/tools/memory/memory_edit.rb +0 -145
- data/lib/swarm_sdk/tools/memory/memory_glob.rb +0 -94
- data/lib/swarm_sdk/tools/memory/memory_grep.rb +0 -147
- data/lib/swarm_sdk/tools/memory/memory_multi_edit.rb +0 -228
- data/lib/swarm_sdk/tools/memory/memory_read.rb +0 -82
- data/lib/swarm_sdk/tools/memory/memory_write.rb +0 -90
- data/lib/swarm_sdk/tools/stores/memory_storage.rb +0 -300
- data/lib/swarm_sdk/tools/stores/storage_read_tracker.rb +0 -61
data/lib/swarm_sdk/agent/chat.rb
CHANGED
|
@@ -40,10 +40,11 @@ module SwarmSDK
|
|
|
40
40
|
# Initialize AgentChat with rate limiting
|
|
41
41
|
#
|
|
42
42
|
# @param definition [Hash] Agent definition containing all configuration
|
|
43
|
+
# @param agent_name [Symbol, nil] Agent identifier (for plugin callbacks)
|
|
43
44
|
# @param global_semaphore [Async::Semaphore, nil] Shared across all agents (not part of definition)
|
|
44
45
|
# @param options [Hash] Additional options to pass to RubyLLM::Chat
|
|
45
46
|
# @raise [ArgumentError] If provider doesn't support custom base_url or provider not specified with base_url
|
|
46
|
-
def initialize(definition:, global_semaphore: nil, **options)
|
|
47
|
+
def initialize(definition:, agent_name: nil, global_semaphore: nil, **options)
|
|
47
48
|
# Extract configuration from definition
|
|
48
49
|
model = definition[:model]
|
|
49
50
|
provider = definition[:provider]
|
|
@@ -88,6 +89,12 @@ module SwarmSDK
|
|
|
88
89
|
super(model: model, assume_model_exists: assume_model_exists, **options)
|
|
89
90
|
end
|
|
90
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
|
+
|
|
91
98
|
# Rate limiting semaphores
|
|
92
99
|
@global_semaphore = global_semaphore
|
|
93
100
|
@local_semaphore = max_concurrent_tools ? Async::Semaphore.new(max_concurrent_tools) : nil
|
|
@@ -102,6 +109,14 @@ module SwarmSDK
|
|
|
102
109
|
# Context tracker (created after agent_context is set)
|
|
103
110
|
@context_tracker = nil
|
|
104
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
|
+
|
|
105
120
|
# Try to fetch real model info for accurate context tracking
|
|
106
121
|
# This searches across ALL providers, so it works even when using proxies
|
|
107
122
|
# (e.g., Claude model through OpenAI-compatible proxy)
|
|
@@ -155,6 +170,57 @@ module SwarmSDK
|
|
|
155
170
|
)
|
|
156
171
|
end
|
|
157
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
|
+
|
|
158
224
|
# Override ask to inject system reminders and periodic TodoWrite reminders
|
|
159
225
|
#
|
|
160
226
|
# Note: This is called BEFORE HookIntegration#ask (due to module include order),
|
|
@@ -165,9 +231,21 @@ module SwarmSDK
|
|
|
165
231
|
# @return [RubyLLM::Message] LLM response
|
|
166
232
|
def ask(prompt, **options)
|
|
167
233
|
# Check if this is the first user message
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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)
|
|
171
249
|
|
|
172
250
|
# Trigger user_prompt hook manually since we're bypassing the normal ask flow
|
|
173
251
|
if @hook_executor
|
|
@@ -189,16 +267,175 @@ module SwarmSDK
|
|
|
189
267
|
# Call complete to get LLM response
|
|
190
268
|
complete(**options)
|
|
191
269
|
else
|
|
192
|
-
#
|
|
270
|
+
# Build prompt with embedded reminders (if needed)
|
|
271
|
+
full_prompt = prompt
|
|
272
|
+
|
|
273
|
+
# Add periodic TodoWrite reminder if needed
|
|
193
274
|
if SystemReminderInjector.should_inject_todowrite_reminder?(self, @last_todowrite_message_index)
|
|
194
|
-
|
|
275
|
+
full_prompt = "#{full_prompt}\n\n#{SystemReminderInjector::TODOWRITE_PERIODIC_REMINDER}"
|
|
195
276
|
# Update tracking
|
|
196
277
|
@last_todowrite_message_index = SystemReminderInjector.find_last_todowrite_index(self)
|
|
197
278
|
end
|
|
198
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
|
+
|
|
199
286
|
# Normal ask behavior for subsequent messages
|
|
200
287
|
# This calls super which goes to HookIntegration's ask override
|
|
201
|
-
|
|
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
|
|
202
439
|
end
|
|
203
440
|
end
|
|
204
441
|
|
|
@@ -289,6 +526,7 @@ module SwarmSDK
|
|
|
289
526
|
return result if result.is_a?(RubyLLM::Tool::Halt)
|
|
290
527
|
|
|
291
528
|
# Add tool result to conversation
|
|
529
|
+
# add_message automatically extracts reminders and stores them as ephemeral
|
|
292
530
|
content = result.is_a?(RubyLLM::Content) ? result : result.to_s
|
|
293
531
|
message = add_message(
|
|
294
532
|
role: :tool,
|
|
@@ -332,6 +570,7 @@ module SwarmSDK
|
|
|
332
570
|
result = pre_result[:custom_result] || "Tool execution blocked by hook"
|
|
333
571
|
@on[:tool_result]&.call(result)
|
|
334
572
|
|
|
573
|
+
# add_message automatically extracts reminders
|
|
335
574
|
content = result.is_a?(RubyLLM::Content) ? result : result.to_s
|
|
336
575
|
message = add_message(
|
|
337
576
|
role: :tool,
|
|
@@ -360,6 +599,7 @@ module SwarmSDK
|
|
|
360
599
|
{ tool_call: tool_call, result: result, message: nil }
|
|
361
600
|
else
|
|
362
601
|
# Add tool result to conversation
|
|
602
|
+
# add_message automatically extracts reminders and stores them as ephemeral
|
|
363
603
|
content = result.is_a?(RubyLLM::Content) ? result : result.to_s
|
|
364
604
|
message = add_message(
|
|
365
605
|
role: :tool,
|
|
@@ -422,7 +662,7 @@ module SwarmSDK
|
|
|
422
662
|
# This is needed for setting agent_name and other provider-specific settings.
|
|
423
663
|
#
|
|
424
664
|
# @return [RubyLLM::Provider::Base] Provider instance
|
|
425
|
-
attr_reader :provider, :global_semaphore, :local_semaphore, :real_model_info, :context_tracker
|
|
665
|
+
attr_reader :provider, :global_semaphore, :local_semaphore, :real_model_info, :context_tracker, :context_manager
|
|
426
666
|
|
|
427
667
|
# Get context window limit for the current model
|
|
428
668
|
#
|
|
@@ -525,6 +765,57 @@ module SwarmSDK
|
|
|
525
765
|
|
|
526
766
|
private
|
|
527
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
|
+
|
|
528
819
|
# Build custom RubyLLM context for base_url/timeout overrides
|
|
529
820
|
#
|
|
530
821
|
# @param provider [String, Symbol] Provider name
|
|
@@ -694,83 +985,157 @@ module SwarmSDK
|
|
|
694
985
|
[]
|
|
695
986
|
end
|
|
696
987
|
|
|
697
|
-
# Execute a tool with
|
|
988
|
+
# Execute a tool with error handling for common issues
|
|
698
989
|
#
|
|
699
|
-
#
|
|
700
|
-
#
|
|
701
|
-
# -
|
|
702
|
-
# -
|
|
703
|
-
#
|
|
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.
|
|
704
997
|
#
|
|
705
998
|
# @param tool_call [RubyLLM::ToolCall] Tool call from LLM
|
|
706
999
|
# @return [String, Object] Tool result or error message
|
|
707
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
|
|
708
1014
|
execute_tool(tool_call)
|
|
709
1015
|
rescue ArgumentError => e
|
|
710
|
-
#
|
|
711
|
-
#
|
|
712
|
-
|
|
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)
|
|
713
1019
|
end
|
|
714
1020
|
|
|
715
|
-
#
|
|
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.
|
|
716
1075
|
#
|
|
717
1076
|
# @param tool_call [RubyLLM::ToolCall] Tool call that failed
|
|
718
1077
|
# @param error [ArgumentError] The ArgumentError raised
|
|
719
|
-
# @return [String] Formatted error message
|
|
720
|
-
def
|
|
1078
|
+
# @return [String] Formatted error message
|
|
1079
|
+
def build_argument_error(tool_call, error)
|
|
721
1080
|
tool_name = tool_call.name
|
|
722
|
-
tool_instance = tools[tool_name.to_sym]
|
|
723
1081
|
|
|
724
|
-
#
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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"
|
|
729
1103
|
end
|
|
730
1104
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
{
|
|
736
|
-
name: param_obj.name.to_s,
|
|
737
|
-
type: param_obj.type,
|
|
738
|
-
description: param_obj.description,
|
|
739
|
-
required: param_obj.required,
|
|
740
|
-
}
|
|
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"
|
|
741
1109
|
end
|
|
742
|
-
else
|
|
743
|
-
[]
|
|
744
1110
|
end
|
|
745
1111
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
if param_info.any?
|
|
752
|
-
required_params = param_info.select { |p| p[:required] }
|
|
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>"
|
|
753
1117
|
|
|
754
|
-
|
|
755
|
-
|
|
1118
|
+
reminder
|
|
1119
|
+
end
|
|
756
1120
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
|
760
1128
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
error_message += "\nOptional parameters:\n"
|
|
764
|
-
optional_params.each do |param|
|
|
765
|
-
error_message += "- #{param[:name]} (#{param[:type]}): #{param[:description]}\n"
|
|
766
|
-
end
|
|
767
|
-
end
|
|
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"
|
|
768
1131
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
error_message += "
|
|
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"
|
|
773
1136
|
end
|
|
1137
|
+
error_message += "\nDo NOT attempt to use tools that are not in this list.\n"
|
|
1138
|
+
error_message += "</system-reminder>"
|
|
774
1139
|
|
|
775
1140
|
error_message
|
|
776
1141
|
end
|
|
@@ -27,7 +27,11 @@ module SwarmSDK
|
|
|
27
27
|
# context.delegation?(call_id: "call_123") # => true
|
|
28
28
|
class Context
|
|
29
29
|
# Thresholds for context limit warnings (in percentage)
|
|
30
|
-
|
|
30
|
+
# 60% triggers automatic compression, 80%/90% are informational warnings
|
|
31
|
+
CONTEXT_WARNING_THRESHOLDS = [60, 80, 90].freeze
|
|
32
|
+
|
|
33
|
+
# Threshold at which automatic compression is triggered
|
|
34
|
+
COMPRESSION_THRESHOLD = 60
|
|
31
35
|
|
|
32
36
|
attr_reader :name, :delegation_tools, :metadata, :warning_thresholds_hit
|
|
33
37
|
|