swarm_memory 2.1.2 → 2.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. checksums.yaml +4 -4
  2. data/lib/claude_swarm/claude_mcp_server.rb +1 -0
  3. data/lib/claude_swarm/cli.rb +5 -18
  4. data/lib/claude_swarm/configuration.rb +30 -19
  5. data/lib/claude_swarm/mcp_generator.rb +5 -10
  6. data/lib/claude_swarm/openai/chat_completion.rb +4 -12
  7. data/lib/claude_swarm/openai/executor.rb +3 -1
  8. data/lib/claude_swarm/openai/responses.rb +13 -32
  9. data/lib/claude_swarm/version.rb +1 -1
  10. data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
  11. data/lib/swarm_cli/commands/run.rb +2 -2
  12. data/lib/swarm_cli/config_loader.rb +14 -14
  13. data/lib/swarm_cli/formatters/human_formatter.rb +70 -0
  14. data/lib/swarm_cli/interactive_repl.rb +11 -5
  15. data/lib/swarm_cli/ui/icons.rb +0 -23
  16. data/lib/swarm_cli/version.rb +1 -1
  17. data/lib/swarm_memory/adapters/base.rb +4 -4
  18. data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
  19. data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
  20. data/lib/swarm_memory/integration/cli_registration.rb +3 -2
  21. data/lib/swarm_memory/integration/sdk_plugin.rb +98 -12
  22. data/lib/swarm_memory/tools/memory_edit.rb +2 -2
  23. data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
  24. data/lib/swarm_memory/tools/memory_read.rb +3 -3
  25. data/lib/swarm_memory/version.rb +1 -1
  26. data/lib/swarm_memory.rb +6 -1
  27. data/lib/swarm_sdk/agent/builder.rb +91 -0
  28. data/lib/swarm_sdk/agent/chat.rb +540 -925
  29. data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +33 -79
  30. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
  31. data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +147 -39
  32. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
  33. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
  34. data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
  35. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
  36. data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +22 -38
  37. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
  38. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
  39. data/lib/swarm_sdk/agent/context.rb +8 -4
  40. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  41. data/lib/swarm_sdk/agent/definition.rb +79 -174
  42. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +182 -0
  43. data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
  44. data/lib/swarm_sdk/builders/base_builder.rb +409 -0
  45. data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
  46. data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
  47. data/lib/swarm_sdk/concerns/validatable.rb +55 -0
  48. data/lib/swarm_sdk/configuration/parser.rb +353 -0
  49. data/lib/swarm_sdk/configuration/translator.rb +255 -0
  50. data/lib/swarm_sdk/configuration.rb +100 -261
  51. data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
  52. data/lib/swarm_sdk/context_compactor.rb +6 -11
  53. data/lib/swarm_sdk/context_management/builder.rb +128 -0
  54. data/lib/swarm_sdk/context_management/context.rb +328 -0
  55. data/lib/swarm_sdk/defaults.rb +196 -0
  56. data/lib/swarm_sdk/events_to_messages.rb +199 -0
  57. data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
  58. data/lib/swarm_sdk/log_collector.rb +192 -16
  59. data/lib/swarm_sdk/log_stream.rb +66 -8
  60. data/lib/swarm_sdk/model_aliases.json +4 -1
  61. data/lib/swarm_sdk/node_context.rb +1 -1
  62. data/lib/swarm_sdk/observer/builder.rb +81 -0
  63. data/lib/swarm_sdk/observer/config.rb +45 -0
  64. data/lib/swarm_sdk/observer/manager.rb +236 -0
  65. data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
  66. data/lib/swarm_sdk/plugin.rb +93 -3
  67. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  68. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
  69. data/lib/swarm_sdk/restore_result.rb +65 -0
  70. data/lib/swarm_sdk/snapshot.rb +156 -0
  71. data/lib/swarm_sdk/snapshot_from_events.rb +397 -0
  72. data/lib/swarm_sdk/state_restorer.rb +476 -0
  73. data/lib/swarm_sdk/state_snapshot.rb +334 -0
  74. data/lib/swarm_sdk/swarm/agent_initializer.rb +428 -79
  75. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  76. data/lib/swarm_sdk/swarm/builder.rb +69 -407
  77. data/lib/swarm_sdk/swarm/executor.rb +213 -0
  78. data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
  79. data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
  80. data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
  81. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  82. data/lib/swarm_sdk/swarm/tool_configurator.rb +88 -149
  83. data/lib/swarm_sdk/swarm.rb +366 -631
  84. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  85. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  86. data/lib/swarm_sdk/tools/bash.rb +11 -3
  87. data/lib/swarm_sdk/tools/delegate.rb +127 -24
  88. data/lib/swarm_sdk/tools/edit.rb +8 -13
  89. data/lib/swarm_sdk/tools/glob.rb +9 -1
  90. data/lib/swarm_sdk/tools/grep.rb +7 -0
  91. data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
  92. data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
  93. data/lib/swarm_sdk/tools/read.rb +28 -18
  94. data/lib/swarm_sdk/tools/registry.rb +122 -10
  95. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
  96. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
  97. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
  98. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  99. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +53 -5
  100. data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
  101. data/lib/swarm_sdk/tools/think.rb +4 -1
  102. data/lib/swarm_sdk/tools/todo_write.rb +27 -8
  103. data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
  104. data/lib/swarm_sdk/tools/write.rb +8 -13
  105. data/lib/swarm_sdk/utils.rb +18 -0
  106. data/lib/swarm_sdk/validation_result.rb +33 -0
  107. data/lib/swarm_sdk/version.rb +1 -1
  108. data/lib/swarm_sdk/{node → workflow}/agent_config.rb +34 -9
  109. data/lib/swarm_sdk/workflow/builder.rb +143 -0
  110. data/lib/swarm_sdk/workflow/executor.rb +497 -0
  111. data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +42 -21
  112. data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
  113. data/lib/swarm_sdk/workflow.rb +554 -0
  114. data/lib/swarm_sdk.rb +393 -22
  115. metadata +51 -16
  116. data/lib/swarm_memory/chat_extension.rb +0 -34
  117. data/lib/swarm_sdk/node_orchestrator.rb +0 -591
  118. data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -582
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Agent
5
+ module ChatHelpers
6
+ # Token usage tracking and context limit management
7
+ #
8
+ # Extracted from Chat to reduce class size and centralize token metrics.
9
+ module TokenTracking
10
+ # Get context window limit for the current model
11
+ #
12
+ # @return [Integer, nil] Maximum context tokens
13
+ def context_limit
14
+ return @explicit_context_window if @explicit_context_window
15
+ return @real_model_info.context_window if @real_model_info&.context_window
16
+
17
+ model_context_window
18
+ rescue StandardError
19
+ nil
20
+ end
21
+
22
+ # Calculate cumulative input tokens for the conversation
23
+ #
24
+ # Gets input_tokens from the most recent assistant message, which represents
25
+ # the total context size sent to the model (not sum of all messages).
26
+ #
27
+ # @return [Integer] Total input tokens used
28
+ def cumulative_input_tokens
29
+ find_last_message { |msg| msg.role == :assistant && msg.input_tokens }&.input_tokens || 0
30
+ end
31
+
32
+ # Calculate cumulative output tokens across all assistant messages
33
+ #
34
+ # @return [Integer] Total output tokens used
35
+ def cumulative_output_tokens
36
+ assistant_messages.sum { |msg| msg.output_tokens || 0 }
37
+ end
38
+
39
+ # Calculate cumulative cached tokens
40
+ #
41
+ # @return [Integer] Total cached tokens used
42
+ def cumulative_cached_tokens
43
+ assistant_messages.sum { |msg| msg.cached_tokens || 0 }
44
+ end
45
+
46
+ # Calculate cumulative cache creation tokens
47
+ #
48
+ # @return [Integer] Total tokens written to cache
49
+ def cumulative_cache_creation_tokens
50
+ assistant_messages.sum { |msg| msg.cache_creation_tokens || 0 }
51
+ end
52
+
53
+ # Calculate effective input tokens (excluding cache hits)
54
+ #
55
+ # @return [Integer] Actual input tokens charged
56
+ def effective_input_tokens
57
+ cumulative_input_tokens - cumulative_cached_tokens
58
+ end
59
+
60
+ # Calculate total tokens used (input + output)
61
+ #
62
+ # @return [Integer] Total tokens used
63
+ def cumulative_total_tokens
64
+ cumulative_input_tokens + cumulative_output_tokens
65
+ end
66
+
67
+ # Calculate percentage of context window used
68
+ #
69
+ # @return [Float] Percentage (0.0 to 100.0)
70
+ def context_usage_percentage
71
+ limit = context_limit
72
+ return 0.0 if limit.nil? || limit.zero?
73
+
74
+ (cumulative_total_tokens.to_f / limit * 100).round(2)
75
+ end
76
+
77
+ # Calculate remaining tokens in context window
78
+ #
79
+ # @return [Integer, nil] Tokens remaining
80
+ def tokens_remaining
81
+ limit = context_limit
82
+ return if limit.nil?
83
+
84
+ limit - cumulative_total_tokens
85
+ end
86
+
87
+ # Compact the conversation history to reduce token usage
88
+ #
89
+ # @param options [Hash] Compression options
90
+ # @return [ContextCompactor::Metrics] Compression statistics
91
+ def compact_context(**options)
92
+ compactor = ContextCompactor.new(self, options)
93
+ compactor.compact
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -30,18 +30,22 @@ module SwarmSDK
30
30
  # 60% triggers automatic compression, 80%/90% are informational warnings
31
31
  CONTEXT_WARNING_THRESHOLDS = [60, 80, 90].freeze
32
32
 
33
- # Threshold at which automatic compression is triggered
34
- COMPRESSION_THRESHOLD = 60
33
+ # Backward compatibility alias - use Defaults module for new code
34
+ COMPRESSION_THRESHOLD = Defaults::Context::COMPRESSION_THRESHOLD_PERCENT
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
@@ -18,11 +18,6 @@ module SwarmSDK
18
18
  # system_prompt: "You build APIs"
19
19
  # })
20
20
  class Definition
21
- DEFAULT_MODEL = "gpt-5"
22
- DEFAULT_PROVIDER = "openai"
23
- DEFAULT_TIMEOUT = 300 # 5 minutes - reasoning models can take a while
24
- BASE_SYSTEM_PROMPT_PATH = File.expand_path("../prompts/base_system_prompt.md.erb", __dir__)
25
-
26
21
  attr_reader :name,
27
22
  :description,
28
23
  :model,
@@ -44,13 +39,21 @@ module SwarmSDK
44
39
  :agent_permissions,
45
40
  :assume_model_exists,
46
41
  :hooks,
47
- :memory
42
+ :plugin_configs,
43
+ :shared_across_delegations
48
44
 
49
45
  attr_accessor :bypass_permissions, :max_concurrent_tools
50
46
 
51
47
  def initialize(name, config = {})
52
48
  @name = name.to_sym
53
49
 
50
+ # Validate name doesn't contain '@' (reserved for delegation instances)
51
+ if @name.to_s.include?("@")
52
+ raise ConfigurationError,
53
+ "Agent names cannot contain '@' character (reserved for delegation instance naming). " \
54
+ "Agent: #{@name}"
55
+ end
56
+
54
57
  # BREAKING CHANGE: Hard error for plural form
55
58
  if config[:directories]
56
59
  raise ConfigurationError,
@@ -64,14 +67,14 @@ module SwarmSDK
64
67
  end
65
68
 
66
69
  @description = config[:description]
67
- @model = config[:model] || DEFAULT_MODEL
68
- @provider = config[:provider] || DEFAULT_PROVIDER
70
+ @model = config[:model] || Defaults::Agent::MODEL
71
+ @provider = config[:provider] || Defaults::Agent::PROVIDER
69
72
  @base_url = config[:base_url]
70
73
  @api_version = config[:api_version]
71
74
  @context_window = config[:context_window] # Explicit context window override
72
75
  @parameters = config[:parameters] || {}
73
76
  @headers = Utils.stringify_keys(config[:headers] || {})
74
- @timeout = config[:timeout] || DEFAULT_TIMEOUT
77
+ @timeout = config[:timeout] || Defaults::Timeouts::AGENT_REQUEST_SECONDS
75
78
  @bypass_permissions = config[:bypass_permissions] || false
76
79
  @max_concurrent_tools = config[:max_concurrent_tools]
77
80
  # Always assume model exists - SwarmSDK validates models separately using models.json
@@ -92,9 +95,12 @@ module SwarmSDK
92
95
  # Parse directory first so it can be used in system prompt rendering
93
96
  @directory = parse_directory(config[:directory])
94
97
 
95
- # Parse memory configuration BEFORE building system prompt
96
- # (memory prompt needs to be appended if memory is enabled)
97
- @memory = parse_memory_config(config[:memory])
98
+ # Extract plugin configurations (generic bucket for all plugin-specific keys)
99
+ # This allows plugins to store their config without SDK knowing about them
100
+ @plugin_configs = extract_plugin_configs(config)
101
+
102
+ # Delegation isolation mode (default: false = isolated instances per delegation)
103
+ @shared_across_delegations = config[:shared_across_delegations] || false
98
104
 
99
105
  # Build system prompt after directory and memory are set
100
106
  @system_prompt = build_full_system_prompt(config[:system_prompt])
@@ -111,7 +117,7 @@ module SwarmSDK
111
117
  # Inject default write restrictions for security
112
118
  @tools = inject_default_write_permissions(@tools)
113
119
 
114
- @delegates_to = Array(config[:delegates_to] || []).map(&:to_sym)
120
+ @delegates_to = Array(config[:delegates_to] || []).map(&:to_sym).uniq
115
121
  @mcp_servers = Array(config[:mcp_servers] || [])
116
122
 
117
123
  # Parse hooks configuration
@@ -121,40 +127,20 @@ module SwarmSDK
121
127
  validate!
122
128
  end
123
129
 
124
- # Check if memory is enabled for this agent
130
+ # Get plugin-specific configuration
125
131
  #
126
- # @return [Boolean]
127
- def memory_enabled?
128
- return false if @memory.nil?
129
-
130
- # MemoryConfig object (from DSL)
131
- return @memory.enabled? if @memory.respond_to?(:enabled?)
132
-
133
- # Hash (from YAML) - check for directory key
134
- if @memory.is_a?(Hash)
135
- directory = @memory[:directory] || @memory["directory"]
136
- return !directory.nil? && !directory.to_s.strip.empty?
137
- end
138
-
139
- false
140
- end
141
-
142
- # Parse memory configuration from Hash or MemoryConfig object
132
+ # Plugins store their configuration in the generic plugin_configs hash.
133
+ # This allows SDK to remain plugin-agnostic while plugins can store
134
+ # arbitrary configuration.
135
+ #
136
+ # @param plugin_name [Symbol] Plugin name (e.g., :memory)
137
+ # @return [Object, nil] Plugin configuration or nil if not present
143
138
  #
144
- # @param memory_config [Hash, Object, nil] Memory configuration
145
- # @return [Object, Hash, nil] Memory config (could be MemoryConfig from swarm_memory or Hash)
146
- def parse_memory_config(memory_config)
147
- return if memory_config.nil?
148
-
149
- # If it's a MemoryConfig object (duck typing - has directory, adapter, mode methods)
150
- # return as-is. This could be SwarmMemory::DSL::MemoryConfig or any compatible object.
151
- return memory_config if memory_config.respond_to?(:directory) &&
152
- memory_config.respond_to?(:adapter) &&
153
- memory_config.respond_to?(:enabled?)
154
-
155
- # If it's a hash (from YAML), keep it as a hash
156
- # Plugin will create storage adapter based on the hash values
157
- memory_config
139
+ # @example
140
+ # agent_definition.plugin_config(:memory)
141
+ # # => { directory: "tmp/memory", mode: :researcher }
142
+ def plugin_config(plugin_name)
143
+ @plugin_configs[plugin_name.to_sym] || @plugin_configs[plugin_name.to_s]
158
144
  end
159
145
 
160
146
  def to_h
@@ -181,6 +167,7 @@ module SwarmSDK
181
167
  assume_model_exists: @assume_model_exists,
182
168
  max_concurrent_tools: @max_concurrent_tools,
183
169
  hooks: @hooks,
170
+ shared_across_delegations: @shared_across_delegations,
184
171
  # Permissions are core SDK functionality (not plugin-specific)
185
172
  default_permissions: @default_permissions,
186
173
  permissions: @agent_permissions,
@@ -273,141 +260,59 @@ module SwarmSDK
273
260
  end
274
261
 
275
262
  def build_full_system_prompt(custom_prompt)
276
- # Build the base prompt based on coding_agent setting
277
- prompt = if @coding_agent
278
- # Coding agent: include full base prompt
279
- rendered_base = render_base_system_prompt
280
-
281
- if custom_prompt && !custom_prompt.strip.empty?
282
- "#{rendered_base}\n\n#{custom_prompt}"
283
- else
284
- rendered_base
285
- end
286
- elsif default_tools_enabled?
287
- # Non-coding agent: optionally include TODO/Scratchpad sections if default tools available
288
- non_coding_base = render_non_coding_base_prompt
289
-
290
- if custom_prompt && !custom_prompt.strip.empty?
291
- # Prepend TODO/Scratchpad info before custom prompt
292
- "#{non_coding_base}\n\n#{custom_prompt}"
293
- else
294
- # No custom prompt: just return TODO/Scratchpad info
295
- non_coding_base
296
- end
297
- else
298
- # No default tools: return only custom prompt
299
- (custom_prompt || "").to_s
300
- end
301
-
302
- # Append plugin contributions to system prompt
303
- plugin_contributions = collect_plugin_prompt_contributions
304
- if plugin_contributions.any?
305
- combined_contributions = plugin_contributions.join("\n\n")
306
- prompt = if prompt && !prompt.strip.empty?
307
- "#{prompt}\n\n#{combined_contributions}"
308
- else
309
- combined_contributions
310
- end
311
- end
312
-
313
- prompt
314
- end
315
-
316
- # Check if default tools are enabled (i.e., not disabled)
317
- #
318
- # @return [Boolean] True if default tools should be included
319
- def default_tools_enabled?
320
- @disable_default_tools != true
263
+ # Delegate to SystemPromptBuilder for all prompt construction logic
264
+ # This keeps Definition focused on data storage while extracting complex logic
265
+ SystemPromptBuilder.build(
266
+ custom_prompt: custom_prompt,
267
+ coding_agent: @coding_agent,
268
+ disable_default_tools: @disable_default_tools,
269
+ directory: @directory,
270
+ definition: self,
271
+ )
321
272
  end
322
273
 
323
- def render_base_system_prompt
324
- cwd = @directory || Dir.pwd
325
- platform = RUBY_PLATFORM
326
- os_version = begin
327
- %x(uname -sr 2>/dev/null).strip
328
- rescue
329
- RUBY_PLATFORM
330
- end
331
- date = Time.now.strftime("%Y-%m-%d")
332
-
333
- template_content = File.read(BASE_SYSTEM_PROMPT_PATH)
334
- ERB.new(template_content).result(binding)
274
+ def parse_directory(directory_config)
275
+ directory_config ||= "."
276
+ File.expand_path(directory_config.to_s)
335
277
  end
336
278
 
337
- # Collect system prompt contributions from all plugins
279
+ # Extract plugin-specific configuration keys from the config hash
338
280
  #
339
- # Asks each registered plugin if it wants to contribute to the system prompt.
340
- # Plugins can return custom instructions based on their configuration.
281
+ # Standard SDK keys are filtered out, leaving only plugin-specific keys.
282
+ # This allows plugins to add their own configuration without SDK modifications.
341
283
  #
342
- # @return [Array<String>] Array of prompt contributions from plugins
343
- def collect_plugin_prompt_contributions
344
- contributions = []
345
-
346
- PluginRegistry.all.each do |plugin|
347
- # Check if plugin has storage enabled for this agent
348
- next unless plugin.storage_enabled?(self)
349
-
350
- # Ask plugin for prompt contribution
351
- # Note: storage is not available yet at this point, so we pass nil
352
- contribution = plugin.system_prompt_contribution(agent_definition: self, storage: nil)
353
- contributions << contribution if contribution && !contribution.strip.empty?
354
- end
355
-
356
- contributions
357
- end
358
-
359
- def render_non_coding_base_prompt
360
- # Simplified base prompt for non-coding agents
361
- # Includes environment info, TODO, and Scratchpad tool information
362
- # Does not steer towards coding tasks
363
- cwd = @directory || Dir.pwd
364
- platform = RUBY_PLATFORM
365
- os_version = begin
366
- %x(uname -sr 2>/dev/null).strip
367
- rescue
368
- RUBY_PLATFORM
369
- end
370
- date = Time.now.strftime("%Y-%m-%d")
371
-
372
- <<~PROMPT.strip
373
- # Today's date
374
-
375
- <today-date>
376
- #{date}
377
- #</today-date>
378
-
379
- # Current Environment
380
-
381
- <env>
382
- Working directory: #{cwd}
383
- Platform: #{platform}
384
- OS Version: #{os_version}
385
- </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
- PROMPT
406
- end
407
-
408
- def parse_directory(directory_config)
409
- directory_config ||= "."
410
- File.expand_path(directory_config.to_s)
284
+ # @param config [Hash] Full agent configuration
285
+ # @return [Hash] Plugin-specific configuration (keys not recognized by SDK)
286
+ def extract_plugin_configs(config)
287
+ standard_keys = [
288
+ :name,
289
+ :description,
290
+ :model,
291
+ :provider,
292
+ :base_url,
293
+ :api_version,
294
+ :context_window,
295
+ :parameters,
296
+ :headers,
297
+ :timeout,
298
+ :bypass_permissions,
299
+ :max_concurrent_tools,
300
+ :assume_model_exists,
301
+ :disable_default_tools,
302
+ :coding_agent,
303
+ :directory,
304
+ :system_prompt,
305
+ :tools,
306
+ :delegates_to,
307
+ :mcp_servers,
308
+ :hooks,
309
+ :default_permissions,
310
+ :permissions,
311
+ :shared_across_delegations,
312
+ :directories,
313
+ ]
314
+
315
+ config.reject { |k, _| standard_keys.include?(k.to_sym) }
411
316
  end
412
317
 
413
318
  # Parse tools configuration with permissions support
@@ -0,0 +1,182 @@
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
+ LogStream.emit_error(e, source: "llm_instrumentation_middleware", context: "emit_request_event", provider: @provider_name)
68
+ RubyLLM.logger.debug("LLM instrumentation request error: #{e.message}")
69
+ end
70
+
71
+ # Emit response event
72
+ #
73
+ # @param env [Faraday::Env] Response environment
74
+ # @param start_time [Time] Request start time
75
+ # @param end_time [Time] Request end time
76
+ # @param duration [Float] Request duration in seconds
77
+ # @return [void]
78
+ def emit_response_event(env, start_time, end_time, duration)
79
+ response_data = {
80
+ provider: @provider_name,
81
+ body: parse_body(env.body),
82
+ duration_seconds: duration.round(3),
83
+ timestamp: end_time.utc.iso8601,
84
+ }
85
+
86
+ # Extract usage information from response body if available
87
+ if env.body.is_a?(String) && !env.body.empty?
88
+ begin
89
+ parsed = JSON.parse(env.body)
90
+ response_data[:usage] = extract_usage(parsed) if parsed.is_a?(Hash)
91
+ response_data[:model] = parsed["model"] if parsed.is_a?(Hash)
92
+ response_data[:finish_reason] = extract_finish_reason(parsed) if parsed.is_a?(Hash)
93
+ rescue JSON::ParserError
94
+ # Not JSON, skip usage extraction
95
+ end
96
+ end
97
+
98
+ @on_response.call(response_data)
99
+ rescue StandardError => e
100
+ # Don't let logging errors break the response
101
+ LogStream.emit_error(e, source: "llm_instrumentation_middleware", context: "emit_response_event", provider: @provider_name)
102
+ RubyLLM.logger.debug("LLM instrumentation response error: #{e.message}")
103
+ end
104
+
105
+ # Sanitize headers by removing sensitive data
106
+ #
107
+ # @param headers [Hash] HTTP headers
108
+ # @return [Hash] Sanitized headers
109
+ def sanitize_headers(headers)
110
+ return {} unless headers
111
+
112
+ headers.transform_keys(&:to_s).transform_values do |value|
113
+ # Redact authorization headers
114
+ if value.to_s.match?(/bearer|token|key/i)
115
+ "[REDACTED]"
116
+ else
117
+ value.to_s
118
+ end
119
+ end
120
+ rescue StandardError
121
+ {}
122
+ end
123
+
124
+ # Parse request/response body
125
+ #
126
+ # @param body [String, Hash, nil] HTTP body
127
+ # @return [Hash, String, nil] Parsed body
128
+ def parse_body(body)
129
+ return if body.nil? || body == ""
130
+
131
+ # Already parsed
132
+ return body if body.is_a?(Hash)
133
+
134
+ # Try to parse JSON
135
+ JSON.parse(body)
136
+ rescue JSON::ParserError
137
+ # Return truncated string if not JSON
138
+ body.to_s[0..1000]
139
+ rescue StandardError
140
+ nil
141
+ end
142
+
143
+ # Extract usage statistics from response
144
+ #
145
+ # Handles different provider formats (OpenAI, Anthropic, etc.)
146
+ #
147
+ # @param parsed [Hash] Parsed response body
148
+ # @return [Hash, nil] Usage statistics
149
+ def extract_usage(parsed)
150
+ usage = parsed["usage"] || parsed.dig("usage")
151
+ return unless usage
152
+
153
+ {
154
+ input_tokens: usage["input_tokens"] || usage["prompt_tokens"],
155
+ output_tokens: usage["output_tokens"] || usage["completion_tokens"],
156
+ total_tokens: usage["total_tokens"],
157
+ }.compact
158
+ rescue StandardError
159
+ nil
160
+ end
161
+
162
+ # Extract finish reason from response
163
+ #
164
+ # Handles different provider formats
165
+ #
166
+ # @param parsed [Hash] Parsed response body
167
+ # @return [String, nil] Finish reason
168
+ def extract_finish_reason(parsed)
169
+ # Anthropic format
170
+ return parsed["stop_reason"] if parsed["stop_reason"]
171
+
172
+ # OpenAI format
173
+ choices = parsed["choices"]
174
+ return unless choices&.is_a?(Array) && !choices.empty?
175
+
176
+ choices.first["finish_reason"]
177
+ rescue StandardError
178
+ nil
179
+ end
180
+ end
181
+ end
182
+ end