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
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Agent
5
+ # Manages conversation context and message optimization
6
+ #
7
+ # Responsibilities:
8
+ # - Handle ephemeral messages (sent to LLM but not persisted)
9
+ # - Extract and strip system reminders
10
+ # - Prepare messages for LLM API calls
11
+ # - Future: Context window management, summarization, truncation
12
+ #
13
+ # @example
14
+ # manager = ContextManager.new
15
+ # manager.add_ephemeral_reminder("<system-reminder>Use caution</system-reminder>")
16
+ # messages_for_llm = manager.prepare_for_llm(persistent_messages)
17
+ # manager.clear_ephemeral # After LLM call
18
+ class ContextManager
19
+ SYSTEM_REMINDER_REGEX = %r{<system-reminder>.*?</system-reminder>}m
20
+
21
+ def initialize
22
+ # Ephemeral content to append to messages for this turn only
23
+ # Format: { message_index => [array of reminder strings] }
24
+ @ephemeral_content = {}
25
+ end
26
+
27
+ # Track ephemeral content to append to a specific message
28
+ #
29
+ # Reminders will be embedded in the message content when sent to LLM,
30
+ # but are NOT persisted in the message history.
31
+ #
32
+ # @param message_index [Integer] Index of message to append to
33
+ # @param content [String] Reminder content to append
34
+ # @return [void]
35
+ def add_ephemeral_content_for_message(message_index, content)
36
+ @ephemeral_content[message_index] ||= []
37
+ @ephemeral_content[message_index] << content
38
+ end
39
+
40
+ # Add ephemeral reminder to the most recent message
41
+ #
42
+ # This will append the reminder to the last message in the array when
43
+ # preparing for LLM, but won't modify the stored message.
44
+ #
45
+ # @param content [String] Reminder content
46
+ # @param messages_array [Array<RubyLLM::Message>] Message array to get index from
47
+ # @return [void]
48
+ def add_ephemeral_reminder(content, messages_array:)
49
+ message_index = messages_array.size - 1
50
+ return if message_index < 0
51
+
52
+ add_ephemeral_content_for_message(message_index, content)
53
+ end
54
+
55
+ # Prepare messages for LLM API call
56
+ #
57
+ # Embeds ephemeral content into messages for this turn only.
58
+ # Does NOT modify the persistent messages array.
59
+ #
60
+ # @param persistent_messages [Array<RubyLLM::Message>] Messages from @messages
61
+ # @return [Array<RubyLLM::Message>] Messages with ephemeral content embedded
62
+ def prepare_for_llm(persistent_messages)
63
+ return persistent_messages.dup if @ephemeral_content.empty?
64
+
65
+ # Clone messages and embed ephemeral content
66
+ messages_for_llm = persistent_messages.map.with_index do |msg, index|
67
+ ephemeral_for_this_msg = @ephemeral_content[index]
68
+
69
+ # No ephemeral content for this message - use as-is
70
+ next msg unless ephemeral_for_this_msg&.any?
71
+
72
+ # Embed ephemeral content in this message
73
+ original_content = msg.content.is_a?(RubyLLM::Content) ? msg.content.text : msg.content.to_s
74
+ embedded_content = [original_content, *ephemeral_for_this_msg].join("\n\n")
75
+
76
+ # Create new message with embedded content
77
+ if msg.content.is_a?(RubyLLM::Content)
78
+ RubyLLM::Message.new(
79
+ role: msg.role,
80
+ content: RubyLLM::Content.new(embedded_content, msg.content.attachments),
81
+ tool_call_id: msg.tool_call_id,
82
+ )
83
+ else
84
+ RubyLLM::Message.new(
85
+ role: msg.role,
86
+ content: embedded_content,
87
+ tool_call_id: msg.tool_call_id,
88
+ )
89
+ end
90
+ end
91
+
92
+ messages_for_llm
93
+ end
94
+
95
+ # Clear all ephemeral content
96
+ #
97
+ # Should be called after LLM response is received.
98
+ #
99
+ # @return [void]
100
+ def clear_ephemeral
101
+ @ephemeral_content.clear
102
+ end
103
+
104
+ # Check if there is pending ephemeral content
105
+ #
106
+ # @return [Boolean] True if ephemeral content exists
107
+ def has_ephemeral?
108
+ @ephemeral_content.any?
109
+ end
110
+
111
+ # Get count of messages with ephemeral content
112
+ #
113
+ # @return [Integer] Number of messages with ephemeral content attached
114
+ def ephemeral_count
115
+ @ephemeral_content.size
116
+ end
117
+
118
+ # Extract all <system-reminder> blocks from content
119
+ #
120
+ # @param content [String] Content to extract from
121
+ # @return [Array<String>] Array of system reminder blocks
122
+ def extract_system_reminders(content)
123
+ return [] if content.nil? || content.empty?
124
+
125
+ content.scan(SYSTEM_REMINDER_REGEX)
126
+ end
127
+
128
+ # Strip all <system-reminder> blocks from content
129
+ #
130
+ # Returns clean content without system reminders.
131
+ #
132
+ # @param content [String] Content to strip from
133
+ # @return [String] Clean content
134
+ def strip_system_reminders(content)
135
+ return content if content.nil? || content.empty?
136
+
137
+ content.gsub(SYSTEM_REMINDER_REGEX, "").strip
138
+ end
139
+
140
+ # Check if content contains system reminders
141
+ #
142
+ # @param content [String] Content to check
143
+ # @return [Boolean] True if reminders found
144
+ def has_system_reminders?(content)
145
+ return false if content.nil? || content.empty?
146
+
147
+ SYSTEM_REMINDER_REGEX.match?(content)
148
+ end
149
+
150
+ # ============================================================================
151
+ # FUTURE: Context Optimization Methods (Hooks for Later Implementation)
152
+ # ============================================================================
153
+
154
+ # Future: Summarize old messages to save context window space
155
+ #
156
+ # @param messages [Array<RubyLLM::Message>] Messages to potentially summarize
157
+ # @param before_index [Integer] Summarize messages before this index
158
+ # @param strategy [Symbol] Summarization strategy (:llm, :truncate, :remove)
159
+ # @return [Array<RubyLLM::Message>] Optimized message array
160
+ def summarize_old_messages(messages, before_index:, strategy: :truncate)
161
+ # TODO: Implement when needed
162
+ messages
163
+ end
164
+
165
+ # Future: Truncate messages to fit within context window
166
+ #
167
+ # @param messages [Array<RubyLLM::Message>] Messages to fit
168
+ # @param max_tokens [Integer] Maximum token budget
169
+ # @param keep_recent [Integer] Number of recent messages to always keep
170
+ # @return [Array<RubyLLM::Message>] Truncated messages
171
+ def truncate_to_fit(messages, max_tokens:, keep_recent: 10)
172
+ # TODO: Implement when needed
173
+ messages
174
+ end
175
+
176
+ # Compress verbose tool results for older messages
177
+ #
178
+ # Uses progressive compression: older messages are compressed more aggressively.
179
+ # Preserves user/assistant messages at full detail (conversational context).
180
+ #
181
+ # @param messages [Array<RubyLLM::Message>] Messages to compress
182
+ # @param keep_recent [Integer] Number of recent messages to keep at full detail
183
+ # @return [Array<RubyLLM::Message>] Compressed messages
184
+ def compress_tool_results(messages, keep_recent: 10)
185
+ messages.map.with_index do |msg, i|
186
+ # Keep recent messages at full detail
187
+ next msg if i >= messages.size - keep_recent
188
+
189
+ # Keep user/assistant messages (conversational flow is important)
190
+ next msg if [:user, :assistant].include?(msg.role)
191
+
192
+ # Compress old tool results
193
+ if msg.role == :tool
194
+ compress_tool_message(msg, age: messages.size - i)
195
+ else
196
+ msg
197
+ end
198
+ end
199
+ end
200
+
201
+ # Compress a single tool message based on age
202
+ #
203
+ # Progressive compression: older messages get compressed more.
204
+ # For re-runnable tools (Read, Grep, Glob, etc.), adds instruction to re-run if needed.
205
+ #
206
+ # @param msg [RubyLLM::Message] Tool message to compress
207
+ # @param age [Integer] How many messages ago (higher = older)
208
+ # @return [RubyLLM::Message] Compressed message
209
+ def compress_tool_message(msg, age:)
210
+ content = msg.content.to_s
211
+
212
+ # Progressive compression based on age
213
+ max_length = case age
214
+ when 0..10 then return msg # Recent: keep full detail
215
+ when 11..20 then 1000 # Medium age: light compression
216
+ when 21..40 then 500 # Old: moderate compression
217
+ when 41..60 then 200 # Very old: heavy compression
218
+ else 100 # Ancient: minimal summary
219
+ end
220
+
221
+ return msg if content.length <= max_length
222
+
223
+ # Compress while preserving structure
224
+ compressed = content.slice(0, max_length)
225
+ truncated_chars = content.length - max_length
226
+ compressed += "\n...[#{truncated_chars} chars truncated for context management]"
227
+
228
+ # Detect if this is a re-runnable tool and add helpful instruction
229
+ tool_name = detect_tool_name(content)
230
+ if rerunnable_tool?(tool_name)
231
+ compressed += "\n\n💡 If you need the full output, re-run the #{tool_name} tool with the same parameters."
232
+ end
233
+
234
+ RubyLLM::Message.new(
235
+ role: :tool,
236
+ content: compressed,
237
+ tool_call_id: msg.tool_call_id,
238
+ )
239
+ end
240
+
241
+ # Detect tool name from content
242
+ #
243
+ # @param content [String] Tool result content
244
+ # @return [String, nil] Tool name or nil
245
+ def detect_tool_name(content)
246
+ # Many tool results start with patterns we can detect
247
+ case content
248
+ when /^\s*\d+→/ # Line numbers (Read, MemoryRead)
249
+ content.include?("memory://") ? "MemoryRead" : "Read"
250
+ when /^Memory entries matching/ # MemoryGlob
251
+ "MemoryGlob"
252
+ when /^Found \d+ files? matching/ # Glob
253
+ "Glob"
254
+ when /matches in \d+ files?|No matches found/ # Grep, MemoryGrep
255
+ content.include?("memory://") ? "MemoryGrep" : "Grep"
256
+ when %r{^Stored at memory://} # MemoryWrite (not re-runnable but identifiable)
257
+ "MemoryWrite"
258
+ when %r{^Deleted memory://} # MemoryDelete
259
+ "MemoryDelete"
260
+ end
261
+ end
262
+
263
+ # Check if a tool is re-runnable (idempotent, can get same data again)
264
+ #
265
+ # @param tool_name [String, nil] Tool name
266
+ # @return [Boolean] True if tool can be re-run safely
267
+ def rerunnable_tool?(tool_name)
268
+ return false if tool_name.nil?
269
+
270
+ # These tools are idempotent - re-running gives same/current data
271
+ ["Read", "MemoryRead", "Grep", "MemoryGrep", "Glob", "MemoryGlob"].include?(tool_name)
272
+ end
273
+
274
+ # Automatically compress messages when context threshold is hit
275
+ #
276
+ # This is called automatically when context usage crosses 60% threshold.
277
+ # Returns compressed messages array for immediate use.
278
+ #
279
+ # @param messages [Array<RubyLLM::Message>] Current message array
280
+ # @param keep_recent [Integer] Number of recent messages to keep full
281
+ # @return [Array<RubyLLM::Message>] Compressed messages
282
+ def auto_compress_on_threshold(messages, keep_recent: 10)
283
+ return messages if @compression_applied
284
+
285
+ # Mark as applied to avoid compressing multiple times
286
+ @compression_applied = true
287
+
288
+ compress_tool_results(messages, keep_recent: keep_recent)
289
+ end
290
+
291
+ # Reset compression flag (when conversation is reset)
292
+ #
293
+ # @return [void]
294
+ def reset_compression
295
+ @compression_applied = false
296
+ end
297
+
298
+ # Future: Detect if context is becoming bloated
299
+ #
300
+ # @param messages [Array<RubyLLM::Message>] Messages to analyze
301
+ # @param threshold [Float] Bloat threshold (0.0-1.0)
302
+ # @return [Hash] Bloat analysis with recommendations
303
+ def analyze_context_bloat(messages, threshold: 0.7)
304
+ # TODO: Implement when needed
305
+ { bloated: false, recommendations: [] }
306
+ end
307
+ end
308
+ end
309
+ end
@@ -125,24 +125,36 @@ module SwarmSDK
125
125
  #
126
126
  # @return [Boolean]
127
127
  def memory_enabled?
128
- @memory&.respond_to?(:enabled?) && @memory.enabled?
128
+ return false if @memory.nil?
129
+
130
+ # MemoryConfig object (from DSL)
131
+ return @memory.enabled? if @memory.respond_to?(:enabled?)
132
+
133
+ # Hash (from YAML) - check for directory key
134
+ if @memory.is_a?(Hash)
135
+ directory = @memory[:directory] || @memory["directory"]
136
+ return !directory.nil? && !directory.to_s.strip.empty?
137
+ end
138
+
139
+ false
129
140
  end
130
141
 
131
142
  # Parse memory configuration from Hash or MemoryConfig object
132
143
  #
133
- # @param memory_config [Hash, Agent::MemoryConfig, nil] Memory configuration
134
- # @return [Agent::MemoryConfig, nil]
144
+ # @param memory_config [Hash, Object, nil] Memory configuration
145
+ # @return [Object, Hash, nil] Memory config (could be MemoryConfig from swarm_memory or Hash)
135
146
  def parse_memory_config(memory_config)
136
147
  return if memory_config.nil?
137
- return memory_config if memory_config.is_a?(Agent::MemoryConfig)
138
-
139
- # Convert hash (from YAML) to MemoryConfig object
140
- config = Agent::MemoryConfig.new
141
- adapter_value = memory_config[:adapter] || memory_config["adapter"] || :filesystem
142
- config.adapter(adapter_value.to_sym)
143
- directory_value = memory_config[:directory] || memory_config["directory"]
144
- config.directory(directory_value) if directory_value
145
- config
148
+
149
+ # If it's a MemoryConfig object (duck typing - has directory, adapter, mode methods)
150
+ # return as-is. This could be SwarmMemory::DSL::MemoryConfig or any compatible object.
151
+ return memory_config if memory_config.respond_to?(:directory) &&
152
+ memory_config.respond_to?(:adapter) &&
153
+ memory_config.respond_to?(:enabled?)
154
+
155
+ # If it's a hash (from YAML), keep it as a hash
156
+ # Plugin will create storage adapter based on the hash values
157
+ memory_config
146
158
  end
147
159
 
148
160
  def to_h
@@ -271,13 +283,14 @@ module SwarmSDK
271
283
  (custom_prompt || "").to_s
272
284
  end
273
285
 
274
- # Append memory instructions if memory is enabled
275
- if memory_enabled?
276
- memory_prompt = render_memory_prompt
286
+ # Append plugin contributions to system prompt
287
+ plugin_contributions = collect_plugin_prompt_contributions
288
+ if plugin_contributions.any?
289
+ combined_contributions = plugin_contributions.join("\n\n")
277
290
  prompt = if prompt && !prompt.strip.empty?
278
- "#{prompt}\n\n#{memory_prompt}"
291
+ "#{prompt}\n\n#{combined_contributions}"
279
292
  else
280
- memory_prompt
293
+ combined_contributions
281
294
  end
282
295
  end
283
296
 
@@ -305,11 +318,26 @@ module SwarmSDK
305
318
  ERB.new(template_content).result(binding)
306
319
  end
307
320
 
308
- def render_memory_prompt
309
- # Load and render the memory system prompt
310
- memory_prompt_path = File.expand_path("../prompts/memory.md.erb", __dir__)
311
- template_content = File.read(memory_prompt_path)
312
- ERB.new(template_content).result(binding)
321
+ # Collect system prompt contributions from all plugins
322
+ #
323
+ # Asks each registered plugin if it wants to contribute to the system prompt.
324
+ # Plugins can return custom instructions based on their configuration.
325
+ #
326
+ # @return [Array<String>] Array of prompt contributions from plugins
327
+ def collect_plugin_prompt_contributions
328
+ contributions = []
329
+
330
+ PluginRegistry.all.each do |plugin|
331
+ # Check if plugin has storage enabled for this agent
332
+ next unless plugin.storage_enabled?(self)
333
+
334
+ # Ask plugin for prompt contribution
335
+ # Note: storage is not available yet at this point, so we pass nil
336
+ contribution = plugin.system_prompt_contribution(agent_definition: self, storage: nil)
337
+ contributions << contribution if contribution && !contribution.strip.empty?
338
+ end
339
+
340
+ contributions
313
341
  end
314
342
 
315
343
  def render_non_coding_base_prompt
@@ -326,13 +354,18 @@ module SwarmSDK
326
354
  date = Time.now.strftime("%Y-%m-%d")
327
355
 
328
356
  <<~PROMPT.strip
329
- # Environment
357
+ # Today's date
358
+
359
+ <today-date>
360
+ #{date}
361
+ #</today-date>
362
+
363
+ # Current Environment
330
364
 
331
365
  <env>
332
366
  Working directory: #{cwd}
333
367
  Platform: #{platform}
334
368
  OS Version: #{os_version}
335
- Today's date: #{date}
336
369
  </env>
337
370
 
338
371
  # Task Management
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ # Base class for SwarmSDK plugins
5
+ #
6
+ # Plugins provide tools, storage, configuration parsing, and lifecycle hooks.
7
+ # Plugins are self-registering - they call SwarmSDK::PluginRegistry.register
8
+ # when the gem is loaded.
9
+ #
10
+ # @example Implementing a plugin
11
+ # class MyPlugin < SwarmSDK::Plugin
12
+ # def name
13
+ # :my_plugin
14
+ # end
15
+ #
16
+ # def tools
17
+ # [:MyTool, :OtherTool]
18
+ # end
19
+ #
20
+ # def create_tool(tool_name, context)
21
+ # # Create and return tool instance
22
+ # end
23
+ # end
24
+ #
25
+ # SwarmSDK::PluginRegistry.register(MyPlugin.new)
26
+ class Plugin
27
+ # Plugin name (must be unique)
28
+ #
29
+ # @return [Symbol] Plugin identifier
30
+ def name
31
+ raise NotImplementedError, "#{self.class} must implement #name"
32
+ end
33
+
34
+ # List of tools provided by this plugin
35
+ #
36
+ # @return [Array<Symbol>] Tool names (e.g., [:MemoryWrite, :MemoryRead])
37
+ def tools
38
+ []
39
+ end
40
+
41
+ # Create a tool instance
42
+ #
43
+ # @param tool_name [Symbol] Tool name (e.g., :MemoryWrite)
44
+ # @param context [Hash] Creation context
45
+ # - :agent_name [Symbol] Agent identifier
46
+ # - :storage [Object] Plugin storage instance (if created)
47
+ # - :agent_definition [Agent::Definition] Full agent definition
48
+ # - :chat [Agent::Chat] Chat instance (for tools that need it)
49
+ # - :tool_configurator [Swarm::ToolConfigurator] For tools that register other tools
50
+ # @return [RubyLLM::Tool] Tool instance
51
+ def create_tool(tool_name, context)
52
+ raise NotImplementedError, "#{self.class} must implement #create_tool"
53
+ end
54
+
55
+ # Create plugin storage for an agent (optional)
56
+ #
57
+ # Called during agent initialization. Return nil if plugin doesn't need storage.
58
+ #
59
+ # @param agent_name [Symbol] Agent identifier
60
+ # @param config [Object] Plugin configuration from agent definition
61
+ # @return [Object, nil] Storage instance or nil
62
+ def create_storage(agent_name:, config:)
63
+ nil
64
+ end
65
+
66
+ # Parse plugin configuration from agent definition
67
+ #
68
+ # @param raw_config [Object] Raw config (DSL object or Hash from YAML)
69
+ # @return [Object] Parsed configuration
70
+ def parse_config(raw_config)
71
+ raw_config
72
+ end
73
+
74
+ # Contribute to agent system prompt (optional)
75
+ #
76
+ # @param agent_definition [Agent::Definition] Agent definition
77
+ # @param storage [Object, nil] Plugin storage instance (if created)
78
+ # @return [String, nil] Prompt contribution or nil
79
+ def system_prompt_contribution(agent_definition:, storage:)
80
+ nil
81
+ end
82
+
83
+ # Tools that should be marked immutable (optional)
84
+ #
85
+ # Immutable tools cannot be removed by other tools (e.g., LoadSkill).
86
+ #
87
+ # @return [Array<Symbol>] Tool names
88
+ def immutable_tools
89
+ []
90
+ end
91
+
92
+ # Agent storage enabled for this agent? (optional)
93
+ #
94
+ # @param agent_definition [Agent::Definition] Agent definition
95
+ # @return [Boolean] True if storage should be created
96
+ def storage_enabled?(agent_definition)
97
+ false
98
+ end
99
+
100
+ # Lifecycle: Called when agent is initialized
101
+ #
102
+ # @param agent_name [Symbol] Agent identifier
103
+ # @param agent [Agent::Chat] Chat instance
104
+ # @param context [Hash] Initialization context
105
+ # - :storage [Object, nil] Plugin storage
106
+ # - :agent_definition [Agent::Definition] Definition
107
+ # - :tool_configurator [Swarm::ToolConfigurator] Configurator
108
+ def on_agent_initialized(agent_name:, agent:, context:)
109
+ # Override if needed
110
+ end
111
+
112
+ # Lifecycle: Called when swarm starts
113
+ #
114
+ # @param swarm [Swarm] Swarm instance
115
+ def on_swarm_started(swarm:)
116
+ # Override if needed
117
+ end
118
+
119
+ # Lifecycle: Called when swarm stops
120
+ #
121
+ # @param swarm [Swarm] Swarm instance
122
+ def on_swarm_stopped(swarm:)
123
+ # Override if needed
124
+ end
125
+
126
+ # Lifecycle: Called on every user message
127
+ #
128
+ # Plugins can return system reminders to inject based on the user's prompt.
129
+ # This enables features like semantic skill discovery, context injection, etc.
130
+ #
131
+ # @param agent_name [Symbol] Agent identifier
132
+ # @param prompt [String] The user's message
133
+ # @param is_first_message [Boolean] True if this is the first message in the conversation
134
+ # @return [Array<String>] System reminders to inject (empty array if none)
135
+ #
136
+ # @example Semantic skill discovery
137
+ # def on_user_message(agent_name:, prompt:, is_first_message:)
138
+ # skills = semantic_search(prompt, threshold: 0.65)
139
+ # return [] if skills.empty?
140
+ #
141
+ # [build_skill_reminder(skills)]
142
+ # end
143
+ def on_user_message(agent_name:, prompt:, is_first_message:)
144
+ []
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ # Plugin registry for managing SwarmSDK extensions
5
+ #
6
+ # Plugins register themselves when loaded, providing tools, storage,
7
+ # and lifecycle hooks without SwarmSDK needing to know about them.
8
+ module PluginRegistry
9
+ @plugins = {}
10
+ @tool_map = {}
11
+
12
+ class << self
13
+ # Register a plugin
14
+ #
15
+ # @param plugin [Plugin] Plugin instance
16
+ # @raise [ArgumentError] If plugin with same name already registered
17
+ def register(plugin)
18
+ raise ArgumentError, "Plugin must inherit from SwarmSDK::Plugin" unless plugin.is_a?(Plugin)
19
+
20
+ name = plugin.name
21
+ raise ArgumentError, "Plugin name required" unless name
22
+ raise ArgumentError, "Plugin #{name} already registered" if @plugins.key?(name)
23
+
24
+ @plugins[name] = plugin
25
+
26
+ # Build tool → plugin mapping
27
+ plugin.tools.each do |tool_name|
28
+ if @tool_map.key?(tool_name)
29
+ raise ArgumentError, "Tool #{tool_name} already registered by #{@tool_map[tool_name].name}"
30
+ end
31
+
32
+ @tool_map[tool_name] = plugin
33
+ end
34
+ end
35
+
36
+ # Get plugin by name
37
+ #
38
+ # @param name [Symbol] Plugin name
39
+ # @return [Plugin, nil] Plugin instance or nil
40
+ def get(name)
41
+ @plugins[name]
42
+ end
43
+
44
+ # Get all registered plugins
45
+ #
46
+ # @return [Array<Plugin>] All plugins
47
+ def all
48
+ @plugins.values
49
+ end
50
+
51
+ # Check if plugin is registered
52
+ #
53
+ # @param name [Symbol] Plugin name
54
+ # @return [Boolean] True if registered
55
+ def registered?(name)
56
+ @plugins.key?(name)
57
+ end
58
+
59
+ # Get plugin that provides a tool
60
+ #
61
+ # @param tool_name [Symbol] Tool name
62
+ # @return [Plugin, nil] Plugin that provides tool or nil
63
+ def plugin_for_tool(tool_name)
64
+ @tool_map[tool_name]
65
+ end
66
+
67
+ # Check if tool is provided by a plugin
68
+ #
69
+ # @param tool_name [Symbol] Tool name
70
+ # @return [Boolean] True if tool is plugin-provided
71
+ def plugin_tool?(tool_name)
72
+ @tool_map.key?(tool_name)
73
+ end
74
+
75
+ # Get all tools provided by plugins
76
+ #
77
+ # @return [Hash<Symbol, Plugin>] Tool name → Plugin mapping
78
+ def tools
79
+ @tool_map.dup
80
+ end
81
+
82
+ # Clear all plugins (for testing)
83
+ #
84
+ # @return [void]
85
+ def clear
86
+ @plugins.clear
87
+ @tool_map.clear
88
+ end
89
+
90
+ # Emit lifecycle event to all plugins
91
+ #
92
+ # @param event [Symbol] Event name
93
+ # @param args [Hash] Event arguments
94
+ def emit_event(event, **args)
95
+ @plugins.each_value do |plugin|
96
+ plugin.public_send(event, **args) if plugin.respond_to?(event)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -215,6 +215,13 @@ IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the
215
215
 
216
216
  You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
217
217
 
218
+
219
+ # Today's date
220
+
221
+ <today-date>
222
+ #{date}
223
+ #</today-date>
224
+
218
225
  # Environment information
219
226
 
220
227
  Here is useful information about the environment you are running in:
@@ -222,7 +229,6 @@ Here is useful information about the environment you are running in:
222
229
  Working directory: <%= cwd %>
223
230
  Platform: <%= platform %>
224
231
  OS Version: <%= os_version %>
225
- Today's date: <%= date %>
226
232
  </env>
227
233
 
228
234
  IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation.