swarm_memory 2.1.2 → 2.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/claude_swarm/claude_mcp_server.rb +1 -0
- data/lib/claude_swarm/cli.rb +5 -18
- data/lib/claude_swarm/configuration.rb +30 -19
- data/lib/claude_swarm/mcp_generator.rb +5 -10
- data/lib/claude_swarm/openai/chat_completion.rb +4 -12
- data/lib/claude_swarm/openai/executor.rb +3 -1
- data/lib/claude_swarm/openai/responses.rb +13 -32
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
- data/lib/swarm_cli/commands/run.rb +2 -2
- data/lib/swarm_cli/config_loader.rb +14 -14
- data/lib/swarm_cli/formatters/human_formatter.rb +70 -0
- data/lib/swarm_cli/interactive_repl.rb +11 -5
- data/lib/swarm_cli/ui/icons.rb +0 -23
- data/lib/swarm_cli/version.rb +1 -1
- data/lib/swarm_memory/adapters/base.rb +4 -4
- data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
- data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
- data/lib/swarm_memory/integration/cli_registration.rb +3 -2
- data/lib/swarm_memory/integration/sdk_plugin.rb +98 -12
- data/lib/swarm_memory/tools/memory_edit.rb +2 -2
- data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
- data/lib/swarm_memory/tools/memory_read.rb +3 -3
- data/lib/swarm_memory/version.rb +1 -1
- data/lib/swarm_memory.rb +6 -1
- data/lib/swarm_sdk/agent/builder.rb +91 -0
- data/lib/swarm_sdk/agent/chat.rb +540 -925
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +33 -79
- data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +147 -39
- data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
- data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
- data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +22 -38
- data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
- data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
- data/lib/swarm_sdk/agent/context.rb +8 -4
- data/lib/swarm_sdk/agent/context_manager.rb +6 -0
- data/lib/swarm_sdk/agent/definition.rb +79 -174
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +182 -0
- data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
- data/lib/swarm_sdk/builders/base_builder.rb +409 -0
- data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
- data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
- data/lib/swarm_sdk/concerns/validatable.rb +55 -0
- data/lib/swarm_sdk/configuration/parser.rb +353 -0
- data/lib/swarm_sdk/configuration/translator.rb +255 -0
- data/lib/swarm_sdk/configuration.rb +100 -261
- data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
- data/lib/swarm_sdk/context_compactor.rb +6 -11
- data/lib/swarm_sdk/context_management/builder.rb +128 -0
- data/lib/swarm_sdk/context_management/context.rb +328 -0
- data/lib/swarm_sdk/defaults.rb +196 -0
- data/lib/swarm_sdk/events_to_messages.rb +199 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
- data/lib/swarm_sdk/log_collector.rb +192 -16
- data/lib/swarm_sdk/log_stream.rb +66 -8
- data/lib/swarm_sdk/model_aliases.json +4 -1
- data/lib/swarm_sdk/node_context.rb +1 -1
- data/lib/swarm_sdk/observer/builder.rb +81 -0
- data/lib/swarm_sdk/observer/config.rb +45 -0
- data/lib/swarm_sdk/observer/manager.rb +236 -0
- data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
- data/lib/swarm_sdk/plugin.rb +93 -3
- data/lib/swarm_sdk/proc_helpers.rb +53 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
- data/lib/swarm_sdk/restore_result.rb +65 -0
- data/lib/swarm_sdk/snapshot.rb +156 -0
- data/lib/swarm_sdk/snapshot_from_events.rb +397 -0
- data/lib/swarm_sdk/state_restorer.rb +476 -0
- data/lib/swarm_sdk/state_snapshot.rb +334 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +428 -79
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
- data/lib/swarm_sdk/swarm/builder.rb +69 -407
- data/lib/swarm_sdk/swarm/executor.rb +213 -0
- data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
- data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
- data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +88 -149
- data/lib/swarm_sdk/swarm.rb +366 -631
- data/lib/swarm_sdk/swarm_loader.rb +145 -0
- data/lib/swarm_sdk/swarm_registry.rb +136 -0
- data/lib/swarm_sdk/tools/bash.rb +11 -3
- data/lib/swarm_sdk/tools/delegate.rb +127 -24
- data/lib/swarm_sdk/tools/edit.rb +8 -13
- data/lib/swarm_sdk/tools/glob.rb +9 -1
- data/lib/swarm_sdk/tools/grep.rb +7 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
- data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
- data/lib/swarm_sdk/tools/read.rb +28 -18
- data/lib/swarm_sdk/tools/registry.rb +122 -10
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +53 -5
- data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
- data/lib/swarm_sdk/tools/think.rb +4 -1
- data/lib/swarm_sdk/tools/todo_write.rb +27 -8
- data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
- data/lib/swarm_sdk/tools/write.rb +8 -13
- data/lib/swarm_sdk/utils.rb +18 -0
- data/lib/swarm_sdk/validation_result.rb +33 -0
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk/{node → workflow}/agent_config.rb +34 -9
- data/lib/swarm_sdk/workflow/builder.rb +143 -0
- data/lib/swarm_sdk/workflow/executor.rb +497 -0
- data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +42 -21
- data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
- data/lib/swarm_sdk/workflow.rb +554 -0
- data/lib/swarm_sdk.rb +393 -22
- metadata +51 -16
- data/lib/swarm_memory/chat_extension.rb +0 -34
- data/lib/swarm_sdk/node_orchestrator.rb +0 -591
- data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -582
|
@@ -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:
|
|
@@ -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 "
|
|
48
|
-
# Example: "
|
|
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
|
|
54
|
-
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}/, "")
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
#
|
|
259
|
-
actual_tools = if respond_to?(:
|
|
260
|
-
|
|
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:
|
|
277
|
-
model:
|
|
278
|
-
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
|