swarm_sdk 2.1.3 → 2.2.0

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/swarm_sdk/agent/builder.rb +33 -0
  3. data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
  4. data/lib/swarm_sdk/agent/chat/hook_integration.rb +41 -0
  5. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
  6. data/lib/swarm_sdk/agent/chat.rb +198 -51
  7. data/lib/swarm_sdk/agent/context.rb +6 -2
  8. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  9. data/lib/swarm_sdk/agent/definition.rb +14 -2
  10. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
  11. data/lib/swarm_sdk/configuration.rb +387 -94
  12. data/lib/swarm_sdk/events_to_messages.rb +181 -0
  13. data/lib/swarm_sdk/log_collector.rb +31 -5
  14. data/lib/swarm_sdk/log_stream.rb +37 -8
  15. data/lib/swarm_sdk/model_aliases.json +4 -1
  16. data/lib/swarm_sdk/node/agent_config.rb +33 -8
  17. data/lib/swarm_sdk/node/builder.rb +39 -18
  18. data/lib/swarm_sdk/node_orchestrator.rb +293 -26
  19. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  20. data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
  21. data/lib/swarm_sdk/restore_result.rb +65 -0
  22. data/lib/swarm_sdk/snapshot.rb +156 -0
  23. data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
  24. data/lib/swarm_sdk/state_restorer.rb +491 -0
  25. data/lib/swarm_sdk/state_snapshot.rb +369 -0
  26. data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
  27. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  28. data/lib/swarm_sdk/swarm/builder.rb +208 -12
  29. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  30. data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
  31. data/lib/swarm_sdk/swarm.rb +337 -42
  32. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  33. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  34. data/lib/swarm_sdk/tools/delegate.rb +92 -7
  35. data/lib/swarm_sdk/tools/read.rb +17 -5
  36. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  37. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
  38. data/lib/swarm_sdk/utils.rb +18 -0
  39. data/lib/swarm_sdk/validation_result.rb +33 -0
  40. data/lib/swarm_sdk/version.rb +1 -1
  41. data/lib/swarm_sdk.rb +40 -8
  42. metadata +17 -6
  43. data/lib/swarm_sdk/mcp.rb +0 -16
@@ -0,0 +1,369 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ # Creates snapshots of swarm conversation state
5
+ #
6
+ # Unified implementation that works for both Swarm and NodeOrchestrator.
7
+ # Captures conversation history, context state, scratchpad contents, and
8
+ # read tracking information.
9
+ #
10
+ # The snapshot is a plain Ruby hash that can be serialized to JSON or any
11
+ # other format. Configuration (agent definitions, tools, prompts) stays in
12
+ # your YAML/DSL and is not included in snapshots.
13
+ #
14
+ # @example Snapshot a swarm
15
+ # swarm = SwarmSDK.build { ... }
16
+ # swarm.execute("Build authentication")
17
+ # snapshot = swarm.snapshot
18
+ # File.write("session.json", JSON.pretty_generate(snapshot))
19
+ #
20
+ # @example Snapshot a node orchestrator
21
+ # orchestrator = NodeOrchestrator.new(...)
22
+ # orchestrator.execute("Build feature")
23
+ # snapshot = orchestrator.snapshot
24
+ # redis.set("session:#{user_id}", JSON.generate(snapshot))
25
+ class StateSnapshot
26
+ # Initialize snapshot creator
27
+ #
28
+ # @param orchestration [Swarm, NodeOrchestrator] Swarm or orchestrator to snapshot
29
+ def initialize(orchestration)
30
+ @orchestration = orchestration
31
+ @type = orchestration.is_a?(SwarmSDK::NodeOrchestrator) ? :node_orchestrator : :swarm
32
+ end
33
+
34
+ # Create snapshot of current state
35
+ #
36
+ # Returns a Snapshot object that encapsulates the snapshot data with
37
+ # convenient methods for serialization and file I/O.
38
+ #
39
+ # @return [Snapshot] Snapshot object
40
+ def snapshot
41
+ data = {
42
+ version: "1.0.0",
43
+ type: @type.to_s,
44
+ snapshot_at: Time.now.utc.iso8601,
45
+ swarm_sdk_version: SwarmSDK::VERSION,
46
+ agents: snapshot_agents,
47
+ delegation_instances: snapshot_delegation_instances,
48
+ read_tracking: snapshot_read_tracking,
49
+ memory_read_tracking: snapshot_memory_read_tracking,
50
+ }
51
+
52
+ # Add scratchpad for both Swarm and NodeOrchestrator (shared across nodes)
53
+ data[:scratchpad] = snapshot_scratchpad
54
+
55
+ # Add type-specific metadata
56
+ if @type == :swarm
57
+ data[:swarm] = snapshot_swarm_metadata
58
+ else
59
+ data[:orchestrator] = snapshot_orchestrator_metadata
60
+ end
61
+
62
+ # Wrap in Snapshot object
63
+ SwarmSDK::Snapshot.new(data)
64
+ end
65
+
66
+ private
67
+
68
+ # Snapshot swarm-specific metadata
69
+ #
70
+ # @return [Hash] Swarm metadata
71
+ def snapshot_swarm_metadata
72
+ {
73
+ id: @orchestration.swarm_id,
74
+ parent_id: @orchestration.parent_swarm_id,
75
+ first_message_sent: @orchestration.first_message_sent?,
76
+ }
77
+ end
78
+
79
+ # Snapshot orchestrator-specific metadata
80
+ #
81
+ # @return [Hash] Orchestrator metadata
82
+ def snapshot_orchestrator_metadata
83
+ {
84
+ id: @orchestration.swarm_id || generate_orchestrator_id,
85
+ parent_id: nil, # NodeOrchestrator doesn't support parent_id
86
+ }
87
+ end
88
+
89
+ # Generate orchestrator ID if not set
90
+ #
91
+ # @return [String] Generated ID
92
+ def generate_orchestrator_id
93
+ name = @orchestration.swarm_name.to_s.gsub(/[^a-z0-9_-]/i, "_").downcase
94
+ "#{name}_#{SecureRandom.hex(4)}"
95
+ end
96
+
97
+ # Snapshot all agent conversations and context state
98
+ #
99
+ # @return [Hash] { agent_name => { conversation:, context_state:, system_prompt: } }
100
+ def snapshot_agents
101
+ result = {}
102
+
103
+ # Get agents from appropriate source
104
+ agents_hash = if @type == :swarm
105
+ @orchestration.agents
106
+ else
107
+ @orchestration.agent_instance_cache[:primary]
108
+ end
109
+
110
+ agents_hash.each do |agent_name, agent_chat|
111
+ # Get system prompt from agent definition
112
+ agent_definition = @orchestration.agent_definitions[agent_name]
113
+ system_prompt = agent_definition&.system_prompt
114
+
115
+ result[agent_name.to_s] = {
116
+ conversation: snapshot_conversation(agent_chat),
117
+ context_state: snapshot_context_state(agent_chat),
118
+ system_prompt: system_prompt,
119
+ }
120
+ end
121
+
122
+ result
123
+ end
124
+
125
+ # Snapshot conversation messages for an agent
126
+ #
127
+ # @param agent_chat [Agent::Chat] Agent chat instance
128
+ # @return [Array<Hash>] Serialized messages
129
+ def snapshot_conversation(agent_chat)
130
+ messages = agent_chat.messages
131
+ messages.map { |msg| serialize_message(msg) }
132
+ end
133
+
134
+ # Serialize a single message
135
+ #
136
+ # Handles RubyLLM::Message serialization with proper handling of:
137
+ # - Content objects (text + attachments)
138
+ # - Tool calls (must manually call .to_h on each)
139
+ # - Tool call IDs, tokens, model IDs
140
+ #
141
+ # @param msg [RubyLLM::Message] Message to serialize
142
+ # @return [Hash] Serialized message
143
+ def serialize_message(msg)
144
+ hash = { role: msg.role }
145
+
146
+ # Handle content - check msg.content directly, not from msg.to_h
147
+ # msg.to_h converts Content to String when no attachments present
148
+ hash[:content] = if msg.content.is_a?(RubyLLM::Content)
149
+ # Content object: serialize with text + attachments
150
+ msg.content.to_h
151
+ else
152
+ # Plain string content
153
+ msg.content
154
+ end
155
+
156
+ # Handle tool calls - must manually extract fields
157
+ # RubyLLM::ToolCall#to_h doesn't reliably serialize id/name fields
158
+ # msg.tool_calls is a Hash<String, ToolCall>, so we need .values
159
+ if msg.tool_calls && !msg.tool_calls.empty?
160
+ hash[:tool_calls] = msg.tool_calls.values.map do |tc|
161
+ {
162
+ id: tc.id,
163
+ name: tc.name,
164
+ arguments: tc.arguments,
165
+ }
166
+ end
167
+ end
168
+
169
+ # Handle other fields
170
+ hash[:tool_call_id] = msg.tool_call_id if msg.tool_call_id
171
+ hash[:input_tokens] = msg.input_tokens if msg.input_tokens
172
+ hash[:output_tokens] = msg.output_tokens if msg.output_tokens
173
+ hash[:model_id] = msg.model_id if msg.model_id
174
+
175
+ hash
176
+ end
177
+
178
+ # Snapshot context state for an agent
179
+ #
180
+ # @param agent_chat [Agent::Chat] Agent chat instance
181
+ # @return [Hash] Context state
182
+ def snapshot_context_state(agent_chat)
183
+ context_manager = agent_chat.context_manager
184
+ agent_context = agent_chat.agent_context
185
+
186
+ {
187
+ warning_thresholds_hit: agent_context.warning_thresholds_hit.to_a,
188
+ # NOTE: @compression_applied initializes to nil, not false
189
+ compression_applied: context_manager.compression_applied,
190
+ last_todowrite_message_index: agent_chat.last_todowrite_message_index,
191
+ active_skill_path: agent_chat.active_skill_path,
192
+ }
193
+ end
194
+
195
+ # Snapshot delegation instance conversations
196
+ #
197
+ # @return [Hash] { "delegate@delegator" => { conversation:, context_state:, system_prompt: } }
198
+ def snapshot_delegation_instances
199
+ result = {}
200
+
201
+ # Get delegation instances from appropriate source
202
+ delegations_hash = if @type == :swarm
203
+ @orchestration.delegation_instances
204
+ else
205
+ @orchestration.agent_instance_cache[:delegations]
206
+ end
207
+
208
+ delegations_hash.each do |instance_name, delegation_chat|
209
+ # Extract base agent name from instance name (e.g., "backend@lead" -> "backend")
210
+ base_name = instance_name.to_s.split("@").first.to_sym
211
+
212
+ # Get system prompt from base agent definition
213
+ agent_definition = @orchestration.agent_definitions[base_name]
214
+ system_prompt = agent_definition&.system_prompt
215
+
216
+ result[instance_name] = {
217
+ conversation: snapshot_conversation(delegation_chat),
218
+ context_state: snapshot_context_state(delegation_chat),
219
+ system_prompt: system_prompt,
220
+ }
221
+ end
222
+
223
+ result
224
+ end
225
+
226
+ # Snapshot scratchpad contents
227
+ #
228
+ # For Swarm: uses scratchpad_storage (returns flat hash)
229
+ # For NodeOrchestrator: returns structured hash with metadata
230
+ # - Enabled mode: { shared: true, data: { path => entry } }
231
+ # - Per-node mode: { shared: false, data: { node_name => { path => entry } } }
232
+ #
233
+ # @return [Hash] Scratchpad snapshot data
234
+ def snapshot_scratchpad
235
+ if @type == :node_orchestrator
236
+ snapshot_node_orchestrator_scratchpad
237
+ else
238
+ snapshot_swarm_scratchpad
239
+ end
240
+ end
241
+
242
+ # Snapshot scratchpad for NodeOrchestrator
243
+ #
244
+ # @return [Hash] Structured scratchpad data with mode metadata
245
+ def snapshot_node_orchestrator_scratchpad
246
+ all_scratchpads = @orchestration.all_scratchpads
247
+ return {} unless all_scratchpads&.any?
248
+
249
+ if @orchestration.shared_scratchpad?
250
+ # Enabled mode: single shared scratchpad
251
+ shared_scratchpad = all_scratchpads[:shared]
252
+ return {} unless shared_scratchpad
253
+
254
+ entries = serialize_scratchpad_entries(shared_scratchpad.all_entries)
255
+ return {} if entries.empty?
256
+
257
+ {
258
+ shared: true,
259
+ data: entries,
260
+ }
261
+ else
262
+ # Per-node mode: separate scratchpads per node
263
+ node_data = {}
264
+ all_scratchpads.each do |node_name, scratchpad|
265
+ next unless scratchpad
266
+
267
+ entries = serialize_scratchpad_entries(scratchpad.all_entries)
268
+ node_data[node_name.to_s] = entries unless entries.empty?
269
+ end
270
+
271
+ return {} if node_data.empty?
272
+
273
+ {
274
+ shared: false,
275
+ data: node_data,
276
+ }
277
+ end
278
+ end
279
+
280
+ # Snapshot scratchpad for Swarm
281
+ #
282
+ # @return [Hash] Flat scratchpad entries
283
+ def snapshot_swarm_scratchpad
284
+ scratchpad = @orchestration.scratchpad_storage
285
+ return {} unless scratchpad
286
+
287
+ entries_hash = scratchpad.all_entries
288
+ return {} unless entries_hash&.any?
289
+
290
+ serialize_scratchpad_entries(entries_hash)
291
+ end
292
+
293
+ # Serialize scratchpad entries to snapshot format
294
+ #
295
+ # @param entries_hash [Hash] { path => Entry }
296
+ # @return [Hash] { path => { content:, title:, updated_at:, size: } }
297
+ def serialize_scratchpad_entries(entries_hash)
298
+ return {} unless entries_hash
299
+
300
+ result = {}
301
+ entries_hash.each do |path, entry|
302
+ result[path] = {
303
+ content: entry.content,
304
+ title: entry.title,
305
+ updated_at: entry.updated_at.iso8601,
306
+ size: entry.size,
307
+ }
308
+ end
309
+ result
310
+ end
311
+
312
+ # Snapshot read tracking state
313
+ #
314
+ # @return [Hash] { agent_name => { file_path => digest } }
315
+ def snapshot_read_tracking
316
+ result = {}
317
+
318
+ # Get all agents (primary + delegations)
319
+ agent_names = all_agent_names
320
+
321
+ agent_names.each do |agent_name|
322
+ files_with_digests = Tools::Stores::ReadTracker.get_read_files(agent_name)
323
+ next if files_with_digests.empty?
324
+
325
+ result[agent_name.to_s] = files_with_digests
326
+ end
327
+
328
+ result
329
+ end
330
+
331
+ # Snapshot memory read tracking state
332
+ #
333
+ # @return [Hash] { agent_name => { entry_path => digest } }
334
+ def snapshot_memory_read_tracking
335
+ return {} unless defined?(SwarmMemory::Core::StorageReadTracker)
336
+
337
+ result = {}
338
+
339
+ # Get all agents (primary + delegations)
340
+ agent_names = all_agent_names
341
+
342
+ agent_names.each do |agent_name|
343
+ entries_with_digests = SwarmMemory::Core::StorageReadTracker.get_read_entries(agent_name)
344
+ next if entries_with_digests.empty?
345
+
346
+ result[agent_name.to_s] = entries_with_digests
347
+ end
348
+
349
+ result
350
+ end
351
+
352
+ # All agent names (primary + delegations)
353
+ #
354
+ # @return [Array<Symbol>] All agent names
355
+ def all_agent_names
356
+ # Get primary agent names - both types use agent_definitions
357
+ agents_hash = @orchestration.agent_definitions.keys
358
+
359
+ # Add delegation instance names
360
+ delegations_hash = if @type == :swarm
361
+ @orchestration.delegation_instances.keys
362
+ else
363
+ @orchestration.agent_instance_cache[:delegations].keys
364
+ end
365
+
366
+ agents_hash + delegations_hash.map(&:to_sym)
367
+ end
368
+ end
369
+ end