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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/lib/swarm_sdk/agent/RETRY_LOGIC.md +127 -0
  3. data/lib/swarm_sdk/agent/builder.rb +16 -42
  4. data/lib/swarm_sdk/agent/chat/context_tracker.rb +43 -0
  5. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +41 -3
  6. data/lib/swarm_sdk/agent/chat.rb +426 -61
  7. data/lib/swarm_sdk/agent/context.rb +5 -1
  8. data/lib/swarm_sdk/agent/context_manager.rb +309 -0
  9. data/lib/swarm_sdk/agent/definition.rb +57 -24
  10. data/lib/swarm_sdk/plugin.rb +147 -0
  11. data/lib/swarm_sdk/plugin_registry.rb +101 -0
  12. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +7 -1
  13. data/lib/swarm_sdk/swarm/agent_initializer.rb +80 -12
  14. data/lib/swarm_sdk/swarm/tool_configurator.rb +116 -44
  15. data/lib/swarm_sdk/swarm.rb +44 -8
  16. data/lib/swarm_sdk/tools/clock.rb +44 -0
  17. data/lib/swarm_sdk/tools/grep.rb +16 -19
  18. data/lib/swarm_sdk/tools/registry.rb +23 -12
  19. data/lib/swarm_sdk/tools/todo_write.rb +1 -1
  20. data/lib/swarm_sdk/version.rb +1 -1
  21. data/lib/swarm_sdk.rb +4 -0
  22. metadata +7 -12
  23. data/lib/swarm_sdk/prompts/memory.md.erb +0 -480
  24. data/lib/swarm_sdk/tools/memory/memory_delete.rb +0 -64
  25. data/lib/swarm_sdk/tools/memory/memory_edit.rb +0 -145
  26. data/lib/swarm_sdk/tools/memory/memory_glob.rb +0 -94
  27. data/lib/swarm_sdk/tools/memory/memory_grep.rb +0 -147
  28. data/lib/swarm_sdk/tools/memory/memory_multi_edit.rb +0 -228
  29. data/lib/swarm_sdk/tools/memory/memory_read.rb +0 -82
  30. data/lib/swarm_sdk/tools/memory/memory_write.rb +0 -90
  31. data/lib/swarm_sdk/tools/stores/memory_storage.rb +0 -300
  32. data/lib/swarm_sdk/tools/stores/storage_read_tracker.rb +0 -61
@@ -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
- if SystemReminderInjector.first_message?(self)
169
- # Manually construct the first message sequence with system reminders
170
- SystemReminderInjector.inject_first_message_reminders(self, prompt)
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
- # Inject periodic TodoWrite reminder if needed
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
- add_message(role: :user, content: SystemReminderInjector::TODOWRITE_PERIODIC_REMINDER)
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
- super(prompt, **options)
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 ArgumentError handling for missing parameters
988
+ # Execute a tool with error handling for common issues
698
989
  #
699
- # When a tool is called with missing required parameters, this catches the
700
- # ArgumentError and returns a helpful message to the LLM with:
701
- # - Which parameter is missing
702
- # - Instructions to retry with correct parameters
703
- # - System reminder showing all required parameters
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
- # Extract parameter info from the error message
711
- # ArgumentError messages typically: "missing keyword: parameter_name" or "missing keywords: param1, param2"
712
- build_missing_parameter_error(tool_call, e)
1016
+ # This is an ArgumentError from INSIDE the tool execution (not missing params)
1017
+ # Still try to provide helpful error message
1018
+ build_argument_error(tool_call, e)
713
1019
  end
714
1020
 
715
- # Build a helpful error message for missing tool parameters
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 with parameter information
720
- def build_missing_parameter_error(tool_call, error)
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
- # Extract which parameters are missing from error message
725
- missing_params = if error.message.match(/missing keyword(?:s)?: (.+)/)
726
- ::Regexp.last_match(1).split(", ").map(&:strip)
727
- else
728
- ["unknown"]
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
- # Get tool parameter information from RubyLLM::Tool
732
- param_info = if tool_instance.respond_to?(:parameters)
733
- # RubyLLM tools have a parameters method that returns { name => Parameter }
734
- tool_instance.parameters.map do |_param_name, param_obj|
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
- # Build error message
747
- error_message = "Error calling #{tool_name}: #{error.message}\n\n"
748
- error_message += "Please retry the tool call with all required parameters.\n\n"
749
-
750
- # Add system reminder with parameter information
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
- error_message += "<system-reminder>\n"
755
- error_message += "The #{tool_name} tool requires the following parameters:\n\n"
1118
+ reminder
1119
+ end
756
1120
 
757
- required_params.each do |param|
758
- error_message += "- #{param[:name]} (#{param[:type]}, REQUIRED): #{param[:description]}\n"
759
- end
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
- optional_params = param_info.reject { |p| p[:required] }
762
- if optional_params.any?
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
- error_message += "\nYou were missing: #{missing_params.join(", ")}\n"
770
- error_message += "</system-reminder>"
771
- else
772
- error_message += "Missing parameters: #{missing_params.join(", ")}"
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
- CONTEXT_WARNING_THRESHOLDS = [80, 90].freeze
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