claude_swarm 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/CHANGELOG.md +27 -0
  4. data/README.md +336 -1037
  5. data/docs/V1_TO_V2_MIGRATION_GUIDE.md +1120 -0
  6. data/docs/v1/README.md +1197 -0
  7. data/docs/v2/CHANGELOG.swarm_cli.md +22 -0
  8. data/docs/v2/CHANGELOG.swarm_memory.md +20 -0
  9. data/docs/v2/CHANGELOG.swarm_sdk.md +287 -10
  10. data/docs/v2/README.md +32 -6
  11. data/docs/v2/guides/complete-tutorial.md +133 -37
  12. data/docs/v2/guides/composable-swarms.md +1178 -0
  13. data/docs/v2/guides/getting-started.md +42 -1
  14. data/docs/v2/guides/snapshots.md +1498 -0
  15. data/docs/v2/reference/architecture-flow.md +5 -3
  16. data/docs/v2/reference/event_payload_structures.md +249 -12
  17. data/docs/v2/reference/execution-flow.md +1 -1
  18. data/docs/v2/reference/ruby-dsl.md +368 -22
  19. data/docs/v2/reference/yaml.md +314 -63
  20. data/examples/snapshot_demo.rb +119 -0
  21. data/examples/v2/dsl/01_basic.rb +0 -2
  22. data/examples/v2/dsl/02_core_parameters.rb +0 -2
  23. data/examples/v2/dsl/03_capabilities.rb +0 -2
  24. data/examples/v2/dsl/04_llm_parameters.rb +0 -2
  25. data/examples/v2/dsl/05_advanced_flags.rb +0 -3
  26. data/examples/v2/dsl/06_permissions.rb +0 -4
  27. data/examples/v2/dsl/07_mcp_server.rb +0 -2
  28. data/examples/v2/dsl/08_swarm_hooks.rb +0 -2
  29. data/examples/v2/dsl/09_agent_hooks.rb +0 -2
  30. data/examples/v2/dsl/10_all_agents_hooks.rb +0 -3
  31. data/examples/v2/dsl/11_delegation.rb +0 -2
  32. data/examples/v2/dsl/12_complete_integration.rb +2 -6
  33. data/examples/v2/node_context_demo.rb +1 -1
  34. data/examples/v2/node_workflow.rb +2 -4
  35. data/examples/v2/plan_and_execute.rb +157 -0
  36. data/lib/claude_swarm/cli.rb +0 -18
  37. data/lib/claude_swarm/configuration.rb +28 -18
  38. data/lib/claude_swarm/openai/chat_completion.rb +2 -11
  39. data/lib/claude_swarm/openai/responses.rb +2 -11
  40. data/lib/claude_swarm/version.rb +1 -1
  41. data/lib/swarm_cli/formatters/human_formatter.rb +103 -0
  42. data/lib/swarm_cli/interactive_repl.rb +9 -3
  43. data/lib/swarm_cli/version.rb +1 -1
  44. data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
  45. data/lib/swarm_memory/integration/cli_registration.rb +3 -2
  46. data/lib/swarm_memory/integration/sdk_plugin.rb +11 -5
  47. data/lib/swarm_memory/tools/memory_edit.rb +2 -2
  48. data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
  49. data/lib/swarm_memory/tools/memory_read.rb +3 -3
  50. data/lib/swarm_memory/version.rb +1 -1
  51. data/lib/swarm_memory.rb +5 -0
  52. data/lib/swarm_sdk/agent/builder.rb +33 -0
  53. data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
  54. data/lib/swarm_sdk/agent/chat/hook_integration.rb +49 -3
  55. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
  56. data/lib/swarm_sdk/agent/chat.rb +200 -51
  57. data/lib/swarm_sdk/agent/context.rb +6 -2
  58. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  59. data/lib/swarm_sdk/agent/definition.rb +14 -2
  60. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
  61. data/lib/swarm_sdk/configuration.rb +387 -94
  62. data/lib/swarm_sdk/events_to_messages.rb +181 -0
  63. data/lib/swarm_sdk/log_collector.rb +31 -5
  64. data/lib/swarm_sdk/log_stream.rb +37 -8
  65. data/lib/swarm_sdk/model_aliases.json +4 -1
  66. data/lib/swarm_sdk/node/agent_config.rb +33 -8
  67. data/lib/swarm_sdk/node/builder.rb +39 -18
  68. data/lib/swarm_sdk/node_orchestrator.rb +293 -26
  69. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  70. data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
  71. data/lib/swarm_sdk/restore_result.rb +65 -0
  72. data/lib/swarm_sdk/snapshot.rb +156 -0
  73. data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
  74. data/lib/swarm_sdk/state_restorer.rb +491 -0
  75. data/lib/swarm_sdk/state_snapshot.rb +369 -0
  76. data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
  77. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  78. data/lib/swarm_sdk/swarm/builder.rb +208 -12
  79. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  80. data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
  81. data/lib/swarm_sdk/swarm.rb +338 -42
  82. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  83. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  84. data/lib/swarm_sdk/tools/delegate.rb +92 -7
  85. data/lib/swarm_sdk/tools/read.rb +17 -5
  86. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  87. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
  88. data/lib/swarm_sdk/utils.rb +18 -0
  89. data/lib/swarm_sdk/validation_result.rb +33 -0
  90. data/lib/swarm_sdk/version.rb +1 -1
  91. data/lib/swarm_sdk.rb +40 -8
  92. data/swarm_cli.gemspec +1 -1
  93. data/swarm_memory.gemspec +2 -2
  94. data/swarm_sdk.gemspec +2 -2
  95. metadata +21 -13
  96. data/examples/learning-assistant/assistant.md +0 -7
  97. data/examples/learning-assistant/example-memories/concept-example.md +0 -90
  98. data/examples/learning-assistant/example-memories/experience-example.md +0 -66
  99. data/examples/learning-assistant/example-memories/fact-example.md +0 -76
  100. data/examples/learning-assistant/example-memories/memory-index.md +0 -78
  101. data/examples/learning-assistant/example-memories/skill-example.md +0 -168
  102. data/examples/learning-assistant/learning_assistant.rb +0 -34
  103. data/examples/learning-assistant/learning_assistant.yml +0 -20
  104. data/lib/swarm_sdk/mcp.rb +0 -16
  105. data/llm.v2.txt +0 -13407
  106. /data/docs/v2/guides/{MEMORY_DEFRAG_GUIDE.md → memory-defrag-guide.md} +0 -0
  107. /data/{llms.txt → llms.claude-swarm.txt} +0 -0
@@ -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,74 @@ 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
- # Check if this is the first user message
234
- is_first = SystemReminderInjector.first_message?(self)
235
-
236
- if is_first
237
- # Collect plugin reminders first
238
- plugin_reminders = collect_plugin_reminders(prompt, is_first_message: true)
239
-
240
- # Build full prompt with embedded plugin reminders
241
- full_prompt = prompt
242
- plugin_reminders.each do |reminder|
243
- full_prompt = "#{full_prompt}\n\n#{reminder}"
244
- end
245
-
246
- # Inject first message reminders (includes system reminders + toolset + after)
247
- # SystemReminderInjector will embed all reminders in the prompt via add_message
248
- SystemReminderInjector.inject_first_message_reminders(self, full_prompt)
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
- # Trigger user_prompt hook manually since we're bypassing the normal ask flow
251
- if @hook_executor
252
- hook_result = trigger_user_prompt(prompt)
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
+ # Extract source from options if provided, default to "user"
275
+ source = options[:source] || "user"
276
+ hook_result = trigger_user_prompt(prompt, source: source)
277
+
278
+ # Check if hook halted execution
279
+ if hook_result[:halted]
280
+ # Return a halted message instead of calling LLM
281
+ return RubyLLM::Message.new(
282
+ role: :assistant,
283
+ content: hook_result[:halt_message],
284
+ model_id: model.id,
285
+ )
286
+ end
253
287
 
254
- # Check if hook halted execution
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
- )
288
+ # NOTE: We ignore modified_prompt for first message since reminders already injected
262
289
  end
263
290
 
264
- # NOTE: We ignore modified_prompt for first message since reminders already injected
265
- end
291
+ # Call complete to get LLM response
292
+ complete(**options)
293
+ else
294
+ # Build prompt with embedded reminders (if needed)
295
+ full_prompt = prompt
296
+
297
+ # Add periodic TodoWrite reminder if needed (only if agent has TodoWrite tool)
298
+ if tools.key?("TodoWrite") && SystemReminderInjector.should_inject_todowrite_reminder?(self, @last_todowrite_message_index)
299
+ full_prompt = "#{full_prompt}\n\n#{SystemReminderInjector::TODOWRITE_PERIODIC_REMINDER}"
300
+ # Update tracking
301
+ @last_todowrite_message_index = SystemReminderInjector.find_last_todowrite_index(self)
302
+ end
266
303
 
267
- # Call complete to get LLM response
268
- complete(**options)
269
- else
270
- # Build prompt with embedded reminders (if needed)
271
- full_prompt = prompt
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
304
+ # Collect plugin reminders and embed them
305
+ plugin_reminders = collect_plugin_reminders(full_prompt, is_first_message: false)
306
+ plugin_reminders.each do |reminder|
307
+ full_prompt = "#{full_prompt}\n\n#{reminder}"
308
+ end
279
309
 
280
- # Collect plugin reminders and embed them
281
- plugin_reminders = collect_plugin_reminders(full_prompt, is_first_message: false)
282
- plugin_reminders.each do |reminder|
283
- full_prompt = "#{full_prompt}\n\n#{reminder}"
310
+ # Normal ask behavior for subsequent messages
311
+ # This calls super which goes to HookIntegration's ask override
312
+ # HookIntegration will call add_message, and we'll extract reminders there
313
+ super(full_prompt, **options)
284
314
  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
315
  end
291
316
  end
292
317
 
@@ -674,7 +699,15 @@ module SwarmSDK
674
699
  # This is needed for setting agent_name and other provider-specific settings.
675
700
  #
676
701
  # @return [RubyLLM::Provider::Base] Provider instance
677
- attr_reader :provider, :global_semaphore, :local_semaphore, :real_model_info, :context_tracker, :context_manager
702
+ attr_reader :provider, :global_semaphore, :local_semaphore, :real_model_info, :context_tracker, :context_manager, :agent_context, :last_todowrite_message_index, :active_skill_path
703
+
704
+ # Setters for snapshot/restore
705
+ attr_writer :last_todowrite_message_index, :active_skill_path
706
+
707
+ # Expose messages array (inherited from RubyLLM::Chat but not publicly accessible)
708
+ #
709
+ # @return [Array<RubyLLM::Message>] Conversation messages
710
+ attr_reader :messages
678
711
 
679
712
  # Get context window limit for the current model
680
713
  #
@@ -718,6 +751,37 @@ module SwarmSDK
718
751
  messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.output_tokens || 0 }
719
752
  end
720
753
 
754
+ # Calculate cumulative cached tokens across all assistant messages
755
+ #
756
+ # Cached tokens are portions of prompts served from the provider's cache.
757
+ # OpenAI reports this automatically for prompts >1024 tokens.
758
+ # Anthropic/Bedrock expose cache control via Content::Raw blocks.
759
+ #
760
+ # @return [Integer] Total cached tokens used in conversation
761
+ def cumulative_cached_tokens
762
+ messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.cached_tokens || 0 }
763
+ end
764
+
765
+ # Calculate cumulative cache creation tokens
766
+ #
767
+ # Cache creation tokens are written to the cache (Anthropic/Bedrock only).
768
+ # These are charged at the normal input rate when first created.
769
+ #
770
+ # @return [Integer] Total tokens written to cache
771
+ def cumulative_cache_creation_tokens
772
+ messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.cache_creation_tokens || 0 }
773
+ end
774
+
775
+ # Calculate effective input tokens (excluding cache hits)
776
+ #
777
+ # This represents the actual tokens charged for input, excluding cached portions.
778
+ # Useful for accurate cost tracking when using prompt caching.
779
+ #
780
+ # @return [Integer] Actual input tokens charged (input minus cached)
781
+ def effective_input_tokens
782
+ cumulative_input_tokens - cumulative_cached_tokens
783
+ end
784
+
721
785
  # Calculate total tokens used (input + output)
722
786
  #
723
787
  # @return [Integer] Total tokens used in conversation
@@ -777,6 +841,85 @@ module SwarmSDK
777
841
 
778
842
  private
779
843
 
844
+ # Inject LLM instrumentation middleware for API request/response logging
845
+ #
846
+ # This middleware captures HTTP requests/responses to LLM providers and
847
+ # emits structured events via LogStream. Only injected when logging is enabled.
848
+ #
849
+ # @return [void]
850
+ def inject_llm_instrumentation
851
+ # Safety checks
852
+ return unless @provider
853
+
854
+ faraday_conn = @provider.connection&.connection
855
+ return unless faraday_conn
856
+
857
+ # Check if middleware is already present to prevent duplicates
858
+ return if @llm_instrumentation_injected
859
+
860
+ # Get provider name for logging
861
+ provider_name = @provider.class.name.split("::").last.downcase
862
+
863
+ # Inject middleware at beginning of stack (position 0)
864
+ # This ensures we capture raw requests before any transformations
865
+ # Use fully qualified name to ensure Zeitwerk loads it
866
+ faraday_conn.builder.insert(
867
+ 0,
868
+ SwarmSDK::Agent::LLMInstrumentationMiddleware,
869
+ on_request: method(:handle_llm_api_request),
870
+ on_response: method(:handle_llm_api_response),
871
+ provider_name: provider_name,
872
+ )
873
+
874
+ # Mark as injected to prevent duplicates
875
+ @llm_instrumentation_injected = true
876
+
877
+ RubyLLM.logger.debug("SwarmSDK: Injected LLM instrumentation middleware for agent #{@agent_name}")
878
+ rescue StandardError => e
879
+ # Don't fail initialization if instrumentation fails
880
+ RubyLLM.logger.error("SwarmSDK: Failed to inject LLM instrumentation: #{e.message}")
881
+ end
882
+
883
+ # Handle LLM API request event
884
+ #
885
+ # Emits llm_api_request event via LogStream with request details.
886
+ #
887
+ # @param data [Hash] Request data from middleware
888
+ # @return [void]
889
+ def handle_llm_api_request(data)
890
+ return unless LogStream.emitter
891
+
892
+ LogStream.emit(
893
+ type: "llm_api_request",
894
+ agent: @agent_name,
895
+ swarm_id: @agent_context&.swarm_id,
896
+ parent_swarm_id: @agent_context&.parent_swarm_id,
897
+ **data,
898
+ )
899
+ rescue StandardError => e
900
+ RubyLLM.logger.error("SwarmSDK: Error emitting llm_api_request event: #{e.message}")
901
+ end
902
+
903
+ # Handle LLM API response event
904
+ #
905
+ # Emits llm_api_response event via LogStream with response details.
906
+ #
907
+ # @param data [Hash] Response data from middleware
908
+ # @return [void]
909
+ def handle_llm_api_response(data)
910
+ return unless LogStream.emitter
911
+
912
+ LogStream.emit(
913
+ type: "llm_api_response",
914
+ agent: @agent_name,
915
+ swarm_id: @agent_context&.swarm_id,
916
+ parent_swarm_id: @agent_context&.parent_swarm_id,
917
+ **data,
918
+ )
919
+ rescue StandardError => e
920
+ RubyLLM.logger.error("SwarmSDK: Error emitting llm_api_response event: #{e.message}")
921
+ end
922
+
780
923
  # Call LLM with retry logic for transient failures
781
924
  #
782
925
  # Retries up to 10 times with fixed 10-second delays for:
@@ -802,10 +945,13 @@ module SwarmSDK
802
945
  LogStream.emit(
803
946
  type: "llm_retry_exhausted",
804
947
  agent: @agent_name,
948
+ swarm_id: @agent_context&.swarm_id,
949
+ parent_swarm_id: @agent_context&.parent_swarm_id,
805
950
  model: @model&.id,
806
951
  attempts: attempts,
807
952
  error_class: e.class.name,
808
953
  error_message: e.message,
954
+ error_backtrace: e.backtrace,
809
955
  )
810
956
  raise
811
957
  end
@@ -814,11 +960,14 @@ module SwarmSDK
814
960
  LogStream.emit(
815
961
  type: "llm_retry_attempt",
816
962
  agent: @agent_name,
963
+ swarm_id: @agent_context&.swarm_id,
964
+ parent_swarm_id: @agent_context&.parent_swarm_id,
817
965
  model: @model&.id,
818
966
  attempt: attempts,
819
967
  max_retries: max_retries,
820
968
  error_class: e.class.name,
821
969
  error_message: e.message,
970
+ error_backtrace: e.backtrace,
822
971
  retry_delay: delay,
823
972
  )
824
973
 
@@ -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,
@@ -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