swarm_memory 2.1.3 → 2.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) 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 +2 -15
  5. data/lib/claude_swarm/mcp_generator.rb +1 -0
  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/run.rb +2 -2
  11. data/lib/swarm_cli/config_loader.rb +11 -11
  12. data/lib/swarm_cli/formatters/human_formatter.rb +70 -0
  13. data/lib/swarm_cli/interactive_repl.rb +11 -5
  14. data/lib/swarm_cli/ui/icons.rb +0 -23
  15. data/lib/swarm_cli/version.rb +1 -1
  16. data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
  17. data/lib/swarm_memory/integration/sdk_plugin.rb +87 -7
  18. data/lib/swarm_memory/version.rb +1 -1
  19. data/lib/swarm_memory.rb +1 -1
  20. data/lib/swarm_sdk/agent/builder.rb +58 -0
  21. data/lib/swarm_sdk/agent/chat.rb +527 -1059
  22. data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +9 -88
  23. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
  24. data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +111 -44
  25. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
  26. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
  27. data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
  28. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
  29. data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +12 -12
  30. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
  31. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
  32. data/lib/swarm_sdk/agent/context.rb +2 -2
  33. data/lib/swarm_sdk/agent/definition.rb +66 -154
  34. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +4 -2
  35. data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
  36. data/lib/swarm_sdk/builders/base_builder.rb +409 -0
  37. data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
  38. data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
  39. data/lib/swarm_sdk/concerns/validatable.rb +55 -0
  40. data/lib/swarm_sdk/configuration/parser.rb +353 -0
  41. data/lib/swarm_sdk/configuration/translator.rb +255 -0
  42. data/lib/swarm_sdk/configuration.rb +65 -543
  43. data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
  44. data/lib/swarm_sdk/context_compactor.rb +6 -11
  45. data/lib/swarm_sdk/context_management/builder.rb +128 -0
  46. data/lib/swarm_sdk/context_management/context.rb +328 -0
  47. data/lib/swarm_sdk/defaults.rb +196 -0
  48. data/lib/swarm_sdk/events_to_messages.rb +18 -0
  49. data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
  50. data/lib/swarm_sdk/log_collector.rb +179 -29
  51. data/lib/swarm_sdk/log_stream.rb +29 -0
  52. data/lib/swarm_sdk/node_context.rb +1 -1
  53. data/lib/swarm_sdk/observer/builder.rb +81 -0
  54. data/lib/swarm_sdk/observer/config.rb +45 -0
  55. data/lib/swarm_sdk/observer/manager.rb +236 -0
  56. data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
  57. data/lib/swarm_sdk/plugin.rb +93 -3
  58. data/lib/swarm_sdk/snapshot.rb +6 -6
  59. data/lib/swarm_sdk/snapshot_from_events.rb +13 -2
  60. data/lib/swarm_sdk/state_restorer.rb +136 -151
  61. data/lib/swarm_sdk/state_snapshot.rb +65 -100
  62. data/lib/swarm_sdk/swarm/agent_initializer.rb +180 -136
  63. data/lib/swarm_sdk/swarm/builder.rb +44 -578
  64. data/lib/swarm_sdk/swarm/executor.rb +213 -0
  65. data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
  66. data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
  67. data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
  68. data/lib/swarm_sdk/swarm/tool_configurator.rb +42 -138
  69. data/lib/swarm_sdk/swarm.rb +137 -679
  70. data/lib/swarm_sdk/tools/bash.rb +11 -3
  71. data/lib/swarm_sdk/tools/delegate.rb +61 -43
  72. data/lib/swarm_sdk/tools/edit.rb +8 -13
  73. data/lib/swarm_sdk/tools/glob.rb +9 -1
  74. data/lib/swarm_sdk/tools/grep.rb +7 -0
  75. data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
  76. data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
  77. data/lib/swarm_sdk/tools/read.rb +11 -13
  78. data/lib/swarm_sdk/tools/registry.rb +122 -10
  79. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +8 -5
  80. data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
  81. data/lib/swarm_sdk/tools/todo_write.rb +7 -0
  82. data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
  83. data/lib/swarm_sdk/tools/write.rb +8 -13
  84. data/lib/swarm_sdk/version.rb +1 -1
  85. data/lib/swarm_sdk/{node → workflow}/agent_config.rb +1 -1
  86. data/lib/swarm_sdk/workflow/builder.rb +143 -0
  87. data/lib/swarm_sdk/workflow/executor.rb +497 -0
  88. data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +3 -3
  89. data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
  90. data/lib/swarm_sdk/{node_orchestrator.rb → workflow.rb} +152 -456
  91. data/lib/swarm_sdk.rb +33 -3
  92. metadata +37 -14
  93. data/lib/swarm_memory/chat_extension.rb +0 -34
  94. data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -589
@@ -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,7 +39,7 @@ module SwarmSDK
44
39
  :agent_permissions,
45
40
  :assume_model_exists,
46
41
  :hooks,
47
- :memory,
42
+ :plugin_configs,
48
43
  :shared_across_delegations
49
44
 
50
45
  attr_accessor :bypass_permissions, :max_concurrent_tools
@@ -72,14 +67,14 @@ module SwarmSDK
72
67
  end
73
68
 
74
69
  @description = config[:description]
75
- @model = config[:model] || DEFAULT_MODEL
76
- @provider = config[:provider] || DEFAULT_PROVIDER
70
+ @model = config[:model] || Defaults::Agent::MODEL
71
+ @provider = config[:provider] || Defaults::Agent::PROVIDER
77
72
  @base_url = config[:base_url]
78
73
  @api_version = config[:api_version]
79
74
  @context_window = config[:context_window] # Explicit context window override
80
75
  @parameters = config[:parameters] || {}
81
76
  @headers = Utils.stringify_keys(config[:headers] || {})
82
- @timeout = config[:timeout] || DEFAULT_TIMEOUT
77
+ @timeout = config[:timeout] || Defaults::Timeouts::AGENT_REQUEST_SECONDS
83
78
  @bypass_permissions = config[:bypass_permissions] || false
84
79
  @max_concurrent_tools = config[:max_concurrent_tools]
85
80
  # Always assume model exists - SwarmSDK validates models separately using models.json
@@ -100,9 +95,9 @@ module SwarmSDK
100
95
  # Parse directory first so it can be used in system prompt rendering
101
96
  @directory = parse_directory(config[:directory])
102
97
 
103
- # Parse memory configuration BEFORE building system prompt
104
- # (memory prompt needs to be appended if memory is enabled)
105
- @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)
106
101
 
107
102
  # Delegation isolation mode (default: false = isolated instances per delegation)
108
103
  @shared_across_delegations = config[:shared_across_delegations] || false
@@ -132,40 +127,20 @@ module SwarmSDK
132
127
  validate!
133
128
  end
134
129
 
135
- # Check if memory is enabled for this agent
130
+ # Get plugin-specific configuration
136
131
  #
137
- # @return [Boolean]
138
- def memory_enabled?
139
- return false if @memory.nil?
140
-
141
- # MemoryConfig object (from DSL)
142
- return @memory.enabled? if @memory.respond_to?(:enabled?)
143
-
144
- # Hash (from YAML) - check for directory key
145
- if @memory.is_a?(Hash)
146
- directory = @memory[:directory] || @memory["directory"]
147
- return !directory.nil? && !directory.to_s.strip.empty?
148
- end
149
-
150
- false
151
- end
152
-
153
- # 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
154
138
  #
155
- # @param memory_config [Hash, Object, nil] Memory configuration
156
- # @return [Object, Hash, nil] Memory config (could be MemoryConfig from swarm_memory or Hash)
157
- def parse_memory_config(memory_config)
158
- return if memory_config.nil?
159
-
160
- # If it's a MemoryConfig object (duck typing - has directory, adapter, mode methods)
161
- # return as-is. This could be SwarmMemory::DSL::MemoryConfig or any compatible object.
162
- return memory_config if memory_config.respond_to?(:directory) &&
163
- memory_config.respond_to?(:adapter) &&
164
- memory_config.respond_to?(:enabled?)
165
-
166
- # If it's a hash (from YAML), keep it as a hash
167
- # Plugin will create storage adapter based on the hash values
168
- 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]
169
144
  end
170
145
 
171
146
  def to_h
@@ -285,122 +260,59 @@ module SwarmSDK
285
260
  end
286
261
 
287
262
  def build_full_system_prompt(custom_prompt)
288
- # Build the base prompt based on coding_agent setting
289
- prompt = if @coding_agent
290
- # Coding agent: include full base prompt
291
- rendered_base = render_base_system_prompt
292
-
293
- if custom_prompt && !custom_prompt.strip.empty?
294
- "#{rendered_base}\n\n#{custom_prompt}"
295
- else
296
- rendered_base
297
- end
298
- elsif default_tools_enabled?
299
- # Non-coding agent: optionally include TODO/Scratchpad sections if default tools available
300
- non_coding_base = render_non_coding_base_prompt
301
-
302
- if custom_prompt && !custom_prompt.strip.empty?
303
- # Prepend TODO/Scratchpad info before custom prompt
304
- "#{non_coding_base}\n\n#{custom_prompt}"
305
- else
306
- # No custom prompt: just return TODO/Scratchpad info
307
- non_coding_base
308
- end
309
- else
310
- # No default tools: return only custom prompt
311
- (custom_prompt || "").to_s
312
- end
313
-
314
- # Append plugin contributions to system prompt
315
- plugin_contributions = collect_plugin_prompt_contributions
316
- if plugin_contributions.any?
317
- combined_contributions = plugin_contributions.join("\n\n")
318
- prompt = if prompt && !prompt.strip.empty?
319
- "#{prompt}\n\n#{combined_contributions}"
320
- else
321
- combined_contributions
322
- end
323
- end
324
-
325
- prompt
326
- end
327
-
328
- # Check if default tools are enabled (i.e., not disabled)
329
- #
330
- # @return [Boolean] True if default tools should be included
331
- def default_tools_enabled?
332
- @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
+ )
333
272
  end
334
273
 
335
- def render_base_system_prompt
336
- cwd = @directory || Dir.pwd
337
- platform = RUBY_PLATFORM
338
- os_version = begin
339
- %x(uname -sr 2>/dev/null).strip
340
- rescue
341
- RUBY_PLATFORM
342
- end
343
- date = Time.now.strftime("%Y-%m-%d")
344
-
345
- template_content = File.read(BASE_SYSTEM_PROMPT_PATH)
346
- ERB.new(template_content).result(binding)
274
+ def parse_directory(directory_config)
275
+ directory_config ||= "."
276
+ File.expand_path(directory_config.to_s)
347
277
  end
348
278
 
349
- # Collect system prompt contributions from all plugins
279
+ # Extract plugin-specific configuration keys from the config hash
350
280
  #
351
- # Asks each registered plugin if it wants to contribute to the system prompt.
352
- # 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.
353
283
  #
354
- # @return [Array<String>] Array of prompt contributions from plugins
355
- def collect_plugin_prompt_contributions
356
- contributions = []
357
-
358
- PluginRegistry.all.each do |plugin|
359
- # Check if plugin has storage enabled for this agent
360
- next unless plugin.storage_enabled?(self)
361
-
362
- # Ask plugin for prompt contribution
363
- # Note: storage is not available yet at this point, so we pass nil
364
- contribution = plugin.system_prompt_contribution(agent_definition: self, storage: nil)
365
- contributions << contribution if contribution && !contribution.strip.empty?
366
- end
367
-
368
- contributions
369
- end
370
-
371
- def render_non_coding_base_prompt
372
- # Simplified base prompt for non-coding agents
373
- # Includes environment info only
374
- # Does not steer towards coding tasks
375
- cwd = @directory || Dir.pwd
376
- platform = RUBY_PLATFORM
377
- os_version = begin
378
- %x(uname -sr 2>/dev/null).strip
379
- rescue
380
- RUBY_PLATFORM
381
- end
382
- date = Time.now.strftime("%Y-%m-%d")
383
-
384
- <<~PROMPT.strip
385
- # Today's date
386
-
387
- <today-date>
388
- #{date}
389
- #</today-date>
390
-
391
- # Current Environment
392
-
393
- <env>
394
- Working directory: #{cwd}
395
- Platform: #{platform}
396
- OS Version: #{os_version}
397
- </env>
398
- PROMPT
399
- end
400
-
401
- def parse_directory(directory_config)
402
- directory_config ||= "."
403
- 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) }
404
316
  end
405
317
 
406
318
  # Parse tools configuration with permissions support
@@ -64,7 +64,8 @@ module SwarmSDK
64
64
  @on_request.call(request_data)
65
65
  rescue StandardError => e
66
66
  # Don't let logging errors break the request
67
- RubyLLM.logger.error("LLM instrumentation request error: #{e.message}")
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}")
68
69
  end
69
70
 
70
71
  # Emit response event
@@ -97,7 +98,8 @@ module SwarmSDK
97
98
  @on_response.call(response_data)
98
99
  rescue StandardError => e
99
100
  # Don't let logging errors break the response
100
- RubyLLM.logger.error("LLM instrumentation response error: #{e.message}")
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}")
101
103
  end
102
104
 
103
105
  # Sanitize headers by removing sensitive data
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Agent
5
+ # Builds system prompts for agents
6
+ #
7
+ # This class encapsulates all system prompt construction logic, including:
8
+ # - Base system prompt rendering (for coding agents)
9
+ # - Non-coding base prompt rendering
10
+ # - Plugin prompt contribution collection
11
+ # - Combining base and custom prompts
12
+ #
13
+ # ## Safety Note for SwarmMemory Integration
14
+ #
15
+ # This is an INTERNAL helper that receives Definition attributes as input.
16
+ # Definition remains the single source of truth with all instance variables.
17
+ # SwarmMemory uses `agent_definition.instance_eval { binding }` for ERB templating,
18
+ # which requires all properties to be on Definition object. This helper is safe
19
+ # because it doesn't affect Definition's structure - it only extracts logic.
20
+ class SystemPromptBuilder
21
+ BASE_SYSTEM_PROMPT_PATH = File.expand_path("../prompts/base_system_prompt.md.erb", __dir__)
22
+
23
+ class << self
24
+ # Build the complete system prompt for an agent
25
+ #
26
+ # @param custom_prompt [String, nil] Custom system prompt from configuration
27
+ # @param coding_agent [Boolean] Whether agent is configured for coding tasks
28
+ # @param disable_default_tools [Boolean, Array, nil] Default tools disable configuration
29
+ # @param directory [String] Agent's working directory
30
+ # @param definition [Definition] Full definition for plugin contributions
31
+ # @return [String] Complete system prompt
32
+ def build(custom_prompt:, coding_agent:, disable_default_tools:, directory:, definition:)
33
+ new(
34
+ custom_prompt: custom_prompt,
35
+ coding_agent: coding_agent,
36
+ disable_default_tools: disable_default_tools,
37
+ directory: directory,
38
+ definition: definition,
39
+ ).build
40
+ end
41
+ end
42
+
43
+ def initialize(custom_prompt:, coding_agent:, disable_default_tools:, directory:, definition:)
44
+ @custom_prompt = custom_prompt
45
+ @coding_agent = coding_agent
46
+ @disable_default_tools = disable_default_tools
47
+ @directory = directory
48
+ @definition = definition
49
+ end
50
+
51
+ def build
52
+ prompt = base_prompt_section
53
+ prompt = append_plugin_contributions(prompt)
54
+ prompt
55
+ end
56
+
57
+ private
58
+
59
+ def base_prompt_section
60
+ if @coding_agent
61
+ build_coding_agent_prompt
62
+ elsif default_tools_enabled?
63
+ build_non_coding_agent_prompt
64
+ else
65
+ (@custom_prompt || "").to_s
66
+ end
67
+ end
68
+
69
+ def build_coding_agent_prompt
70
+ rendered_base = render_base_system_prompt
71
+
72
+ if @custom_prompt && !@custom_prompt.strip.empty?
73
+ "#{rendered_base}\n\n#{@custom_prompt}"
74
+ else
75
+ rendered_base
76
+ end
77
+ end
78
+
79
+ def build_non_coding_agent_prompt
80
+ non_coding_base = render_non_coding_base_prompt
81
+
82
+ if @custom_prompt && !@custom_prompt.strip.empty?
83
+ "#{non_coding_base}\n\n#{@custom_prompt}"
84
+ else
85
+ non_coding_base
86
+ end
87
+ end
88
+
89
+ def default_tools_enabled?
90
+ @disable_default_tools != true
91
+ end
92
+
93
+ def render_base_system_prompt
94
+ cwd = @directory || Dir.pwd
95
+ platform = RUBY_PLATFORM
96
+ os_version = begin
97
+ %x(uname -sr 2>/dev/null).strip
98
+ rescue
99
+ RUBY_PLATFORM
100
+ end
101
+ date = Time.now.strftime("%Y-%m-%d")
102
+
103
+ template_content = File.read(BASE_SYSTEM_PROMPT_PATH)
104
+ ERB.new(template_content).result(binding)
105
+ end
106
+
107
+ def render_non_coding_base_prompt
108
+ cwd = @directory || Dir.pwd
109
+ platform = RUBY_PLATFORM
110
+ os_version = begin
111
+ %x(uname -sr 2>/dev/null).strip
112
+ rescue
113
+ RUBY_PLATFORM
114
+ end
115
+ date = Time.now.strftime("%Y-%m-%d")
116
+
117
+ <<~PROMPT.strip
118
+ # Today's date
119
+
120
+ <today-date>
121
+ #{date}
122
+ #</today-date>
123
+
124
+ # Current Environment
125
+
126
+ <env>
127
+ Working directory: #{cwd}
128
+ Platform: #{platform}
129
+ OS Version: #{os_version}
130
+ </env>
131
+ PROMPT
132
+ end
133
+
134
+ def append_plugin_contributions(prompt)
135
+ contributions = collect_plugin_prompt_contributions
136
+ return prompt if contributions.empty?
137
+
138
+ combined_contributions = contributions.join("\n\n")
139
+
140
+ if prompt && !prompt.strip.empty?
141
+ "#{prompt}\n\n#{combined_contributions}"
142
+ else
143
+ combined_contributions
144
+ end
145
+ end
146
+
147
+ def collect_plugin_prompt_contributions
148
+ contributions = []
149
+
150
+ PluginRegistry.all.each do |plugin|
151
+ next unless plugin.storage_enabled?(@definition)
152
+
153
+ contribution = plugin.system_prompt_contribution(agent_definition: @definition, storage: nil)
154
+ contributions << contribution if contribution && !contribution.strip.empty?
155
+ end
156
+
157
+ contributions
158
+ end
159
+ end
160
+ end
161
+ end