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.
- 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 +2 -15
- data/lib/claude_swarm/mcp_generator.rb +1 -0
- 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/run.rb +2 -2
- data/lib/swarm_cli/config_loader.rb +11 -11
- 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/filesystem_adapter.rb +11 -34
- data/lib/swarm_memory/integration/sdk_plugin.rb +87 -7
- data/lib/swarm_memory/version.rb +1 -1
- data/lib/swarm_memory.rb +1 -1
- data/lib/swarm_sdk/agent/builder.rb +58 -0
- data/lib/swarm_sdk/agent/chat.rb +527 -1059
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +9 -88
- data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +111 -44
- 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 +12 -12
- 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 +2 -2
- data/lib/swarm_sdk/agent/definition.rb +66 -154
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +4 -2
- 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 +65 -543
- 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 +18 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
- data/lib/swarm_sdk/log_collector.rb +179 -29
- data/lib/swarm_sdk/log_stream.rb +29 -0
- 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/snapshot.rb +6 -6
- data/lib/swarm_sdk/snapshot_from_events.rb +13 -2
- data/lib/swarm_sdk/state_restorer.rb +136 -151
- data/lib/swarm_sdk/state_snapshot.rb +65 -100
- data/lib/swarm_sdk/swarm/agent_initializer.rb +180 -136
- data/lib/swarm_sdk/swarm/builder.rb +44 -578
- 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/tool_configurator.rb +42 -138
- data/lib/swarm_sdk/swarm.rb +137 -679
- data/lib/swarm_sdk/tools/bash.rb +11 -3
- data/lib/swarm_sdk/tools/delegate.rb +61 -43
- 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 +11 -13
- data/lib/swarm_sdk/tools/registry.rb +122 -10
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +8 -5
- data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
- data/lib/swarm_sdk/tools/todo_write.rb +7 -0
- data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
- data/lib/swarm_sdk/tools/write.rb +8 -13
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk/{node → workflow}/agent_config.rb +1 -1
- 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} +3 -3
- data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
- data/lib/swarm_sdk/{node_orchestrator.rb → workflow.rb} +152 -456
- data/lib/swarm_sdk.rb +33 -3
- metadata +37 -14
- data/lib/swarm_memory/chat_extension.rb +0 -34
- 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
|
-
|
|
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 "
|
|
67
|
-
# Example: "
|
|
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
|
|
73
|
-
agent_name = tool_name.to_s.sub(
|
|
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
|
-
|
|
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
|
-
#
|
|
74
|
+
# NOTE: The ask() method override has been removed.
|
|
75
75
|
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
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
|
-
#
|
|
80
|
-
#
|
|
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
|
|
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:
|
|
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
|
-
#
|
|
263
|
-
actual_tools = if respond_to?(:
|
|
264
|
-
|
|
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:
|
|
281
|
-
model:
|
|
282
|
-
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
|
-
|
|
370
|
-
#
|
|
371
|
-
|
|
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
|