swarm_memory 2.1.2 → 2.1.3
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/configuration.rb +28 -4
- data/lib/claude_swarm/mcp_generator.rb +4 -10
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
- data/lib/swarm_cli/config_loader.rb +3 -3
- data/lib/swarm_cli/version.rb +1 -1
- data/lib/swarm_memory/adapters/base.rb +4 -4
- 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 +11 -5
- 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 +5 -0
- data/lib/swarm_sdk/agent/builder.rb +33 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +41 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
- data/lib/swarm_sdk/agent/chat.rb +198 -51
- data/lib/swarm_sdk/agent/context.rb +6 -2
- data/lib/swarm_sdk/agent/context_manager.rb +6 -0
- data/lib/swarm_sdk/agent/definition.rb +15 -22
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
- data/lib/swarm_sdk/configuration.rb +420 -103
- data/lib/swarm_sdk/events_to_messages.rb +181 -0
- data/lib/swarm_sdk/log_collector.rb +31 -5
- data/lib/swarm_sdk/log_stream.rb +37 -8
- data/lib/swarm_sdk/model_aliases.json +4 -1
- data/lib/swarm_sdk/node/agent_config.rb +33 -8
- data/lib/swarm_sdk/node/builder.rb +39 -18
- data/lib/swarm_sdk/node_orchestrator.rb +293 -26
- 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/providers/openai_with_responses.rb +22 -15
- 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 +386 -0
- data/lib/swarm_sdk/state_restorer.rb +491 -0
- data/lib/swarm_sdk/state_snapshot.rb +369 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
- data/lib/swarm_sdk/swarm/builder.rb +208 -12
- data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
- data/lib/swarm_sdk/swarm.rb +367 -90
- data/lib/swarm_sdk/swarm_loader.rb +145 -0
- data/lib/swarm_sdk/swarm_registry.rb +136 -0
- data/lib/swarm_sdk/tools/delegate.rb +92 -7
- data/lib/swarm_sdk/tools/read.rb +17 -5
- 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 +45 -0
- data/lib/swarm_sdk/tools/stores/storage.rb +4 -4
- data/lib/swarm_sdk/tools/think.rb +4 -1
- data/lib/swarm_sdk/tools/todo_write.rb +20 -8
- 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.rb +362 -21
- metadata +17 -5
data/lib/swarm_sdk/agent/chat.rb
CHANGED
|
@@ -150,6 +150,7 @@ module SwarmSDK
|
|
|
150
150
|
raise StateError, "Agent context not set. Call setup_context first." unless @agent_context
|
|
151
151
|
|
|
152
152
|
@context_tracker.setup_logging
|
|
153
|
+
inject_llm_instrumentation
|
|
153
154
|
end
|
|
154
155
|
|
|
155
156
|
# Emit model lookup warning if one occurred during initialization
|
|
@@ -164,6 +165,8 @@ module SwarmSDK
|
|
|
164
165
|
LogStream.emit(
|
|
165
166
|
type: "model_lookup_warning",
|
|
166
167
|
agent: agent_name,
|
|
168
|
+
swarm_id: @agent_context&.swarm_id,
|
|
169
|
+
parent_swarm_id: @agent_context&.parent_swarm_id,
|
|
167
170
|
model: @model_lookup_error[:model],
|
|
168
171
|
error_message: @model_lookup_error[:error_message],
|
|
169
172
|
suggestions: @model_lookup_error[:suggestions].map { |s| { id: s.id, name: s.name, context_window: s.context_window } },
|
|
@@ -221,6 +224,17 @@ module SwarmSDK
|
|
|
221
224
|
!@active_skill_path.nil?
|
|
222
225
|
end
|
|
223
226
|
|
|
227
|
+
# Clear conversation history
|
|
228
|
+
#
|
|
229
|
+
# Removes all messages from the conversation history and clears tool executions.
|
|
230
|
+
# Used by composable swarms when keep_context: false is specified.
|
|
231
|
+
#
|
|
232
|
+
# @return [void]
|
|
233
|
+
def clear_conversation
|
|
234
|
+
@messages.clear if @messages.respond_to?(:clear)
|
|
235
|
+
@context_manager&.clear_ephemeral
|
|
236
|
+
end
|
|
237
|
+
|
|
224
238
|
# Override ask to inject system reminders and periodic TodoWrite reminders
|
|
225
239
|
#
|
|
226
240
|
# Note: This is called BEFORE HookIntegration#ask (due to module include order),
|
|
@@ -230,63 +244,72 @@ module SwarmSDK
|
|
|
230
244
|
# @param options [Hash] Additional options to pass to complete
|
|
231
245
|
# @return [RubyLLM::Message] LLM response
|
|
232
246
|
def ask(prompt, **options)
|
|
233
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
# Serialize ask() calls to prevent message corruption from concurrent fibers
|
|
248
|
+
# Uses Async::Semaphore (not Mutex) because SwarmSDK runs in fiber context
|
|
249
|
+
# This protects against parallel delegation scenarios where multiple delegation
|
|
250
|
+
# instances call the same underlying primary agent (e.g., tester@frontend and
|
|
251
|
+
# tester@backend both calling database in parallel).
|
|
252
|
+
@ask_semaphore ||= Async::Semaphore.new(1)
|
|
253
|
+
|
|
254
|
+
@ask_semaphore.acquire do
|
|
255
|
+
# Check if this is the first user message
|
|
256
|
+
is_first = SystemReminderInjector.first_message?(self)
|
|
257
|
+
|
|
258
|
+
if is_first
|
|
259
|
+
# Collect plugin reminders first
|
|
260
|
+
plugin_reminders = collect_plugin_reminders(prompt, is_first_message: true)
|
|
261
|
+
|
|
262
|
+
# Build full prompt with embedded plugin reminders
|
|
263
|
+
full_prompt = prompt
|
|
264
|
+
plugin_reminders.each do |reminder|
|
|
265
|
+
full_prompt = "#{full_prompt}\n\n#{reminder}"
|
|
266
|
+
end
|
|
249
267
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
268
|
+
# Inject first message reminders (includes system reminders + toolset + after)
|
|
269
|
+
# SystemReminderInjector will embed all reminders in the prompt via add_message
|
|
270
|
+
SystemReminderInjector.inject_first_message_reminders(self, full_prompt)
|
|
271
|
+
|
|
272
|
+
# Trigger user_prompt hook manually since we're bypassing the normal ask flow
|
|
273
|
+
if @hook_executor
|
|
274
|
+
hook_result = trigger_user_prompt(prompt)
|
|
275
|
+
|
|
276
|
+
# Check if hook halted execution
|
|
277
|
+
if hook_result[:halted]
|
|
278
|
+
# Return a halted message instead of calling LLM
|
|
279
|
+
return RubyLLM::Message.new(
|
|
280
|
+
role: :assistant,
|
|
281
|
+
content: hook_result[:halt_message],
|
|
282
|
+
model_id: model.id,
|
|
283
|
+
)
|
|
284
|
+
end
|
|
253
285
|
|
|
254
|
-
|
|
255
|
-
if hook_result[:halted]
|
|
256
|
-
# Return a halted message instead of calling LLM
|
|
257
|
-
return RubyLLM::Message.new(
|
|
258
|
-
role: :assistant,
|
|
259
|
-
content: hook_result[:halt_message],
|
|
260
|
-
model_id: model.id,
|
|
261
|
-
)
|
|
286
|
+
# NOTE: We ignore modified_prompt for first message since reminders already injected
|
|
262
287
|
end
|
|
263
288
|
|
|
264
|
-
#
|
|
265
|
-
|
|
289
|
+
# Call complete to get LLM response
|
|
290
|
+
complete(**options)
|
|
291
|
+
else
|
|
292
|
+
# Build prompt with embedded reminders (if needed)
|
|
293
|
+
full_prompt = prompt
|
|
294
|
+
|
|
295
|
+
# Add periodic TodoWrite reminder if needed (only if agent has TodoWrite tool)
|
|
296
|
+
if tools.key?("TodoWrite") && SystemReminderInjector.should_inject_todowrite_reminder?(self, @last_todowrite_message_index)
|
|
297
|
+
full_prompt = "#{full_prompt}\n\n#{SystemReminderInjector::TODOWRITE_PERIODIC_REMINDER}"
|
|
298
|
+
# Update tracking
|
|
299
|
+
@last_todowrite_message_index = SystemReminderInjector.find_last_todowrite_index(self)
|
|
300
|
+
end
|
|
266
301
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
# Add periodic TodoWrite reminder if needed
|
|
274
|
-
if SystemReminderInjector.should_inject_todowrite_reminder?(self, @last_todowrite_message_index)
|
|
275
|
-
full_prompt = "#{full_prompt}\n\n#{SystemReminderInjector::TODOWRITE_PERIODIC_REMINDER}"
|
|
276
|
-
# Update tracking
|
|
277
|
-
@last_todowrite_message_index = SystemReminderInjector.find_last_todowrite_index(self)
|
|
278
|
-
end
|
|
302
|
+
# Collect plugin reminders and embed them
|
|
303
|
+
plugin_reminders = collect_plugin_reminders(full_prompt, is_first_message: false)
|
|
304
|
+
plugin_reminders.each do |reminder|
|
|
305
|
+
full_prompt = "#{full_prompt}\n\n#{reminder}"
|
|
306
|
+
end
|
|
279
307
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
full_prompt
|
|
308
|
+
# Normal ask behavior for subsequent messages
|
|
309
|
+
# This calls super which goes to HookIntegration's ask override
|
|
310
|
+
# HookIntegration will call add_message, and we'll extract reminders there
|
|
311
|
+
super(full_prompt, **options)
|
|
284
312
|
end
|
|
285
|
-
|
|
286
|
-
# Normal ask behavior for subsequent messages
|
|
287
|
-
# This calls super which goes to HookIntegration's ask override
|
|
288
|
-
# HookIntegration will call add_message, and we'll extract reminders there
|
|
289
|
-
super(full_prompt, **options)
|
|
290
313
|
end
|
|
291
314
|
end
|
|
292
315
|
|
|
@@ -674,7 +697,15 @@ module SwarmSDK
|
|
|
674
697
|
# This is needed for setting agent_name and other provider-specific settings.
|
|
675
698
|
#
|
|
676
699
|
# @return [RubyLLM::Provider::Base] Provider instance
|
|
677
|
-
attr_reader :provider, :global_semaphore, :local_semaphore, :real_model_info, :context_tracker, :context_manager
|
|
700
|
+
attr_reader :provider, :global_semaphore, :local_semaphore, :real_model_info, :context_tracker, :context_manager, :agent_context, :last_todowrite_message_index, :active_skill_path
|
|
701
|
+
|
|
702
|
+
# Setters for snapshot/restore
|
|
703
|
+
attr_writer :last_todowrite_message_index, :active_skill_path
|
|
704
|
+
|
|
705
|
+
# Expose messages array (inherited from RubyLLM::Chat but not publicly accessible)
|
|
706
|
+
#
|
|
707
|
+
# @return [Array<RubyLLM::Message>] Conversation messages
|
|
708
|
+
attr_reader :messages
|
|
678
709
|
|
|
679
710
|
# Get context window limit for the current model
|
|
680
711
|
#
|
|
@@ -718,6 +749,37 @@ module SwarmSDK
|
|
|
718
749
|
messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.output_tokens || 0 }
|
|
719
750
|
end
|
|
720
751
|
|
|
752
|
+
# Calculate cumulative cached tokens across all assistant messages
|
|
753
|
+
#
|
|
754
|
+
# Cached tokens are portions of prompts served from the provider's cache.
|
|
755
|
+
# OpenAI reports this automatically for prompts >1024 tokens.
|
|
756
|
+
# Anthropic/Bedrock expose cache control via Content::Raw blocks.
|
|
757
|
+
#
|
|
758
|
+
# @return [Integer] Total cached tokens used in conversation
|
|
759
|
+
def cumulative_cached_tokens
|
|
760
|
+
messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.cached_tokens || 0 }
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
# Calculate cumulative cache creation tokens
|
|
764
|
+
#
|
|
765
|
+
# Cache creation tokens are written to the cache (Anthropic/Bedrock only).
|
|
766
|
+
# These are charged at the normal input rate when first created.
|
|
767
|
+
#
|
|
768
|
+
# @return [Integer] Total tokens written to cache
|
|
769
|
+
def cumulative_cache_creation_tokens
|
|
770
|
+
messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.cache_creation_tokens || 0 }
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
# Calculate effective input tokens (excluding cache hits)
|
|
774
|
+
#
|
|
775
|
+
# This represents the actual tokens charged for input, excluding cached portions.
|
|
776
|
+
# Useful for accurate cost tracking when using prompt caching.
|
|
777
|
+
#
|
|
778
|
+
# @return [Integer] Actual input tokens charged (input minus cached)
|
|
779
|
+
def effective_input_tokens
|
|
780
|
+
cumulative_input_tokens - cumulative_cached_tokens
|
|
781
|
+
end
|
|
782
|
+
|
|
721
783
|
# Calculate total tokens used (input + output)
|
|
722
784
|
#
|
|
723
785
|
# @return [Integer] Total tokens used in conversation
|
|
@@ -777,6 +839,85 @@ module SwarmSDK
|
|
|
777
839
|
|
|
778
840
|
private
|
|
779
841
|
|
|
842
|
+
# Inject LLM instrumentation middleware for API request/response logging
|
|
843
|
+
#
|
|
844
|
+
# This middleware captures HTTP requests/responses to LLM providers and
|
|
845
|
+
# emits structured events via LogStream. Only injected when logging is enabled.
|
|
846
|
+
#
|
|
847
|
+
# @return [void]
|
|
848
|
+
def inject_llm_instrumentation
|
|
849
|
+
# Safety checks
|
|
850
|
+
return unless @provider
|
|
851
|
+
|
|
852
|
+
faraday_conn = @provider.connection&.connection
|
|
853
|
+
return unless faraday_conn
|
|
854
|
+
|
|
855
|
+
# Check if middleware is already present to prevent duplicates
|
|
856
|
+
return if @llm_instrumentation_injected
|
|
857
|
+
|
|
858
|
+
# Get provider name for logging
|
|
859
|
+
provider_name = @provider.class.name.split("::").last.downcase
|
|
860
|
+
|
|
861
|
+
# Inject middleware at beginning of stack (position 0)
|
|
862
|
+
# This ensures we capture raw requests before any transformations
|
|
863
|
+
# Use fully qualified name to ensure Zeitwerk loads it
|
|
864
|
+
faraday_conn.builder.insert(
|
|
865
|
+
0,
|
|
866
|
+
SwarmSDK::Agent::LLMInstrumentationMiddleware,
|
|
867
|
+
on_request: method(:handle_llm_api_request),
|
|
868
|
+
on_response: method(:handle_llm_api_response),
|
|
869
|
+
provider_name: provider_name,
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
# Mark as injected to prevent duplicates
|
|
873
|
+
@llm_instrumentation_injected = true
|
|
874
|
+
|
|
875
|
+
RubyLLM.logger.debug("SwarmSDK: Injected LLM instrumentation middleware for agent #{@agent_name}")
|
|
876
|
+
rescue StandardError => e
|
|
877
|
+
# Don't fail initialization if instrumentation fails
|
|
878
|
+
RubyLLM.logger.error("SwarmSDK: Failed to inject LLM instrumentation: #{e.message}")
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
# Handle LLM API request event
|
|
882
|
+
#
|
|
883
|
+
# Emits llm_api_request event via LogStream with request details.
|
|
884
|
+
#
|
|
885
|
+
# @param data [Hash] Request data from middleware
|
|
886
|
+
# @return [void]
|
|
887
|
+
def handle_llm_api_request(data)
|
|
888
|
+
return unless LogStream.emitter
|
|
889
|
+
|
|
890
|
+
LogStream.emit(
|
|
891
|
+
type: "llm_api_request",
|
|
892
|
+
agent: @agent_name,
|
|
893
|
+
swarm_id: @agent_context&.swarm_id,
|
|
894
|
+
parent_swarm_id: @agent_context&.parent_swarm_id,
|
|
895
|
+
**data,
|
|
896
|
+
)
|
|
897
|
+
rescue StandardError => e
|
|
898
|
+
RubyLLM.logger.error("SwarmSDK: Error emitting llm_api_request event: #{e.message}")
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
# Handle LLM API response event
|
|
902
|
+
#
|
|
903
|
+
# Emits llm_api_response event via LogStream with response details.
|
|
904
|
+
#
|
|
905
|
+
# @param data [Hash] Response data from middleware
|
|
906
|
+
# @return [void]
|
|
907
|
+
def handle_llm_api_response(data)
|
|
908
|
+
return unless LogStream.emitter
|
|
909
|
+
|
|
910
|
+
LogStream.emit(
|
|
911
|
+
type: "llm_api_response",
|
|
912
|
+
agent: @agent_name,
|
|
913
|
+
swarm_id: @agent_context&.swarm_id,
|
|
914
|
+
parent_swarm_id: @agent_context&.parent_swarm_id,
|
|
915
|
+
**data,
|
|
916
|
+
)
|
|
917
|
+
rescue StandardError => e
|
|
918
|
+
RubyLLM.logger.error("SwarmSDK: Error emitting llm_api_response event: #{e.message}")
|
|
919
|
+
end
|
|
920
|
+
|
|
780
921
|
# Call LLM with retry logic for transient failures
|
|
781
922
|
#
|
|
782
923
|
# Retries up to 10 times with fixed 10-second delays for:
|
|
@@ -802,10 +943,13 @@ module SwarmSDK
|
|
|
802
943
|
LogStream.emit(
|
|
803
944
|
type: "llm_retry_exhausted",
|
|
804
945
|
agent: @agent_name,
|
|
946
|
+
swarm_id: @agent_context&.swarm_id,
|
|
947
|
+
parent_swarm_id: @agent_context&.parent_swarm_id,
|
|
805
948
|
model: @model&.id,
|
|
806
949
|
attempts: attempts,
|
|
807
950
|
error_class: e.class.name,
|
|
808
951
|
error_message: e.message,
|
|
952
|
+
error_backtrace: e.backtrace,
|
|
809
953
|
)
|
|
810
954
|
raise
|
|
811
955
|
end
|
|
@@ -814,11 +958,14 @@ module SwarmSDK
|
|
|
814
958
|
LogStream.emit(
|
|
815
959
|
type: "llm_retry_attempt",
|
|
816
960
|
agent: @agent_name,
|
|
961
|
+
swarm_id: @agent_context&.swarm_id,
|
|
962
|
+
parent_swarm_id: @agent_context&.parent_swarm_id,
|
|
817
963
|
model: @model&.id,
|
|
818
964
|
attempt: attempts,
|
|
819
965
|
max_retries: max_retries,
|
|
820
966
|
error_class: e.class.name,
|
|
821
967
|
error_message: e.message,
|
|
968
|
+
error_backtrace: e.backtrace,
|
|
822
969
|
retry_delay: delay,
|
|
823
970
|
)
|
|
824
971
|
|
|
@@ -33,15 +33,19 @@ module SwarmSDK
|
|
|
33
33
|
# Threshold at which automatic compression is triggered
|
|
34
34
|
COMPRESSION_THRESHOLD = 60
|
|
35
35
|
|
|
36
|
-
attr_reader :name, :delegation_tools, :metadata, :warning_thresholds_hit
|
|
36
|
+
attr_reader :name, :delegation_tools, :metadata, :warning_thresholds_hit, :swarm_id, :parent_swarm_id
|
|
37
37
|
|
|
38
38
|
# Initialize a new agent context
|
|
39
39
|
#
|
|
40
40
|
# @param name [Symbol, String] Agent name
|
|
41
|
+
# @param swarm_id [String] Swarm ID for event tracking
|
|
42
|
+
# @param parent_swarm_id [String, nil] Parent swarm ID (nil for root swarms)
|
|
41
43
|
# @param delegation_tools [Array<String>] Names of tools that are delegations
|
|
42
44
|
# @param metadata [Hash] Optional metadata about the agent
|
|
43
|
-
def initialize(name:, delegation_tools: [], metadata: {})
|
|
45
|
+
def initialize(name:, swarm_id:, parent_swarm_id: nil, delegation_tools: [], metadata: {})
|
|
44
46
|
@name = name.to_sym
|
|
47
|
+
@swarm_id = swarm_id
|
|
48
|
+
@parent_swarm_id = parent_swarm_id
|
|
45
49
|
@delegation_tools = Set.new(delegation_tools.map(&:to_s))
|
|
46
50
|
@metadata = metadata
|
|
47
51
|
@delegation_call_ids = Set.new
|
|
@@ -18,10 +18,16 @@ module SwarmSDK
|
|
|
18
18
|
class ContextManager
|
|
19
19
|
SYSTEM_REMINDER_REGEX = %r{<system-reminder>.*?</system-reminder>}m
|
|
20
20
|
|
|
21
|
+
# Expose compression state for snapshot/restore
|
|
22
|
+
# NOTE: @compression_applied initializes to nil (not false), only set to true when compression runs
|
|
23
|
+
attr_reader :compression_applied
|
|
24
|
+
attr_writer :compression_applied
|
|
25
|
+
|
|
21
26
|
def initialize
|
|
22
27
|
# Ephemeral content to append to messages for this turn only
|
|
23
28
|
# Format: { message_index => [array of reminder strings] }
|
|
24
29
|
@ephemeral_content = {}
|
|
30
|
+
# NOTE: @compression_applied is NOT initialized here - starts as nil
|
|
25
31
|
end
|
|
26
32
|
|
|
27
33
|
# Track ephemeral content to append to a specific message
|
|
@@ -44,13 +44,21 @@ module SwarmSDK
|
|
|
44
44
|
:agent_permissions,
|
|
45
45
|
:assume_model_exists,
|
|
46
46
|
:hooks,
|
|
47
|
-
:memory
|
|
47
|
+
:memory,
|
|
48
|
+
:shared_across_delegations
|
|
48
49
|
|
|
49
50
|
attr_accessor :bypass_permissions, :max_concurrent_tools
|
|
50
51
|
|
|
51
52
|
def initialize(name, config = {})
|
|
52
53
|
@name = name.to_sym
|
|
53
54
|
|
|
55
|
+
# Validate name doesn't contain '@' (reserved for delegation instances)
|
|
56
|
+
if @name.to_s.include?("@")
|
|
57
|
+
raise ConfigurationError,
|
|
58
|
+
"Agent names cannot contain '@' character (reserved for delegation instance naming). " \
|
|
59
|
+
"Agent: #{@name}"
|
|
60
|
+
end
|
|
61
|
+
|
|
54
62
|
# BREAKING CHANGE: Hard error for plural form
|
|
55
63
|
if config[:directories]
|
|
56
64
|
raise ConfigurationError,
|
|
@@ -96,6 +104,9 @@ module SwarmSDK
|
|
|
96
104
|
# (memory prompt needs to be appended if memory is enabled)
|
|
97
105
|
@memory = parse_memory_config(config[:memory])
|
|
98
106
|
|
|
107
|
+
# Delegation isolation mode (default: false = isolated instances per delegation)
|
|
108
|
+
@shared_across_delegations = config[:shared_across_delegations] || false
|
|
109
|
+
|
|
99
110
|
# Build system prompt after directory and memory are set
|
|
100
111
|
@system_prompt = build_full_system_prompt(config[:system_prompt])
|
|
101
112
|
|
|
@@ -111,7 +122,7 @@ module SwarmSDK
|
|
|
111
122
|
# Inject default write restrictions for security
|
|
112
123
|
@tools = inject_default_write_permissions(@tools)
|
|
113
124
|
|
|
114
|
-
@delegates_to = Array(config[:delegates_to] || []).map(&:to_sym)
|
|
125
|
+
@delegates_to = Array(config[:delegates_to] || []).map(&:to_sym).uniq
|
|
115
126
|
@mcp_servers = Array(config[:mcp_servers] || [])
|
|
116
127
|
|
|
117
128
|
# Parse hooks configuration
|
|
@@ -181,6 +192,7 @@ module SwarmSDK
|
|
|
181
192
|
assume_model_exists: @assume_model_exists,
|
|
182
193
|
max_concurrent_tools: @max_concurrent_tools,
|
|
183
194
|
hooks: @hooks,
|
|
195
|
+
shared_across_delegations: @shared_across_delegations,
|
|
184
196
|
# Permissions are core SDK functionality (not plugin-specific)
|
|
185
197
|
default_permissions: @default_permissions,
|
|
186
198
|
permissions: @agent_permissions,
|
|
@@ -358,7 +370,7 @@ module SwarmSDK
|
|
|
358
370
|
|
|
359
371
|
def render_non_coding_base_prompt
|
|
360
372
|
# Simplified base prompt for non-coding agents
|
|
361
|
-
# Includes environment info
|
|
373
|
+
# Includes environment info only
|
|
362
374
|
# Does not steer towards coding tasks
|
|
363
375
|
cwd = @directory || Dir.pwd
|
|
364
376
|
platform = RUBY_PLATFORM
|
|
@@ -383,25 +395,6 @@ module SwarmSDK
|
|
|
383
395
|
Platform: #{platform}
|
|
384
396
|
OS Version: #{os_version}
|
|
385
397
|
</env>
|
|
386
|
-
|
|
387
|
-
# Task Management
|
|
388
|
-
|
|
389
|
-
You have access to the TodoWrite tool to help you manage and plan tasks. Use this tool to track your progress and give visibility into your work.
|
|
390
|
-
|
|
391
|
-
When working on multi-step tasks:
|
|
392
|
-
1. Create a todo list with all known tasks before starting work
|
|
393
|
-
2. Mark each task as in_progress when you start it
|
|
394
|
-
3. Mark each task as completed IMMEDIATELY after finishing it
|
|
395
|
-
4. Complete ALL pending todos before finishing your response
|
|
396
|
-
|
|
397
|
-
# Scratchpad Storage
|
|
398
|
-
|
|
399
|
-
You have access to Scratchpad tools for storing and retrieving information:
|
|
400
|
-
- **ScratchpadWrite**: Store detailed outputs, analysis, or results that are too long for direct responses
|
|
401
|
-
- **ScratchpadRead**: Retrieve previously stored content
|
|
402
|
-
- **ScratchpadList**: List available scratchpad entries
|
|
403
|
-
|
|
404
|
-
Use the scratchpad to share information that would otherwise clutter your responses.
|
|
405
398
|
PROMPT
|
|
406
399
|
end
|
|
407
400
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
module Agent
|
|
5
|
+
# Faraday middleware for capturing LLM API requests and responses
|
|
6
|
+
#
|
|
7
|
+
# This middleware intercepts HTTP calls to LLM providers and emits
|
|
8
|
+
# structured events via LogStream for logging and monitoring.
|
|
9
|
+
#
|
|
10
|
+
# Events emitted:
|
|
11
|
+
# - llm_api_request: Before sending request to LLM API
|
|
12
|
+
# - llm_api_response: After receiving response from LLM API
|
|
13
|
+
#
|
|
14
|
+
# The middleware is injected at runtime into the provider's Faraday
|
|
15
|
+
# connection stack (see Agent::Chat#inject_llm_instrumentation).
|
|
16
|
+
class LLMInstrumentationMiddleware < Faraday::Middleware
|
|
17
|
+
# Initialize middleware
|
|
18
|
+
#
|
|
19
|
+
# @param app [Faraday::Connection] Faraday app
|
|
20
|
+
# @param on_request [Proc] Callback for request events
|
|
21
|
+
# @param on_response [Proc] Callback for response events
|
|
22
|
+
# @param provider_name [String] Provider name for logging
|
|
23
|
+
def initialize(app, on_request:, on_response:, provider_name:)
|
|
24
|
+
super(app)
|
|
25
|
+
@on_request = on_request
|
|
26
|
+
@on_response = on_response
|
|
27
|
+
@provider_name = provider_name
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Intercept HTTP call
|
|
31
|
+
#
|
|
32
|
+
# @param env [Faraday::Env] Request environment
|
|
33
|
+
# @return [Faraday::Response] HTTP response
|
|
34
|
+
def call(env)
|
|
35
|
+
start_time = Time.now
|
|
36
|
+
|
|
37
|
+
# Emit request event
|
|
38
|
+
emit_request_event(env, start_time)
|
|
39
|
+
|
|
40
|
+
# Execute request
|
|
41
|
+
@app.call(env).on_complete do |response_env|
|
|
42
|
+
end_time = Time.now
|
|
43
|
+
duration = end_time - start_time
|
|
44
|
+
|
|
45
|
+
# Emit response event
|
|
46
|
+
emit_response_event(response_env, start_time, end_time, duration)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Emit request event
|
|
53
|
+
#
|
|
54
|
+
# @param env [Faraday::Env] Request environment
|
|
55
|
+
# @param timestamp [Time] Request timestamp
|
|
56
|
+
# @return [void]
|
|
57
|
+
def emit_request_event(env, timestamp)
|
|
58
|
+
request_data = {
|
|
59
|
+
provider: @provider_name,
|
|
60
|
+
body: parse_body(env.body),
|
|
61
|
+
timestamp: timestamp.utc.iso8601,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@on_request.call(request_data)
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
# Don't let logging errors break the request
|
|
67
|
+
RubyLLM.logger.error("LLM instrumentation request error: #{e.message}")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Emit response event
|
|
71
|
+
#
|
|
72
|
+
# @param env [Faraday::Env] Response environment
|
|
73
|
+
# @param start_time [Time] Request start time
|
|
74
|
+
# @param end_time [Time] Request end time
|
|
75
|
+
# @param duration [Float] Request duration in seconds
|
|
76
|
+
# @return [void]
|
|
77
|
+
def emit_response_event(env, start_time, end_time, duration)
|
|
78
|
+
response_data = {
|
|
79
|
+
provider: @provider_name,
|
|
80
|
+
body: parse_body(env.body),
|
|
81
|
+
duration_seconds: duration.round(3),
|
|
82
|
+
timestamp: end_time.utc.iso8601,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Extract usage information from response body if available
|
|
86
|
+
if env.body.is_a?(String) && !env.body.empty?
|
|
87
|
+
begin
|
|
88
|
+
parsed = JSON.parse(env.body)
|
|
89
|
+
response_data[:usage] = extract_usage(parsed) if parsed.is_a?(Hash)
|
|
90
|
+
response_data[:model] = parsed["model"] if parsed.is_a?(Hash)
|
|
91
|
+
response_data[:finish_reason] = extract_finish_reason(parsed) if parsed.is_a?(Hash)
|
|
92
|
+
rescue JSON::ParserError
|
|
93
|
+
# Not JSON, skip usage extraction
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
@on_response.call(response_data)
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
# Don't let logging errors break the response
|
|
100
|
+
RubyLLM.logger.error("LLM instrumentation response error: #{e.message}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Sanitize headers by removing sensitive data
|
|
104
|
+
#
|
|
105
|
+
# @param headers [Hash] HTTP headers
|
|
106
|
+
# @return [Hash] Sanitized headers
|
|
107
|
+
def sanitize_headers(headers)
|
|
108
|
+
return {} unless headers
|
|
109
|
+
|
|
110
|
+
headers.transform_keys(&:to_s).transform_values do |value|
|
|
111
|
+
# Redact authorization headers
|
|
112
|
+
if value.to_s.match?(/bearer|token|key/i)
|
|
113
|
+
"[REDACTED]"
|
|
114
|
+
else
|
|
115
|
+
value.to_s
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
rescue StandardError
|
|
119
|
+
{}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Parse request/response body
|
|
123
|
+
#
|
|
124
|
+
# @param body [String, Hash, nil] HTTP body
|
|
125
|
+
# @return [Hash, String, nil] Parsed body
|
|
126
|
+
def parse_body(body)
|
|
127
|
+
return if body.nil? || body == ""
|
|
128
|
+
|
|
129
|
+
# Already parsed
|
|
130
|
+
return body if body.is_a?(Hash)
|
|
131
|
+
|
|
132
|
+
# Try to parse JSON
|
|
133
|
+
JSON.parse(body)
|
|
134
|
+
rescue JSON::ParserError
|
|
135
|
+
# Return truncated string if not JSON
|
|
136
|
+
body.to_s[0..1000]
|
|
137
|
+
rescue StandardError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Extract usage statistics from response
|
|
142
|
+
#
|
|
143
|
+
# Handles different provider formats (OpenAI, Anthropic, etc.)
|
|
144
|
+
#
|
|
145
|
+
# @param parsed [Hash] Parsed response body
|
|
146
|
+
# @return [Hash, nil] Usage statistics
|
|
147
|
+
def extract_usage(parsed)
|
|
148
|
+
usage = parsed["usage"] || parsed.dig("usage")
|
|
149
|
+
return unless usage
|
|
150
|
+
|
|
151
|
+
{
|
|
152
|
+
input_tokens: usage["input_tokens"] || usage["prompt_tokens"],
|
|
153
|
+
output_tokens: usage["output_tokens"] || usage["completion_tokens"],
|
|
154
|
+
total_tokens: usage["total_tokens"],
|
|
155
|
+
}.compact
|
|
156
|
+
rescue StandardError
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Extract finish reason from response
|
|
161
|
+
#
|
|
162
|
+
# Handles different provider formats
|
|
163
|
+
#
|
|
164
|
+
# @param parsed [Hash] Parsed response body
|
|
165
|
+
# @return [String, nil] Finish reason
|
|
166
|
+
def extract_finish_reason(parsed)
|
|
167
|
+
# Anthropic format
|
|
168
|
+
return parsed["stop_reason"] if parsed["stop_reason"]
|
|
169
|
+
|
|
170
|
+
# OpenAI format
|
|
171
|
+
choices = parsed["choices"]
|
|
172
|
+
return unless choices&.is_a?(Array) && !choices.empty?
|
|
173
|
+
|
|
174
|
+
choices.first["finish_reason"]
|
|
175
|
+
rescue StandardError
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|