claude_swarm 1.0.9 → 1.0.10

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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/CLAUDE.md +347 -191
  4. data/docs/v2/CHANGELOG.swarm_cli.md +8 -0
  5. data/docs/v2/CHANGELOG.swarm_memory.md +7 -1
  6. data/docs/v2/CHANGELOG.swarm_sdk.md +184 -9
  7. data/docs/v2/README.md +6 -1
  8. data/docs/v2/guides/complete-tutorial.md +2 -2
  9. data/docs/v2/guides/getting-started.md +7 -7
  10. data/docs/v2/guides/migrating-to-2.3.md +541 -0
  11. data/docs/v2/guides/snapshots.md +14 -14
  12. data/docs/v2/reference/architecture-flow.md +3 -3
  13. data/docs/v2/reference/event_payload_structures.md +1 -1
  14. data/docs/v2/reference/ruby-dsl.md +157 -14
  15. data/docs/v2/reference/yaml.md +170 -52
  16. data/examples/snapshot_demo.rb +2 -2
  17. data/lib/claude_swarm/mcp_generator.rb +7 -20
  18. data/lib/claude_swarm/version.rb +1 -1
  19. data/lib/swarm_cli/commands/run.rb +2 -2
  20. data/lib/swarm_cli/config_loader.rb +11 -11
  21. data/lib/swarm_cli/formatters/human_formatter.rb +0 -33
  22. data/lib/swarm_cli/interactive_repl.rb +2 -2
  23. data/lib/swarm_cli/ui/icons.rb +0 -23
  24. data/lib/swarm_cli/version.rb +1 -1
  25. data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
  26. data/lib/swarm_memory/integration/sdk_plugin.rb +87 -7
  27. data/lib/swarm_memory/version.rb +1 -1
  28. data/lib/swarm_memory.rb +1 -1
  29. data/lib/swarm_sdk/agent/builder.rb +58 -0
  30. data/lib/swarm_sdk/agent/chat.rb +527 -1061
  31. data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +9 -88
  32. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
  33. data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +108 -46
  34. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
  35. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
  36. data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
  37. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
  38. data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +12 -12
  39. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
  40. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
  41. data/lib/swarm_sdk/agent/context.rb +2 -2
  42. data/lib/swarm_sdk/agent/definition.rb +66 -154
  43. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +4 -2
  44. data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
  45. data/lib/swarm_sdk/builders/base_builder.rb +409 -0
  46. data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
  47. data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
  48. data/lib/swarm_sdk/concerns/validatable.rb +55 -0
  49. data/lib/swarm_sdk/configuration/parser.rb +353 -0
  50. data/lib/swarm_sdk/configuration/translator.rb +255 -0
  51. data/lib/swarm_sdk/configuration.rb +65 -543
  52. data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
  53. data/lib/swarm_sdk/context_compactor.rb +6 -11
  54. data/lib/swarm_sdk/context_management/builder.rb +128 -0
  55. data/lib/swarm_sdk/context_management/context.rb +328 -0
  56. data/lib/swarm_sdk/defaults.rb +196 -0
  57. data/lib/swarm_sdk/events_to_messages.rb +18 -0
  58. data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
  59. data/lib/swarm_sdk/log_collector.rb +179 -29
  60. data/lib/swarm_sdk/log_stream.rb +29 -0
  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/snapshot.rb +6 -6
  68. data/lib/swarm_sdk/snapshot_from_events.rb +13 -2
  69. data/lib/swarm_sdk/state_restorer.rb +136 -151
  70. data/lib/swarm_sdk/state_snapshot.rb +65 -100
  71. data/lib/swarm_sdk/swarm/agent_initializer.rb +180 -136
  72. data/lib/swarm_sdk/swarm/builder.rb +44 -578
  73. data/lib/swarm_sdk/swarm/executor.rb +213 -0
  74. data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
  75. data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
  76. data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
  77. data/lib/swarm_sdk/swarm/tool_configurator.rb +42 -138
  78. data/lib/swarm_sdk/swarm.rb +137 -680
  79. data/lib/swarm_sdk/tools/bash.rb +11 -3
  80. data/lib/swarm_sdk/tools/delegate.rb +61 -43
  81. data/lib/swarm_sdk/tools/edit.rb +8 -13
  82. data/lib/swarm_sdk/tools/glob.rb +9 -1
  83. data/lib/swarm_sdk/tools/grep.rb +7 -0
  84. data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
  85. data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
  86. data/lib/swarm_sdk/tools/read.rb +11 -13
  87. data/lib/swarm_sdk/tools/registry.rb +122 -10
  88. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +8 -5
  89. data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
  90. data/lib/swarm_sdk/tools/todo_write.rb +7 -0
  91. data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
  92. data/lib/swarm_sdk/tools/write.rb +8 -13
  93. data/lib/swarm_sdk/version.rb +1 -1
  94. data/lib/swarm_sdk/{node → workflow}/agent_config.rb +1 -1
  95. data/lib/swarm_sdk/workflow/builder.rb +143 -0
  96. data/lib/swarm_sdk/workflow/executor.rb +497 -0
  97. data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +3 -3
  98. data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
  99. data/lib/swarm_sdk/{node_orchestrator.rb → workflow.rb} +152 -456
  100. data/lib/swarm_sdk.rb +33 -3
  101. data/rubocop/cop/security/no_reflection_methods.rb +1 -1
  102. data/swarm_memory.gemspec +1 -1
  103. data/swarm_sdk.gemspec +4 -2
  104. data/team_full.yml +24 -24
  105. metadata +35 -11
  106. data/lib/swarm_memory/chat_extension.rb +0 -34
  107. 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,43 +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 (may include source: "user" or "delegation")
81
- # @return [RubyLLM::Message] LLM response
82
- def ask(prompt, **options)
83
- # Extract source for hook tracking (not passed to RubyLLM)
84
- source = options.delete(:source) || "user"
85
-
86
- # Trigger user_prompt hook before sending to LLM (can halt or modify prompt)
87
- if @hook_executor
88
- hook_result = trigger_user_prompt(prompt, source: source)
89
-
90
- # Check if hook halted execution
91
- if hook_result[:halted]
92
- # Return a halted message instead of calling LLM
93
- return RubyLLM::Message.new(
94
- role: :assistant,
95
- content: hook_result[:halt_message],
96
- model_id: model.id,
97
- )
98
- end
99
-
100
- # Use modified prompt if hook provided one (stdout injection)
101
- prompt = hook_result[:modified_prompt] if hook_result[:modified_prompt]
102
- end
103
-
104
- # Call original ask implementation (Agent::Chat handles system reminders)
105
- super(prompt, **options)
106
- 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.
107
84
 
108
85
  # Override check_context_warnings to trigger our hook system
109
86
  #
110
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
111
93
  def check_context_warnings
112
94
  return unless respond_to?(:context_usage_percentage)
113
95
 
@@ -121,20 +103,40 @@ module SwarmSDK
121
103
  # Mark threshold as hit
122
104
  @agent_context.hit_warning_threshold?(threshold)
123
105
 
124
- # 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
125
126
  LogStream.emit(
126
127
  type: "context_limit_warning",
127
128
  agent: @agent_context.name,
128
- model: model.id,
129
+ model: model_id,
129
130
  threshold: "#{threshold}%",
130
131
  current_usage: "#{current_percentage}%",
131
132
  tokens_used: cumulative_total_tokens,
132
133
  tokens_remaining: tokens_remaining,
133
134
  context_limit: context_limit,
134
135
  metadata: @agent_context.metadata,
136
+ compression_triggered: compression_triggered,
135
137
  )
136
138
 
137
- # Trigger hook system
139
+ # Trigger hook system (user-defined handlers)
138
140
  trigger_context_warning(threshold, current_percentage) if @hook_executor
139
141
  end
140
142
  end
@@ -220,6 +222,45 @@ module SwarmSDK
220
222
 
221
223
  private
222
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
+
223
264
  # Trigger context_warning hooks
224
265
  #
225
266
  # Hooks have access to the chat instance via metadata[:chat]
@@ -263,9 +304,9 @@ module SwarmSDK
263
304
  def trigger_user_prompt(prompt, source: "user")
264
305
  return { halted: false, modified_prompt: prompt } unless @hook_executor
265
306
 
266
- # Filter out delegation tools from tools list
267
- actual_tools = if respond_to?(:tools) && @agent_context
268
- 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
269
310
  else
270
311
  []
271
312
  end
@@ -281,9 +322,9 @@ module SwarmSDK
281
322
  event: :user_prompt,
282
323
  metadata: {
283
324
  prompt: prompt,
284
- message_count: messages.size,
285
- model: model.id,
286
- provider: model.provider,
325
+ message_count: message_count,
326
+ model: model_id,
327
+ provider: model_provider,
287
328
  tools: actual_tools,
288
329
  delegates_to: delegate_agents,
289
330
  source: source,
@@ -371,16 +412,37 @@ module SwarmSDK
371
412
  digest = case tool_call.name
372
413
  when "Read"
373
414
  Tools::Stores::ReadTracker.get_read_files(@agent_context.name)[File.expand_path(path)]
374
- when "MemoryRead"
375
- # Only query if SwarmMemory is loaded (optional dependency)
376
- if defined?(SwarmMemory::Core::StorageReadTracker)
377
- SwarmMemory::Core::StorageReadTracker.get_read_entries(@agent_context.name)[path]
378
- end
415
+ else
416
+ # Query registered plugins for digest (e.g., MemoryRead from SwarmMemory plugin)
417
+ query_plugin_for_digest(tool_call.name, path)
379
418
  end
380
419
 
381
420
  digest ? { read_digest: digest, read_path: path } : {}
382
421
  end
383
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
+
384
446
  # Wrap a tool result in our Hooks::ToolResult value object
385
447
  #
386
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