swarm_sdk 2.0.0.pre.2
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 +7 -0
- data/lib/swarm_sdk/agent/builder.rb +333 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +271 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
- data/lib/swarm_sdk/agent/chat/logging_helpers.rb +99 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +114 -0
- data/lib/swarm_sdk/agent/chat.rb +779 -0
- data/lib/swarm_sdk/agent/context.rb +108 -0
- data/lib/swarm_sdk/agent/definition.rb +335 -0
- data/lib/swarm_sdk/configuration.rb +251 -0
- data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
- data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
- data/lib/swarm_sdk/context_compactor.rb +340 -0
- data/lib/swarm_sdk/hooks/adapter.rb +359 -0
- data/lib/swarm_sdk/hooks/context.rb +163 -0
- data/lib/swarm_sdk/hooks/definition.rb +80 -0
- data/lib/swarm_sdk/hooks/error.rb +29 -0
- data/lib/swarm_sdk/hooks/executor.rb +146 -0
- data/lib/swarm_sdk/hooks/registry.rb +143 -0
- data/lib/swarm_sdk/hooks/result.rb +150 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
- data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
- data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
- data/lib/swarm_sdk/log_collector.rb +83 -0
- data/lib/swarm_sdk/log_stream.rb +69 -0
- data/lib/swarm_sdk/markdown_parser.rb +46 -0
- data/lib/swarm_sdk/permissions/config.rb +239 -0
- data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
- data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
- data/lib/swarm_sdk/permissions/validator.rb +173 -0
- data/lib/swarm_sdk/permissions_builder.rb +122 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +237 -0
- data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
- data/lib/swarm_sdk/result.rb +97 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +224 -0
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +62 -0
- data/lib/swarm_sdk/swarm/builder.rb +240 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +267 -0
- data/lib/swarm_sdk/swarm.rb +837 -0
- data/lib/swarm_sdk/tools/bash.rb +274 -0
- data/lib/swarm_sdk/tools/delegate.rb +152 -0
- data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
- data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
- data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
- data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
- data/lib/swarm_sdk/tools/edit.rb +150 -0
- data/lib/swarm_sdk/tools/glob.rb +158 -0
- data/lib/swarm_sdk/tools/grep.rb +231 -0
- data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
- data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
- data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
- data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
- data/lib/swarm_sdk/tools/read.rb +251 -0
- data/lib/swarm_sdk/tools/registry.rb +73 -0
- data/lib/swarm_sdk/tools/scratchpad_list.rb +88 -0
- data/lib/swarm_sdk/tools/scratchpad_read.rb +59 -0
- data/lib/swarm_sdk/tools/scratchpad_write.rb +88 -0
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
- data/lib/swarm_sdk/tools/stores/scratchpad.rb +153 -0
- data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
- data/lib/swarm_sdk/tools/todo_write.rb +216 -0
- data/lib/swarm_sdk/tools/write.rb +117 -0
- data/lib/swarm_sdk/utils.rb +50 -0
- data/lib/swarm_sdk/version.rb +5 -0
- data/lib/swarm_sdk.rb +69 -0
- metadata +169 -0
@@ -0,0 +1,372 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Agent
|
5
|
+
class Chat < RubyLLM::Chat
|
6
|
+
# Integrates SwarmSDK's hook system with Agent::Chat
|
7
|
+
#
|
8
|
+
# Responsibilities:
|
9
|
+
# - Setup hook system (registry, executor, agent hooks)
|
10
|
+
# - Provide trigger methods for all hook events
|
11
|
+
# - Wrap ask() to inject user_prompt hooks
|
12
|
+
# - Handle hook results (halt, replace, continue, reprompt)
|
13
|
+
#
|
14
|
+
# This module is included in Agent::Chat and provides methods for triggering hooks.
|
15
|
+
# It overrides ask() to inject user_prompt hooks, but does NOT override
|
16
|
+
# handle_tool_calls (that's handled in Agent::Chat with explicit hook calls).
|
17
|
+
module HookIntegration
|
18
|
+
# Expose hook system components for ContextTracker
|
19
|
+
attr_reader :hook_executor, :hook_swarm, :hook_agent_hooks
|
20
|
+
|
21
|
+
# Setup the hook system for this agent chat
|
22
|
+
#
|
23
|
+
# This must be called after setup_context and before the first ask/complete.
|
24
|
+
# It wires up the hook system to trigger at the right times.
|
25
|
+
#
|
26
|
+
# @param registry [Hooks::Registry] Shared registry for named hooks and swarm defaults
|
27
|
+
# @param agent_definition [Agent::Definition] Agent configuration with hooks
|
28
|
+
# @param swarm [Swarm, nil] Reference to swarm for context
|
29
|
+
# @return [void]
|
30
|
+
def setup_hooks(registry:, agent_definition:, swarm: nil)
|
31
|
+
@hook_registry = registry
|
32
|
+
@hook_swarm = swarm
|
33
|
+
@hook_executor = Hooks::Executor.new(registry, logger: RubyLLM.logger)
|
34
|
+
|
35
|
+
# Extract agent hooks based on format
|
36
|
+
hooks = agent_definition.hooks || {}
|
37
|
+
|
38
|
+
# Check if hooks are pre-parsed HookDefinition objects (from DSL)
|
39
|
+
# or raw YAML hash (to be processed by Hooks::Adapter in pass_5)
|
40
|
+
@hook_agent_hooks = if hooks.is_a?(Hash) && hooks.values.all? { |v| v.is_a?(Array) && v.all? { |item| item.is_a?(Hooks::Definition) } }
|
41
|
+
# DSL hooks - already parsed, use them
|
42
|
+
hooks
|
43
|
+
else
|
44
|
+
# YAML hooks - raw hash, will be processed in pass_5 by Hooks::Adapter
|
45
|
+
# For now, use empty hash (pass_5 will add them later)
|
46
|
+
{}
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Add a hook programmatically at runtime
|
51
|
+
#
|
52
|
+
# This allows agents to add hooks dynamically, which is useful for
|
53
|
+
# implementing adaptive behavior or runtime monitoring.
|
54
|
+
#
|
55
|
+
# @param event [Symbol] Event type (e.g., :pre_tool_use)
|
56
|
+
# @param matcher [String, Regexp, nil] Optional regex pattern for tool names
|
57
|
+
# @param priority [Integer] Execution priority (higher = earlier)
|
58
|
+
# @param block [Proc] Hook implementation
|
59
|
+
def add_hook(event, matcher: nil, priority: 0, &block)
|
60
|
+
raise ArgumentError, "Hooks not set up. Call setup_hooks first." unless @hook_executor
|
61
|
+
|
62
|
+
definition = Hooks::Definition.new(
|
63
|
+
event: event,
|
64
|
+
matcher: matcher,
|
65
|
+
priority: priority,
|
66
|
+
proc: block,
|
67
|
+
)
|
68
|
+
|
69
|
+
@hook_agent_hooks[event] ||= []
|
70
|
+
@hook_agent_hooks[event] << definition
|
71
|
+
@hook_agent_hooks[event].sort_by! { |cb| -cb.priority }
|
72
|
+
end
|
73
|
+
|
74
|
+
# Override ask to trigger user_prompt hooks
|
75
|
+
#
|
76
|
+
# This wraps the Agent::Chat#ask implementation to inject hooks AFTER
|
77
|
+
# system reminders are handled.
|
78
|
+
#
|
79
|
+
# @param prompt [String] User prompt
|
80
|
+
# @param options [Hash] Additional options
|
81
|
+
# @return [RubyLLM::Message] LLM response
|
82
|
+
def ask(prompt, **options)
|
83
|
+
# Trigger user_prompt hook before sending to LLM (can halt or modify prompt)
|
84
|
+
if @hook_executor
|
85
|
+
hook_result = trigger_user_prompt(prompt)
|
86
|
+
|
87
|
+
# Check if hook halted execution
|
88
|
+
if hook_result[:halted]
|
89
|
+
# Return a halted message instead of calling LLM
|
90
|
+
return RubyLLM::Message.new(
|
91
|
+
role: :assistant,
|
92
|
+
content: hook_result[:halt_message],
|
93
|
+
model_id: model.id,
|
94
|
+
)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Use modified prompt if hook provided one (stdout injection)
|
98
|
+
prompt = hook_result[:modified_prompt] if hook_result[:modified_prompt]
|
99
|
+
end
|
100
|
+
|
101
|
+
# Call original ask implementation (Agent::Chat handles system reminders)
|
102
|
+
super(prompt, **options)
|
103
|
+
end
|
104
|
+
|
105
|
+
# Override check_context_warnings to trigger our hook system
|
106
|
+
#
|
107
|
+
# This wraps the default context warning behavior to also trigger hooks.
|
108
|
+
def check_context_warnings
|
109
|
+
return unless respond_to?(:context_usage_percentage)
|
110
|
+
|
111
|
+
current_percentage = context_usage_percentage
|
112
|
+
|
113
|
+
Context::CONTEXT_WARNING_THRESHOLDS.each do |threshold|
|
114
|
+
# Only warn once per threshold
|
115
|
+
next if @agent_context.warning_threshold_hit?(threshold)
|
116
|
+
next if current_percentage < threshold
|
117
|
+
|
118
|
+
# Mark threshold as hit
|
119
|
+
@agent_context.hit_warning_threshold?(threshold)
|
120
|
+
|
121
|
+
# Emit existing log event (for backward compatibility)
|
122
|
+
LogStream.emit(
|
123
|
+
type: "context_limit_warning",
|
124
|
+
agent: @agent_context.name,
|
125
|
+
model: model.id,
|
126
|
+
threshold: "#{threshold}%",
|
127
|
+
current_usage: "#{current_percentage}%",
|
128
|
+
tokens_used: cumulative_total_tokens,
|
129
|
+
tokens_remaining: tokens_remaining,
|
130
|
+
context_limit: context_limit,
|
131
|
+
metadata: @agent_context.metadata,
|
132
|
+
)
|
133
|
+
|
134
|
+
# Trigger hook system
|
135
|
+
trigger_context_warning(threshold, current_percentage) if @hook_executor
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
# Trigger pre_tool_use hooks
|
140
|
+
#
|
141
|
+
# Should be called by Agent::Chat before tool execution.
|
142
|
+
# Returns a hash indicating whether to proceed and any custom result.
|
143
|
+
#
|
144
|
+
# @param tool_call [RubyLLM::ToolCall] Tool call from LLM
|
145
|
+
# @return [Hash] { proceed: true/false, custom_result: result (if any) }
|
146
|
+
def trigger_pre_tool_use(tool_call)
|
147
|
+
return { proceed: true } unless @hook_executor
|
148
|
+
|
149
|
+
context = build_hook_context(
|
150
|
+
event: :pre_tool_use,
|
151
|
+
tool_call: wrap_tool_call_to_hooks(tool_call),
|
152
|
+
)
|
153
|
+
|
154
|
+
agent_hooks = @hook_agent_hooks[:pre_tool_use] || []
|
155
|
+
|
156
|
+
result = @hook_executor.execute_safe(
|
157
|
+
event: :pre_tool_use,
|
158
|
+
context: context,
|
159
|
+
callbacks: agent_hooks,
|
160
|
+
)
|
161
|
+
|
162
|
+
# Return custom result if hook provides one
|
163
|
+
if result.replace?
|
164
|
+
{ proceed: false, custom_result: result.value }
|
165
|
+
elsif result.halt?
|
166
|
+
{ proceed: false, custom_result: result.value || "Tool execution blocked by hook" }
|
167
|
+
elsif result.finish_agent?
|
168
|
+
# Finish agent execution immediately with this message
|
169
|
+
{ proceed: false, finish_agent: true, custom_result: result.value }
|
170
|
+
elsif result.finish_swarm?
|
171
|
+
# Finish entire swarm execution immediately with this message
|
172
|
+
{ proceed: false, finish_swarm: true, custom_result: result.value }
|
173
|
+
else
|
174
|
+
{ proceed: true }
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
# Trigger post_tool_use hooks
|
179
|
+
#
|
180
|
+
# Should be called by Agent::Chat after tool execution.
|
181
|
+
# Returns modified result if hook replaces it, or a special marker for finish actions.
|
182
|
+
#
|
183
|
+
# @param result [String, Object] Tool execution result
|
184
|
+
# @param tool_call [RubyLLM::ToolCall] Tool call object with full context
|
185
|
+
# @return [Object, Hash] Modified result if hook replaces it, hash with :finish_agent or :finish_swarm if finishing, otherwise original result
|
186
|
+
def trigger_post_tool_use(result, tool_call:)
|
187
|
+
return result unless @hook_executor
|
188
|
+
|
189
|
+
context = build_hook_context(
|
190
|
+
event: :post_tool_use,
|
191
|
+
tool_result: wrap_tool_result(tool_call.id, tool_call.name, result),
|
192
|
+
)
|
193
|
+
|
194
|
+
agent_hooks = @hook_agent_hooks[:post_tool_use] || []
|
195
|
+
|
196
|
+
hook_result = @hook_executor.execute_safe(
|
197
|
+
event: :post_tool_use,
|
198
|
+
context: context,
|
199
|
+
callbacks: agent_hooks,
|
200
|
+
)
|
201
|
+
|
202
|
+
# Return modified result or finish markers
|
203
|
+
if hook_result.replace?
|
204
|
+
hook_result.value
|
205
|
+
elsif hook_result.finish_agent?
|
206
|
+
{ __finish_agent__: true, message: hook_result.value }
|
207
|
+
elsif hook_result.finish_swarm?
|
208
|
+
{ __finish_swarm__: true, message: hook_result.value }
|
209
|
+
else
|
210
|
+
result
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
private
|
215
|
+
|
216
|
+
# Trigger context_warning hooks
|
217
|
+
#
|
218
|
+
# Hooks have access to the chat instance via metadata[:chat]
|
219
|
+
# to access and manipulate the messages array.
|
220
|
+
#
|
221
|
+
# @param threshold [Integer] Warning threshold percentage
|
222
|
+
# @param current_usage [Float] Current usage percentage
|
223
|
+
# @return [void]
|
224
|
+
def trigger_context_warning(threshold, current_usage)
|
225
|
+
return unless @hook_executor
|
226
|
+
|
227
|
+
context = build_hook_context(
|
228
|
+
event: :context_warning,
|
229
|
+
metadata: {
|
230
|
+
chat: self, # Provide access to chat instance (for messages array)
|
231
|
+
threshold: threshold,
|
232
|
+
percentage: current_usage,
|
233
|
+
tokens_used: cumulative_total_tokens,
|
234
|
+
tokens_remaining: tokens_remaining,
|
235
|
+
context_limit: context_limit,
|
236
|
+
},
|
237
|
+
)
|
238
|
+
|
239
|
+
agent_hooks = @hook_agent_hooks[:context_warning] || []
|
240
|
+
|
241
|
+
@hook_executor.execute_safe(
|
242
|
+
event: :context_warning,
|
243
|
+
context: context,
|
244
|
+
callbacks: agent_hooks,
|
245
|
+
)
|
246
|
+
end
|
247
|
+
|
248
|
+
# Trigger user_prompt hooks
|
249
|
+
#
|
250
|
+
# This fires before sending a user message to the LLM.
|
251
|
+
# Can halt execution or append hook stdout to prompt.
|
252
|
+
#
|
253
|
+
# @param prompt [String] User's message/prompt
|
254
|
+
# @return [Hash] { halted: bool, halt_message: String, modified_prompt: String }
|
255
|
+
def trigger_user_prompt(prompt)
|
256
|
+
return { halted: false, modified_prompt: prompt } unless @hook_executor
|
257
|
+
|
258
|
+
# Filter out delegation tools from tools list
|
259
|
+
actual_tools = if respond_to?(:tools) && @agent_context
|
260
|
+
tools.keys.reject { |tool_name| @agent_context.delegation_tool?(tool_name.to_s) }
|
261
|
+
else
|
262
|
+
[]
|
263
|
+
end
|
264
|
+
|
265
|
+
# Extract agent names from delegation tool names
|
266
|
+
delegate_agents = if @agent_context&.delegation_tools
|
267
|
+
@agent_context.delegation_tools.map { |tool_name| @context_tracker.extract_delegate_agent_name(tool_name) }
|
268
|
+
else
|
269
|
+
[]
|
270
|
+
end
|
271
|
+
|
272
|
+
context = build_hook_context(
|
273
|
+
event: :user_prompt,
|
274
|
+
metadata: {
|
275
|
+
prompt: prompt,
|
276
|
+
message_count: messages.size,
|
277
|
+
model: model.id,
|
278
|
+
provider: model.provider,
|
279
|
+
tools: actual_tools,
|
280
|
+
delegates_to: delegate_agents,
|
281
|
+
timestamp: Time.now.utc.iso8601,
|
282
|
+
},
|
283
|
+
)
|
284
|
+
|
285
|
+
agent_hooks = @hook_agent_hooks[:user_prompt] || []
|
286
|
+
|
287
|
+
result = @hook_executor.execute_safe(
|
288
|
+
event: :user_prompt,
|
289
|
+
context: context,
|
290
|
+
callbacks: agent_hooks,
|
291
|
+
)
|
292
|
+
|
293
|
+
# Handle hook result
|
294
|
+
if result.halt?
|
295
|
+
# Hook blocked execution
|
296
|
+
{ halted: true, halt_message: result.value }
|
297
|
+
elsif result.replace?
|
298
|
+
# Hook provided stdout to append to prompt (exit code 0)
|
299
|
+
modified_prompt = "#{prompt}\n\n<hook-context>\n#{result.value}\n</hook-context>"
|
300
|
+
{ halted: false, modified_prompt: modified_prompt }
|
301
|
+
else
|
302
|
+
# Normal continue
|
303
|
+
{ halted: false, modified_prompt: prompt }
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
# Build a hook context object
|
308
|
+
#
|
309
|
+
# @param event [Symbol] Event type
|
310
|
+
# @param tool_call [Hooks::ToolCall, nil] Tool call object
|
311
|
+
# @param tool_result [Hooks::ToolResult, nil] Tool result object
|
312
|
+
# @param metadata [Hash] Additional metadata
|
313
|
+
# @return [Hooks::Context] Context object
|
314
|
+
def build_hook_context(event:, tool_call: nil, tool_result: nil, metadata: {})
|
315
|
+
Hooks::Context.new(
|
316
|
+
event: event,
|
317
|
+
agent_name: @agent_context&.name || "unknown",
|
318
|
+
agent_definition: nil, # Could store this in setup_hooks if needed
|
319
|
+
swarm: @hook_swarm,
|
320
|
+
tool_call: tool_call,
|
321
|
+
tool_result: tool_result,
|
322
|
+
metadata: metadata,
|
323
|
+
)
|
324
|
+
end
|
325
|
+
|
326
|
+
# Wrap a RubyLLM tool call in our Hooks::ToolCall value object
|
327
|
+
#
|
328
|
+
# @param tool_call [RubyLLM::ToolCall] RubyLLM tool call
|
329
|
+
# @return [Hooks::ToolCall] Our wrapped tool call
|
330
|
+
def wrap_tool_call_to_hooks(tool_call)
|
331
|
+
Hooks::ToolCall.new(
|
332
|
+
id: tool_call.id,
|
333
|
+
name: tool_call.name,
|
334
|
+
parameters: tool_call.arguments,
|
335
|
+
)
|
336
|
+
end
|
337
|
+
|
338
|
+
# Wrap a tool result in our Hooks::ToolResult value object
|
339
|
+
#
|
340
|
+
# @param tool_call_id [String] Tool call ID
|
341
|
+
# @param tool_name [String] Tool name
|
342
|
+
# @param result [Object] Tool execution result
|
343
|
+
# @return [Hooks::ToolResult] Our wrapped result
|
344
|
+
def wrap_tool_result(tool_call_id, tool_name, result)
|
345
|
+
success = !result.is_a?(StandardError)
|
346
|
+
error = result.is_a?(StandardError) ? result.message : nil
|
347
|
+
|
348
|
+
Hooks::ToolResult.new(
|
349
|
+
tool_call_id: tool_call_id,
|
350
|
+
tool_name: tool_name,
|
351
|
+
content: success ? result : nil,
|
352
|
+
success: success,
|
353
|
+
error: error,
|
354
|
+
)
|
355
|
+
end
|
356
|
+
|
357
|
+
# Check if a tool call is a delegation tool
|
358
|
+
#
|
359
|
+
# Delegation tools fire their own pre_delegation/post_delegation events
|
360
|
+
# and should NOT fire pre_tool_use/post_tool_use events.
|
361
|
+
#
|
362
|
+
# @param tool_call [RubyLLM::ToolCall] Tool call to check
|
363
|
+
# @return [Boolean] true if this is a delegation tool
|
364
|
+
def delegation_tool_call?(tool_call)
|
365
|
+
return false unless @agent_context
|
366
|
+
|
367
|
+
@agent_context.delegation_tool?(tool_call.name)
|
368
|
+
end
|
369
|
+
end
|
370
|
+
end
|
371
|
+
end
|
372
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Agent
|
5
|
+
class Chat < RubyLLM::Chat
|
6
|
+
# Helper methods for logging and serialization of tool calls and results
|
7
|
+
#
|
8
|
+
# Responsibilities:
|
9
|
+
# - Format tool calls for logging
|
10
|
+
# - Serialize tool results (handling different types)
|
11
|
+
# - Calculate LLM costs based on token usage
|
12
|
+
#
|
13
|
+
# These are stateless utility methods that operate on data structures.
|
14
|
+
module LoggingHelpers
|
15
|
+
# Format tool calls for logging
|
16
|
+
#
|
17
|
+
# @param tool_calls_hash [Hash] Tool calls from message
|
18
|
+
# @return [Array<Hash>, nil] Formatted tool calls
|
19
|
+
def format_tool_calls(tool_calls_hash)
|
20
|
+
return unless tool_calls_hash
|
21
|
+
|
22
|
+
tool_calls_hash.map do |_id, tc|
|
23
|
+
{
|
24
|
+
id: tc.id,
|
25
|
+
name: tc.name,
|
26
|
+
arguments: tc.arguments,
|
27
|
+
}
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Serialize a tool result for logging
|
32
|
+
#
|
33
|
+
# Handles multiple result types:
|
34
|
+
# - String: pass through
|
35
|
+
# - Hash/Array: pass through
|
36
|
+
# - RubyLLM::Content: extract text and attachment info
|
37
|
+
# - Other: convert to string
|
38
|
+
#
|
39
|
+
# @param result [String, Hash, Array, RubyLLM::Content, Object] Tool result
|
40
|
+
# @return [String, Hash, Array] Serialized result
|
41
|
+
def serialize_result(result)
|
42
|
+
case result
|
43
|
+
when String then result
|
44
|
+
when Hash, Array then result
|
45
|
+
when RubyLLM::Content
|
46
|
+
# Format Content objects to show text and attachment info
|
47
|
+
parts = []
|
48
|
+
parts << result.text if result.text && !result.text.empty?
|
49
|
+
|
50
|
+
if result.attachments.any?
|
51
|
+
attachment_info = result.attachments.map do |att|
|
52
|
+
"#{att.source} (#{att.mime_type})"
|
53
|
+
end.join(", ")
|
54
|
+
parts << "[Attachments: #{attachment_info}]"
|
55
|
+
end
|
56
|
+
|
57
|
+
parts.join(" ")
|
58
|
+
else
|
59
|
+
result.to_s
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# Calculate LLM cost for a message
|
64
|
+
#
|
65
|
+
# Uses RubyLLM's model registry to get pricing information.
|
66
|
+
# Returns zero cost if pricing is unavailable.
|
67
|
+
#
|
68
|
+
# @param message [RubyLLM::Message] Message with token counts
|
69
|
+
# @return [Hash] Cost breakdown { input_cost:, output_cost:, total_cost: }
|
70
|
+
def calculate_cost(message)
|
71
|
+
return zero_cost unless message.input_tokens && message.output_tokens
|
72
|
+
|
73
|
+
model_info = RubyLLM.models.find(message.model_id)
|
74
|
+
return zero_cost unless model_info
|
75
|
+
|
76
|
+
# Prices are per million tokens (USD)
|
77
|
+
input_cost = (message.input_tokens / 1_000_000.0) * model_info.input_price_per_million
|
78
|
+
output_cost = (message.output_tokens / 1_000_000.0) * model_info.output_price_per_million
|
79
|
+
|
80
|
+
{
|
81
|
+
input_cost: input_cost,
|
82
|
+
output_cost: output_cost,
|
83
|
+
total_cost: input_cost + output_cost,
|
84
|
+
}
|
85
|
+
rescue StandardError
|
86
|
+
# Model not found in registry or pricing not available
|
87
|
+
zero_cost
|
88
|
+
end
|
89
|
+
|
90
|
+
# Zero cost fallback
|
91
|
+
#
|
92
|
+
# @return [Hash] Zero cost breakdown
|
93
|
+
def zero_cost
|
94
|
+
{ input_cost: 0.0, output_cost: 0.0, total_cost: 0.0 }
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Agent
|
5
|
+
class Chat < RubyLLM::Chat
|
6
|
+
# Handles injection of system reminders at strategic points in the conversation
|
7
|
+
#
|
8
|
+
# Responsibilities:
|
9
|
+
# - Inject reminders before/after first user message
|
10
|
+
# - Inject periodic TodoWrite reminders
|
11
|
+
# - Track when reminders were last injected
|
12
|
+
#
|
13
|
+
# This class is stateless - it operates on the chat's message history.
|
14
|
+
class SystemReminderInjector
|
15
|
+
# System reminder to inject BEFORE the first user message
|
16
|
+
BEFORE_FIRST_MESSAGE_REMINDER = <<~REMINDER.strip
|
17
|
+
<system-reminder>
|
18
|
+
As you answer the user's questions, you can use the following context:
|
19
|
+
|
20
|
+
# important-instruction-reminders
|
21
|
+
|
22
|
+
Do what has been asked; nothing more, nothing less.
|
23
|
+
NEVER create files unless they're absolutely necessary for achieving your goal.
|
24
|
+
ALWAYS prefer editing an existing file to creating a new one.
|
25
|
+
NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
|
26
|
+
|
27
|
+
IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.
|
28
|
+
|
29
|
+
</system-reminder>
|
30
|
+
REMINDER
|
31
|
+
|
32
|
+
# System reminder to inject AFTER the first user message
|
33
|
+
AFTER_FIRST_MESSAGE_REMINDER = <<~REMINDER.strip
|
34
|
+
<system-reminder>Your todo list is currently empty. DO NOT mention this to the user. If this task requires multiple steps: (1) FIRST analyze the scope by searching/reading files, (2) SECOND create a COMPLETE todo list with ALL tasks before starting work, (3) THIRD execute tasks one by one. Only skip the todo list for simple single-step tasks. Do not mention this message to the user.</system-reminder>
|
35
|
+
REMINDER
|
36
|
+
|
37
|
+
# Periodic reminder about TodoWrite tool usage
|
38
|
+
TODOWRITE_PERIODIC_REMINDER = <<~REMINDER.strip
|
39
|
+
<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>
|
40
|
+
REMINDER
|
41
|
+
|
42
|
+
# Number of messages between TodoWrite reminders
|
43
|
+
TODOWRITE_REMINDER_INTERVAL = 8
|
44
|
+
|
45
|
+
class << self
|
46
|
+
# Check if this is the first user message in the conversation
|
47
|
+
#
|
48
|
+
# @param chat [Agent::Chat] The chat instance
|
49
|
+
# @return [Boolean] true if no user messages exist yet
|
50
|
+
def first_message?(chat)
|
51
|
+
chat.messages.none? { |msg| msg.role == :user }
|
52
|
+
end
|
53
|
+
|
54
|
+
# Inject first message reminders (before + after user message)
|
55
|
+
#
|
56
|
+
# This manually constructs the first message sequence with system reminders
|
57
|
+
# sandwiching the actual user prompt.
|
58
|
+
#
|
59
|
+
# @param chat [Agent::Chat] The chat instance
|
60
|
+
# @param prompt [String] The user's actual prompt
|
61
|
+
# @return [void]
|
62
|
+
def inject_first_message_reminders(chat, prompt)
|
63
|
+
chat.add_message(role: :user, content: BEFORE_FIRST_MESSAGE_REMINDER)
|
64
|
+
chat.add_message(role: :user, content: prompt)
|
65
|
+
chat.add_message(role: :user, content: AFTER_FIRST_MESSAGE_REMINDER)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Check if we should inject a periodic TodoWrite reminder
|
69
|
+
#
|
70
|
+
# Injects a reminder if:
|
71
|
+
# 1. Enough messages have passed (>= 5)
|
72
|
+
# 2. TodoWrite hasn't been used in the last TODOWRITE_REMINDER_INTERVAL messages
|
73
|
+
#
|
74
|
+
# @param chat [Agent::Chat] The chat instance
|
75
|
+
# @param last_todowrite_index [Integer, nil] Index of last TodoWrite usage
|
76
|
+
# @return [Boolean] true if reminder should be injected
|
77
|
+
def should_inject_todowrite_reminder?(chat, last_todowrite_index)
|
78
|
+
# Need at least a few messages before reminding
|
79
|
+
return false if chat.messages.count < 5
|
80
|
+
|
81
|
+
# Find the last message that contains TodoWrite tool usage
|
82
|
+
last_todo_index = chat.messages.rindex do |msg|
|
83
|
+
msg.role == :tool && msg.content.to_s.include?("TodoWrite")
|
84
|
+
end
|
85
|
+
|
86
|
+
# Check if enough messages have passed since last TodoWrite
|
87
|
+
if last_todo_index.nil? && last_todowrite_index.nil?
|
88
|
+
# Never used TodoWrite - check if we've exceeded interval
|
89
|
+
chat.messages.count >= TODOWRITE_REMINDER_INTERVAL
|
90
|
+
elsif last_todo_index
|
91
|
+
# Recently used - don't remind
|
92
|
+
false
|
93
|
+
elsif last_todowrite_index
|
94
|
+
# Used before - check if interval has passed
|
95
|
+
chat.messages.count - last_todowrite_index >= TODOWRITE_REMINDER_INTERVAL
|
96
|
+
else
|
97
|
+
false
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Update the last TodoWrite index by finding it in messages
|
102
|
+
#
|
103
|
+
# @param chat [Agent::Chat] The chat instance
|
104
|
+
# @return [Integer, nil] Index of last TodoWrite usage, or nil
|
105
|
+
def find_last_todowrite_index(chat)
|
106
|
+
chat.messages.rindex do |msg|
|
107
|
+
msg.role == :tool && msg.content.to_s.include?("TodoWrite")
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|