swarm_sdk 2.2.0 → 2.4.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.
- checksums.yaml +4 -4
- data/lib/swarm_sdk/agent/builder.rb +58 -0
- data/lib/swarm_sdk/agent/chat.rb +527 -1059
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/context_tracker.rb +9 -88
- data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +204 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/hook_integration.rb +111 -44
- data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +78 -0
- data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +262 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/logging_helpers.rb +1 -1
- data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +83 -0
- data/lib/swarm_sdk/agent/{chat → chat_helpers}/system_reminder_injector.rb +11 -13
- data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +79 -0
- data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +98 -0
- data/lib/swarm_sdk/agent/context.rb +1 -2
- data/lib/swarm_sdk/agent/definition.rb +66 -154
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +4 -2
- data/lib/swarm_sdk/agent/system_prompt_builder.rb +161 -0
- data/lib/swarm_sdk/builders/base_builder.rb +409 -0
- data/lib/swarm_sdk/concerns/cleanupable.rb +39 -0
- data/lib/swarm_sdk/concerns/snapshotable.rb +67 -0
- data/lib/swarm_sdk/concerns/validatable.rb +55 -0
- data/lib/swarm_sdk/config.rb +301 -0
- data/lib/swarm_sdk/configuration/parser.rb +353 -0
- data/lib/swarm_sdk/configuration/translator.rb +255 -0
- data/lib/swarm_sdk/configuration.rb +65 -543
- data/lib/swarm_sdk/context_compactor/token_counter.rb +2 -6
- data/lib/swarm_sdk/context_compactor.rb +6 -11
- data/lib/swarm_sdk/context_management/builder.rb +128 -0
- data/lib/swarm_sdk/context_management/context.rb +328 -0
- data/lib/swarm_sdk/defaults.rb +196 -0
- data/lib/swarm_sdk/events_to_messages.rb +18 -0
- data/lib/swarm_sdk/hooks/adapter.rb +3 -3
- data/lib/swarm_sdk/hooks/shell_executor.rb +4 -2
- data/lib/swarm_sdk/log_collector.rb +179 -29
- data/lib/swarm_sdk/log_stream.rb +29 -0
- data/lib/swarm_sdk/models.json +4333 -1
- data/lib/swarm_sdk/node_context.rb +1 -1
- data/lib/swarm_sdk/observer/builder.rb +81 -0
- data/lib/swarm_sdk/observer/config.rb +45 -0
- data/lib/swarm_sdk/observer/manager.rb +236 -0
- data/lib/swarm_sdk/patterns/agent_observer.rb +160 -0
- data/lib/swarm_sdk/plugin.rb +93 -3
- data/lib/swarm_sdk/snapshot.rb +6 -6
- data/lib/swarm_sdk/snapshot_from_events.rb +13 -2
- data/lib/swarm_sdk/state_restorer.rb +136 -151
- data/lib/swarm_sdk/state_snapshot.rb +65 -100
- data/lib/swarm_sdk/swarm/agent_initializer.rb +180 -136
- data/lib/swarm_sdk/swarm/builder.rb +44 -578
- data/lib/swarm_sdk/swarm/executor.rb +213 -0
- data/lib/swarm_sdk/swarm/hook_triggers.rb +150 -0
- data/lib/swarm_sdk/swarm/logging_callbacks.rb +340 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +7 -4
- data/lib/swarm_sdk/swarm/tool_configurator.rb +44 -140
- data/lib/swarm_sdk/swarm.rb +146 -689
- data/lib/swarm_sdk/tools/bash.rb +14 -8
- data/lib/swarm_sdk/tools/delegate.rb +61 -43
- data/lib/swarm_sdk/tools/edit.rb +8 -13
- data/lib/swarm_sdk/tools/glob.rb +12 -4
- data/lib/swarm_sdk/tools/grep.rb +7 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +15 -11
- data/lib/swarm_sdk/tools/path_resolver.rb +51 -2
- data/lib/swarm_sdk/tools/read.rb +16 -18
- data/lib/swarm_sdk/tools/registry.rb +122 -10
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +9 -5
- data/lib/swarm_sdk/tools/stores/storage.rb +0 -6
- data/lib/swarm_sdk/tools/todo_write.rb +7 -0
- data/lib/swarm_sdk/tools/web_fetch.rb +20 -17
- data/lib/swarm_sdk/tools/write.rb +8 -13
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk/{node → workflow}/agent_config.rb +1 -1
- data/lib/swarm_sdk/workflow/builder.rb +143 -0
- data/lib/swarm_sdk/workflow/executor.rb +497 -0
- data/lib/swarm_sdk/{node/builder.rb → workflow/node_builder.rb} +7 -5
- data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +5 -3
- data/lib/swarm_sdk/{node_orchestrator.rb → workflow.rb} +152 -456
- data/lib/swarm_sdk.rb +64 -104
- metadata +68 -15
- data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -589
|
@@ -0,0 +1,262 @@
|
|
|
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
|
+
# @param model_id [String] Model ID to lookup
|
|
174
|
+
def fetch_real_model_info(model_id)
|
|
175
|
+
@model_lookup_error = nil
|
|
176
|
+
@real_model_info = begin
|
|
177
|
+
RubyLLM.models.find(model_id)
|
|
178
|
+
rescue StandardError => e
|
|
179
|
+
suggestions = suggest_similar_models(model_id)
|
|
180
|
+
@model_lookup_error = {
|
|
181
|
+
model: model_id,
|
|
182
|
+
error_message: e.message,
|
|
183
|
+
suggestions: suggestions,
|
|
184
|
+
}
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Enable RubyLLM's native Responses API on the chat instance
|
|
190
|
+
#
|
|
191
|
+
# Uses RubyLLM's built-in support for OpenAI's Responses API (v1/responses endpoint)
|
|
192
|
+
# which provides automatic stateful conversation tracking with 5-minute TTL.
|
|
193
|
+
#
|
|
194
|
+
# @param chat [RubyLLM::Chat] Chat instance to configure
|
|
195
|
+
# @param api_version [String] API version (should be "v1/responses")
|
|
196
|
+
# @param base_url [String, nil] Custom endpoint URL if any
|
|
197
|
+
def enable_responses_api(chat, api_version, base_url)
|
|
198
|
+
return unless api_version == "v1/responses"
|
|
199
|
+
|
|
200
|
+
# Warn if using custom endpoint (typically doesn't support Responses API)
|
|
201
|
+
if base_url && !base_url.include?("api.openai.com")
|
|
202
|
+
RubyLLM.logger.warn(
|
|
203
|
+
"SwarmSDK: Responses API requested but using custom endpoint #{base_url}. " \
|
|
204
|
+
"Custom endpoints typically don't support /v1/responses.",
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Enable native RubyLLM Responses API support
|
|
209
|
+
# - stateful: true enables automatic previous_response_id tracking
|
|
210
|
+
# - store: true enables server-side conversation storage
|
|
211
|
+
chat.with_responses_api(stateful: true, store: true)
|
|
212
|
+
RubyLLM.logger.debug("SwarmSDK: Enabled native Responses API support")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Configure LLM parameters with proper temperature normalization
|
|
216
|
+
#
|
|
217
|
+
# @param params [Hash] Parameter hash
|
|
218
|
+
# @return [self]
|
|
219
|
+
def configure_parameters(params)
|
|
220
|
+
return self if params.nil? || params.empty?
|
|
221
|
+
|
|
222
|
+
if params[:temperature]
|
|
223
|
+
@llm_chat.with_temperature(params[:temperature])
|
|
224
|
+
params = params.except(:temperature)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
@llm_chat.with_params(**params) if params.any?
|
|
228
|
+
|
|
229
|
+
self
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Configure custom HTTP headers for LLM requests
|
|
233
|
+
#
|
|
234
|
+
# @param headers [Hash, nil] Custom HTTP headers
|
|
235
|
+
# @return [self]
|
|
236
|
+
def configure_headers(custom_headers)
|
|
237
|
+
return self if custom_headers.nil? || custom_headers.empty?
|
|
238
|
+
|
|
239
|
+
@llm_chat.with_headers(**custom_headers)
|
|
240
|
+
|
|
241
|
+
self
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Suggest similar models when a model is not found
|
|
245
|
+
#
|
|
246
|
+
# @param query [String] Model name to search for
|
|
247
|
+
# @return [Array<RubyLLM::Model::Info>] Up to 3 similar models
|
|
248
|
+
def suggest_similar_models(query)
|
|
249
|
+
normalized_query = query.to_s.downcase.gsub(/[.\-_]/, "")
|
|
250
|
+
|
|
251
|
+
RubyLLM.models.all.select do |model_info|
|
|
252
|
+
normalized_id = model_info.id.downcase.gsub(/[.\-_]/, "")
|
|
253
|
+
normalized_id.include?(normalized_query) ||
|
|
254
|
+
model_info.name&.downcase&.gsub(/[.\-_]/, "")&.include?(normalized_query)
|
|
255
|
+
end.first(3)
|
|
256
|
+
rescue StandardError
|
|
257
|
+
[]
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,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,8 +30,7 @@ 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
|
-
#
|
|
34
|
-
COMPRESSION_THRESHOLD = 60
|
|
33
|
+
# NOTE: Compression threshold now accessed via SwarmSDK.config.context_compression_threshold
|
|
35
34
|
|
|
36
35
|
attr_reader :name, :delegation_tools, :metadata, :warning_thresholds_hit, :swarm_id, :parent_swarm_id
|
|
37
36
|
|