claude_swarm 1.0.10 → 1.0.11

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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/{CHANGELOG.md → CHANGELOG.claude-swarm.md} +3 -0
  3. data/CLAUDE.md +0 -1
  4. data/decisions/2025-11-22-001-global-agent-registry.md +172 -0
  5. data/docs/v2/CHANGELOG.swarm_cli.md +12 -0
  6. data/docs/v2/CHANGELOG.swarm_memory.md +139 -0
  7. data/docs/v2/CHANGELOG.swarm_sdk.md +249 -1
  8. data/docs/v2/README.md +15 -5
  9. data/docs/v2/guides/complete-tutorial.md +93 -7
  10. data/docs/v2/guides/getting-started.md +3 -1
  11. data/docs/v2/guides/memory-adapters.md +41 -0
  12. data/docs/v2/guides/{migrating-to-2.3.md → migrating-to-2.x.md} +213 -8
  13. data/docs/v2/guides/plugins.md +52 -5
  14. data/docs/v2/guides/rails-integration.md +6 -0
  15. data/docs/v2/guides/swarm-memory.md +2 -13
  16. data/docs/v2/reference/cli.md +0 -1
  17. data/docs/v2/reference/configuration_reference.md +300 -0
  18. data/docs/v2/reference/event_payload_structures.md +26 -4
  19. data/docs/v2/reference/ruby-dsl.md +457 -4
  20. data/docs/v2/reference/swarm_memory_technical_details.md +7 -29
  21. data/docs/v2/reference/yaml.md +2 -2
  22. data/lib/claude_swarm/mcp_generator.rb +1 -1
  23. data/lib/claude_swarm/orchestrator.rb +8 -1
  24. data/lib/claude_swarm/version.rb +1 -1
  25. data/lib/swarm_cli/version.rb +1 -1
  26. data/lib/swarm_memory/core/semantic_index.rb +10 -2
  27. data/lib/swarm_memory/core/storage.rb +7 -2
  28. data/lib/swarm_memory/dsl/memory_config.rb +37 -0
  29. data/lib/swarm_memory/integration/sdk_plugin.rb +120 -27
  30. data/lib/swarm_memory/optimization/defragmenter.rb +1 -1
  31. data/lib/swarm_memory/prompts/memory_researcher.md.erb +0 -1
  32. data/lib/swarm_memory/tools/load_skill.rb +0 -1
  33. data/lib/swarm_memory/tools/memory_edit.rb +2 -1
  34. data/lib/swarm_memory/tools/memory_read.rb +1 -1
  35. data/lib/swarm_memory/version.rb +1 -1
  36. data/lib/swarm_memory.rb +7 -5
  37. data/lib/swarm_sdk/agent/chat.rb +1 -1
  38. data/lib/swarm_sdk/agent/chat_helpers/context_tracker.rb +4 -0
  39. data/lib/swarm_sdk/agent/chat_helpers/hook_integration.rb +1 -1
  40. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +38 -4
  41. data/lib/swarm_sdk/agent/chat_helpers/logging_helpers.rb +2 -2
  42. data/lib/swarm_sdk/agent/chat_helpers/system_reminder_injector.rb +3 -5
  43. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +48 -0
  44. data/lib/swarm_sdk/agent/context.rb +1 -2
  45. data/lib/swarm_sdk/agent/definition.rb +3 -3
  46. data/lib/swarm_sdk/agent/system_prompt_builder.rb +1 -1
  47. data/lib/swarm_sdk/agent_registry.rb +146 -0
  48. data/lib/swarm_sdk/builders/base_builder.rb +91 -12
  49. data/lib/swarm_sdk/config.rb +302 -0
  50. data/lib/swarm_sdk/configuration/parser.rb +22 -2
  51. data/lib/swarm_sdk/configuration.rb +13 -4
  52. data/lib/swarm_sdk/context_compactor/token_counter.rb +2 -6
  53. data/lib/swarm_sdk/custom_tool_registry.rb +226 -0
  54. data/lib/swarm_sdk/hooks/adapter.rb +3 -3
  55. data/lib/swarm_sdk/hooks/shell_executor.rb +4 -3
  56. data/lib/swarm_sdk/models.json +4333 -1
  57. data/lib/swarm_sdk/models.rb +43 -2
  58. data/lib/swarm_sdk/plugin.rb +2 -2
  59. data/lib/swarm_sdk/result.rb +52 -0
  60. data/lib/swarm_sdk/swarm/agent_initializer.rb +1 -1
  61. data/lib/swarm_sdk/swarm/hook_triggers.rb +1 -0
  62. data/lib/swarm_sdk/swarm/logging_callbacks.rb +1 -0
  63. data/lib/swarm_sdk/swarm/tool_configurator.rb +18 -4
  64. data/lib/swarm_sdk/swarm.rb +76 -13
  65. data/lib/swarm_sdk/tools/bash.rb +7 -9
  66. data/lib/swarm_sdk/tools/glob.rb +5 -5
  67. data/lib/swarm_sdk/tools/read.rb +8 -8
  68. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +4 -3
  69. data/lib/swarm_sdk/tools/web_fetch.rb +20 -18
  70. data/lib/swarm_sdk/version.rb +1 -1
  71. data/lib/swarm_sdk/workflow/builder.rb +49 -0
  72. data/lib/swarm_sdk/workflow/node_builder.rb +4 -2
  73. data/lib/swarm_sdk/workflow/transformer_executor.rb +4 -3
  74. data/lib/swarm_sdk.rb +261 -105
  75. data/swarm_cli.gemspec +1 -1
  76. data/swarm_memory.gemspec +8 -3
  77. data/swarm_sdk.gemspec +4 -4
  78. data/team_full.yml +104 -300
  79. metadata +9 -5
  80. data/lib/swarm_memory/tools/memory_multi_edit.rb +0 -281
  81. /data/lib/swarm_memory/{errors.rb → error.rb} +0 -0
@@ -84,6 +84,43 @@ module SwarmMemory
84
84
  @mode = value.to_sym
85
85
  end
86
86
 
87
+ # DSL method to set/get semantic weight for hybrid search
88
+ #
89
+ # Controls how much semantic (embedding) similarity affects search results.
90
+ # Default is 0.5 (50%). Set to 1.0 for pure semantic search.
91
+ #
92
+ # @param value [Float, nil] Weight between 0.0 and 1.0
93
+ # @return [Float, nil] Current semantic weight
94
+ #
95
+ # @example Pure semantic search (no keyword penalty)
96
+ # semantic_weight 1.0
97
+ # keyword_weight 0.0
98
+ def semantic_weight(value = nil)
99
+ if value.nil?
100
+ @adapter_options[:semantic_weight]
101
+ else
102
+ @adapter_options[:semantic_weight] = value.to_f
103
+ end
104
+ end
105
+
106
+ # DSL method to set/get keyword weight for hybrid search
107
+ #
108
+ # Controls how much keyword (tag) matching affects search results.
109
+ # Default is 0.5 (50%). Set to 0.0 to disable keyword matching.
110
+ #
111
+ # @param value [Float, nil] Weight between 0.0 and 1.0
112
+ # @return [Float, nil] Current keyword weight
113
+ #
114
+ # @example Disable keyword matching
115
+ # keyword_weight 0.0
116
+ def keyword_weight(value = nil)
117
+ if value.nil?
118
+ @adapter_options[:keyword_weight]
119
+ else
120
+ @adapter_options[:keyword_weight] = value.to_f
121
+ end
122
+ end
123
+
87
124
  # Check if memory is enabled
88
125
  #
89
126
  # @return [Boolean] True if adapter is configured with required options
@@ -22,6 +22,9 @@ module SwarmMemory
22
22
  # Track memory mode for each agent: { agent_name => mode }
23
23
  # Modes: :assistant (default), :retrieval, :researcher
24
24
  @modes = {}
25
+ # Track threshold configuration for each agent: { agent_name => config }
26
+ # Enables per-adapter threshold tuning with ENV fallback
27
+ @threshold_configs = {}
25
28
  end
26
29
 
27
30
  # Plugin identifier
@@ -49,7 +52,6 @@ module SwarmMemory
49
52
  :MemoryGrep,
50
53
  :MemoryWrite,
51
54
  :MemoryEdit,
52
- :MemoryMultiEdit,
53
55
  :MemoryDelete,
54
56
  :MemoryDefrag,
55
57
  ]
@@ -75,7 +77,6 @@ module SwarmMemory
75
77
  :MemoryGrep,
76
78
  :MemoryWrite,
77
79
  :MemoryEdit,
78
- :MemoryMultiEdit,
79
80
  :MemoryDelete,
80
81
  :MemoryDefrag,
81
82
  ]
@@ -109,10 +110,12 @@ module SwarmMemory
109
110
  # MemoryConfig object (from DSL)
110
111
  [config.adapter_type, config.adapter_options]
111
112
  elsif config.is_a?(Hash)
112
- # Hash (from YAML)
113
+ # Hash (from YAML) - symbolize keys for adapter compatibility
113
114
  adapter = (config[:adapter] || config["adapter"] || :filesystem).to_sym
114
- options = config.reject { |k, _v| k == :adapter || k == "adapter" || k == :mode || k == "mode" }
115
- [adapter, options]
115
+ options = config.reject { |k, _v| [:adapter, "adapter", :mode, "mode"].include?(k) }
116
+ # Symbolize keys so adapter receives keyword arguments correctly
117
+ symbolized_options = options.transform_keys { |k| k.to_s.to_sym }
118
+ [adapter, symbolized_options]
116
119
  else
117
120
  raise SwarmSDK::ConfigurationError, "Invalid memory configuration for #{agent_name}"
118
121
  end
@@ -124,7 +127,17 @@ module SwarmMemory
124
127
  raise SwarmSDK::ConfigurationError, "#{e.message} for agent #{agent_name}"
125
128
  end
126
129
 
127
- # Instantiate adapter with options
130
+ # Extract hybrid search weights and other SDK-level config (before passing to adapter)
131
+ # Keys are already symbolized at this point
132
+ semantic_weight = adapter_options.delete(:semantic_weight)
133
+ keyword_weight = adapter_options.delete(:keyword_weight)
134
+
135
+ # Remove other SDK-level threshold configs that shouldn't go to adapter
136
+ adapter_options.delete(:discovery_threshold)
137
+ adapter_options.delete(:discovery_threshold_short)
138
+ adapter_options.delete(:adaptive_word_cutoff)
139
+
140
+ # Instantiate adapter with options (weights removed, adapter doesn't need them)
128
141
  # Note: Adapter is responsible for validating its own requirements
129
142
  begin
130
143
  adapter = adapter_class.new(**adapter_options)
@@ -136,8 +149,13 @@ module SwarmMemory
136
149
  # Create embedder for semantic search
137
150
  embedder = Embeddings::InformersEmbedder.new
138
151
 
139
- # Create storage with embedder (enables semantic features)
140
- Core::Storage.new(adapter: adapter, embedder: embedder)
152
+ # Create storage with embedder and hybrid search weights
153
+ Core::Storage.new(
154
+ adapter: adapter,
155
+ embedder: embedder,
156
+ semantic_weight: semantic_weight,
157
+ keyword_weight: keyword_weight,
158
+ )
141
159
  end
142
160
 
143
161
  # Parse memory configuration
@@ -199,24 +217,36 @@ module SwarmMemory
199
217
  end
200
218
  end
201
219
 
202
- # Check if storage should be created for this agent
220
+ # Check if memory is configured for this agent
221
+ #
222
+ # Delegates adapter-specific validation to the adapter itself.
223
+ # Filesystem adapter requires 'directory', custom adapters may use other keys.
203
224
  #
204
225
  # @param agent_definition [Agent::Definition] Agent definition
205
- # @return [Boolean] True if agent has memory configuration
206
- def storage_enabled?(agent_definition)
226
+ # @return [Boolean] True if agent has valid memory configuration
227
+ def memory_configured?(agent_definition)
207
228
  memory_config = agent_definition.plugin_config(:memory)
208
229
  return false if memory_config.nil?
209
230
 
210
- # MemoryConfig object (from DSL)
231
+ # MemoryConfig object (from DSL) - delegates to its enabled? method
211
232
  return memory_config.enabled? if memory_config.respond_to?(:enabled?)
212
233
 
213
- # Hash (from YAML) - check for directory key
214
- if memory_config.is_a?(Hash)
234
+ # Hash (from YAML)
235
+ return false unless memory_config.is_a?(Hash)
236
+ return false if memory_config.empty?
237
+
238
+ adapter = (memory_config[:adapter] || memory_config["adapter"] || :filesystem).to_sym
239
+
240
+ case adapter
241
+ when :filesystem
242
+ # Filesystem adapter requires directory
215
243
  directory = memory_config[:directory] || memory_config["directory"]
216
- return !directory.nil? && !directory.to_s.strip.empty?
244
+ !directory.nil? && !directory.to_s.strip.empty?
245
+ else
246
+ # Custom adapters: presence of config is sufficient
247
+ # Adapter will validate its own requirements during initialization
248
+ true
217
249
  end
218
-
219
- false
220
250
  end
221
251
 
222
252
  # Contribute to agent serialization
@@ -293,9 +323,18 @@ module SwarmMemory
293
323
 
294
324
  builder.instance_eval do
295
325
  memory do
326
+ # Standard options
296
327
  directory(memory_config[:directory]) if memory_config[:directory]
297
328
  adapter(memory_config[:adapter]) if memory_config[:adapter]
298
329
  mode(memory_config[:mode]) if memory_config[:mode]
330
+
331
+ # Pass through all custom adapter options
332
+ # Handle both symbol and string keys (YAML may have either)
333
+ standard_keys = [:directory, :adapter, :mode, "directory", "adapter", "mode"]
334
+ custom_keys = memory_config.keys - standard_keys
335
+ custom_keys.each do |key|
336
+ option(key.to_sym, memory_config[key]) # Normalize to symbol
337
+ end
299
338
  end
300
339
  end
301
340
  end
@@ -336,6 +375,7 @@ module SwarmMemory
336
375
  # Store storage and mode using BASE NAME
337
376
  @storages[base_name] = storage # ← Changed from agent_name to base_name
338
377
  @modes[base_name] = mode # ← Changed from agent_name to base_name
378
+ @threshold_configs[base_name] = extract_threshold_config(memory_config)
339
379
 
340
380
  # Get mode-specific tools
341
381
  allowed_tools = tools_for_mode(mode)
@@ -384,20 +424,27 @@ module SwarmMemory
384
424
  # V7.0: Extract base name for storage lookup (delegation instances share storage)
385
425
  base_name = agent_name.to_s.split("@").first.to_sym
386
426
  storage = @storages[base_name] # ← Changed from agent_name to base_name
427
+ config = @threshold_configs[base_name] || {}
387
428
 
388
429
  return [] unless storage&.semantic_index
389
430
  return [] if prompt.nil? || prompt.empty?
390
431
 
391
432
  # Adaptive threshold based on query length
392
433
  # Short queries use lower threshold as they have less semantic richness
393
- # Optimal: cutoff=10 words, short=0.25, normal=0.35 (discovered via systematic evaluation)
434
+ # Fallback chain: config ENV default
394
435
  word_count = prompt.split.size
395
- word_cutoff = (ENV["SWARM_MEMORY_ADAPTIVE_WORD_CUTOFF"] || "10").to_i
436
+ word_cutoff = config[:adaptive_word_cutoff] ||
437
+ ENV["SWARM_MEMORY_ADAPTIVE_WORD_CUTOFF"]&.to_i ||
438
+ 10
396
439
 
397
440
  threshold = if word_count < word_cutoff
398
- (ENV["SWARM_MEMORY_DISCOVERY_THRESHOLD_SHORT"] || "0.25").to_f
441
+ config[:discovery_threshold_short] ||
442
+ ENV["SWARM_MEMORY_DISCOVERY_THRESHOLD_SHORT"]&.to_f ||
443
+ 0.25
399
444
  else
400
- (ENV["SWARM_MEMORY_DISCOVERY_THRESHOLD"] || "0.35").to_f
445
+ config[:discovery_threshold] ||
446
+ ENV["SWARM_MEMORY_DISCOVERY_THRESHOLD"]&.to_f ||
447
+ 0.35
401
448
  end
402
449
  reminders = []
403
450
 
@@ -482,9 +529,15 @@ module SwarmMemory
482
529
  }
483
530
  end
484
531
 
485
- # Get actual weights being used (from ENV or defaults)
486
- semantic_weight = (ENV["SWARM_MEMORY_SEMANTIC_WEIGHT"] || "0.5").to_f
487
- keyword_weight = (ENV["SWARM_MEMORY_KEYWORD_WEIGHT"] || "0.5").to_f
532
+ # Get actual weights being used (fallback chain: config → ENV defaults)
533
+ base_name = agent_name.to_s.split("@").first.to_sym
534
+ config = @threshold_configs[base_name] || {}
535
+ semantic_weight = config[:semantic_weight] ||
536
+ ENV["SWARM_MEMORY_SEMANTIC_WEIGHT"]&.to_f ||
537
+ 0.5
538
+ keyword_weight = config[:keyword_weight] ||
539
+ ENV["SWARM_MEMORY_KEYWORD_WEIGHT"]&.to_f ||
540
+ 0.5
488
541
 
489
542
  SwarmSDK::LogStream.emit(
490
543
  type: "semantic_skill_search",
@@ -545,9 +598,15 @@ module SwarmMemory
545
598
  }
546
599
  end
547
600
 
548
- # Get actual weights being used (from ENV or defaults)
549
- semantic_weight = (ENV["SWARM_MEMORY_SEMANTIC_WEIGHT"] || "0.5").to_f
550
- keyword_weight = (ENV["SWARM_MEMORY_KEYWORD_WEIGHT"] || "0.5").to_f
601
+ # Get actual weights being used (fallback chain: config → ENV defaults)
602
+ base_name = agent_name.to_s.split("@").first.to_sym
603
+ config = @threshold_configs[base_name] || {}
604
+ semantic_weight = config[:semantic_weight] ||
605
+ ENV["SWARM_MEMORY_SEMANTIC_WEIGHT"]&.to_f ||
606
+ 0.5
607
+ keyword_weight = config[:keyword_weight] ||
608
+ ENV["SWARM_MEMORY_KEYWORD_WEIGHT"]&.to_f ||
609
+ 0.5
551
610
 
552
611
  SwarmSDK::LogStream.emit(
553
612
  type: "semantic_memory_search",
@@ -626,6 +685,40 @@ module SwarmMemory
626
685
 
627
686
  reminder
628
687
  end
688
+
689
+ # Extract threshold configuration from memory config
690
+ #
691
+ # Supports both MemoryConfig objects (from DSL) and Hash configs (from YAML).
692
+ # Extracts semantic search thresholds and hybrid search weights.
693
+ #
694
+ # @param memory_config [MemoryConfig, Hash, nil] Memory configuration
695
+ # @return [Hash] Threshold config with symbol keys
696
+ def extract_threshold_config(memory_config)
697
+ return {} unless memory_config
698
+
699
+ threshold_keys = [
700
+ :discovery_threshold,
701
+ :discovery_threshold_short,
702
+ :adaptive_word_cutoff,
703
+ :semantic_weight,
704
+ :keyword_weight,
705
+ ]
706
+
707
+ if memory_config.respond_to?(:adapter_options)
708
+ # MemoryConfig object (from DSL)
709
+ memory_config.adapter_options.slice(*threshold_keys)
710
+ elsif memory_config.is_a?(Hash)
711
+ # Hash (from YAML) - handle both symbol and string keys
712
+ result = {}
713
+ threshold_keys.each do |key|
714
+ value = memory_config[key] || memory_config[key.to_s]
715
+ result[key] = value if value
716
+ end
717
+ result
718
+ else
719
+ {}
720
+ end
721
+ end
629
722
  end
630
723
  end
631
724
  end
@@ -191,7 +191,7 @@ module SwarmMemory
191
191
  " Semantic similarity: N/A (no embeddings)"
192
192
  end
193
193
  report << ""
194
- report << " **Suggestion:** Review both entries and consider merging with MemoryMultiEdit"
194
+ report << " **Suggestion:** Review both entries and consider merging with MemoryEdit"
195
195
  report << ""
196
196
  end
197
197
 
@@ -28,7 +28,6 @@ You have persistent memory that learns from conversations and helps you answer q
28
28
  - `MemoryGlob` - Browse memory by path pattern
29
29
  - `MemoryWrite` - Create new memory
30
30
  - `MemoryEdit` - Update existing memory
31
- - `MemoryMultiEdit` - Update multiple memories at once
32
31
  - `MemoryDelete` - Delete a memory
33
32
  - `MemoryDefrag` - Optimize memory storage
34
33
  - `LoadSkill` - Load a skill and swap tools
@@ -148,7 +148,6 @@ module SwarmMemory
148
148
  "MemoryWrite",
149
149
  "MemoryRead",
150
150
  "MemoryEdit",
151
- "MemoryMultiEdit",
152
151
  "MemoryDelete",
153
152
  "MemoryGlob",
154
153
  "MemoryGrep",
@@ -162,11 +162,12 @@ module SwarmMemory
162
162
  # Get existing entry metadata
163
163
  entry = @storage.read_entry(file_path: file_path)
164
164
 
165
- # Write updated content back (preserving the title)
165
+ # Write updated content back (preserving the title and metadata)
166
166
  @storage.write(
167
167
  file_path: file_path,
168
168
  content: new_content,
169
169
  title: entry.title,
170
+ metadata: entry.metadata,
170
171
  )
171
172
 
172
173
  # Build success message
@@ -35,7 +35,7 @@ module SwarmMemory
35
35
  - MemoryRead(file_path: "skill/debugging/api-errors.md") - Read a skill before loading it
36
36
 
37
37
  **Important:**
38
- - Always read entries before editing them with MemoryEdit or MemoryMultiEdit
38
+ - Always read entries before editing them with MemoryEdit
39
39
  - Line numbers in output are for reference only - don't include them when editing
40
40
  - Each read is tracked to enforce read-before-edit patterns
41
41
  DESC
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmMemory
4
- VERSION = "2.1.4"
4
+ VERSION = "2.2.3"
5
5
  end
data/lib/swarm_memory.rb CHANGED
@@ -21,8 +21,8 @@ rescue LoadError
21
21
  warn("Warning: informers gem not found. Semantic search will be unavailable. Run: gem install informers")
22
22
  end
23
23
 
24
- # Load errors and version first
25
- require_relative "swarm_memory/errors"
24
+ # Load error classes and version first (before Zeitwerk)
25
+ require_relative "swarm_memory/error"
26
26
  require_relative "swarm_memory/version"
27
27
 
28
28
  # Setup Zeitwerk loader
@@ -36,6 +36,11 @@ loader.inflector.inflect(
36
36
  "dsl" => "DSL",
37
37
  "sdk_plugin" => "SDKPlugin",
38
38
  )
39
+
40
+ # Ignore files that are manually loaded above
41
+ loader.ignore("#{__dir__}/swarm_memory/error.rb")
42
+ loader.ignore("#{__dir__}/swarm_memory/version.rb")
43
+
39
44
  loader.setup
40
45
 
41
46
  # Explicitly load DSL components and extensions to inject into SwarmSDK
@@ -118,8 +123,6 @@ module SwarmMemory
118
123
  Tools::MemoryRead.new(storage: storage, agent_name: agent_name)
119
124
  when :MemoryEdit
120
125
  Tools::MemoryEdit.new(storage: storage, agent_name: agent_name)
121
- when :MemoryMultiEdit
122
- Tools::MemoryMultiEdit.new(storage: storage, agent_name: agent_name)
123
126
  when :MemoryDelete
124
127
  Tools::MemoryDelete.new(storage: storage)
125
128
  when :MemoryGlob
@@ -153,7 +156,6 @@ module SwarmMemory
153
156
  Tools::MemoryWrite.new(storage: storage, agent_name: agent_name),
154
157
  Tools::MemoryRead.new(storage: storage, agent_name: agent_name),
155
158
  Tools::MemoryEdit.new(storage: storage, agent_name: agent_name),
156
- Tools::MemoryMultiEdit.new(storage: storage, agent_name: agent_name),
157
159
  Tools::MemoryDelete.new(storage: storage),
158
160
  Tools::MemoryGlob.new(storage: storage),
159
161
  Tools::MemoryGrep.new(storage: storage),
@@ -122,7 +122,7 @@ module SwarmSDK
122
122
  max_concurrent_tools = definition[:max_concurrent_tools]
123
123
  base_url = definition[:base_url]
124
124
  api_version = definition[:api_version]
125
- timeout = definition[:timeout] || Defaults::Timeouts::AGENT_REQUEST_SECONDS
125
+ timeout = definition[:timeout] || SwarmSDK.config.agent_request_timeout
126
126
  assume_model_exists = definition[:assume_model_exists]
127
127
  system_prompt = definition[:system_prompt]
128
128
  parameters = definition[:parameters]
@@ -55,10 +55,14 @@ module SwarmSDK
55
55
  #
56
56
  # Registers RubyLLM callbacks to collect data and emit log events.
57
57
  # Should only be called when LogStream.emitter is set.
58
+ # This method is idempotent - calling it multiple times has no effect.
58
59
  #
59
60
  # @return [void]
60
61
  def setup_logging
62
+ return if @logging_setup
63
+
61
64
  register_logging_callbacks
65
+ @logging_setup = true
62
66
  end
63
67
 
64
68
  # Extract agent name from delegation tool name
@@ -117,7 +117,7 @@ module SwarmSDK
117
117
 
118
118
  # Trigger automatic compression at 60% ONLY if no custom handler
119
119
  compression_triggered = false
120
- if threshold == Context::COMPRESSION_THRESHOLD && !has_custom_handler
120
+ if threshold == SwarmSDK.config.context_compression_threshold && !has_custom_handler
121
121
  compressed_count = apply_automatic_compression
122
122
  compression_triggered = compressed_count > 0
123
123
  end
@@ -47,7 +47,7 @@ module SwarmSDK
47
47
  #
48
48
  # @return [RubyLLM::Chat] Chat instance
49
49
  def instantiate_chat(model_id:, provider_name:, base_url:, timeout:, assume_model_exists:, chat_options:)
50
- if base_url || timeout != Defaults::Timeouts::AGENT_REQUEST_SECONDS
50
+ if base_url || timeout != SwarmSDK.config.agent_request_timeout
51
51
  instantiate_with_custom_context(
52
52
  model_id: model_id,
53
53
  provider_name: provider_name,
@@ -123,29 +123,63 @@ module SwarmSDK
123
123
  end
124
124
 
125
125
  # Configure provider-specific base URL
126
+ #
127
+ # @param config [RubyLLM::Config] RubyLLM configuration context
128
+ # @param provider [String] Provider name
129
+ # @param base_url [String] Custom base URL
130
+ # @raise [ConfigurationError] If API key is required but not configured
131
+ # @raise [ArgumentError] If provider doesn't support custom base_url
126
132
  def configure_provider_base_url(config, provider, base_url)
127
133
  case provider.to_s
128
134
  when "openai", "deepseek", "perplexity", "mistral", "openrouter"
129
135
  config.openai_api_base = base_url
130
- config.openai_api_key = ENV["OPENAI_API_KEY"] || "dummy-key-for-local"
136
+ api_key = SwarmSDK.config.openai_api_key
137
+
138
+ # For local endpoints, API key is optional
139
+ # For cloud endpoints, require API key
140
+ unless api_key || local_endpoint?(base_url)
141
+ raise ConfigurationError,
142
+ "OpenAI API key required for '#{provider}' with base_url '#{base_url}'. " \
143
+ "Configure with: SwarmSDK.configure { |c| c.openai_api_key = '...' }"
144
+ end
145
+
146
+ config.openai_api_key = api_key if api_key
131
147
  config.openai_use_system_role = true
132
148
  when "ollama"
133
149
  config.ollama_api_base = base_url
150
+ # Ollama doesn't need an API key
134
151
  when "gpustack"
135
152
  config.gpustack_api_base = base_url
136
- config.gpustack_api_key = ENV["GPUSTACK_API_KEY"] || "dummy-key"
153
+ api_key = SwarmSDK.config.gpustack_api_key
154
+ config.gpustack_api_key = api_key if api_key
137
155
  else
138
156
  raise ArgumentError, "Provider '#{provider}' doesn't support custom base_url."
139
157
  end
140
158
  end
141
159
 
160
+ # Check if a URL points to a local endpoint
161
+ #
162
+ # @param url [String] URL to check
163
+ # @return [Boolean] true if URL is a local endpoint
164
+ def local_endpoint?(url)
165
+ uri = URI.parse(url)
166
+ ["localhost", "127.0.0.1", "0.0.0.0"].include?(uri.host)
167
+ rescue URI::InvalidURIError
168
+ false
169
+ end
170
+
142
171
  # Fetch real model info for accurate context tracking
143
172
  #
173
+ # Uses SwarmSDK::Models for model lookup (reads from models.json).
174
+ # Falls back to RubyLLM.models if not found in SwarmSDK.
175
+ #
144
176
  # @param model_id [String] Model ID to lookup
145
177
  def fetch_real_model_info(model_id)
146
178
  @model_lookup_error = nil
147
179
  @real_model_info = begin
148
- RubyLLM.models.find(model_id)
180
+ # Try SwarmSDK::Models first (reads from local models.json)
181
+ # Returns ModelInfo object with method access (context_window, etc.)
182
+ SwarmSDK::Models.find(model_id) || RubyLLM.models.find(model_id)
149
183
  rescue StandardError => e
150
184
  suggestions = suggest_similar_models(model_id)
151
185
  @model_lookup_error = {
@@ -74,8 +74,8 @@ module SwarmSDK
74
74
  model_info = SwarmSDK::Models.find(message.model_id)
75
75
  return zero_cost unless model_info
76
76
 
77
- # Extract pricing from SwarmSDK's models.json structure
78
- pricing = model_info["pricing"] || model_info[:pricing]
77
+ # Extract pricing from SwarmSDK's ModelInfo (method access for top-level, Hash for nested)
78
+ pricing = model_info.pricing
79
79
  return zero_cost unless pricing
80
80
 
81
81
  text_pricing = pricing["text_tokens"] || pricing[:text_tokens]
@@ -22,9 +22,6 @@ module SwarmSDK
22
22
  <system-reminder>The TodoWrite tool hasn't been used recently. If you're working on tasks that would benefit from tracking progress, consider using the TodoWrite tool to track progress. Also consider cleaning up the todo list if has become stale and no longer matches what you are working on. Only use it if it's relevant to the current work. This is just a gentle reminder - ignore if not applicable.</system-reminder>
23
23
  REMINDER
24
24
 
25
- # Backward compatibility alias - use Defaults module for new code
26
- TODOWRITE_REMINDER_INTERVAL = Defaults::Context::TODOWRITE_REMINDER_INTERVAL
27
-
28
25
  class << self
29
26
  # Check if this is the first user message in the conversation
30
27
  #
@@ -106,15 +103,16 @@ module SwarmSDK
106
103
  end
107
104
 
108
105
  # Check if enough messages have passed since last TodoWrite
106
+ reminder_interval = SwarmSDK.config.todowrite_reminder_interval
109
107
  if last_todo_index.nil? && last_todowrite_index.nil?
110
108
  # Never used TodoWrite - check if we've exceeded interval
111
- chat.message_count >= TODOWRITE_REMINDER_INTERVAL
109
+ chat.message_count >= reminder_interval
112
110
  elsif last_todo_index
113
111
  # Recently used - don't remind
114
112
  false
115
113
  elsif last_todowrite_index
116
114
  # Used before - check if interval has passed
117
- chat.message_count - last_todowrite_index >= TODOWRITE_REMINDER_INTERVAL
115
+ chat.message_count - last_todowrite_index >= reminder_interval
118
116
  else
119
117
  false
120
118
  end
@@ -84,6 +84,35 @@ module SwarmSDK
84
84
  limit - cumulative_total_tokens
85
85
  end
86
86
 
87
+ # Calculate cumulative input cost based on tokens and model pricing
88
+ #
89
+ # @return [Float] Total input cost in dollars
90
+ def cumulative_input_cost
91
+ pricing = model_pricing
92
+ return 0.0 unless pricing
93
+
94
+ input_price = pricing["input_per_million"] || pricing[:input_per_million] || 0.0
95
+ (cumulative_input_tokens / 1_000_000.0) * input_price
96
+ end
97
+
98
+ # Calculate cumulative output cost based on tokens and model pricing
99
+ #
100
+ # @return [Float] Total output cost in dollars
101
+ def cumulative_output_cost
102
+ pricing = model_pricing
103
+ return 0.0 unless pricing
104
+
105
+ output_price = pricing["output_per_million"] || pricing[:output_per_million] || 0.0
106
+ (cumulative_output_tokens / 1_000_000.0) * output_price
107
+ end
108
+
109
+ # Calculate cumulative total cost (input + output)
110
+ #
111
+ # @return [Float] Total cost in dollars
112
+ def cumulative_total_cost
113
+ cumulative_input_cost + cumulative_output_cost
114
+ end
115
+
87
116
  # Compact the conversation history to reduce token usage
88
117
  #
89
118
  # @param options [Hash] Compression options
@@ -92,6 +121,25 @@ module SwarmSDK
92
121
  compactor = ContextCompactor.new(self, options)
93
122
  compactor.compact
94
123
  end
124
+
125
+ private
126
+
127
+ # Get pricing info for the current model
128
+ #
129
+ # Extracts standard text token pricing from model info.
130
+ #
131
+ # @return [Hash, nil] Pricing hash with input_per_million and output_per_million
132
+ def model_pricing
133
+ return unless @real_model_info&.pricing
134
+
135
+ pricing = @real_model_info.pricing
136
+ text_pricing = pricing["text_tokens"] || pricing[:text_tokens]
137
+ return unless text_pricing
138
+
139
+ text_pricing["standard"] || text_pricing[:standard]
140
+ rescue StandardError
141
+ nil
142
+ end
95
143
  end
96
144
  end
97
145
  end
@@ -30,8 +30,7 @@ module SwarmSDK
30
30
  # 60% triggers automatic compression, 80%/90% are informational warnings
31
31
  CONTEXT_WARNING_THRESHOLDS = [60, 80, 90].freeze
32
32
 
33
- # Backward compatibility alias - use Defaults module for new code
34
- COMPRESSION_THRESHOLD = Defaults::Context::COMPRESSION_THRESHOLD_PERCENT
33
+ # NOTE: Compression threshold now accessed via SwarmSDK.config.context_compression_threshold
35
34
 
36
35
  attr_reader :name, :delegation_tools, :metadata, :warning_thresholds_hit, :swarm_id, :parent_swarm_id
37
36
 
@@ -67,14 +67,14 @@ module SwarmSDK
67
67
  end
68
68
 
69
69
  @description = config[:description]
70
- @model = config[:model] || Defaults::Agent::MODEL
71
- @provider = config[:provider] || Defaults::Agent::PROVIDER
70
+ @model = config[:model] || SwarmSDK.config.default_model
71
+ @provider = config[:provider] || SwarmSDK.config.default_provider
72
72
  @base_url = config[:base_url]
73
73
  @api_version = config[:api_version]
74
74
  @context_window = config[:context_window] # Explicit context window override
75
75
  @parameters = config[:parameters] || {}
76
76
  @headers = Utils.stringify_keys(config[:headers] || {})
77
- @timeout = config[:timeout] || Defaults::Timeouts::AGENT_REQUEST_SECONDS
77
+ @timeout = config[:timeout] || SwarmSDK.config.agent_request_timeout
78
78
  @bypass_permissions = config[:bypass_permissions] || false
79
79
  @max_concurrent_tools = config[:max_concurrent_tools]
80
80
  # Always assume model exists - SwarmSDK validates models separately using models.json
@@ -148,7 +148,7 @@ module SwarmSDK
148
148
  contributions = []
149
149
 
150
150
  PluginRegistry.all.each do |plugin|
151
- next unless plugin.storage_enabled?(@definition)
151
+ next unless plugin.memory_configured?(@definition)
152
152
 
153
153
  contribution = plugin.system_prompt_contribution(agent_definition: @definition, storage: nil)
154
154
  contributions << contribution if contribution && !contribution.strip.empty?