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
|
@@ -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
|
-
|
|
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,
|
|
134
|
-
# @return [
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
#
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
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#{
|
|
291
|
+
"#{prompt}\n\n#{combined_contributions}"
|
|
279
292
|
else
|
|
280
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
#
|
|
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.
|