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.
- checksums.yaml +4 -4
- data/lib/swarm_sdk/agent/builder.rb +33 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +41 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
- data/lib/swarm_sdk/agent/chat.rb +198 -51
- data/lib/swarm_sdk/agent/context.rb +6 -2
- data/lib/swarm_sdk/agent/context_manager.rb +6 -0
- data/lib/swarm_sdk/agent/definition.rb +14 -2
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
- data/lib/swarm_sdk/configuration.rb +387 -94
- data/lib/swarm_sdk/events_to_messages.rb +181 -0
- data/lib/swarm_sdk/log_collector.rb +31 -5
- data/lib/swarm_sdk/log_stream.rb +37 -8
- data/lib/swarm_sdk/model_aliases.json +4 -1
- data/lib/swarm_sdk/node/agent_config.rb +33 -8
- data/lib/swarm_sdk/node/builder.rb +39 -18
- data/lib/swarm_sdk/node_orchestrator.rb +293 -26
- data/lib/swarm_sdk/proc_helpers.rb +53 -0
- data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
- data/lib/swarm_sdk/restore_result.rb +65 -0
- data/lib/swarm_sdk/snapshot.rb +156 -0
- data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
- data/lib/swarm_sdk/state_restorer.rb +491 -0
- data/lib/swarm_sdk/state_snapshot.rb +369 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
- data/lib/swarm_sdk/swarm/builder.rb +208 -12
- data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
- data/lib/swarm_sdk/swarm.rb +337 -42
- data/lib/swarm_sdk/swarm_loader.rb +145 -0
- data/lib/swarm_sdk/swarm_registry.rb +136 -0
- data/lib/swarm_sdk/tools/delegate.rb +92 -7
- data/lib/swarm_sdk/tools/read.rb +17 -5
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
- data/lib/swarm_sdk/utils.rb +18 -0
- data/lib/swarm_sdk/validation_result.rb +33 -0
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk.rb +40 -8
- metadata +17 -6
- 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
|