swarm_memory 2.1.2 → 2.1.4

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 (118) hide show
  1. checksums.yaml +4 -4
  2. data/lib/claude_swarm/claude_mcp_server.rb +1 -0
  3. data/lib/claude_swarm/cli.rb +5 -18
  4. data/lib/claude_swarm/configuration.rb +30 -19
  5. data/lib/claude_swarm/mcp_generator.rb +5 -10
  6. data/lib/claude_swarm/openai/chat_completion.rb +4 -12
  7. data/lib/claude_swarm/openai/executor.rb +3 -1
  8. data/lib/claude_swarm/openai/responses.rb +13 -32
  9. data/lib/claude_swarm/version.rb +1 -1
  10. data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
  11. data/lib/swarm_cli/commands/run.rb +2 -2
  12. data/lib/swarm_cli/config_loader.rb +14 -14
  13. data/lib/swarm_cli/formatters/human_formatter.rb +70 -0
  14. data/lib/swarm_cli/interactive_repl.rb +11 -5
  15. data/lib/swarm_cli/ui/icons.rb +0 -23
  16. data/lib/swarm_cli/version.rb +1 -1
  17. data/lib/swarm_memory/adapters/base.rb +4 -4
  18. data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
  19. data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
  20. data/lib/swarm_memory/integration/cli_registration.rb +3 -2
  21. data/lib/swarm_memory/integration/sdk_plugin.rb +98 -12
  22. data/lib/swarm_memory/tools/memory_edit.rb +2 -2
  23. data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
  24. data/lib/swarm_memory/tools/memory_read.rb +3 -3
  25. data/lib/swarm_memory/version.rb +1 -1
  26. data/lib/swarm_memory.rb +6 -1
  27. data/lib/swarm_sdk/agent/builder.rb +91 -0
  28. data/lib/swarm_sdk/agent/chat.rb +540 -925
  29. data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +33 -79
  30. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
  31. data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +147 -39
  32. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
  33. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
  34. data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
  35. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
  36. data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +22 -38
  37. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
  38. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
  39. data/lib/swarm_sdk/agent/context.rb +8 -4
  40. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  41. data/lib/swarm_sdk/agent/definition.rb +79 -174
  42. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +182 -0
  43. data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
  44. data/lib/swarm_sdk/builders/base_builder.rb +409 -0
  45. data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
  46. data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
  47. data/lib/swarm_sdk/concerns/validatable.rb +55 -0
  48. data/lib/swarm_sdk/configuration/parser.rb +353 -0
  49. data/lib/swarm_sdk/configuration/translator.rb +255 -0
  50. data/lib/swarm_sdk/configuration.rb +100 -261
  51. data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
  52. data/lib/swarm_sdk/context_compactor.rb +6 -11
  53. data/lib/swarm_sdk/context_management/builder.rb +128 -0
  54. data/lib/swarm_sdk/context_management/context.rb +328 -0
  55. data/lib/swarm_sdk/defaults.rb +196 -0
  56. data/lib/swarm_sdk/events_to_messages.rb +199 -0
  57. data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
  58. data/lib/swarm_sdk/log_collector.rb +192 -16
  59. data/lib/swarm_sdk/log_stream.rb +66 -8
  60. data/lib/swarm_sdk/model_aliases.json +4 -1
  61. data/lib/swarm_sdk/node_context.rb +1 -1
  62. data/lib/swarm_sdk/observer/builder.rb +81 -0
  63. data/lib/swarm_sdk/observer/config.rb +45 -0
  64. data/lib/swarm_sdk/observer/manager.rb +236 -0
  65. data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
  66. data/lib/swarm_sdk/plugin.rb +93 -3
  67. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  68. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
  69. data/lib/swarm_sdk/restore_result.rb +65 -0
  70. data/lib/swarm_sdk/snapshot.rb +156 -0
  71. data/lib/swarm_sdk/snapshot_from_events.rb +397 -0
  72. data/lib/swarm_sdk/state_restorer.rb +476 -0
  73. data/lib/swarm_sdk/state_snapshot.rb +334 -0
  74. data/lib/swarm_sdk/swarm/agent_initializer.rb +428 -79
  75. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  76. data/lib/swarm_sdk/swarm/builder.rb +69 -407
  77. data/lib/swarm_sdk/swarm/executor.rb +213 -0
  78. data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
  79. data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
  80. data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
  81. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  82. data/lib/swarm_sdk/swarm/tool_configurator.rb +88 -149
  83. data/lib/swarm_sdk/swarm.rb +366 -631
  84. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  85. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  86. data/lib/swarm_sdk/tools/bash.rb +11 -3
  87. data/lib/swarm_sdk/tools/delegate.rb +127 -24
  88. data/lib/swarm_sdk/tools/edit.rb +8 -13
  89. data/lib/swarm_sdk/tools/glob.rb +9 -1
  90. data/lib/swarm_sdk/tools/grep.rb +7 -0
  91. data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
  92. data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
  93. data/lib/swarm_sdk/tools/read.rb +28 -18
  94. data/lib/swarm_sdk/tools/registry.rb +122 -10
  95. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
  96. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
  97. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
  98. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  99. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +53 -5
  100. data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
  101. data/lib/swarm_sdk/tools/think.rb +4 -1
  102. data/lib/swarm_sdk/tools/todo_write.rb +27 -8
  103. data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
  104. data/lib/swarm_sdk/tools/write.rb +8 -13
  105. data/lib/swarm_sdk/utils.rb +18 -0
  106. data/lib/swarm_sdk/validation_result.rb +33 -0
  107. data/lib/swarm_sdk/version.rb +1 -1
  108. data/lib/swarm_sdk/{node → workflow}/agent_config.rb +34 -9
  109. data/lib/swarm_sdk/workflow/builder.rb +143 -0
  110. data/lib/swarm_sdk/workflow/executor.rb +497 -0
  111. data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +42 -21
  112. data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
  113. data/lib/swarm_sdk/workflow.rb +554 -0
  114. data/lib/swarm_sdk.rb +393 -22
  115. metadata +51 -16
  116. data/lib/swarm_memory/chat_extension.rb +0 -34
  117. data/lib/swarm_sdk/node_orchestrator.rb +0 -591
  118. data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -582
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ # Reconstructs RubyLLM::Message objects from SwarmSDK event streams
5
+ #
6
+ # This class enables conversation replay and analysis from event logs.
7
+ # It uses timestamps to maintain chronological ordering of messages.
8
+ #
9
+ # ## Limitations
10
+ #
11
+ # This reconstructs ONLY conversation messages. It does NOT restore:
12
+ # - Context state (warning thresholds, compression, todowrite index)
13
+ # - Scratchpad contents
14
+ # - Read tracking information
15
+ # - Full swarm state
16
+ #
17
+ # For full state restoration, use StateSnapshot/StateRestorer or SnapshotFromEvents.
18
+ #
19
+ # ## Usage
20
+ #
21
+ # # Collect events during execution
22
+ # events = []
23
+ # swarm.execute("Build feature") do |event|
24
+ # events << event
25
+ # end
26
+ #
27
+ # # Reconstruct conversation for an agent
28
+ # messages = SwarmSDK::EventsToMessages.reconstruct(events, agent: :backend)
29
+ #
30
+ # # View conversation
31
+ # messages.each do |msg|
32
+ # puts "[#{msg.role}] #{msg.content}"
33
+ # end
34
+ #
35
+ # ## Event Requirements
36
+ #
37
+ # Events must have:
38
+ # - `:timestamp` field (ISO 8601 format) for ordering
39
+ # - `:agent` field to filter by agent
40
+ # - `:type` field to identify event type
41
+ #
42
+ # Supported event types:
43
+ # - `user_prompt`: Reconstructs user message (prompt in metadata or top-level)
44
+ # - `agent_step`: Reconstructs assistant message with tool calls
45
+ # - `agent_stop`: Reconstructs final assistant message
46
+ # - `tool_result`: Reconstructs tool result message
47
+ # - `delegation_result`: Reconstructs tool result message from delegation
48
+ class EventsToMessages
49
+ class << self
50
+ # Reconstruct messages for an agent from event stream
51
+ #
52
+ # @param events [Array<Hash>] Event stream with timestamps
53
+ # @param agent [Symbol, String] Agent name to reconstruct messages for
54
+ # @return [Array<RubyLLM::Message>] Reconstructed messages in chronological order
55
+ #
56
+ # @example
57
+ # messages = EventsToMessages.reconstruct(events, agent: :backend)
58
+ # messages.each { |msg| puts msg.content }
59
+ def reconstruct(events, agent:)
60
+ new(events, agent).reconstruct
61
+ end
62
+ end
63
+
64
+ # Initialize reconstructor
65
+ #
66
+ # @param events [Array<Hash>] Event stream
67
+ # @param agent [Symbol, String] Agent name
68
+ def initialize(events, agent)
69
+ @events = events
70
+ @agent = agent.to_sym
71
+ end
72
+
73
+ # Reconstruct messages from events
74
+ #
75
+ # Filters events by agent, sorts by timestamp, and converts to RubyLLM::Message objects.
76
+ #
77
+ # @return [Array<RubyLLM::Message>] Reconstructed messages
78
+ def reconstruct
79
+ messages = []
80
+
81
+ # Filter events for this agent and sort by timestamp
82
+ agent_events = @events
83
+ .select { |e| normalize_agent(e[:agent]) == @agent }
84
+ .sort_by { |e| parse_timestamp(e[:timestamp]) }
85
+
86
+ agent_events.each do |event|
87
+ message = case event[:type]&.to_s
88
+ when "user_prompt"
89
+ reconstruct_user_message(event)
90
+ when "agent_step", "agent_stop"
91
+ reconstruct_assistant_message(event)
92
+ when "tool_result"
93
+ reconstruct_tool_result_message(event)
94
+ when "delegation_result"
95
+ reconstruct_delegation_result_message(event)
96
+ end
97
+
98
+ messages << message if message
99
+ end
100
+
101
+ messages
102
+ end
103
+
104
+ private
105
+
106
+ # Reconstruct user message from user_prompt event
107
+ #
108
+ # Extracts prompt from metadata or top-level field.
109
+ #
110
+ # @param event [Hash] user_prompt event
111
+ # @return [RubyLLM::Message, nil] User message or nil if prompt not found
112
+ def reconstruct_user_message(event)
113
+ # Try to extract prompt from metadata (current location) or top-level (potential future location)
114
+ prompt = event.dig(:metadata, :prompt) || event[:prompt]
115
+ return unless prompt && !prompt.to_s.empty?
116
+
117
+ RubyLLM::Message.new(
118
+ role: :user,
119
+ content: prompt,
120
+ )
121
+ end
122
+
123
+ # Reconstruct assistant message from agent_step or agent_stop event
124
+ #
125
+ # Converts tool_calls array to hash format expected by RubyLLM.
126
+ #
127
+ # @param event [Hash] agent_step or agent_stop event
128
+ # @return [RubyLLM::Message] Assistant message
129
+ def reconstruct_assistant_message(event)
130
+ # Convert tool_calls array to hash (RubyLLM format)
131
+ # Events emit tool_calls as Array, but RubyLLM expects Hash<String, ToolCall>
132
+ tool_calls_hash = if event[:tool_calls] && !event[:tool_calls].empty?
133
+ event[:tool_calls].each_with_object({}) do |tc, hash|
134
+ hash[tc[:id].to_s] = RubyLLM::ToolCall.new(
135
+ id: tc[:id],
136
+ name: tc[:name],
137
+ arguments: tc[:arguments] || {},
138
+ )
139
+ end
140
+ end
141
+
142
+ RubyLLM::Message.new(
143
+ role: :assistant,
144
+ content: event[:content] || "",
145
+ tool_calls: tool_calls_hash,
146
+ input_tokens: event.dig(:usage, :input_tokens),
147
+ output_tokens: event.dig(:usage, :output_tokens),
148
+ model_id: event[:model],
149
+ )
150
+ end
151
+
152
+ # Reconstruct tool result message from tool_result event
153
+ #
154
+ # @param event [Hash] tool_result event
155
+ # @return [RubyLLM::Message] Tool result message
156
+ def reconstruct_tool_result_message(event)
157
+ RubyLLM::Message.new(
158
+ role: :tool,
159
+ content: event[:result].to_s,
160
+ tool_call_id: event[:tool_call_id],
161
+ )
162
+ end
163
+
164
+ # Reconstruct tool result message from delegation_result event
165
+ #
166
+ # delegation_result events are emitted when a delegation completes,
167
+ # and they should be converted to tool result messages in the conversation.
168
+ #
169
+ # @param event [Hash] delegation_result event
170
+ # @return [RubyLLM::Message] Tool result message
171
+ def reconstruct_delegation_result_message(event)
172
+ RubyLLM::Message.new(
173
+ role: :tool,
174
+ content: event[:result].to_s,
175
+ tool_call_id: event[:tool_call_id],
176
+ )
177
+ end
178
+
179
+ # Parse timestamp string to Time object
180
+ #
181
+ # @param timestamp [String, nil] ISO 8601 timestamp
182
+ # @return [Time] Parsed time or epoch if nil/invalid
183
+ def parse_timestamp(timestamp)
184
+ return Time.at(0) unless timestamp
185
+
186
+ Time.parse(timestamp)
187
+ rescue ArgumentError
188
+ Time.at(0)
189
+ end
190
+
191
+ # Normalize agent name to symbol
192
+ #
193
+ # @param agent [Symbol, String, nil] Agent name
194
+ # @return [Symbol] Normalized agent name
195
+ def normalize_agent(agent)
196
+ agent.to_s.to_sym
197
+ end
198
+ end
199
+ end
@@ -47,7 +47,8 @@ module SwarmSDK
47
47
  # )
48
48
  # # => Result (continue or halt based on exit code)
49
49
  class ShellExecutor
50
- DEFAULT_TIMEOUT = 60
50
+ # Backward compatibility alias - use Defaults module for new code
51
+ DEFAULT_TIMEOUT = Defaults::Timeouts::HOOK_SHELL_SECONDS
51
52
 
52
53
  class << self
53
54
  # Execute a shell command hook
@@ -1,50 +1,226 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmSDK
4
- # LogCollector manages subscriber callbacks for log events.
4
+ # LogCollector manages subscriber callbacks for log events with filtering support.
5
5
  #
6
6
  # This module acts as an emitter implementation that forwards events
7
7
  # to user-registered callbacks. It's designed to be set as the LogStream
8
8
  # emitter during swarm execution.
9
9
  #
10
+ # ## Features
11
+ #
12
+ # - **Filtered Subscriptions**: Subscribe to specific agents, event types, or swarm IDs
13
+ # - **Unsubscribe Support**: Remove subscriptions by ID to prevent memory leaks
14
+ # - **Error Isolation**: One subscriber's error doesn't break others
15
+ # - **Thread Safety**: Fiber-local storage for multi-threaded environments
16
+ #
17
+ # ## Thread Safety for Multi-Threaded Environments (Puma, Sidekiq)
18
+ #
19
+ # Subscriptions are stored in Fiber-local storage (Fiber[:log_subscriptions]) instead
20
+ # of class instance variables. This ensures callbacks registered in the parent
21
+ # thread/fiber are accessible to child fibers created by Async reactor.
22
+ #
23
+ # Why: In Puma/Sidekiq, class instance variables (@subscriptions) are thread-isolated
24
+ # and don't properly propagate to child fibers. Using Fiber-local storage ensures
25
+ # events emitted from within Async blocks can reach registered subscriptions.
26
+ #
27
+ # Child fibers inherit parent fiber-local storage automatically, so events
28
+ # emitted from agent callbacks (on_tool_call, on_end_message, etc.) executing
29
+ # in child fibers can still reach the parent's registered subscriptions.
30
+ #
10
31
  # ## Usage
11
32
  #
12
- # # Register a callback (before execution starts)
13
- # LogCollector.on_log do |event|
14
- # puts JSON.generate(event)
15
- # end
33
+ # # Subscribe to all events
34
+ # sub_id = LogCollector.subscribe { |event| puts event }
35
+ #
36
+ # # Subscribe to specific agent
37
+ # sub_id = LogCollector.subscribe(filter: { agent: :backend }) { |event|
38
+ # puts "Backend: #{event}"
39
+ # }
16
40
  #
17
- # # During execution, LogStream calls emit
18
- # LogCollector.emit(type: "user_prompt", agent: :backend)
41
+ # # Subscribe to specific event types
42
+ # sub_id = LogCollector.subscribe(filter: { type: ["tool_call", "tool_result"] }) { |event|
43
+ # log_tool_activity(event)
44
+ # }
45
+ #
46
+ # # Subscribe with regex matching
47
+ # sub_id = LogCollector.subscribe(filter: { type: /^tool_/ }) { |event|
48
+ # track_tool_usage(event)
49
+ # }
50
+ #
51
+ # # Unsubscribe when done
52
+ # LogCollector.unsubscribe(sub_id)
19
53
  #
20
54
  # # After execution, reset for next use
21
55
  # LogCollector.reset!
22
56
  #
23
57
  module LogCollector
58
+ # Subscription object with filtering capabilities
59
+ #
60
+ # Encapsulates a callback with optional filters. Filters can match
61
+ # against agent name, event type, swarm_id, or any event field.
62
+ class Subscription
63
+ attr_reader :id, :filter, :callback
64
+
65
+ # Initialize a subscription
66
+ #
67
+ # @param filter [Hash] Filter criteria
68
+ # @param callback [Proc] Block to call when event matches
69
+ def initialize(filter: {}, &callback)
70
+ @id = SecureRandom.uuid
71
+ @filter = normalize_filter(filter)
72
+ @callback = callback
73
+ end
74
+
75
+ # Check if event matches filter criteria
76
+ #
77
+ # Empty filter matches all events. Multiple filter keys are AND'd together.
78
+ #
79
+ # @param event [Hash] Event entry with :type, :agent, etc.
80
+ # @return [Boolean] True if event matches filter
81
+ def matches?(event)
82
+ return true if @filter.empty?
83
+
84
+ @filter.all? do |key, matcher|
85
+ value = event[key]
86
+ case matcher
87
+ when Array
88
+ # Match if value is in array (handles symbols/strings)
89
+ matcher.include?(value) || matcher.map(&:to_s).include?(value.to_s)
90
+ when Regexp
91
+ # Regex matching
92
+ value.to_s.match?(matcher)
93
+ when Proc
94
+ # Custom matcher
95
+ matcher.call(value)
96
+ else
97
+ # Exact match (handles symbols/strings)
98
+ matcher == value || matcher.to_s == value.to_s
99
+ end
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ # Normalize filter keys to symbols for consistent matching
106
+ #
107
+ # @param filter [Hash] Raw filter
108
+ # @return [Hash] Normalized filter with symbol keys
109
+ def normalize_filter(filter)
110
+ filter.transform_keys(&:to_sym)
111
+ end
112
+ end
113
+
24
114
  class << self
25
- # Register a callback to receive log events
115
+ # Subscribe to log events with optional filtering
116
+ #
117
+ # Registers a callback that will receive events matching the filter criteria.
118
+ # Returns a subscription ID that can be used to unsubscribe later.
26
119
  #
120
+ # @param filter [Hash] Filter criteria (empty = all events)
121
+ # - :agent [Symbol, String, Array, Regexp] Agent name(s) to observe
122
+ # - :type [String, Array, Regexp] Event type(s) to receive
123
+ # - :swarm_id [String] Specific swarm instance
124
+ # - Any other key matches against event fields
27
125
  # @yield [Hash] Log event entry
28
- def on_log(&block)
29
- @callbacks ||= []
30
- @callbacks << block
126
+ # @return [String] Subscription ID for unsubscribe
127
+ #
128
+ # @example Subscribe to all events
129
+ # LogCollector.subscribe { |event| puts event }
130
+ #
131
+ # @example Subscribe to specific agent's tool calls
132
+ # sub_id = LogCollector.subscribe(
133
+ # filter: { agent: :backend, type: /^tool_/ }
134
+ # ) do |event|
135
+ # puts "Backend used tool: #{event[:tool]}"
136
+ # end
137
+ #
138
+ # @example Subscribe to multiple agents
139
+ # LogCollector.subscribe(
140
+ # filter: { agent: [:backend, :frontend], type: "agent_stop" }
141
+ # ) { |e| record_completion(e) }
142
+ def subscribe(filter: {}, &block)
143
+ subscription = Subscription.new(filter: filter, &block)
144
+ subscriptions << subscription
145
+ subscription.id
146
+ end
147
+
148
+ # Unsubscribe by ID
149
+ #
150
+ # Removes a subscription to prevent memory leaks and stop receiving events.
151
+ #
152
+ # @param subscription_id [String] ID returned from subscribe
153
+ # @return [Subscription, nil] Removed subscription or nil if not found
154
+ def unsubscribe(subscription_id)
155
+ index = subscriptions.find_index { |s| s.id == subscription_id }
156
+ return unless index
157
+
158
+ subscriptions.delete_at(index)
159
+ end
160
+
161
+ # Clear all subscriptions
162
+ #
163
+ # Removes all subscriptions. Useful for testing or execution cleanup.
164
+ #
165
+ # @return [void]
166
+ def clear_subscriptions
167
+ subscriptions.clear
168
+ end
169
+
170
+ # Get current subscription count
171
+ #
172
+ # @return [Integer] Number of active subscriptions
173
+ def subscription_count
174
+ subscriptions.size
31
175
  end
32
176
 
33
- # Emit an event to all registered callbacks
177
+ # Emit an event to all matching subscribers
178
+ #
179
+ # Automatically adds a timestamp if one doesn't exist.
180
+ # Errors in individual subscribers are isolated - one bad subscriber
181
+ # won't prevent others from receiving events.
34
182
  #
35
183
  # @param entry [Hash] Log event entry
36
184
  # @return [void]
37
185
  def emit(entry)
38
- Array(@callbacks).each do |callback|
39
- callback.call(entry)
186
+ entry_with_timestamp = ensure_timestamp(entry)
187
+
188
+ subscriptions.each do |subscription|
189
+ next unless subscription.matches?(entry_with_timestamp)
190
+
191
+ begin
192
+ subscription.callback.call(entry_with_timestamp)
193
+ rescue StandardError => e
194
+ # Error isolation - don't let one subscriber break others
195
+ RubyLLM.logger.error("SwarmSDK: Subscription #{subscription.id} error: #{e.message}")
196
+ end
40
197
  end
41
198
  end
42
199
 
43
- # Reset the collector (clears callbacks for next execution)
200
+ # Reset the collector (clears subscriptions for next execution)
44
201
  #
45
202
  # @return [void]
46
203
  def reset!
47
- @callbacks = []
204
+ Fiber[:log_subscriptions] = []
205
+ end
206
+
207
+ private
208
+
209
+ # Get subscriptions from Fiber-local storage
210
+ #
211
+ # @return [Array<Subscription>] Current subscriptions
212
+ def subscriptions
213
+ Fiber[:log_subscriptions] ||= []
214
+ end
215
+
216
+ # Ensure event has a timestamp
217
+ #
218
+ # @param entry [Hash] Event entry
219
+ # @return [Hash] Entry with timestamp
220
+ def ensure_timestamp(entry)
221
+ return entry if entry.key?(:timestamp)
222
+
223
+ entry.merge(timestamp: Time.now.utc.iso8601(6))
48
224
  end
49
225
  end
50
226
  end
@@ -16,9 +16,15 @@ module SwarmSDK
16
16
  # message_count: 5
17
17
  # )
18
18
  #
19
- # ## Fiber Safety
19
+ # ## Thread Safety
20
20
  #
21
- # LogStream is fiber-safe when following this pattern:
21
+ # LogStream is thread-safe and fiber-safe:
22
+ # - Uses Fiber storage for per-request isolation in multi-threaded servers (Puma, Sidekiq)
23
+ # - Each thread/request has its own emitter instance
24
+ # - Child fibers inherit the emitter from their parent fiber
25
+ # - No cross-thread contamination of log events
26
+ #
27
+ # Usage pattern:
22
28
  # 1. Set emitter BEFORE starting Async execution
23
29
  # 2. During Async execution, only emit() (reads emitter)
24
30
  # 3. Each event includes agent context for identification
@@ -35,34 +41,86 @@ module SwarmSDK
35
41
  # Emit a log event
36
42
  #
37
43
  # Adds timestamp and forwards to the registered emitter.
44
+ # Auto-injects execution_id, swarm_id, and parent_swarm_id from Fiber storage.
45
+ # Explicit values in data override auto-injected ones.
38
46
  #
39
47
  # @param data [Hash] Event data (type, agent, and event-specific fields)
40
48
  # @return [void]
41
49
  def emit(**data)
42
- return unless @emitter
50
+ emitter = Fiber[:log_stream_emitter]
51
+ return unless emitter
52
+
53
+ # Auto-inject execution context from Fiber storage
54
+ # Explicit values in data override auto-injected ones
55
+ auto_injected = {
56
+ execution_id: Fiber[:execution_id],
57
+ swarm_id: Fiber[:swarm_id],
58
+ parent_swarm_id: Fiber[:parent_swarm_id],
59
+ }.compact
43
60
 
44
- entry = data.merge(timestamp: Time.now.utc.iso8601).compact
61
+ entry = auto_injected.merge(data).merge(timestamp: Time.now.utc.iso8601(6)).compact
45
62
 
46
- @emitter.emit(entry)
63
+ emitter.emit(entry)
47
64
  end
48
65
 
49
66
  # Set the emitter (for dependency injection in tests)
50
67
  #
68
+ # Stores emitter in Fiber storage for thread-safe, per-request isolation.
69
+ #
51
70
  # @param emitter [#emit] Object responding to emit(Hash)
52
- attr_accessor :emitter
71
+ # @return [void]
72
+ def emitter=(emitter)
73
+ Fiber[:log_stream_emitter] = emitter
74
+ end
75
+
76
+ # Get the current emitter
77
+ #
78
+ # @return [#emit, nil] Current emitter or nil if not set
79
+ def emitter
80
+ Fiber[:log_stream_emitter]
81
+ end
53
82
 
54
83
  # Reset the emitter (for test cleanup)
55
84
  #
56
85
  # @return [void]
57
86
  def reset!
58
- @emitter = nil
87
+ Fiber[:log_stream_emitter] = nil
59
88
  end
60
89
 
61
90
  # Check if logging is enabled
62
91
  #
63
92
  # @return [Boolean] true if an emitter is configured
64
93
  def enabled?
65
- !@emitter.nil?
94
+ !Fiber[:log_stream_emitter].nil?
95
+ end
96
+
97
+ # Emit an internal error event
98
+ #
99
+ # Provides consistent error event emission for all internal errors.
100
+ # These are errors that occur during execution but are handled gracefully
101
+ # (with fallback behavior) rather than causing failures.
102
+ #
103
+ # @param error [Exception] The caught exception
104
+ # @param source [String] Source module/class (e.g., "hook_triggers", "context_compactor")
105
+ # @param context [String] Specific operation context (e.g., "swarm_stop", "summarization")
106
+ # @param agent [Symbol, String, nil] Agent name if applicable
107
+ # @param metadata [Hash] Additional context data
108
+ # @return [void]
109
+ def emit_error(error, source:, context:, agent: nil, **metadata)
110
+ emit(
111
+ type: "internal_error",
112
+ source: source,
113
+ context: context,
114
+ agent: agent,
115
+ error_class: error.class.name,
116
+ error_message: error.message,
117
+ backtrace: error.backtrace&.first(5),
118
+ **metadata,
119
+ )
120
+ rescue StandardError
121
+ # Absolute fallback - if emit_error itself fails, don't break execution
122
+ # This should never happen, but we must be defensive
123
+ nil
66
124
  end
67
125
  end
68
126
  end
@@ -1,5 +1,8 @@
1
1
  {
2
2
  "sonnet": "claude-sonnet-4-5-20250929",
3
3
  "opus": "claude-opus-4-1-20250805",
4
- "haiku": "claude-haiku-4-5-20251001"
4
+ "haiku": "claude-haiku-4-5-20251001",
5
+ "claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
6
+ "claude-opus-4-1": "claude-opus-4-1-20250805",
7
+ "claude-haiku-4-5": "claude-haiku-4-5-20251001"
5
8
  }
@@ -168,7 +168,7 @@ module SwarmSDK
168
168
  end
169
169
 
170
170
  # Control flow methods for transformers
171
- # These return special hashes that NodeOrchestrator recognizes
171
+ # These return special hashes that Workflow recognizes
172
172
 
173
173
  # Skip current node's LLM execution and return content immediately
174
174
  #
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Observer
5
+ # DSL for configuring observer agents
6
+ #
7
+ # Used by Swarm::Builder#observer to provide a clean DSL for defining
8
+ # event handlers and observer configuration options.
9
+ #
10
+ # @example Basic usage
11
+ # observer :profiler do
12
+ # on :swarm_start do |event|
13
+ # "Analyze this prompt: #{event[:prompt]}"
14
+ # end
15
+ #
16
+ # timeout 120
17
+ # max_concurrent 2
18
+ # end
19
+ class Builder
20
+ # Initialize builder with agent name and config
21
+ #
22
+ # @param agent_name [Symbol] Name of the observer agent
23
+ # @param config [Observer::Config] Configuration object to populate
24
+ def initialize(agent_name, config)
25
+ @agent_name = agent_name
26
+ @config = config
27
+ end
28
+
29
+ # Register an event handler
30
+ #
31
+ # The block receives the event hash and should return:
32
+ # - A prompt string to trigger the observer agent
33
+ # - nil to skip execution for this event
34
+ #
35
+ # @param event_type [Symbol] Type of event to handle (e.g., :swarm_start, :tool_call)
36
+ # @yield [Hash] Event hash
37
+ # @yieldreturn [String, nil] Prompt or nil to skip
38
+ # @return [void]
39
+ #
40
+ # @example
41
+ # on :tool_call do |event|
42
+ # next unless event[:tool_name] == "Bash"
43
+ # "Check this command: #{event[:arguments][:command]}"
44
+ # end
45
+ def on(event_type, &block)
46
+ @config.add_handler(event_type, &block)
47
+ end
48
+
49
+ # Set maximum concurrent executions for this observer
50
+ #
51
+ # Limits how many instances of this observer agent can run simultaneously.
52
+ # Useful for resource-intensive observers.
53
+ #
54
+ # @param n [Integer] Maximum concurrent executions
55
+ # @return [void]
56
+ def max_concurrent(n)
57
+ @config.options[:max_concurrent] = n
58
+ end
59
+
60
+ # Set timeout for observer execution
61
+ #
62
+ # Observer tasks will be cancelled after this duration.
63
+ #
64
+ # @param seconds [Integer] Timeout in seconds (default: 60)
65
+ # @return [void]
66
+ def timeout(seconds)
67
+ @config.options[:timeout] = seconds
68
+ end
69
+
70
+ # Wait for observer to complete before swarm execution ends
71
+ #
72
+ # By default, observers are fire-and-forget. This option causes
73
+ # the main execution to wait for this observer to complete.
74
+ #
75
+ # @return [void]
76
+ def wait_for_completion!
77
+ @config.options[:fire_and_forget] = false
78
+ end
79
+ end
80
+ end
81
+ end