swarm_sdk 2.1.3 → 2.3.0

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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/lib/swarm_sdk/agent/builder.rb +91 -0
  3. data/lib/swarm_sdk/agent/chat.rb +540 -925
  4. data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +33 -79
  5. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
  6. data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +147 -39
  7. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
  8. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +233 -0
  9. data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
  10. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
  11. data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +22 -38
  12. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
  13. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
  14. data/lib/swarm_sdk/agent/context.rb +8 -4
  15. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  16. data/lib/swarm_sdk/agent/definition.rb +79 -155
  17. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +182 -0
  18. data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
  19. data/lib/swarm_sdk/builders/base_builder.rb +409 -0
  20. data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
  21. data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
  22. data/lib/swarm_sdk/concerns/validatable.rb +55 -0
  23. data/lib/swarm_sdk/configuration/parser.rb +353 -0
  24. data/lib/swarm_sdk/configuration/translator.rb +255 -0
  25. data/lib/swarm_sdk/configuration.rb +72 -257
  26. data/lib/swarm_sdk/context_compactor/token_counter.rb +3 -3
  27. data/lib/swarm_sdk/context_compactor.rb +6 -11
  28. data/lib/swarm_sdk/context_management/builder.rb +128 -0
  29. data/lib/swarm_sdk/context_management/context.rb +328 -0
  30. data/lib/swarm_sdk/defaults.rb +196 -0
  31. data/lib/swarm_sdk/events_to_messages.rb +199 -0
  32. data/lib/swarm_sdk/hooks/shell_executor.rb +2 -1
  33. data/lib/swarm_sdk/log_collector.rb +192 -16
  34. data/lib/swarm_sdk/log_stream.rb +66 -8
  35. data/lib/swarm_sdk/model_aliases.json +4 -1
  36. data/lib/swarm_sdk/node_context.rb +1 -1
  37. data/lib/swarm_sdk/observer/builder.rb +81 -0
  38. data/lib/swarm_sdk/observer/config.rb +45 -0
  39. data/lib/swarm_sdk/observer/manager.rb +236 -0
  40. data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
  41. data/lib/swarm_sdk/plugin.rb +93 -3
  42. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  43. data/lib/swarm_sdk/restore_result.rb +65 -0
  44. data/lib/swarm_sdk/snapshot.rb +156 -0
  45. data/lib/swarm_sdk/snapshot_from_events.rb +397 -0
  46. data/lib/swarm_sdk/state_restorer.rb +476 -0
  47. data/lib/swarm_sdk/state_snapshot.rb +334 -0
  48. data/lib/swarm_sdk/swarm/agent_initializer.rb +428 -79
  49. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  50. data/lib/swarm_sdk/swarm/builder.rb +69 -407
  51. data/lib/swarm_sdk/swarm/executor.rb +213 -0
  52. data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
  53. data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
  54. data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
  55. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  56. data/lib/swarm_sdk/swarm/tool_configurator.rb +88 -149
  57. data/lib/swarm_sdk/swarm.rb +337 -584
  58. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  59. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  60. data/lib/swarm_sdk/tools/bash.rb +11 -3
  61. data/lib/swarm_sdk/tools/delegate.rb +127 -24
  62. data/lib/swarm_sdk/tools/edit.rb +8 -13
  63. data/lib/swarm_sdk/tools/glob.rb +9 -1
  64. data/lib/swarm_sdk/tools/grep.rb +7 -0
  65. data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
  66. data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
  67. data/lib/swarm_sdk/tools/read.rb +28 -18
  68. data/lib/swarm_sdk/tools/registry.rb +122 -10
  69. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  70. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +53 -5
  71. data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
  72. data/lib/swarm_sdk/tools/todo_write.rb +7 -0
  73. data/lib/swarm_sdk/tools/web_fetch.rb +3 -2
  74. data/lib/swarm_sdk/tools/write.rb +8 -13
  75. data/lib/swarm_sdk/utils.rb +18 -0
  76. data/lib/swarm_sdk/validation_result.rb +33 -0
  77. data/lib/swarm_sdk/version.rb +1 -1
  78. data/lib/swarm_sdk/{node → workflow}/agent_config.rb +34 -9
  79. data/lib/swarm_sdk/workflow/builder.rb +143 -0
  80. data/lib/swarm_sdk/workflow/executor.rb +497 -0
  81. data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +42 -21
  82. data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
  83. data/lib/swarm_sdk/workflow.rb +554 -0
  84. data/lib/swarm_sdk.rb +73 -11
  85. metadata +79 -16
  86. data/lib/swarm_sdk/mcp.rb +0 -16
  87. data/lib/swarm_sdk/node_orchestrator.rb +0 -591
  88. data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -582
@@ -1,17 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmSDK
4
+ # Configuration facade that delegates to Parser and Translator
5
+ #
6
+ # This class maintains the public API while internally delegating to:
7
+ # - Configuration::Parser - YAML parsing, validation, and normalization
8
+ # - Configuration::Translator - Translation to Swarm/Workflow DSL builders
9
+ #
10
+ # ## Public API (unchanged)
11
+ # - Configuration.load_file(path) - Load from file
12
+ # - Configuration.new(yaml_content, base_dir:) - Load from string
13
+ # - config.load_and_validate - Parse and validate
14
+ # - config.to_swarm(allow_filesystem_tools:) - Convert to Swarm/Workflow
15
+ # - config.agent_names - Get list of agent names
16
+ # - config.connections_for(agent_name) - Get delegation targets
17
+ #
18
+ # ## Architecture
19
+ # The facade pattern keeps backward compatibility while separating concerns:
20
+ # - Parser handles all YAML parsing and validation logic
21
+ # - Translator handles all DSL builder translation logic
22
+ # - Configuration delegates to both, exposing parsed data via attr_readers
4
23
  class Configuration
5
- ENV_VAR_WITH_DEFAULT_PATTERN = /\$\{([^:}]+)(:=([^}]*))?\}/
6
-
7
- attr_reader :swarm_name, :lead_agent, :agents, :all_agents_config, :swarm_hooks, :all_agents_hooks, :scratchpad_enabled
24
+ attr_reader :config_type,
25
+ :swarm_name,
26
+ :swarm_id,
27
+ :lead_agent,
28
+ :start_node,
29
+ :agents,
30
+ :all_agents_config,
31
+ :swarm_hooks,
32
+ :all_agents_hooks,
33
+ :scratchpad_enabled,
34
+ :nodes,
35
+ :external_swarms
8
36
 
9
37
  class << self
10
38
  # Load configuration from YAML file
11
39
  #
12
- # Convenience method that reads the file and uses the file's directory
13
- # as the base directory for resolving agent file paths.
14
- #
15
40
  # @param path [String, Pathname] Path to YAML configuration file
16
41
  # @return [Configuration] Validated configuration instance
17
42
  # @raise [ConfigurationError] If file not found or invalid
@@ -41,30 +66,24 @@ module SwarmSDK
41
66
 
42
67
  @yaml_content = yaml_content
43
68
  @base_dir = Pathname.new(base_dir).expand_path
44
- @agents = {}
45
- @all_agents_config = {} # Settings applied to all agents
46
- @swarm_hooks = {} # Swarm-level hooks (swarm_start, swarm_stop)
47
- @all_agents_hooks = {} # Hooks applied to all agents
69
+ @parser = nil
70
+ @translator = nil
48
71
  end
49
72
 
73
+ # Parse and validate YAML configuration
74
+ #
75
+ # Delegates to Parser for all parsing logic, then syncs parsed data
76
+ # to instance variables for backward compatibility.
77
+ #
78
+ # @return [self]
50
79
  def load_and_validate
51
- @config = YAML.safe_load(@yaml_content, permitted_classes: [Symbol], aliases: true)
80
+ @parser = Parser.new(@yaml_content, base_dir: @base_dir)
81
+ @parser.parse
52
82
 
53
- unless @config.is_a?(Hash)
54
- raise ConfigurationError, "Invalid YAML syntax: configuration must be a Hash"
55
- end
83
+ # Sync parsed data to instance variables for backward compatibility
84
+ sync_from_parser
56
85
 
57
- @config = Utils.symbolize_keys(@config)
58
- interpolate_env_vars!(@config)
59
- validate_version
60
- load_all_agents_config
61
- load_hooks_config
62
- validate_swarm
63
- load_agents
64
- detect_circular_dependencies
65
86
  self
66
- rescue Psych::SyntaxError => e
67
- raise ConfigurationError, "Invalid YAML syntax: #{e.message}"
68
87
  end
69
88
 
70
89
  def agent_names
@@ -72,249 +91,45 @@ module SwarmSDK
72
91
  end
73
92
 
74
93
  def connections_for(agent_name)
75
- @agents[agent_name]&.delegates_to || []
94
+ agent_config = @agents[agent_name]
95
+ return [] unless agent_config
96
+
97
+ delegates = agent_config[:delegates_to] || []
98
+ Array(delegates).map(&:to_sym)
76
99
  end
77
100
 
78
- # Convert configuration to Swarm instance using Ruby API
101
+ # Convert configuration to Swarm or Workflow using appropriate builder
79
102
  #
80
- # This method bridges YAML configuration to the Ruby API, making YAML
81
- # a thin convenience layer over the programmatic interface.
103
+ # Delegates to Translator for all DSL translation logic.
82
104
  #
83
- # @return [Swarm] Configured swarm instance
84
- def to_swarm
85
- swarm = Swarm.new(
86
- name: @swarm_name,
87
- global_concurrency: Swarm::DEFAULT_GLOBAL_CONCURRENCY,
88
- default_local_concurrency: Swarm::DEFAULT_LOCAL_CONCURRENCY,
89
- scratchpad_enabled: @scratchpad_enabled,
90
- )
91
-
92
- # Add all agents - pass definitions directly
93
- @agents.each do |_name, agent_def|
94
- swarm.add_agent(agent_def)
95
- end
96
-
97
- # Set lead agent
98
- swarm.lead = @lead_agent
105
+ # @param allow_filesystem_tools [Boolean, nil] Whether to allow filesystem tools (nil uses global setting)
106
+ # @return [Swarm, Workflow] Configured swarm or workflow
107
+ def to_swarm(allow_filesystem_tools: nil)
108
+ raise ConfigurationError, "Configuration not loaded. Call load_and_validate first." unless @parser
99
109
 
100
- swarm
110
+ @translator = Translator.new(@parser)
111
+ @translator.to_swarm(allow_filesystem_tools: allow_filesystem_tools)
101
112
  end
102
113
 
103
114
  private
104
115
 
105
- def interpolate_env_vars!(obj)
106
- case obj
107
- when String
108
- interpolate_env_string(obj)
109
- when Hash
110
- obj.transform_values! { |v| interpolate_env_vars!(v) }
111
- when Array
112
- obj.map! { |v| interpolate_env_vars!(v) }
113
- else
114
- obj
115
- end
116
- end
117
-
118
- def interpolate_env_string(str)
119
- str.gsub(ENV_VAR_WITH_DEFAULT_PATTERN) do |_match|
120
- env_var = Regexp.last_match(1)
121
- has_default = Regexp.last_match(2)
122
- default_value = Regexp.last_match(3)
123
-
124
- if ENV.key?(env_var)
125
- ENV[env_var]
126
- elsif has_default
127
- default_value || ""
128
- else
129
- raise ConfigurationError, "Environment variable '#{env_var}' is not set"
130
- end
131
- end
132
- end
133
-
134
- def validate_version
135
- version = @config[:version]
136
- raise ConfigurationError, "Missing 'version' field in configuration" unless version
137
- raise ConfigurationError, "SwarmSDK requires version: 2 configuration. Got version: #{version}" unless version == 2
138
- end
139
-
140
- def load_all_agents_config
141
- return unless @config[:swarm]
142
-
143
- @all_agents_config = @config[:swarm][:all_agents] || {}
144
-
145
- # Convert disable_default_tools array elements to symbols
146
- if @all_agents_config[:disable_default_tools].is_a?(Array)
147
- @all_agents_config[:disable_default_tools] = @all_agents_config[:disable_default_tools].map(&:to_sym)
148
- end
149
- end
150
-
151
- def load_hooks_config
152
- return unless @config[:swarm]
153
-
154
- # Load swarm-level hooks (only swarm_start, swarm_stop allowed)
155
- @swarm_hooks = Utils.symbolize_keys(@config[:swarm][:hooks] || {})
156
-
157
- # Load all_agents hooks (applied as swarm defaults)
158
- if @config[:swarm][:all_agents]
159
- @all_agents_hooks = Utils.symbolize_keys(@config[:swarm][:all_agents][:hooks] || {})
160
- end
161
- end
162
-
163
- def validate_swarm
164
- raise ConfigurationError, "Missing 'swarm' field in configuration" unless @config[:swarm]
165
-
166
- swarm = @config[:swarm]
167
- raise ConfigurationError, "Missing 'name' field in swarm configuration" unless swarm[:name]
168
- raise ConfigurationError, "Missing 'agents' field in swarm configuration" unless swarm[:agents]
169
- raise ConfigurationError, "Missing 'lead' field in swarm configuration" unless swarm[:lead]
170
- raise ConfigurationError, "No agents defined" if swarm[:agents].empty?
171
-
172
- @swarm_name = swarm[:name]
173
- @lead_agent = swarm[:lead].to_sym # Convert to symbol for consistency
174
- @scratchpad_enabled = swarm[:use_scratchpad].nil? ? true : swarm[:use_scratchpad] # Default: enabled
175
- end
176
-
177
- def load_agents
178
- swarm_agents = @config[:swarm][:agents]
179
-
180
- swarm_agents.each do |name, agent_config|
181
- # Support three formats:
182
- # 1. String: assistant: "agents/assistant.md" (file path)
183
- # 2. Hash with agent_file: assistant: { agent_file: "..." }
184
- # 3. Hash with inline definition: assistant: { description: "...", model: "..." }
185
-
186
- if agent_config.is_a?(String)
187
- # Format 1: Direct file path as string
188
- file_path = agent_config
189
- merged_config = merge_all_agents_config({})
190
- @agents[name] = load_agent_from_file(name, file_path, merged_config)
191
- else
192
- # Format 2 or 3: Hash configuration
193
- agent_config ||= {}
194
-
195
- # Merge all_agents_config into agent config
196
- # Agent-specific config overrides all_agents config
197
- merged_config = merge_all_agents_config(agent_config)
198
-
199
- @agents[name] = if agent_config[:agent_file]
200
- # Format 2: Hash with agent_file key
201
- load_agent_from_file(name, agent_config[:agent_file], merged_config)
202
- else
203
- # Format 3: Inline definition
204
- Agent::Definition.new(name, merged_config)
205
- end
206
- end
207
- end
208
-
209
- unless @agents.key?(@lead_agent)
210
- raise ConfigurationError, "Lead agent '#{@lead_agent}' not found in agents"
211
- end
212
- end
213
-
214
- # Merge all_agents config with agent-specific config
215
- # Agent config takes precedence over all_agents config
116
+ # Sync parsed data from Parser to instance variables
216
117
  #
217
- # Merge strategy:
218
- # - Arrays (tools, delegates_to): Concatenate
219
- # - Hashes (parameters, headers): Merge (agent values override)
220
- # - Scalars (model, provider, base_url, timeout, coding_agent): Agent overrides
221
- #
222
- # @param agent_config [Hash] Agent-specific configuration
223
- # @return [Hash] Merged configuration
224
- def merge_all_agents_config(agent_config)
225
- merged = @all_agents_config.dup
226
-
227
- # For arrays, concatenate
228
- # For hashes, merge (agent values override)
229
- # For scalars, agent value overrides
230
- agent_config.each do |key, value|
231
- case key
232
- when :tools
233
- # Concatenate tools: all_agents.tools + agent.tools
234
- merged[:tools] = Array(merged[:tools]) + Array(value)
235
- when :delegates_to
236
- # Concatenate delegates_to
237
- merged[:delegates_to] = Array(merged[:delegates_to]) + Array(value)
238
- when :parameters
239
- # Merge parameters: all_agents.parameters + agent.parameters
240
- # Agent values override all_agents values for same keys
241
- merged[:parameters] = (merged[:parameters] || {}).merge(value || {})
242
- when :headers
243
- # Merge headers: all_agents.headers + agent.headers
244
- # Agent values override all_agents values for same keys
245
- merged[:headers] = (merged[:headers] || {}).merge(value || {})
246
- when :disable_default_tools
247
- # Convert array elements to symbols if it's an array
248
- merged[key] = value.is_a?(Array) ? value.map(&:to_sym) : value
249
- else
250
- # For everything else (model, provider, base_url, timeout, coding_agent, etc.),
251
- # agent value overrides all_agents value
252
- merged[key] = value
253
- end
254
- end
255
-
256
- # Pass all_agents permissions as default_permissions for backward compat with AgentDefinition
257
- if @all_agents_config[:permissions]
258
- merged[:default_permissions] = @all_agents_config[:permissions]
259
- end
260
-
261
- merged
262
- end
263
-
264
- def load_agent_from_file(name, file_path, merged_config)
265
- agent_file_path = resolve_agent_file_path(file_path)
266
-
267
- unless File.exist?(agent_file_path)
268
- raise ConfigurationError, "Agent file not found: #{agent_file_path}"
269
- end
270
-
271
- content = File.read(agent_file_path)
272
- # Parse markdown and merge with YAML config
273
- agent_def_from_file = MarkdownParser.parse(content, name)
274
-
275
- # Merge: YAML config overrides markdown file (YAML takes precedence)
276
- # This allows YAML to override any settings from the markdown file
277
- final_config = agent_def_from_file.to_h.compact.merge(merged_config.compact)
278
-
279
- Agent::Definition.new(name, final_config)
280
- rescue StandardError => e
281
- raise ConfigurationError, "Error loading agent '#{name}' from file '#{file_path}': #{e.message}"
282
- end
283
-
284
- def resolve_agent_file_path(file_path)
285
- return file_path if Pathname.new(file_path).absolute?
286
-
287
- @base_dir.join(file_path).to_s
288
- end
289
-
290
- def detect_circular_dependencies
291
- @agents.each_key do |agent_name|
292
- visited = Set.new
293
- path = []
294
- detect_cycle_from(agent_name, visited, path)
295
- end
296
- end
297
-
298
- def detect_cycle_from(agent_name, visited, path)
299
- return if visited.include?(agent_name)
300
-
301
- if path.include?(agent_name)
302
- cycle_start = path.index(agent_name)
303
- cycle = path[cycle_start..] + [agent_name]
304
- raise CircularDependencyError, "Circular dependency detected: #{cycle.join(" -> ")}"
305
- end
306
-
307
- path.push(agent_name)
308
- connections_for(agent_name).each do |connection|
309
- connection_sym = connection.to_sym # Convert to symbol for lookup
310
- unless @agents.key?(connection_sym)
311
- raise ConfigurationError, "Agent '#{agent_name}' has connection to unknown agent '#{connection}'"
312
- end
313
-
314
- detect_cycle_from(connection_sym, visited, path)
315
- end
316
- path.pop
317
- visited.add(agent_name)
118
+ # This maintains backward compatibility with code that accesses
119
+ # @config_type, @agents, etc. directly via attr_readers.
120
+ def sync_from_parser
121
+ @config_type = @parser.config_type
122
+ @swarm_name = @parser.swarm_name
123
+ @swarm_id = @parser.swarm_id
124
+ @lead_agent = @parser.lead_agent
125
+ @start_node = @parser.start_node
126
+ @agents = @parser.agents
127
+ @all_agents_config = @parser.all_agents_config
128
+ @swarm_hooks = @parser.swarm_hooks
129
+ @all_agents_hooks = @parser.all_agents_hooks
130
+ @external_swarms = @parser.external_swarms
131
+ @nodes = @parser.nodes
132
+ @scratchpad_enabled = @parser.scratchpad_mode # NOTE: attr_reader says scratchpad_enabled
318
133
  end
319
134
  end
320
135
  end
@@ -17,9 +17,9 @@ module SwarmSDK
17
17
  # total_tokens = TokenCounter.estimate_messages(messages)
18
18
  #
19
19
  class TokenCounter
20
- # Average characters per token for different content types
21
- CHARS_PER_TOKEN_PROSE = 4.0
22
- CHARS_PER_TOKEN_CODE = 3.5
20
+ # Backward compatibility aliases - use Defaults module for new code
21
+ CHARS_PER_TOKEN_PROSE = Defaults::TokenEstimation::CHARS_PER_TOKEN_PROSE
22
+ CHARS_PER_TOKEN_CODE = Defaults::TokenEstimation::CHARS_PER_TOKEN_CODE
23
23
 
24
24
  class << self
25
25
  # Estimate tokens for a single message
@@ -58,7 +58,7 @@ module SwarmSDK
58
58
  # @return [ContextCompactor::Metrics] Compression metrics
59
59
  def compact
60
60
  start_time = Time.now
61
- original_messages = @chat.messages.dup
61
+ original_messages = @chat.messages
62
62
 
63
63
  # Emit compression_started event
64
64
  LogStream.emit(
@@ -308,7 +308,8 @@ module SwarmSDK
308
308
  response.content
309
309
  rescue StandardError => e
310
310
  # If summarization fails, create a simple fallback summary
311
- RubyLLM.logger.warn("ContextCompactor: Summarization failed: #{e.message}")
311
+ LogStream.emit_error(e, source: "context_compactor", context: "generate_summary", agent: @agent_name)
312
+ RubyLLM.logger.debug("ContextCompactor: Summarization failed: #{e.message}")
312
313
 
313
314
  <<~FALLBACK
314
315
  ## Summary
@@ -322,19 +323,13 @@ module SwarmSDK
322
323
 
323
324
  # Replace messages in the chat
324
325
  #
325
- # RubyLLM::Chat doesn't have a public API for replacing all messages,
326
- # so we need to work with the internal messages array.
326
+ # Delegates to the Chat's replace_messages method which provides
327
+ # a safe abstraction over the internal message array.
327
328
  #
328
329
  # @param new_messages [Array<RubyLLM::Message>] New message array
329
330
  # @return [void]
330
331
  def replace_messages(new_messages)
331
- # Clear existing messages
332
- @chat.messages.clear
333
-
334
- # Add new messages
335
- new_messages.each do |msg|
336
- @chat.messages << msg
337
- end
332
+ @chat.replace_messages(new_messages)
338
333
  end
339
334
  end
340
335
  end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module ContextManagement
5
+ # DSL for defining context management handlers
6
+ #
7
+ # This builder provides a clean, idiomatic way to register handlers for
8
+ # context warning thresholds. Handlers receive a rich context object
9
+ # with message manipulation methods.
10
+ #
11
+ # @example Basic usage
12
+ # context_management do
13
+ # on :warning_60 do |ctx|
14
+ # ctx.compress_tool_results(keep_recent: 10)
15
+ # end
16
+ #
17
+ # on :warning_80 do |ctx|
18
+ # ctx.prune_old_messages(keep_recent: 20)
19
+ # end
20
+ # end
21
+ #
22
+ # @example Progressive compression
23
+ # context_management do
24
+ # on :warning_60 do |ctx|
25
+ # ctx.compress_tool_results(keep_recent: 15, truncate_to: 500)
26
+ # end
27
+ #
28
+ # on :warning_80 do |ctx|
29
+ # ctx.prune_old_messages(keep_recent: 30)
30
+ # ctx.compress_tool_results(keep_recent: 5, truncate_to: 200)
31
+ # end
32
+ #
33
+ # on :warning_90 do |ctx|
34
+ # ctx.log_action("emergency_pruning", tokens_remaining: ctx.tokens_remaining)
35
+ # ctx.prune_old_messages(keep_recent: 15)
36
+ # end
37
+ # end
38
+ class Builder
39
+ # Map semantic event names to threshold percentages
40
+ EVENT_MAP = {
41
+ warning_60: 60,
42
+ warning_80: 80,
43
+ warning_90: 90,
44
+ }.freeze
45
+
46
+ def initialize
47
+ @handlers = {} # { threshold => block }
48
+ end
49
+
50
+ # Register a handler for a context warning threshold
51
+ #
52
+ # Handlers take full responsibility for managing context at their threshold.
53
+ # When a handler is registered for a threshold, automatic compression is disabled
54
+ # for that threshold.
55
+ #
56
+ # @param event [Symbol] Event name (:warning_60, :warning_80, :warning_90)
57
+ # @yield [ContextManagement::Context] Context with message manipulation methods
58
+ # @return [void]
59
+ #
60
+ # @raise [ArgumentError] If event is unknown or block is missing
61
+ #
62
+ # @example Compress tool results at 60%
63
+ # on :warning_60 do |ctx|
64
+ # ctx.compress_tool_results(keep_recent: 10)
65
+ # end
66
+ #
67
+ # @example Custom logic at 80%
68
+ # on :warning_80 do |ctx|
69
+ # if ctx.usage_percentage > 85
70
+ # ctx.prune_old_messages(keep_recent: 10)
71
+ # else
72
+ # ctx.summarize_old_exchanges(older_than: 20)
73
+ # end
74
+ # end
75
+ #
76
+ # @example Log and prune at 90%
77
+ # on :warning_90 do |ctx|
78
+ # ctx.log_action("critical_threshold", remaining: ctx.tokens_remaining)
79
+ # ctx.prune_old_messages(keep_recent: 10)
80
+ # end
81
+ def on(event, &block)
82
+ threshold = EVENT_MAP[event]
83
+ raise ArgumentError, "Unknown event: #{event}. Valid events: #{EVENT_MAP.keys.join(", ")}" unless threshold
84
+ raise ArgumentError, "Block required for #{event}" unless block
85
+
86
+ @handlers[threshold] = block
87
+ end
88
+
89
+ # Build hook definitions from handlers
90
+ #
91
+ # Creates Hooks::Definition objects that wrap user blocks to provide
92
+ # rich context objects instead of raw Hooks::Context. Each handler
93
+ # becomes a hook for the :context_warning event.
94
+ #
95
+ # @return [Array<Hooks::Definition>] Hook definitions for :context_warning event
96
+ def build
97
+ @handlers.map do |threshold, user_block|
98
+ # Create a hook that filters by threshold and wraps context
99
+ Hooks::Definition.new(
100
+ event: :context_warning,
101
+ matcher: nil, # No tool matching needed
102
+ priority: 0,
103
+ proc: create_threshold_matcher(threshold, user_block),
104
+ )
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ # Create a proc that matches threshold and wraps context
111
+ #
112
+ # @param target_threshold [Integer] Threshold to match (60, 80, 90)
113
+ # @param user_block [Proc] User's handler block
114
+ # @return [Proc] Hook proc
115
+ def create_threshold_matcher(target_threshold, user_block)
116
+ proc do |hooks_context|
117
+ # Only execute for matching threshold
118
+ current_threshold = hooks_context.metadata[:threshold]
119
+ next unless current_threshold == target_threshold
120
+
121
+ # Wrap in rich context object
122
+ rich_context = Context.new(hooks_context)
123
+ user_block.call(rich_context)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end