swarm_sdk 2.2.0 → 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.
- 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 +233 -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 +12 -12
- 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 +2 -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/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 +3 -3
- 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/shell_executor.rb +2 -1
- data/lib/swarm_sdk/log_collector.rb +179 -29
- data/lib/swarm_sdk/log_stream.rb +29 -0
- 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 +42 -138
- data/lib/swarm_sdk/swarm.rb +137 -679
- data/lib/swarm_sdk/tools/bash.rb +11 -3
- 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 +9 -1
- 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 +11 -13
- data/lib/swarm_sdk/tools/registry.rb +122 -10
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +8 -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 +3 -2
- 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} +3 -3
- data/lib/swarm_sdk/{node → workflow}/transformer_executor.rb +3 -2
- data/lib/swarm_sdk/{node_orchestrator.rb → workflow.rb} +152 -456
- data/lib/swarm_sdk.rb +33 -3
- metadata +67 -15
- data/lib/swarm_sdk/providers/openai_with_responses.rb +0 -589
|
@@ -0,0 +1,233 @@
|
|
|
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 != Defaults::Timeouts::AGENT_REQUEST_SECONDS
|
|
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
|
+
def configure_provider_base_url(config, provider, base_url)
|
|
127
|
+
case provider.to_s
|
|
128
|
+
when "openai", "deepseek", "perplexity", "mistral", "openrouter"
|
|
129
|
+
config.openai_api_base = base_url
|
|
130
|
+
config.openai_api_key = ENV["OPENAI_API_KEY"] || "dummy-key-for-local"
|
|
131
|
+
config.openai_use_system_role = true
|
|
132
|
+
when "ollama"
|
|
133
|
+
config.ollama_api_base = base_url
|
|
134
|
+
when "gpustack"
|
|
135
|
+
config.gpustack_api_base = base_url
|
|
136
|
+
config.gpustack_api_key = ENV["GPUSTACK_API_KEY"] || "dummy-key"
|
|
137
|
+
else
|
|
138
|
+
raise ArgumentError, "Provider '#{provider}' doesn't support custom base_url."
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Fetch real model info for accurate context tracking
|
|
143
|
+
#
|
|
144
|
+
# @param model_id [String] Model ID to lookup
|
|
145
|
+
def fetch_real_model_info(model_id)
|
|
146
|
+
@model_lookup_error = nil
|
|
147
|
+
@real_model_info = begin
|
|
148
|
+
RubyLLM.models.find(model_id)
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
suggestions = suggest_similar_models(model_id)
|
|
151
|
+
@model_lookup_error = {
|
|
152
|
+
model: model_id,
|
|
153
|
+
error_message: e.message,
|
|
154
|
+
suggestions: suggestions,
|
|
155
|
+
}
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Enable RubyLLM's native Responses API on the chat instance
|
|
161
|
+
#
|
|
162
|
+
# Uses RubyLLM's built-in support for OpenAI's Responses API (v1/responses endpoint)
|
|
163
|
+
# which provides automatic stateful conversation tracking with 5-minute TTL.
|
|
164
|
+
#
|
|
165
|
+
# @param chat [RubyLLM::Chat] Chat instance to configure
|
|
166
|
+
# @param api_version [String] API version (should be "v1/responses")
|
|
167
|
+
# @param base_url [String, nil] Custom endpoint URL if any
|
|
168
|
+
def enable_responses_api(chat, api_version, base_url)
|
|
169
|
+
return unless api_version == "v1/responses"
|
|
170
|
+
|
|
171
|
+
# Warn if using custom endpoint (typically doesn't support Responses API)
|
|
172
|
+
if base_url && !base_url.include?("api.openai.com")
|
|
173
|
+
RubyLLM.logger.warn(
|
|
174
|
+
"SwarmSDK: Responses API requested but using custom endpoint #{base_url}. " \
|
|
175
|
+
"Custom endpoints typically don't support /v1/responses.",
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Enable native RubyLLM Responses API support
|
|
180
|
+
# - stateful: true enables automatic previous_response_id tracking
|
|
181
|
+
# - store: true enables server-side conversation storage
|
|
182
|
+
chat.with_responses_api(stateful: true, store: true)
|
|
183
|
+
RubyLLM.logger.debug("SwarmSDK: Enabled native Responses API support")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Configure LLM parameters with proper temperature normalization
|
|
187
|
+
#
|
|
188
|
+
# @param params [Hash] Parameter hash
|
|
189
|
+
# @return [self]
|
|
190
|
+
def configure_parameters(params)
|
|
191
|
+
return self if params.nil? || params.empty?
|
|
192
|
+
|
|
193
|
+
if params[:temperature]
|
|
194
|
+
@llm_chat.with_temperature(params[:temperature])
|
|
195
|
+
params = params.except(:temperature)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
@llm_chat.with_params(**params) if params.any?
|
|
199
|
+
|
|
200
|
+
self
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Configure custom HTTP headers for LLM requests
|
|
204
|
+
#
|
|
205
|
+
# @param headers [Hash, nil] Custom HTTP headers
|
|
206
|
+
# @return [self]
|
|
207
|
+
def configure_headers(custom_headers)
|
|
208
|
+
return self if custom_headers.nil? || custom_headers.empty?
|
|
209
|
+
|
|
210
|
+
@llm_chat.with_headers(**custom_headers)
|
|
211
|
+
|
|
212
|
+
self
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Suggest similar models when a model is not found
|
|
216
|
+
#
|
|
217
|
+
# @param query [String] Model name to search for
|
|
218
|
+
# @return [Array<RubyLLM::Model::Info>] Up to 3 similar models
|
|
219
|
+
def suggest_similar_models(query)
|
|
220
|
+
normalized_query = query.to_s.downcase.gsub(/[.\-_]/, "")
|
|
221
|
+
|
|
222
|
+
RubyLLM.models.all.select do |model_info|
|
|
223
|
+
normalized_id = model_info.id.downcase.gsub(/[.\-_]/, "")
|
|
224
|
+
normalized_id.include?(normalized_query) ||
|
|
225
|
+
model_info.name&.downcase&.gsub(/[.\-_]/, "")&.include?(normalized_query)
|
|
226
|
+
end.first(3)
|
|
227
|
+
rescue StandardError
|
|
228
|
+
[]
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
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,8 +22,8 @@ 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
|
-
#
|
|
26
|
-
TODOWRITE_REMINDER_INTERVAL =
|
|
25
|
+
# Backward compatibility alias - use Defaults module for new code
|
|
26
|
+
TODOWRITE_REMINDER_INTERVAL = Defaults::Context::TODOWRITE_REMINDER_INTERVAL
|
|
27
27
|
|
|
28
28
|
class << self
|
|
29
29
|
# Check if this is the first user message in the conversation
|
|
@@ -31,7 +31,7 @@ module SwarmSDK
|
|
|
31
31
|
# @param chat [Agent::Chat] The chat instance
|
|
32
32
|
# @return [Boolean] true if no user messages exist yet
|
|
33
33
|
def first_message?(chat)
|
|
34
|
-
chat.
|
|
34
|
+
!chat.has_user_message?
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
# Inject first message reminders
|
|
@@ -55,7 +55,7 @@ module SwarmSDK
|
|
|
55
55
|
]
|
|
56
56
|
|
|
57
57
|
# Only include todo list reminder if agent has TodoWrite tool
|
|
58
|
-
parts << AFTER_FIRST_MESSAGE_REMINDER if chat.
|
|
58
|
+
parts << AFTER_FIRST_MESSAGE_REMINDER if chat.has_tool?("TodoWrite")
|
|
59
59
|
|
|
60
60
|
full_content = parts.join("\n\n")
|
|
61
61
|
|
|
@@ -68,7 +68,7 @@ module SwarmSDK
|
|
|
68
68
|
|
|
69
69
|
# Track reminders to embed in this message when sending to LLM
|
|
70
70
|
reminders.each do |reminder|
|
|
71
|
-
chat.
|
|
71
|
+
chat.add_ephemeral_reminder(reminder)
|
|
72
72
|
end
|
|
73
73
|
end
|
|
74
74
|
|
|
@@ -77,7 +77,7 @@ module SwarmSDK
|
|
|
77
77
|
# @param chat [Agent::Chat] The chat instance
|
|
78
78
|
# @return [String] System reminder with tool list
|
|
79
79
|
def build_toolset_reminder(chat)
|
|
80
|
-
tools_list = chat.
|
|
80
|
+
tools_list = chat.tool_names
|
|
81
81
|
|
|
82
82
|
reminder = "<system-reminder>\n"
|
|
83
83
|
reminder += "Tools available: #{tools_list.join(", ")}\n\n"
|
|
@@ -98,23 +98,23 @@ module SwarmSDK
|
|
|
98
98
|
# @return [Boolean] true if reminder should be injected
|
|
99
99
|
def should_inject_todowrite_reminder?(chat, last_todowrite_index)
|
|
100
100
|
# Need at least a few messages before reminding
|
|
101
|
-
return false if chat.
|
|
101
|
+
return false if chat.message_count < 5
|
|
102
102
|
|
|
103
103
|
# Find the last message that contains TodoWrite tool usage
|
|
104
|
-
last_todo_index = chat.
|
|
104
|
+
last_todo_index = chat.find_last_message_index do |msg|
|
|
105
105
|
msg.role == :tool && msg.content.to_s.include?("TodoWrite")
|
|
106
106
|
end
|
|
107
107
|
|
|
108
108
|
# Check if enough messages have passed since last TodoWrite
|
|
109
109
|
if last_todo_index.nil? && last_todowrite_index.nil?
|
|
110
110
|
# Never used TodoWrite - check if we've exceeded interval
|
|
111
|
-
chat.
|
|
111
|
+
chat.message_count >= TODOWRITE_REMINDER_INTERVAL
|
|
112
112
|
elsif last_todo_index
|
|
113
113
|
# Recently used - don't remind
|
|
114
114
|
false
|
|
115
115
|
elsif last_todowrite_index
|
|
116
116
|
# Used before - check if interval has passed
|
|
117
|
-
chat.
|
|
117
|
+
chat.message_count - last_todowrite_index >= TODOWRITE_REMINDER_INTERVAL
|
|
118
118
|
else
|
|
119
119
|
false
|
|
120
120
|
end
|
|
@@ -125,7 +125,7 @@ module SwarmSDK
|
|
|
125
125
|
# @param chat [Agent::Chat] The chat instance
|
|
126
126
|
# @return [Integer, nil] Index of last TodoWrite usage, or nil
|
|
127
127
|
def find_last_todowrite_index(chat)
|
|
128
|
-
chat.
|
|
128
|
+
chat.find_last_message_index do |msg|
|
|
129
129
|
msg.role == :tool && msg.content.to_s.include?("TodoWrite")
|
|
130
130
|
end
|
|
131
131
|
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,8 @@ 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 =
|
|
33
|
+
# Backward compatibility alias - use Defaults module for new code
|
|
34
|
+
COMPRESSION_THRESHOLD = Defaults::Context::COMPRESSION_THRESHOLD_PERCENT
|
|
35
35
|
|
|
36
36
|
attr_reader :name, :delegation_tools, :metadata, :warning_thresholds_hit, :swarm_id, :parent_swarm_id
|
|
37
37
|
|