swarm_memory 2.1.3 → 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 (94) 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 +2 -15
  5. data/lib/claude_swarm/mcp_generator.rb +1 -0
  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/run.rb +2 -2
  11. data/lib/swarm_cli/config_loader.rb +11 -11
  12. data/lib/swarm_cli/formatters/human_formatter.rb +70 -0
  13. data/lib/swarm_cli/interactive_repl.rb +11 -5
  14. data/lib/swarm_cli/ui/icons.rb +0 -23
  15. data/lib/swarm_cli/version.rb +1 -1
  16. data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
  17. data/lib/swarm_memory/integration/sdk_plugin.rb +87 -7
  18. data/lib/swarm_memory/version.rb +1 -1
  19. data/lib/swarm_memory.rb +1 -1
  20. data/lib/swarm_sdk/agent/builder.rb +58 -0
  21. data/lib/swarm_sdk/agent/chat.rb +527 -1059
  22. data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +9 -88
  23. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
  24. data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +111 -44
  25. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
  26. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
  27. data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
  28. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
  29. data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +12 -12
  30. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
  31. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
  32. data/lib/swarm_sdk/agent/context.rb +2 -2
  33. data/lib/swarm_sdk/agent/definition.rb +66 -154
  34. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +4 -2
  35. data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
  36. data/lib/swarm_sdk/builders/base_builder.rb +409 -0
  37. data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
  38. data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
  39. data/lib/swarm_sdk/concerns/validatable.rb +55 -0
  40. data/lib/swarm_sdk/configuration/parser.rb +353 -0
  41. data/lib/swarm_sdk/configuration/translator.rb +255 -0
  42. data/lib/swarm_sdk/configuration.rb +65 -543
  43. data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
  44. data/lib/swarm_sdk/context_compactor.rb +6 -11
  45. data/lib/swarm_sdk/context_management/builder.rb +128 -0
  46. data/lib/swarm_sdk/context_management/context.rb +328 -0
  47. data/lib/swarm_sdk/defaults.rb +196 -0
  48. data/lib/swarm_sdk/events_to_messages.rb +18 -0
  49. data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
  50. data/lib/swarm_sdk/log_collector.rb +179 -29
  51. data/lib/swarm_sdk/log_stream.rb +29 -0
  52. data/lib/swarm_sdk/node_context.rb +1 -1
  53. data/lib/swarm_sdk/observer/builder.rb +81 -0
  54. data/lib/swarm_sdk/observer/config.rb +45 -0
  55. data/lib/swarm_sdk/observer/manager.rb +236 -0
  56. data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
  57. data/lib/swarm_sdk/plugin.rb +93 -3
  58. data/lib/swarm_sdk/snapshot.rb +6 -6
  59. data/lib/swarm_sdk/snapshot_from_events.rb +13 -2
  60. data/lib/swarm_sdk/state_restorer.rb +136 -151
  61. data/lib/swarm_sdk/state_snapshot.rb +65 -100
  62. data/lib/swarm_sdk/swarm/agent_initializer.rb +180 -136
  63. data/lib/swarm_sdk/swarm/builder.rb +44 -578
  64. data/lib/swarm_sdk/swarm/executor.rb +213 -0
  65. data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
  66. data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
  67. data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
  68. data/lib/swarm_sdk/swarm/tool_configurator.rb +42 -138
  69. data/lib/swarm_sdk/swarm.rb +137 -679
  70. data/lib/swarm_sdk/tools/bash.rb +11 -3
  71. data/lib/swarm_sdk/tools/delegate.rb +61 -43
  72. data/lib/swarm_sdk/tools/edit.rb +8 -13
  73. data/lib/swarm_sdk/tools/glob.rb +9 -1
  74. data/lib/swarm_sdk/tools/grep.rb +7 -0
  75. data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
  76. data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
  77. data/lib/swarm_sdk/tools/read.rb +11 -13
  78. data/lib/swarm_sdk/tools/registry.rb +122 -10
  79. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +8 -5
  80. data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
  81. data/lib/swarm_sdk/tools/todo_write.rb +7 -0
  82. data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
  83. data/lib/swarm_sdk/tools/write.rb +8 -13
  84. data/lib/swarm_sdk/version.rb +1 -1
  85. data/lib/swarm_sdk/{node → workflow}/agent_config.rb +1 -1
  86. data/lib/swarm_sdk/workflow/builder.rb +143 -0
  87. data/lib/swarm_sdk/workflow/executor.rb +497 -0
  88. data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +3 -3
  89. data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
  90. data/lib/swarm_sdk/{node_orchestrator.rb → workflow.rb} +152 -456
  91. data/lib/swarm_sdk.rb +33 -3
  92. metadata +37 -14
  93. data/lib/swarm_memory/chat_extension.rb +0 -34
  94. data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -589
@@ -2,7 +2,7 @@
2
2
 
3
3
  module SwarmSDK
4
4
  module Agent
5
- class Chat < RubyLLM::Chat
5
+ module ChatHelpers
6
6
  # Manages context tracking, delegation tracking, and logging callbacks
7
7
  #
8
8
  # Responsibilities:
@@ -63,65 +63,19 @@ module SwarmSDK
63
63
 
64
64
  # Extract agent name from delegation tool name
65
65
  #
66
- # Converts "DelegateTaskTo[AgentName]" to "agent_name"
67
- # Example: "DelegateTaskToWorker" -> "worker"
66
+ # Converts "#{Tools::Delegate::TOOL_NAME_PREFIX}[AgentName]" to "agent_name"
67
+ # Example: "WorkWithWorker" -> "worker"
68
68
  #
69
69
  # @param tool_name [String] Delegation tool name
70
70
  # @return [String] Agent name
71
71
  def extract_delegate_agent_name(tool_name)
72
- # Remove "DelegateTaskTo" prefix and lowercase first letter
73
- agent_name = tool_name.to_s.sub(/^DelegateTaskTo/, "")
72
+ # Remove tool name prefix and lowercase first letter
73
+ agent_name = tool_name.to_s.sub(/^#{Tools::Delegate::TOOL_NAME_PREFIX}/, "")
74
74
  # Convert from PascalCase to lowercase (e.g., "Worker" -> "worker", "BackendDev" -> "backendDev")
75
75
  agent_name[0] = agent_name[0].downcase unless agent_name.empty?
76
76
  agent_name
77
77
  end
78
78
 
79
- # Check if context usage has crossed warning thresholds and emit warnings
80
- #
81
- # This should be called after each LLM response to check if we've crossed
82
- # any warning thresholds (80%, 90%, etc.)
83
- #
84
- # @return [void]
85
- def check_context_warnings
86
- current_percentage = @chat.context_usage_percentage
87
-
88
- Context::CONTEXT_WARNING_THRESHOLDS.each do |threshold|
89
- # Only warn once per threshold
90
- next if @agent_context.warning_threshold_hit?(threshold)
91
- next if current_percentage < threshold
92
-
93
- # Mark threshold as hit and emit warning
94
- @agent_context.hit_warning_threshold?(threshold)
95
-
96
- # Emit context_threshold_hit event for snapshot reconstruction
97
- LogStream.emit(
98
- type: "context_threshold_hit",
99
- agent: @agent_context.name,
100
- threshold: threshold,
101
- current_usage_percentage: current_percentage.round(2),
102
- )
103
-
104
- # Trigger automatic compression at 60% threshold
105
- if threshold == Context::COMPRESSION_THRESHOLD
106
- trigger_automatic_compression
107
- end
108
-
109
- # Emit legacy context_limit_warning for backwards compatibility
110
- LogStream.emit(
111
- type: "context_limit_warning",
112
- agent: @agent_context.name,
113
- model: @chat.model.id,
114
- threshold: "#{threshold}%",
115
- current_usage: "#{current_percentage}%",
116
- tokens_used: @chat.cumulative_total_tokens,
117
- tokens_remaining: @chat.tokens_remaining,
118
- context_limit: @chat.context_limit,
119
- metadata: @agent_context.metadata,
120
- compression_triggered: threshold == Context::COMPRESSION_THRESHOLD,
121
- )
122
- end
123
- end
124
-
125
79
  private
126
80
 
127
81
  # Extract usage information from an assistant message
@@ -187,6 +141,10 @@ module SwarmSDK
187
141
  # Final response (finish_reason: "stop") - fire agent_stop
188
142
  trigger_agent_stop(message, tool_executions: @tool_executions)
189
143
  end
144
+
145
+ # Check context warnings after each assistant message
146
+ # Uses unified implementation in HookIntegration
147
+ @chat.check_context_warnings if @chat.respond_to?(:check_context_warnings)
190
148
  when :tool
191
149
  # Handle delegation tracking and logging (technical plumbing)
192
150
  if @agent_context.delegation?(call_id: message.tool_call_id)
@@ -305,43 +263,6 @@ module SwarmSDK
305
263
  )
306
264
  end
307
265
  end
308
-
309
- # Trigger automatic message compression
310
- #
311
- # Called when context usage crosses 60% threshold. Compresses old tool
312
- # results to save context window space while preserving accuracy.
313
- #
314
- # @return [void]
315
- def trigger_automatic_compression
316
- return unless @chat.respond_to?(:context_manager)
317
-
318
- # Calculate tokens before compression
319
- tokens_before = @chat.cumulative_total_tokens
320
-
321
- # Get compressed messages from ContextManager
322
- compressed = @chat.context_manager.auto_compress_on_threshold(@chat.messages, keep_recent: 10)
323
-
324
- # Count how many messages were actually compressed
325
- messages_compressed = compressed.count do |msg|
326
- msg.content.to_s.include?("[truncated for context management]")
327
- end
328
-
329
- # Replace messages array with compressed version
330
- @chat.messages.clear
331
- compressed.each { |msg| @chat.messages << msg }
332
-
333
- # Log compression event
334
- LogStream.emit(
335
- type: "context_compression",
336
- agent: @agent_context.name,
337
- total_messages: @chat.messages.size,
338
- messages_compressed: messages_compressed,
339
- tokens_before: tokens_before,
340
- current_usage: "#{@chat.context_usage_percentage}%",
341
- compression_strategy: "progressive_tool_result_compression",
342
- keep_recent: 10,
343
- ) if LogStream.enabled?
344
- end
345
266
  end
346
267
  end
347
268
  end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module SwarmSDK
6
+ module Agent
7
+ module ChatHelpers
8
+ # Minimal event emitter that mirrors RubyLLM::Chat's callback pattern
9
+ #
10
+ # Provides multi-subscriber support for events like tool_call, tool_result,
11
+ # new_message, end_message. This is thread-safe and supports unsubscription.
12
+ module EventEmitter
13
+ # Represents an active subscription to a callback event.
14
+ # Returned by {#subscribe} and can be used to unsubscribe later.
15
+ class Subscription
16
+ attr_reader :tag
17
+
18
+ def initialize(callback_list, callback, monitor:, tag: nil)
19
+ @callback_list = callback_list
20
+ @callback = callback
21
+ @monitor = monitor
22
+ @tag = tag
23
+ @active = true
24
+ end
25
+
26
+ # Removes this subscription from the callback list.
27
+ # @return [Boolean] true if successfully unsubscribed, false if already inactive
28
+ def unsubscribe # rubocop:disable Naming/PredicateMethod
29
+ @monitor.synchronize do
30
+ return false unless @active
31
+
32
+ @callback_list.delete(@callback)
33
+ @active = false
34
+ end
35
+ true
36
+ end
37
+
38
+ # Checks if this subscription is still active.
39
+ # @return [Boolean] true if still subscribed
40
+ def active?
41
+ @monitor.synchronize do
42
+ @active && @callback_list.include?(@callback)
43
+ end
44
+ end
45
+
46
+ def inspect
47
+ "#<#{self.class.name} tag=#{@tag.inspect} active=#{active?}>"
48
+ end
49
+ end
50
+
51
+ # Initialize the event emitter system
52
+ #
53
+ # Sets up @callbacks hash and @callback_monitor for thread safety.
54
+ # Must be called in Chat#initialize.
55
+ #
56
+ # @return [void]
57
+ def initialize_event_emitter
58
+ @callbacks = {
59
+ new_message: [],
60
+ end_message: [],
61
+ tool_call: [],
62
+ tool_result: [],
63
+ }
64
+ @callback_monitor = Monitor.new
65
+ end
66
+
67
+ # Subscribes to an event with the given block.
68
+ # Returns a {Subscription} that can be used to unsubscribe.
69
+ #
70
+ # @param event [Symbol] The event to subscribe to
71
+ # @param tag [String, nil] Optional tag for debugging/identification
72
+ # @yield The block to call when the event fires
73
+ # @return [Subscription] An object that can be used to unsubscribe
74
+ # @raise [ArgumentError] if event is not recognized
75
+ def subscribe(event, tag: nil, &block)
76
+ @callback_monitor.synchronize do
77
+ unless @callbacks.key?(event)
78
+ raise ArgumentError, "Unknown event: #{event}. Valid events: #{@callbacks.keys.join(", ")}"
79
+ end
80
+
81
+ @callbacks[event] << block
82
+ Subscription.new(@callbacks[event], block, monitor: @callback_monitor, tag: tag)
83
+ end
84
+ end
85
+
86
+ # Subscribes to an event that automatically unsubscribes after firing once.
87
+ #
88
+ # @param event [Symbol] The event to subscribe to
89
+ # @param tag [String, nil] Optional tag for debugging/identification
90
+ # @yield The block to call when the event fires (once)
91
+ # @return [Subscription] An object that can be used to unsubscribe before it fires
92
+ def once(event, tag: nil, &block)
93
+ subscription = nil
94
+ wrapper = lambda do |*args|
95
+ subscription&.unsubscribe
96
+ block.call(*args)
97
+ end
98
+ subscription = subscribe(event, tag: tag, &wrapper)
99
+ end
100
+
101
+ # Registers a callback for when a new message starts being generated.
102
+ # Multiple callbacks can be registered and all will fire in registration order.
103
+ #
104
+ # @yield Block called when a new message starts
105
+ # @return [self] for chaining
106
+ def on_new_message(&block)
107
+ subscribe(:new_message, &block)
108
+ self
109
+ end
110
+
111
+ # Registers a callback for when a message is complete.
112
+ # Multiple callbacks can be registered and all will fire in registration order.
113
+ #
114
+ # @yield [Message] Block called with the completed message
115
+ # @return [self] for chaining
116
+ def on_end_message(&block)
117
+ subscribe(:end_message, &block)
118
+ self
119
+ end
120
+
121
+ # Registers a callback for when a tool is called.
122
+ # Multiple callbacks can be registered and all will fire in registration order.
123
+ #
124
+ # @yield [ToolCall] Block called with the tool call object
125
+ # @return [self] for chaining
126
+ def on_tool_call(&block)
127
+ subscribe(:tool_call, &block)
128
+ self
129
+ end
130
+
131
+ # Registers a callback for when a tool returns a result.
132
+ # Multiple callbacks can be registered and all will fire in registration order.
133
+ #
134
+ # @yield [Object] Block called with the tool result
135
+ # @return [self] for chaining
136
+ def on_tool_result(&block)
137
+ subscribe(:tool_result, &block)
138
+ self
139
+ end
140
+
141
+ # Clears all callbacks for the specified event, or all events if none specified.
142
+ #
143
+ # @param event [Symbol, nil] The event to clear callbacks for, or nil for all events
144
+ # @return [self] for chaining
145
+ def clear_callbacks(event = nil)
146
+ @callback_monitor.synchronize do
147
+ if event
148
+ @callbacks[event]&.clear
149
+ else
150
+ @callbacks.each_value(&:clear)
151
+ end
152
+ end
153
+ self
154
+ end
155
+
156
+ # Returns the number of callbacks registered for the specified event.
157
+ #
158
+ # @param event [Symbol, nil] The event to count callbacks for, or nil for all events
159
+ # @return [Integer, Hash] Count for specific event, or hash of counts for all events
160
+ def callback_count(event = nil)
161
+ @callback_monitor.synchronize do
162
+ if event
163
+ @callbacks[event]&.size || 0
164
+ else
165
+ @callbacks.transform_values(&:size)
166
+ end
167
+ end
168
+ end
169
+
170
+ private
171
+
172
+ # Emits an event to all registered subscribers.
173
+ # Callbacks are executed in registration order (FIFO).
174
+ # Errors in callbacks are isolated - one failing callback doesn't prevent others from running.
175
+ #
176
+ # @param event [Symbol] The event to emit
177
+ # @param args [Array] Arguments to pass to each callback
178
+ # @return [void]
179
+ def emit(event, *args)
180
+ # Snapshot callbacks under lock (fast operation)
181
+ callbacks = @callback_monitor.synchronize { @callbacks[event]&.dup || [] }
182
+
183
+ # Execute callbacks outside lock (safe, non-blocking)
184
+ callbacks.each do |callback|
185
+ callback.call(*args)
186
+ rescue StandardError => e
187
+ handle_callback_error(event, callback, e)
188
+ end
189
+ end
190
+
191
+ # Hook for custom error handling when a callback raises an exception.
192
+ # Override this method in Chat to customize error behavior.
193
+ #
194
+ # @param event [Symbol] The event that was being emitted
195
+ # @param callback [Proc] The callback that raised the error
196
+ # @param error [StandardError] The error that was raised
197
+ # @return [void]
198
+ def handle_callback_error(event, _callback, error)
199
+ warn("[SwarmSDK] Callback error in #{event}: #{error.class} - #{error.message}")
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module SwarmSDK
4
4
  module Agent
5
- class Chat < RubyLLM::Chat
5
+ module ChatHelpers
6
6
  # Integrates SwarmSDK's hook system with Agent::Chat
7
7
  #
8
8
  # Responsibilities:
@@ -71,40 +71,25 @@ module SwarmSDK
71
71
  @hook_agent_hooks[event].sort_by! { |cb| -cb.priority }
72
72
  end
73
73
 
74
- # Override ask to trigger user_prompt hooks
74
+ # NOTE: The ask() method override has been removed.
75
75
  #
76
- # This wraps the Agent::Chat#ask implementation to inject hooks AFTER
77
- # system reminders are handled.
76
+ # In the new wrapper-based architecture, Agent::Chat#ask handles:
77
+ # 1. System reminder injection
78
+ # 2. User prompt hooks via trigger_user_prompt
79
+ # 3. Global semaphore acquisition
80
+ # 4. Delegation to RubyLLM::Chat
78
81
  #
79
- # @param prompt [String] User prompt
80
- # @param options [Hash] Additional options
81
- # @return [RubyLLM::Message] LLM response
82
- def ask(prompt, **options)
83
- # Trigger user_prompt hook before sending to LLM (can halt or modify prompt)
84
- if @hook_executor
85
- hook_result = trigger_user_prompt(prompt)
86
-
87
- # Check if hook halted execution
88
- if hook_result[:halted]
89
- # Return a halted message instead of calling LLM
90
- return RubyLLM::Message.new(
91
- role: :assistant,
92
- content: hook_result[:halt_message],
93
- model_id: model.id,
94
- )
95
- end
96
-
97
- # Use modified prompt if hook provided one (stdout injection)
98
- prompt = hook_result[:modified_prompt] if hook_result[:modified_prompt]
99
- end
100
-
101
- # Call original ask implementation (Agent::Chat handles system reminders)
102
- super(prompt, **options)
103
- end
82
+ # The hook integration is now done directly in Agent::Chat#ask rather than
83
+ # through module override, since there's no inheritance chain to call super on.
104
84
 
105
85
  # Override check_context_warnings to trigger our hook system
106
86
  #
107
87
  # This wraps the default context warning behavior to also trigger hooks.
88
+ # Unified implementation that:
89
+ # 1. Emits context_threshold_hit events (for snapshot reconstruction)
90
+ # 2. Optionally triggers automatic compression at 60% (if no custom handler)
91
+ # 3. Emits context_limit_warning events (backward compatibility)
92
+ # 4. Triggers user-defined context_warning hooks
108
93
  def check_context_warnings
109
94
  return unless respond_to?(:context_usage_percentage)
110
95
 
@@ -118,20 +103,40 @@ module SwarmSDK
118
103
  # Mark threshold as hit
119
104
  @agent_context.hit_warning_threshold?(threshold)
120
105
 
121
- # Emit existing log event (for backward compatibility)
106
+ # Emit context_threshold_hit event (for snapshot reconstruction) - CRITICAL
107
+ LogStream.emit(
108
+ type: "context_threshold_hit",
109
+ agent: @agent_context.name,
110
+ threshold: threshold,
111
+ current_usage_percentage: current_percentage.round(2),
112
+ )
113
+
114
+ # Check if user has defined custom handler for context_warning
115
+ # Custom handlers take responsibility for managing context at this threshold
116
+ has_custom_handler = (@hook_agent_hooks[:context_warning] || []).any?
117
+
118
+ # Trigger automatic compression at 60% ONLY if no custom handler
119
+ compression_triggered = false
120
+ if threshold == Context::COMPRESSION_THRESHOLD && !has_custom_handler
121
+ compressed_count = apply_automatic_compression
122
+ compression_triggered = compressed_count > 0
123
+ end
124
+
125
+ # Emit legacy context_limit_warning for backwards compatibility
122
126
  LogStream.emit(
123
127
  type: "context_limit_warning",
124
128
  agent: @agent_context.name,
125
- model: model.id,
129
+ model: model_id,
126
130
  threshold: "#{threshold}%",
127
131
  current_usage: "#{current_percentage}%",
128
132
  tokens_used: cumulative_total_tokens,
129
133
  tokens_remaining: tokens_remaining,
130
134
  context_limit: context_limit,
131
135
  metadata: @agent_context.metadata,
136
+ compression_triggered: compression_triggered,
132
137
  )
133
138
 
134
- # Trigger hook system
139
+ # Trigger hook system (user-defined handlers)
135
140
  trigger_context_warning(threshold, current_percentage) if @hook_executor
136
141
  end
137
142
  end
@@ -217,6 +222,45 @@ module SwarmSDK
217
222
 
218
223
  private
219
224
 
225
+ # Apply automatic message compression when context threshold is hit
226
+ #
227
+ # Called when context usage crosses 60% threshold and no custom handler exists.
228
+ # Compresses old tool results to save context window space while preserving accuracy.
229
+ #
230
+ # @return [Integer] Number of messages compressed (0 if compression not applied)
231
+ def apply_automatic_compression
232
+ return 0 unless respond_to?(:context_manager) && respond_to?(:messages)
233
+
234
+ # Calculate tokens before compression
235
+ tokens_before = cumulative_total_tokens
236
+
237
+ # Get compressed messages from ContextManager
238
+ compressed = context_manager.auto_compress_on_threshold(messages, keep_recent: 10)
239
+
240
+ # Count how many messages were actually compressed
241
+ messages_compressed = compressed.count do |msg|
242
+ msg.content.to_s.include?("[truncated for context management]")
243
+ end
244
+
245
+ # Replace messages using proper abstraction
246
+ replace_messages(compressed)
247
+
248
+ # Log compression event
249
+ LogStream.emit(
250
+ type: "context_compression",
251
+ agent: @agent_context.name,
252
+ total_messages: message_count,
253
+ messages_compressed: messages_compressed,
254
+ tokens_before: tokens_before,
255
+ current_usage: "#{context_usage_percentage}%",
256
+ compression_strategy: "progressive_tool_result_compression",
257
+ keep_recent: 10,
258
+ triggered_by: "auto_compression_threshold",
259
+ ) if LogStream.enabled?
260
+
261
+ messages_compressed
262
+ end
263
+
220
264
  # Trigger context_warning hooks
221
265
  #
222
266
  # Hooks have access to the chat instance via metadata[:chat]
@@ -255,13 +299,14 @@ module SwarmSDK
255
299
  # Can halt execution or append hook stdout to prompt.
256
300
  #
257
301
  # @param prompt [String] User's message/prompt
302
+ # @param source [String] Source of the prompt ("user" or "delegation")
258
303
  # @return [Hash] { halted: bool, halt_message: String, modified_prompt: String }
259
- def trigger_user_prompt(prompt)
304
+ def trigger_user_prompt(prompt, source: "user")
260
305
  return { halted: false, modified_prompt: prompt } unless @hook_executor
261
306
 
262
- # Filter out delegation tools from tools list
263
- actual_tools = if respond_to?(:tools) && @agent_context
264
- tools.keys.reject { |tool_name| @agent_context.delegation_tool?(tool_name.to_s) }
307
+ # Get tool names without delegation tools using proper abstraction
308
+ actual_tools = if respond_to?(:non_delegation_tool_names) && @agent_context
309
+ non_delegation_tool_names
265
310
  else
266
311
  []
267
312
  end
@@ -277,11 +322,12 @@ module SwarmSDK
277
322
  event: :user_prompt,
278
323
  metadata: {
279
324
  prompt: prompt,
280
- message_count: messages.size,
281
- model: model.id,
282
- provider: model.provider,
325
+ message_count: message_count,
326
+ model: model_id,
327
+ provider: model_provider,
283
328
  tools: actual_tools,
284
329
  delegates_to: delegate_agents,
330
+ source: source,
285
331
  timestamp: Time.now.utc.iso8601,
286
332
  },
287
333
  )
@@ -366,16 +412,37 @@ module SwarmSDK
366
412
  digest = case tool_call.name
367
413
  when "Read"
368
414
  Tools::Stores::ReadTracker.get_read_files(@agent_context.name)[File.expand_path(path)]
369
- when "MemoryRead"
370
- # Only query if SwarmMemory is loaded (optional dependency)
371
- if defined?(SwarmMemory::Core::StorageReadTracker)
372
- SwarmMemory::Core::StorageReadTracker.get_read_entries(@agent_context.name)[path]
373
- end
415
+ else
416
+ # Query registered plugins for digest (e.g., MemoryRead from SwarmMemory plugin)
417
+ query_plugin_for_digest(tool_call.name, path)
374
418
  end
375
419
 
376
420
  digest ? { read_digest: digest, read_path: path } : {}
377
421
  end
378
422
 
423
+ # Query registered plugins for a tool result digest
424
+ #
425
+ # This allows plugins to provide digest tracking for their own tools
426
+ # (e.g., MemoryRead tracking in SwarmMemory plugin).
427
+ #
428
+ # @param tool_name [String] Name of the tool
429
+ # @param path [String] Path or identifier of the resource
430
+ # @return [String, nil] Digest from first plugin that responds, or nil
431
+ def query_plugin_for_digest(tool_name, path)
432
+ return unless @agent_context
433
+
434
+ PluginRegistry.all.each do |plugin|
435
+ digest = plugin.get_tool_result_digest(
436
+ agent_name: @agent_context.name,
437
+ tool_name: tool_name,
438
+ path: path,
439
+ )
440
+ return digest if digest
441
+ end
442
+
443
+ nil
444
+ end
445
+
379
446
  # Wrap a tool result in our Hooks::ToolResult value object
380
447
  #
381
448
  # @param tool_call_id [String] Tool call ID
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Agent
5
+ module ChatHelpers
6
+ # LLM instrumentation for API request/response logging
7
+ #
8
+ # Extracted from Chat to reduce class size and centralize observability logic.
9
+ module Instrumentation
10
+ private
11
+
12
+ # Inject LLM instrumentation middleware for API request/response logging
13
+ #
14
+ # @return [void]
15
+ def inject_llm_instrumentation
16
+ return unless @provider
17
+
18
+ faraday_conn = @provider.connection&.connection
19
+ return unless faraday_conn
20
+ return if @llm_instrumentation_injected
21
+
22
+ provider_name = @provider.class.name.split("::").last.downcase
23
+
24
+ faraday_conn.builder.insert(
25
+ 0,
26
+ SwarmSDK::Agent::LLMInstrumentationMiddleware,
27
+ on_request: method(:handle_llm_api_request),
28
+ on_response: method(:handle_llm_api_response),
29
+ provider_name: provider_name,
30
+ )
31
+
32
+ @llm_instrumentation_injected = true
33
+
34
+ RubyLLM.logger.debug("SwarmSDK: Injected LLM instrumentation middleware for agent #{@agent_name}")
35
+ rescue StandardError => e
36
+ LogStream.emit_error(e, source: "instrumentation", context: "inject_middleware", agent: @agent_name)
37
+ RubyLLM.logger.debug("SwarmSDK: Failed to inject LLM instrumentation: #{e.message}")
38
+ end
39
+
40
+ # Handle LLM API request event
41
+ #
42
+ # @param data [Hash] Request data from middleware
43
+ def handle_llm_api_request(data)
44
+ return unless LogStream.emitter
45
+
46
+ LogStream.emit(
47
+ type: "llm_api_request",
48
+ agent: @agent_name,
49
+ swarm_id: @agent_context&.swarm_id,
50
+ parent_swarm_id: @agent_context&.parent_swarm_id,
51
+ **data,
52
+ )
53
+ rescue StandardError => e
54
+ LogStream.emit_error(e, source: "instrumentation", context: "emit_llm_api_request", agent: @agent_name)
55
+ RubyLLM.logger.debug("SwarmSDK: Error emitting llm_api_request event: #{e.message}")
56
+ end
57
+
58
+ # Handle LLM API response event
59
+ #
60
+ # @param data [Hash] Response data from middleware
61
+ def handle_llm_api_response(data)
62
+ return unless LogStream.emitter
63
+
64
+ LogStream.emit(
65
+ type: "llm_api_response",
66
+ agent: @agent_name,
67
+ swarm_id: @agent_context&.swarm_id,
68
+ parent_swarm_id: @agent_context&.parent_swarm_id,
69
+ **data,
70
+ )
71
+ rescue StandardError => e
72
+ LogStream.emit_error(e, source: "instrumentation", context: "emit_llm_api_response", agent: @agent_name)
73
+ RubyLLM.logger.debug("SwarmSDK: Error emitting llm_api_response event: #{e.message}")
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end