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.
- checksums.yaml +4 -4
- data/lib/claude_swarm/claude_mcp_server.rb +1 -0
- data/lib/claude_swarm/cli.rb +5 -18
- data/lib/claude_swarm/configuration.rb +30 -19
- data/lib/claude_swarm/mcp_generator.rb +5 -10
- data/lib/claude_swarm/openai/chat_completion.rb +4 -12
- data/lib/claude_swarm/openai/executor.rb +3 -1
- data/lib/claude_swarm/openai/responses.rb +13 -32
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
- data/lib/swarm_cli/commands/run.rb +2 -2
- data/lib/swarm_cli/config_loader.rb +14 -14
- data/lib/swarm_cli/formatters/human_formatter.rb +70 -0
- data/lib/swarm_cli/interactive_repl.rb +11 -5
- data/lib/swarm_cli/ui/icons.rb +0 -23
- data/lib/swarm_cli/version.rb +1 -1
- data/lib/swarm_memory/adapters/base.rb +4 -4
- data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
- data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
- data/lib/swarm_memory/integration/cli_registration.rb +3 -2
- data/lib/swarm_memory/integration/sdk_plugin.rb +98 -12
- data/lib/swarm_memory/tools/memory_edit.rb +2 -2
- data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
- data/lib/swarm_memory/tools/memory_read.rb +3 -3
- data/lib/swarm_memory/version.rb +1 -1
- data/lib/swarm_memory.rb +6 -1
- data/lib/swarm_sdk/agent/builder.rb +91 -0
- data/lib/swarm_sdk/agent/chat.rb +540 -925
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +33 -79
- data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +147 -39
- data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
- data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
- data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +22 -38
- data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
- data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
- data/lib/swarm_sdk/agent/context.rb +8 -4
- data/lib/swarm_sdk/agent/context_manager.rb +6 -0
- data/lib/swarm_sdk/agent/definition.rb +79 -174
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +182 -0
- data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
- data/lib/swarm_sdk/builders/base_builder.rb +409 -0
- data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
- data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
- data/lib/swarm_sdk/concerns/validatable.rb +55 -0
- data/lib/swarm_sdk/configuration/parser.rb +353 -0
- data/lib/swarm_sdk/configuration/translator.rb +255 -0
- data/lib/swarm_sdk/configuration.rb +100 -261
- data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
- data/lib/swarm_sdk/context_compactor.rb +6 -11
- data/lib/swarm_sdk/context_management/builder.rb +128 -0
- data/lib/swarm_sdk/context_management/context.rb +328 -0
- data/lib/swarm_sdk/defaults.rb +196 -0
- data/lib/swarm_sdk/events_to_messages.rb +199 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
- data/lib/swarm_sdk/log_collector.rb +192 -16
- data/lib/swarm_sdk/log_stream.rb +66 -8
- data/lib/swarm_sdk/model_aliases.json +4 -1
- data/lib/swarm_sdk/node_context.rb +1 -1
- data/lib/swarm_sdk/observer/builder.rb +81 -0
- data/lib/swarm_sdk/observer/config.rb +45 -0
- data/lib/swarm_sdk/observer/manager.rb +236 -0
- data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
- data/lib/swarm_sdk/plugin.rb +93 -3
- data/lib/swarm_sdk/proc_helpers.rb +53 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
- 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 +397 -0
- data/lib/swarm_sdk/state_restorer.rb +476 -0
- data/lib/swarm_sdk/state_snapshot.rb +334 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +428 -79
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
- data/lib/swarm_sdk/swarm/builder.rb +69 -407
- data/lib/swarm_sdk/swarm/executor.rb +213 -0
- data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
- data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
- data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +88 -149
- data/lib/swarm_sdk/swarm.rb +366 -631
- data/lib/swarm_sdk/swarm_loader.rb +145 -0
- data/lib/swarm_sdk/swarm_registry.rb +136 -0
- data/lib/swarm_sdk/tools/bash.rb +11 -3
- data/lib/swarm_sdk/tools/delegate.rb +127 -24
- data/lib/swarm_sdk/tools/edit.rb +8 -13
- data/lib/swarm_sdk/tools/glob.rb +9 -1
- data/lib/swarm_sdk/tools/grep.rb +7 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
- data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
- data/lib/swarm_sdk/tools/read.rb +28 -18
- data/lib/swarm_sdk/tools/registry.rb +122 -10
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +53 -5
- data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
- data/lib/swarm_sdk/tools/think.rb +4 -1
- data/lib/swarm_sdk/tools/todo_write.rb +27 -8
- data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
- data/lib/swarm_sdk/tools/write.rb +8 -13
- 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/{node → workflow}/agent_config.rb +34 -9
- data/lib/swarm_sdk/workflow/builder.rb +143 -0
- data/lib/swarm_sdk/workflow/executor.rb +497 -0
- data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +42 -21
- data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
- data/lib/swarm_sdk/workflow.rb +554 -0
- data/lib/swarm_sdk.rb +393 -22
- metadata +51 -16
- data/lib/swarm_memory/chat_extension.rb +0 -34
- data/lib/swarm_sdk/node_orchestrator.rb +0 -591
- 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
|
-
|
|
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
|
-
# #
|
|
13
|
-
# LogCollector.
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
-
# #
|
|
18
|
-
# LogCollector.
|
|
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
|
-
#
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
200
|
+
# Reset the collector (clears subscriptions for next execution)
|
|
44
201
|
#
|
|
45
202
|
# @return [void]
|
|
46
203
|
def reset!
|
|
47
|
-
|
|
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
|
data/lib/swarm_sdk/log_stream.rb
CHANGED
|
@@ -16,9 +16,15 @@ module SwarmSDK
|
|
|
16
16
|
# message_count: 5
|
|
17
17
|
# )
|
|
18
18
|
#
|
|
19
|
-
# ##
|
|
19
|
+
# ## Thread Safety
|
|
20
20
|
#
|
|
21
|
-
# LogStream is
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|