claude_swarm 1.0.9 → 1.0.11

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/{CHANGELOG.md → CHANGELOG.claude-swarm.md} +10 -0
  3. data/CLAUDE.md +346 -191
  4. data/decisions/2025-11-22-001-global-agent-registry.md +172 -0
  5. data/docs/v2/CHANGELOG.swarm_cli.md +20 -0
  6. data/docs/v2/CHANGELOG.swarm_memory.md +146 -1
  7. data/docs/v2/CHANGELOG.swarm_sdk.md +433 -10
  8. data/docs/v2/README.md +20 -5
  9. data/docs/v2/guides/complete-tutorial.md +95 -9
  10. data/docs/v2/guides/getting-started.md +10 -8
  11. data/docs/v2/guides/memory-adapters.md +41 -0
  12. data/docs/v2/guides/migrating-to-2.x.md +746 -0
  13. data/docs/v2/guides/plugins.md +52 -5
  14. data/docs/v2/guides/rails-integration.md +6 -0
  15. data/docs/v2/guides/snapshots.md +14 -14
  16. data/docs/v2/guides/swarm-memory.md +2 -13
  17. data/docs/v2/reference/architecture-flow.md +3 -3
  18. data/docs/v2/reference/cli.md +0 -1
  19. data/docs/v2/reference/configuration_reference.md +300 -0
  20. data/docs/v2/reference/event_payload_structures.md +27 -5
  21. data/docs/v2/reference/ruby-dsl.md +614 -18
  22. data/docs/v2/reference/swarm_memory_technical_details.md +7 -29
  23. data/docs/v2/reference/yaml.md +172 -54
  24. data/examples/snapshot_demo.rb +2 -2
  25. data/lib/claude_swarm/mcp_generator.rb +8 -21
  26. data/lib/claude_swarm/orchestrator.rb +8 -1
  27. data/lib/claude_swarm/version.rb +1 -1
  28. data/lib/swarm_cli/commands/run.rb +2 -2
  29. data/lib/swarm_cli/config_loader.rb +11 -11
  30. data/lib/swarm_cli/formatters/human_formatter.rb +0 -33
  31. data/lib/swarm_cli/interactive_repl.rb +2 -2
  32. data/lib/swarm_cli/ui/icons.rb +0 -23
  33. data/lib/swarm_cli/version.rb +1 -1
  34. data/lib/swarm_memory/adapters/filesystem_adapter.rb +11 -34
  35. data/lib/swarm_memory/core/semantic_index.rb +10 -2
  36. data/lib/swarm_memory/core/storage.rb +7 -2
  37. data/lib/swarm_memory/dsl/memory_config.rb +37 -0
  38. data/lib/swarm_memory/integration/sdk_plugin.rb +201 -28
  39. data/lib/swarm_memory/optimization/defragmenter.rb +1 -1
  40. data/lib/swarm_memory/prompts/memory_researcher.md.erb +0 -1
  41. data/lib/swarm_memory/tools/load_skill.rb +0 -1
  42. data/lib/swarm_memory/tools/memory_edit.rb +2 -1
  43. data/lib/swarm_memory/tools/memory_read.rb +1 -1
  44. data/lib/swarm_memory/version.rb +1 -1
  45. data/lib/swarm_memory.rb +8 -6
  46. data/lib/swarm_sdk/agent/builder.rb +58 -0
  47. data/lib/swarm_sdk/agent/chat.rb +527 -1061
  48. data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +13 -88
  49. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
  50. data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +108 -46
  51. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
  52. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +267 -0
  53. data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +3 -3
  54. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
  55. data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +11 -13
  56. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
  57. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +146 -0
  58. data/lib/swarm_sdk/agent/context.rb +1 -2
  59. data/lib/swarm_sdk/agent/definition.rb +66 -154
  60. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +4 -2
  61. data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
  62. data/lib/swarm_sdk/agent_registry.rb +146 -0
  63. data/lib/swarm_sdk/builders/base_builder.rb +488 -0
  64. data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
  65. data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
  66. data/lib/swarm_sdk/concerns/validatable.rb +55 -0
  67. data/lib/swarm_sdk/config.rb +302 -0
  68. data/lib/swarm_sdk/configuration/parser.rb +373 -0
  69. data/lib/swarm_sdk/configuration/translator.rb +255 -0
  70. data/lib/swarm_sdk/configuration.rb +77 -546
  71. data/lib/swarm_sdk/context_compactor/token_counter.rb +2 -6
  72. data/lib/swarm_sdk/context_compactor.rb +6 -11
  73. data/lib/swarm_sdk/context_management/builder.rb +128 -0
  74. data/lib/swarm_sdk/context_management/context.rb +328 -0
  75. data/lib/swarm_sdk/custom_tool_registry.rb +226 -0
  76. data/lib/swarm_sdk/defaults.rb +196 -0
  77. data/lib/swarm_sdk/events_to_messages.rb +18 -0
  78. data/lib/swarm_sdk/hooks/adapter.rb +3 -3
  79. data/lib/swarm_sdk/hooks/shell_executor.rb +4 -2
  80. data/lib/swarm_sdk/log_collector.rb +179 -29
  81. data/lib/swarm_sdk/log_stream.rb +29 -0
  82. data/lib/swarm_sdk/models.json +4333 -1
  83. data/lib/swarm_sdk/models.rb +43 -2
  84. data/lib/swarm_sdk/node_context.rb +1 -1
  85. data/lib/swarm_sdk/observer/builder.rb +81 -0
  86. data/lib/swarm_sdk/observer/config.rb +45 -0
  87. data/lib/swarm_sdk/observer/manager.rb +236 -0
  88. data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
  89. data/lib/swarm_sdk/plugin.rb +95 -5
  90. data/lib/swarm_sdk/result.rb +52 -0
  91. data/lib/swarm_sdk/snapshot.rb +6 -6
  92. data/lib/swarm_sdk/snapshot_from_events.rb +13 -2
  93. data/lib/swarm_sdk/state_restorer.rb +136 -151
  94. data/lib/swarm_sdk/state_snapshot.rb +65 -100
  95. data/lib/swarm_sdk/swarm/agent_initializer.rb +181 -137
  96. data/lib/swarm_sdk/swarm/builder.rb +44 -578
  97. data/lib/swarm_sdk/swarm/executor.rb +213 -0
  98. data/lib/swarm_sdk/swarm/hook_triggers.rb +151 -0
  99. data/lib/swarm_sdk/swarm/logging_callbacks.rb +341 -0
  100. data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
  101. data/lib/swarm_sdk/swarm/tool_configurator.rb +58 -140
  102. data/lib/swarm_sdk/swarm.rb +203 -683
  103. data/lib/swarm_sdk/tools/bash.rb +14 -8
  104. data/lib/swarm_sdk/tools/delegate.rb +61 -43
  105. data/lib/swarm_sdk/tools/edit.rb +8 -13
  106. data/lib/swarm_sdk/tools/glob.rb +12 -4
  107. data/lib/swarm_sdk/tools/grep.rb +7 -0
  108. data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
  109. data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
  110. data/lib/swarm_sdk/tools/read.rb +16 -18
  111. data/lib/swarm_sdk/tools/registry.rb +122 -10
  112. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +9 -5
  113. data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
  114. data/lib/swarm_sdk/tools/todo_write.rb +7 -0
  115. data/lib/swarm_sdk/tools/web_fetch.rb +20 -17
  116. data/lib/swarm_sdk/tools/write.rb +8 -13
  117. data/lib/swarm_sdk/version.rb +1 -1
  118. data/lib/swarm_sdk/{node → workflow}/agent_config.rb +1 -1
  119. data/lib/swarm_sdk/workflow/builder.rb +192 -0
  120. data/lib/swarm_sdk/workflow/executor.rb +497 -0
  121. data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +7 -5
  122. data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +5 -3
  123. data/lib/swarm_sdk/{node_orchestrator.rb → workflow.rb} +152 -456
  124. data/lib/swarm_sdk.rb +294 -108
  125. data/rubocop/cop/security/no_reflection_methods.rb +1 -1
  126. data/swarm_cli.gemspec +1 -1
  127. data/swarm_memory.gemspec +8 -3
  128. data/swarm_sdk.gemspec +6 -4
  129. data/team_full.yml +124 -320
  130. metadata +42 -14
  131. data/lib/swarm_memory/chat_extension.rb +0 -34
  132. data/lib/swarm_memory/tools/memory_multi_edit.rb +0 -281
  133. data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -589
  134. /data/lib/swarm_memory/{errors.rb → error.rb} +0 -0
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Agent
5
+ module ChatHelpers
6
+ # LLM configuration and provider setup
7
+ #
8
+ # Extracted from Chat to reduce class size and centralize RubyLLM setup logic.
9
+ module LlmConfiguration
10
+ private
11
+
12
+ # Create the internal RubyLLM::Chat instance
13
+ #
14
+ # @return [RubyLLM::Chat] Chat instance
15
+ def create_llm_chat(model_id:, provider_name:, base_url:, api_version:, timeout:, assume_model_exists:, max_concurrent_tools:)
16
+ chat_options = build_chat_options(max_concurrent_tools)
17
+
18
+ chat = instantiate_chat(
19
+ model_id: model_id,
20
+ provider_name: provider_name,
21
+ base_url: base_url,
22
+ timeout: timeout,
23
+ assume_model_exists: assume_model_exists,
24
+ chat_options: chat_options,
25
+ )
26
+
27
+ # Enable RubyLLM's native Responses API if configured
28
+ enable_responses_api(chat, api_version, base_url) if api_version == "v1/responses"
29
+
30
+ chat
31
+ end
32
+
33
+ # Build chat options hash
34
+ #
35
+ # @param max_concurrent_tools [Integer, nil] Max concurrent tool executions
36
+ # @return [Hash] Chat options
37
+ def build_chat_options(max_concurrent_tools)
38
+ return {} unless max_concurrent_tools
39
+
40
+ {
41
+ tool_concurrency: :async,
42
+ max_concurrency: max_concurrent_tools,
43
+ }
44
+ end
45
+
46
+ # Instantiate RubyLLM::Chat with appropriate configuration
47
+ #
48
+ # @return [RubyLLM::Chat] Chat instance
49
+ def instantiate_chat(model_id:, provider_name:, base_url:, timeout:, assume_model_exists:, chat_options:)
50
+ if base_url || timeout != SwarmSDK.config.agent_request_timeout
51
+ instantiate_with_custom_context(
52
+ model_id: model_id,
53
+ provider_name: provider_name,
54
+ base_url: base_url,
55
+ timeout: timeout,
56
+ assume_model_exists: assume_model_exists,
57
+ chat_options: chat_options,
58
+ )
59
+ elsif provider_name
60
+ instantiate_with_provider(
61
+ model_id: model_id,
62
+ provider_name: provider_name,
63
+ assume_model_exists: assume_model_exists,
64
+ chat_options: chat_options,
65
+ )
66
+ else
67
+ instantiate_default(
68
+ model_id: model_id,
69
+ assume_model_exists: assume_model_exists,
70
+ chat_options: chat_options,
71
+ )
72
+ end
73
+ end
74
+
75
+ # Instantiate chat with custom context (base_url/timeout overrides)
76
+ def instantiate_with_custom_context(model_id:, provider_name:, base_url:, timeout:, assume_model_exists:, chat_options:)
77
+ raise ArgumentError, "Provider must be specified when base_url is set" if base_url && !provider_name
78
+
79
+ context = build_custom_context(provider: provider_name, base_url: base_url, timeout: timeout)
80
+ assume_model_exists = base_url ? true : false if assume_model_exists.nil?
81
+
82
+ RubyLLM.chat(
83
+ model: model_id,
84
+ provider: provider_name,
85
+ assume_model_exists: assume_model_exists,
86
+ context: context,
87
+ **chat_options,
88
+ )
89
+ end
90
+
91
+ # Instantiate chat with explicit provider
92
+ def instantiate_with_provider(model_id:, provider_name:, assume_model_exists:, chat_options:)
93
+ assume_model_exists = false if assume_model_exists.nil?
94
+
95
+ RubyLLM.chat(
96
+ model: model_id,
97
+ provider: provider_name,
98
+ assume_model_exists: assume_model_exists,
99
+ **chat_options,
100
+ )
101
+ end
102
+
103
+ # Instantiate chat with default configuration
104
+ def instantiate_default(model_id:, assume_model_exists:, chat_options:)
105
+ assume_model_exists = false if assume_model_exists.nil?
106
+
107
+ RubyLLM.chat(
108
+ model: model_id,
109
+ assume_model_exists: assume_model_exists,
110
+ **chat_options,
111
+ )
112
+ end
113
+
114
+ # Build custom RubyLLM context for base_url/timeout overrides
115
+ #
116
+ # @return [RubyLLM::Context] Configured context
117
+ def build_custom_context(provider:, base_url:, timeout:)
118
+ RubyLLM.context do |config|
119
+ config.request_timeout = timeout
120
+
121
+ configure_provider_base_url(config, provider, base_url) if base_url
122
+ end
123
+ end
124
+
125
+ # Configure provider-specific base URL
126
+ #
127
+ # @param config [RubyLLM::Config] RubyLLM configuration context
128
+ # @param provider [String] Provider name
129
+ # @param base_url [String] Custom base URL
130
+ # @raise [ConfigurationError] If API key is required but not configured
131
+ # @raise [ArgumentError] If provider doesn't support custom base_url
132
+ def configure_provider_base_url(config, provider, base_url)
133
+ case provider.to_s
134
+ when "openai", "deepseek", "perplexity", "mistral", "openrouter"
135
+ config.openai_api_base = base_url
136
+ api_key = SwarmSDK.config.openai_api_key
137
+
138
+ # For local endpoints, API key is optional
139
+ # For cloud endpoints, require API key
140
+ unless api_key || local_endpoint?(base_url)
141
+ raise ConfigurationError,
142
+ "OpenAI API key required for '#{provider}' with base_url '#{base_url}'. " \
143
+ "Configure with: SwarmSDK.configure { |c| c.openai_api_key = '...' }"
144
+ end
145
+
146
+ config.openai_api_key = api_key if api_key
147
+ config.openai_use_system_role = true
148
+ when "ollama"
149
+ config.ollama_api_base = base_url
150
+ # Ollama doesn't need an API key
151
+ when "gpustack"
152
+ config.gpustack_api_base = base_url
153
+ api_key = SwarmSDK.config.gpustack_api_key
154
+ config.gpustack_api_key = api_key if api_key
155
+ else
156
+ raise ArgumentError, "Provider '#{provider}' doesn't support custom base_url."
157
+ end
158
+ end
159
+
160
+ # Check if a URL points to a local endpoint
161
+ #
162
+ # @param url [String] URL to check
163
+ # @return [Boolean] true if URL is a local endpoint
164
+ def local_endpoint?(url)
165
+ uri = URI.parse(url)
166
+ ["localhost", "127.0.0.1", "0.0.0.0"].include?(uri.host)
167
+ rescue URI::InvalidURIError
168
+ false
169
+ end
170
+
171
+ # Fetch real model info for accurate context tracking
172
+ #
173
+ # Uses SwarmSDK::Models for model lookup (reads from models.json).
174
+ # Falls back to RubyLLM.models if not found in SwarmSDK.
175
+ #
176
+ # @param model_id [String] Model ID to lookup
177
+ def fetch_real_model_info(model_id)
178
+ @model_lookup_error = nil
179
+ @real_model_info = begin
180
+ # Try SwarmSDK::Models first (reads from local models.json)
181
+ # Returns ModelInfo object with method access (context_window, etc.)
182
+ SwarmSDK::Models.find(model_id) || RubyLLM.models.find(model_id)
183
+ rescue StandardError => e
184
+ suggestions = suggest_similar_models(model_id)
185
+ @model_lookup_error = {
186
+ model: model_id,
187
+ error_message: e.message,
188
+ suggestions: suggestions,
189
+ }
190
+ nil
191
+ end
192
+ end
193
+
194
+ # Enable RubyLLM's native Responses API on the chat instance
195
+ #
196
+ # Uses RubyLLM's built-in support for OpenAI's Responses API (v1/responses endpoint)
197
+ # which provides automatic stateful conversation tracking with 5-minute TTL.
198
+ #
199
+ # @param chat [RubyLLM::Chat] Chat instance to configure
200
+ # @param api_version [String] API version (should be "v1/responses")
201
+ # @param base_url [String, nil] Custom endpoint URL if any
202
+ def enable_responses_api(chat, api_version, base_url)
203
+ return unless api_version == "v1/responses"
204
+
205
+ # Warn if using custom endpoint (typically doesn't support Responses API)
206
+ if base_url && !base_url.include?("api.openai.com")
207
+ RubyLLM.logger.warn(
208
+ "SwarmSDK: Responses API requested but using custom endpoint #{base_url}. " \
209
+ "Custom endpoints typically don't support /v1/responses.",
210
+ )
211
+ end
212
+
213
+ # Enable native RubyLLM Responses API support
214
+ # - stateful: true enables automatic previous_response_id tracking
215
+ # - store: true enables server-side conversation storage
216
+ chat.with_responses_api(stateful: true, store: true)
217
+ RubyLLM.logger.debug("SwarmSDK: Enabled native Responses API support")
218
+ end
219
+
220
+ # Configure LLM parameters with proper temperature normalization
221
+ #
222
+ # @param params [Hash] Parameter hash
223
+ # @return [self]
224
+ def configure_parameters(params)
225
+ return self if params.nil? || params.empty?
226
+
227
+ if params[:temperature]
228
+ @llm_chat.with_temperature(params[:temperature])
229
+ params = params.except(:temperature)
230
+ end
231
+
232
+ @llm_chat.with_params(**params) if params.any?
233
+
234
+ self
235
+ end
236
+
237
+ # Configure custom HTTP headers for LLM requests
238
+ #
239
+ # @param headers [Hash, nil] Custom HTTP headers
240
+ # @return [self]
241
+ def configure_headers(custom_headers)
242
+ return self if custom_headers.nil? || custom_headers.empty?
243
+
244
+ @llm_chat.with_headers(**custom_headers)
245
+
246
+ self
247
+ end
248
+
249
+ # Suggest similar models when a model is not found
250
+ #
251
+ # @param query [String] Model name to search for
252
+ # @return [Array<RubyLLM::Model::Info>] Up to 3 similar models
253
+ def suggest_similar_models(query)
254
+ normalized_query = query.to_s.downcase.gsub(/[.\-_]/, "")
255
+
256
+ RubyLLM.models.all.select do |model_info|
257
+ normalized_id = model_info.id.downcase.gsub(/[.\-_]/, "")
258
+ normalized_id.include?(normalized_query) ||
259
+ model_info.name&.downcase&.gsub(/[.\-_]/, "")&.include?(normalized_query)
260
+ end.first(3)
261
+ rescue StandardError
262
+ []
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module SwarmSDK
4
4
  module Agent
5
- class Chat < RubyLLM::Chat
5
+ module ChatHelpers
6
6
  # Helper methods for logging and serialization of tool calls and results
7
7
  #
8
8
  # Responsibilities:
@@ -74,8 +74,8 @@ module SwarmSDK
74
74
  model_info = SwarmSDK::Models.find(message.model_id)
75
75
  return zero_cost unless model_info
76
76
 
77
- # Extract pricing from SwarmSDK's models.json structure
78
- pricing = model_info["pricing"] || model_info[:pricing]
77
+ # Extract pricing from SwarmSDK's ModelInfo (method access for top-level, Hash for nested)
78
+ pricing = model_info.pricing
79
79
  return zero_cost unless pricing
80
80
 
81
81
  text_pricing = pricing["text_tokens"] || pricing[:text_tokens]
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Agent
5
+ module ChatHelpers
6
+ # Message serialization and deserialization for snapshots
7
+ #
8
+ # Extracted from Chat to reduce class size and centralize persistence logic.
9
+ module Serialization
10
+ # Create snapshot of current conversation state
11
+ #
12
+ # @return [Hash] Serialized conversation data
13
+ def conversation_snapshot
14
+ {
15
+ messages: @llm_chat.messages.map { |msg| serialize_message(msg) },
16
+ model_id: model_id,
17
+ provider: model_provider,
18
+ timestamp: Time.now.utc.iso8601,
19
+ }
20
+ end
21
+
22
+ # Restore conversation from snapshot
23
+ #
24
+ # @param snapshot [Hash] Serialized conversation data
25
+ # @return [self]
26
+ def restore_conversation(snapshot)
27
+ raise ArgumentError, "Invalid snapshot: missing messages" unless snapshot[:messages]
28
+
29
+ @llm_chat.messages.clear
30
+ snapshot[:messages].each do |msg_data|
31
+ @llm_chat.messages << deserialize_message(msg_data)
32
+ end
33
+
34
+ self
35
+ end
36
+
37
+ private
38
+
39
+ # Serialize a RubyLLM::Message to a plain hash
40
+ #
41
+ # @param message [RubyLLM::Message] Message to serialize
42
+ # @return [Hash] Serialized message data
43
+ def serialize_message(message)
44
+ data = message.to_h
45
+
46
+ # Convert tool_calls to plain hashes (they're ToolCall objects)
47
+ if data[:tool_calls]
48
+ data[:tool_calls] = data[:tool_calls].transform_values(&:to_h)
49
+ end
50
+
51
+ # Handle Content objects
52
+ if data[:content].respond_to?(:to_h)
53
+ data[:content] = data[:content].to_h
54
+ end
55
+
56
+ data
57
+ end
58
+
59
+ # Deserialize a hash back to a RubyLLM::Message
60
+ #
61
+ # @param data [Hash] Serialized message data
62
+ # @return [RubyLLM::Message] Reconstructed message
63
+ def deserialize_message(data)
64
+ data = data.transform_keys(&:to_sym)
65
+
66
+ # Convert tool_calls back to ToolCall objects
67
+ if data[:tool_calls]
68
+ data[:tool_calls] = data[:tool_calls].transform_values do |tc_data|
69
+ tc_data = tc_data.transform_keys(&:to_sym)
70
+ RubyLLM::ToolCall.new(
71
+ id: tc_data[:id],
72
+ name: tc_data[:name],
73
+ arguments: tc_data[:arguments] || {},
74
+ )
75
+ end
76
+ end
77
+
78
+ RubyLLM::Message.new(**data)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module SwarmSDK
4
4
  module Agent
5
- class Chat < RubyLLM::Chat
5
+ module ChatHelpers
6
6
  # Handles injection of system reminders at strategic points in the conversation
7
7
  #
8
8
  # Responsibilities:
@@ -22,16 +22,13 @@ module SwarmSDK
22
22
  <system-reminder>The TodoWrite tool hasn't been used recently. If you're working on tasks that would benefit from tracking progress, consider using the TodoWrite tool to track progress. Also consider cleaning up the todo list if has become stale and no longer matches what you are working on. Only use it if it's relevant to the current work. This is just a gentle reminder - ignore if not applicable.</system-reminder>
23
23
  REMINDER
24
24
 
25
- # Number of messages between TodoWrite reminders
26
- TODOWRITE_REMINDER_INTERVAL = 8
27
-
28
25
  class << self
29
26
  # Check if this is the first user message in the conversation
30
27
  #
31
28
  # @param chat [Agent::Chat] The chat instance
32
29
  # @return [Boolean] true if no user messages exist yet
33
30
  def first_message?(chat)
34
- chat.messages.none? { |msg| msg.role == :user }
31
+ !chat.has_user_message?
35
32
  end
36
33
 
37
34
  # Inject first message reminders
@@ -55,7 +52,7 @@ module SwarmSDK
55
52
  ]
56
53
 
57
54
  # Only include todo list reminder if agent has TodoWrite tool
58
- parts << AFTER_FIRST_MESSAGE_REMINDER if chat.tools.key?("TodoWrite")
55
+ parts << AFTER_FIRST_MESSAGE_REMINDER if chat.has_tool?("TodoWrite")
59
56
 
60
57
  full_content = parts.join("\n\n")
61
58
 
@@ -68,7 +65,7 @@ module SwarmSDK
68
65
 
69
66
  # Track reminders to embed in this message when sending to LLM
70
67
  reminders.each do |reminder|
71
- chat.context_manager.add_ephemeral_reminder(reminder, messages_array: chat.messages)
68
+ chat.add_ephemeral_reminder(reminder)
72
69
  end
73
70
  end
74
71
 
@@ -77,7 +74,7 @@ module SwarmSDK
77
74
  # @param chat [Agent::Chat] The chat instance
78
75
  # @return [String] System reminder with tool list
79
76
  def build_toolset_reminder(chat)
80
- tools_list = chat.tools.values.map(&:name).sort
77
+ tools_list = chat.tool_names
81
78
 
82
79
  reminder = "<system-reminder>\n"
83
80
  reminder += "Tools available: #{tools_list.join(", ")}\n\n"
@@ -98,23 +95,24 @@ module SwarmSDK
98
95
  # @return [Boolean] true if reminder should be injected
99
96
  def should_inject_todowrite_reminder?(chat, last_todowrite_index)
100
97
  # Need at least a few messages before reminding
101
- return false if chat.messages.count < 5
98
+ return false if chat.message_count < 5
102
99
 
103
100
  # Find the last message that contains TodoWrite tool usage
104
- last_todo_index = chat.messages.rindex do |msg|
101
+ last_todo_index = chat.find_last_message_index do |msg|
105
102
  msg.role == :tool && msg.content.to_s.include?("TodoWrite")
106
103
  end
107
104
 
108
105
  # Check if enough messages have passed since last TodoWrite
106
+ reminder_interval = SwarmSDK.config.todowrite_reminder_interval
109
107
  if last_todo_index.nil? && last_todowrite_index.nil?
110
108
  # Never used TodoWrite - check if we've exceeded interval
111
- chat.messages.count >= TODOWRITE_REMINDER_INTERVAL
109
+ chat.message_count >= reminder_interval
112
110
  elsif last_todo_index
113
111
  # Recently used - don't remind
114
112
  false
115
113
  elsif last_todowrite_index
116
114
  # Used before - check if interval has passed
117
- chat.messages.count - last_todowrite_index >= TODOWRITE_REMINDER_INTERVAL
115
+ chat.message_count - last_todowrite_index >= reminder_interval
118
116
  else
119
117
  false
120
118
  end
@@ -125,7 +123,7 @@ module SwarmSDK
125
123
  # @param chat [Agent::Chat] The chat instance
126
124
  # @return [Integer, nil] Index of last TodoWrite usage, or nil
127
125
  def find_last_todowrite_index(chat)
128
- chat.messages.rindex do |msg|
126
+ chat.find_last_message_index do |msg|
129
127
  msg.role == :tool && msg.content.to_s.include?("TodoWrite")
130
128
  end
131
129
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Agent
5
+ module ChatHelpers
6
+ # System reminder collection and injection
7
+ #
8
+ # Extracted from Chat to reduce class size and centralize reminder logic.
9
+ module SystemReminders
10
+ # Collect reminders from all plugins
11
+ #
12
+ # @param prompt [String] User's message
13
+ # @param is_first_message [Boolean] True if first message
14
+ # @return [Array<String>] Array of reminder strings
15
+ def collect_plugin_reminders(prompt, is_first_message:)
16
+ return [] unless @agent_name
17
+
18
+ PluginRegistry.all.flat_map do |plugin|
19
+ plugin.on_user_message(
20
+ agent_name: @agent_name,
21
+ prompt: prompt,
22
+ is_first_message: is_first_message,
23
+ )
24
+ end.compact
25
+ end
26
+
27
+ # Collect all system reminders for this message
28
+ #
29
+ # Returns an array of reminder strings that should be injected as ephemeral content.
30
+ # These are sent to the LLM but not stored in message history.
31
+ #
32
+ # @param prompt [String] User prompt
33
+ # @param is_first [Boolean] Whether this is the first message
34
+ # @return [Array<String>] Array of reminder strings
35
+ def collect_system_reminders(prompt, is_first)
36
+ reminders = []
37
+
38
+ if is_first
39
+ # Add toolset reminder on first message
40
+ reminders << build_toolset_reminder
41
+
42
+ # Add todo list reminder if agent has TodoWrite tool
43
+ reminders << SystemReminderInjector::AFTER_FIRST_MESSAGE_REMINDER if has_tool?(:TodoWrite)
44
+
45
+ # Collect plugin reminders
46
+ reminders.concat(collect_plugin_reminders(prompt, is_first_message: true))
47
+ else
48
+ # Add periodic TodoWrite reminder if needed
49
+ if has_tool?(:TodoWrite) && SystemReminderInjector.should_inject_todowrite_reminder?(self, @last_todowrite_message_index)
50
+ reminders << SystemReminderInjector::TODOWRITE_PERIODIC_REMINDER
51
+ @last_todowrite_message_index = SystemReminderInjector.find_last_todowrite_index(self)
52
+ end
53
+
54
+ # Collect plugin reminders
55
+ reminders.concat(collect_plugin_reminders(prompt, is_first_message: false))
56
+ end
57
+
58
+ reminders
59
+ end
60
+
61
+ private
62
+
63
+ # Build toolset reminder listing all available tools
64
+ #
65
+ # @return [String] System reminder with tool list
66
+ def build_toolset_reminder
67
+ tools_list = tool_names
68
+
69
+ reminder = "<system-reminder>\n"
70
+ reminder += "Tools available: #{tools_list.join(", ")}\n\n"
71
+ reminder += "Only use tools from this list. Do not attempt to use tools that are not listed here.\n"
72
+ reminder += "</system-reminder>"
73
+
74
+ reminder
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,146 @@
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
+ # Calculate cumulative input cost based on tokens and model pricing
88
+ #
89
+ # @return [Float] Total input cost in dollars
90
+ def cumulative_input_cost
91
+ pricing = model_pricing
92
+ return 0.0 unless pricing
93
+
94
+ input_price = pricing["input_per_million"] || pricing[:input_per_million] || 0.0
95
+ (cumulative_input_tokens / 1_000_000.0) * input_price
96
+ end
97
+
98
+ # Calculate cumulative output cost based on tokens and model pricing
99
+ #
100
+ # @return [Float] Total output cost in dollars
101
+ def cumulative_output_cost
102
+ pricing = model_pricing
103
+ return 0.0 unless pricing
104
+
105
+ output_price = pricing["output_per_million"] || pricing[:output_per_million] || 0.0
106
+ (cumulative_output_tokens / 1_000_000.0) * output_price
107
+ end
108
+
109
+ # Calculate cumulative total cost (input + output)
110
+ #
111
+ # @return [Float] Total cost in dollars
112
+ def cumulative_total_cost
113
+ cumulative_input_cost + cumulative_output_cost
114
+ end
115
+
116
+ # Compact the conversation history to reduce token usage
117
+ #
118
+ # @param options [Hash] Compression options
119
+ # @return [ContextCompactor::Metrics] Compression statistics
120
+ def compact_context(**options)
121
+ compactor = ContextCompactor.new(self, options)
122
+ compactor.compact
123
+ end
124
+
125
+ private
126
+
127
+ # Get pricing info for the current model
128
+ #
129
+ # Extracts standard text token pricing from model info.
130
+ #
131
+ # @return [Hash, nil] Pricing hash with input_per_million and output_per_million
132
+ def model_pricing
133
+ return unless @real_model_info&.pricing
134
+
135
+ pricing = @real_model_info.pricing
136
+ text_pricing = pricing["text_tokens"] || pricing[:text_tokens]
137
+ return unless text_pricing
138
+
139
+ text_pricing["standard"] || text_pricing[:standard]
140
+ rescue StandardError
141
+ nil
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end