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
@@ -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:
@@ -13,6 +13,25 @@ module SwarmSDK
13
13
  # - Check context warnings
14
14
  #
15
15
  # This is a stateful helper that's instantiated per Agent::Chat instance.
16
+ #
17
+ # ## Thread Safety and Fiber-Local Storage
18
+ #
19
+ # IMPORTANT: LogStream.emit calls in this class DO NOT explicitly pass
20
+ # swarm_id, parent_swarm_id, or execution_id. These values are automatically
21
+ # injected from Fiber-local storage (Fiber[:swarm_id], etc.) by LogStream.emit.
22
+ #
23
+ # Why: In threaded environments (Puma, Sidekiq), swarm/agent instances may be
24
+ # reused across multiple requests/jobs. If we explicitly pass @agent_context.swarm_id,
25
+ # callbacks would use STALE values from the first request, causing events to be
26
+ # lost or misattributed.
27
+ #
28
+ # By relying on Fiber-local storage, each request/job gets the correct context
29
+ # even when reusing the same swarm instance. Fiber storage is set at the start
30
+ # of Swarm#execute and inherited by child fibers (tool calls, delegations).
31
+ #
32
+ # This design works correctly in both:
33
+ # - Single-threaded environments (rails runner, console)
34
+ # - Multi-threaded environments (Puma, Sidekiq)
16
35
  class ContextTracker
17
36
  include LoggingHelpers
18
37
 
@@ -44,56 +63,19 @@ module SwarmSDK
44
63
 
45
64
  # Extract agent name from delegation tool name
46
65
  #
47
- # Converts "DelegateTaskTo[AgentName]" to "agent_name"
48
- # Example: "DelegateTaskToWorker" -> "worker"
66
+ # Converts "#{Tools::Delegate::TOOL_NAME_PREFIX}[AgentName]" to "agent_name"
67
+ # Example: "WorkWithWorker" -> "worker"
49
68
  #
50
69
  # @param tool_name [String] Delegation tool name
51
70
  # @return [String] Agent name
52
71
  def extract_delegate_agent_name(tool_name)
53
- # Remove "DelegateTaskTo" prefix and lowercase first letter
54
- 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}/, "")
55
74
  # Convert from PascalCase to lowercase (e.g., "Worker" -> "worker", "BackendDev" -> "backendDev")
56
75
  agent_name[0] = agent_name[0].downcase unless agent_name.empty?
57
76
  agent_name
58
77
  end
59
78
 
60
- # Check if context usage has crossed warning thresholds and emit warnings
61
- #
62
- # This should be called after each LLM response to check if we've crossed
63
- # any warning thresholds (80%, 90%, etc.)
64
- #
65
- # @return [void]
66
- def check_context_warnings
67
- current_percentage = @chat.context_usage_percentage
68
-
69
- Context::CONTEXT_WARNING_THRESHOLDS.each do |threshold|
70
- # Only warn once per threshold
71
- next if @agent_context.warning_threshold_hit?(threshold)
72
- next if current_percentage < threshold
73
-
74
- # Mark threshold as hit and emit warning
75
- @agent_context.hit_warning_threshold?(threshold)
76
-
77
- # Trigger automatic compression at 60% threshold
78
- if threshold == Context::COMPRESSION_THRESHOLD
79
- trigger_automatic_compression
80
- end
81
-
82
- LogStream.emit(
83
- type: "context_limit_warning",
84
- agent: @agent_context.name,
85
- model: @chat.model.id,
86
- threshold: "#{threshold}%",
87
- current_usage: "#{current_percentage}%",
88
- tokens_used: @chat.cumulative_total_tokens,
89
- tokens_remaining: @chat.tokens_remaining,
90
- context_limit: @chat.context_limit,
91
- metadata: @agent_context.metadata,
92
- compression_triggered: threshold == Context::COMPRESSION_THRESHOLD,
93
- )
94
- end
95
- end
96
-
97
79
  private
98
80
 
99
81
  # Extract usage information from an assistant message
@@ -107,6 +89,9 @@ module SwarmSDK
107
89
  cumulative_input_tokens: @chat.cumulative_input_tokens,
108
90
  cumulative_output_tokens: @chat.cumulative_output_tokens,
109
91
  cumulative_total_tokens: @chat.cumulative_total_tokens,
92
+ cumulative_cached_tokens: @chat.cumulative_cached_tokens,
93
+ cumulative_cache_creation_tokens: @chat.cumulative_cache_creation_tokens,
94
+ effective_input_tokens: @chat.effective_input_tokens,
110
95
  context_limit: @chat.context_limit,
111
96
  tokens_used_percentage: "#{@chat.context_usage_percentage}%",
112
97
  tokens_remaining: @chat.tokens_remaining,
@@ -118,6 +103,8 @@ module SwarmSDK
118
103
  {
119
104
  input_tokens: message.input_tokens,
120
105
  output_tokens: message.output_tokens,
106
+ cached_tokens: message.cached_tokens,
107
+ cache_creation_tokens: message.cache_creation_tokens,
121
108
  total_tokens: (message.input_tokens || 0) + (message.output_tokens || 0),
122
109
  input_cost: cost_info[:input_cost],
123
110
  output_cost: cost_info[:output_cost],
@@ -154,6 +141,10 @@ module SwarmSDK
154
141
  # Final response (finish_reason: "stop") - fire agent_stop
155
142
  trigger_agent_stop(message, tool_executions: @tool_executions)
156
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)
157
148
  when :tool
158
149
  # Handle delegation tracking and logging (technical plumbing)
159
150
  if @agent_context.delegation?(call_id: message.tool_call_id)
@@ -272,43 +263,6 @@ module SwarmSDK
272
263
  )
273
264
  end
274
265
  end
275
-
276
- # Trigger automatic message compression
277
- #
278
- # Called when context usage crosses 60% threshold. Compresses old tool
279
- # results to save context window space while preserving accuracy.
280
- #
281
- # @return [void]
282
- def trigger_automatic_compression
283
- return unless @chat.respond_to?(:context_manager)
284
-
285
- # Calculate tokens before compression
286
- tokens_before = @chat.cumulative_total_tokens
287
-
288
- # Get compressed messages from ContextManager
289
- compressed = @chat.context_manager.auto_compress_on_threshold(@chat.messages, keep_recent: 10)
290
-
291
- # Count how many messages were actually compressed
292
- messages_compressed = compressed.count do |msg|
293
- msg.content.to_s.include?("[truncated for context management]")
294
- end
295
-
296
- # Replace messages array with compressed version
297
- @chat.messages.clear
298
- compressed.each { |msg| @chat.messages << msg }
299
-
300
- # Log compression event
301
- LogStream.emit(
302
- type: "context_compression",
303
- agent: @agent_context.name,
304
- total_messages: @chat.messages.size,
305
- messages_compressed: messages_compressed,
306
- tokens_before: tokens_before,
307
- current_usage: "#{@chat.context_usage_percentage}%",
308
- compression_strategy: "progressive_tool_result_compression",
309
- keep_recent: 10,
310
- ) if LogStream.enabled?
311
- end
312
266
  end
313
267
  end
314
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
@@ -186,9 +191,13 @@ module SwarmSDK
186
191
  def trigger_post_tool_use(result, tool_call:)
187
192
  return result unless @hook_executor
188
193
 
194
+ # Extract tracking digest for Read/MemoryRead tools
195
+ metadata_with_digest = extract_tool_tracking_digest(tool_call, result)
196
+
189
197
  context = build_hook_context(
190
198
  event: :post_tool_use,
191
199
  tool_result: wrap_tool_result(tool_call.id, tool_call.name, result),
200
+ metadata: metadata_with_digest,
192
201
  )
193
202
 
194
203
  agent_hooks = @hook_agent_hooks[:post_tool_use] || []
@@ -213,6 +222,45 @@ module SwarmSDK
213
222
 
214
223
  private
215
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
+
216
264
  # Trigger context_warning hooks
217
265
  #
218
266
  # Hooks have access to the chat instance via metadata[:chat]
@@ -251,13 +299,14 @@ module SwarmSDK
251
299
  # Can halt execution or append hook stdout to prompt.
252
300
  #
253
301
  # @param prompt [String] User's message/prompt
302
+ # @param source [String] Source of the prompt ("user" or "delegation")
254
303
  # @return [Hash] { halted: bool, halt_message: String, modified_prompt: String }
255
- def trigger_user_prompt(prompt)
304
+ def trigger_user_prompt(prompt, source: "user")
256
305
  return { halted: false, modified_prompt: prompt } unless @hook_executor
257
306
 
258
- # Filter out delegation tools from tools list
259
- actual_tools = if respond_to?(:tools) && @agent_context
260
- 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
261
310
  else
262
311
  []
263
312
  end
@@ -273,11 +322,12 @@ module SwarmSDK
273
322
  event: :user_prompt,
274
323
  metadata: {
275
324
  prompt: prompt,
276
- message_count: messages.size,
277
- model: model.id,
278
- provider: model.provider,
325
+ message_count: message_count,
326
+ model: model_id,
327
+ provider: model_provider,
279
328
  tools: actual_tools,
280
329
  delegates_to: delegate_agents,
330
+ source: source,
281
331
  timestamp: Time.now.utc.iso8601,
282
332
  },
283
333
  )
@@ -335,6 +385,64 @@ module SwarmSDK
335
385
  )
336
386
  end
337
387
 
388
+ # Extract tracking digest for Read/MemoryRead tools
389
+ #
390
+ # Queries the appropriate tracker after tool execution to get the digest
391
+ # that was calculated and stored during the read operation.
392
+ #
393
+ # @param tool_call [RubyLLM::ToolCall] Tool call with arguments
394
+ # @param result [Object] Tool execution result (to check for errors)
395
+ # @return [Hash] Metadata hash with digest if applicable
396
+ def extract_tool_tracking_digest(tool_call, result)
397
+ # Only add digest for successful Read/MemoryRead tool calls
398
+ return {} if result.is_a?(StandardError)
399
+ return {} unless ["Read", "MemoryRead"].include?(tool_call.name)
400
+
401
+ # Extract path from arguments
402
+ path = case tool_call.name
403
+ when "Read"
404
+ tool_call.arguments[:file_path] || tool_call.arguments["file_path"]
405
+ when "MemoryRead"
406
+ tool_call.arguments[:file_path] || tool_call.arguments["file_path"]
407
+ end
408
+
409
+ return {} unless path
410
+
411
+ # Query tracker for digest
412
+ digest = case tool_call.name
413
+ when "Read"
414
+ Tools::Stores::ReadTracker.get_read_files(@agent_context.name)[File.expand_path(path)]
415
+ else
416
+ # Query registered plugins for digest (e.g., MemoryRead from SwarmMemory plugin)
417
+ query_plugin_for_digest(tool_call.name, path)
418
+ end
419
+
420
+ digest ? { read_digest: digest, read_path: path } : {}
421
+ end
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
+
338
446
  # Wrap a tool result in our Hooks::ToolResult value object
339
447
  #
340
448
  # @param tool_call_id [String] Tool call ID