claude_swarm 1.0.6 → 1.0.7
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/.ruby-version +1 -1
- data/CHANGELOG.md +14 -0
- data/README.md +336 -1037
- data/docs/V1_TO_V2_MIGRATION_GUIDE.md +1120 -0
- data/docs/v1/README.md +1195 -0
- data/docs/v2/CHANGELOG.swarm_cli.md +22 -0
- data/docs/v2/CHANGELOG.swarm_memory.md +20 -0
- data/docs/v2/CHANGELOG.swarm_sdk.md +287 -10
- data/docs/v2/README.md +32 -6
- data/docs/v2/guides/complete-tutorial.md +133 -37
- data/docs/v2/guides/composable-swarms.md +1178 -0
- data/docs/v2/guides/getting-started.md +42 -1
- data/docs/v2/guides/snapshots.md +1498 -0
- data/docs/v2/reference/architecture-flow.md +5 -3
- data/docs/v2/reference/event_payload_structures.md +249 -12
- data/docs/v2/reference/execution-flow.md +1 -1
- data/docs/v2/reference/ruby-dsl.md +368 -22
- data/docs/v2/reference/yaml.md +314 -63
- data/examples/snapshot_demo.rb +119 -0
- data/examples/v2/dsl/01_basic.rb +0 -2
- data/examples/v2/dsl/02_core_parameters.rb +0 -2
- data/examples/v2/dsl/03_capabilities.rb +0 -2
- data/examples/v2/dsl/04_llm_parameters.rb +0 -2
- data/examples/v2/dsl/05_advanced_flags.rb +0 -3
- data/examples/v2/dsl/06_permissions.rb +0 -4
- data/examples/v2/dsl/07_mcp_server.rb +0 -2
- data/examples/v2/dsl/08_swarm_hooks.rb +0 -2
- data/examples/v2/dsl/09_agent_hooks.rb +0 -2
- data/examples/v2/dsl/10_all_agents_hooks.rb +0 -3
- data/examples/v2/dsl/11_delegation.rb +0 -2
- data/examples/v2/dsl/12_complete_integration.rb +2 -6
- data/examples/v2/node_context_demo.rb +1 -1
- data/examples/v2/node_workflow.rb +2 -4
- data/examples/v2/plan_and_execute.rb +157 -0
- data/lib/claude_swarm/configuration.rb +28 -4
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/swarm_cli/formatters/human_formatter.rb +103 -0
- data/lib/swarm_cli/interactive_repl.rb +9 -3
- data/lib/swarm_cli/version.rb +1 -1
- data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
- data/lib/swarm_memory/integration/cli_registration.rb +3 -2
- data/lib/swarm_memory/integration/sdk_plugin.rb +11 -5
- data/lib/swarm_memory/tools/memory_edit.rb +2 -2
- data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
- data/lib/swarm_memory/tools/memory_read.rb +3 -3
- data/lib/swarm_memory/version.rb +1 -1
- data/lib/swarm_memory.rb +5 -0
- data/lib/swarm_sdk/agent/builder.rb +33 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +49 -3
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
- data/lib/swarm_sdk/agent/chat.rb +200 -51
- data/lib/swarm_sdk/agent/context.rb +6 -2
- data/lib/swarm_sdk/agent/context_manager.rb +6 -0
- data/lib/swarm_sdk/agent/definition.rb +14 -2
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
- data/lib/swarm_sdk/configuration.rb +387 -94
- data/lib/swarm_sdk/events_to_messages.rb +181 -0
- data/lib/swarm_sdk/log_collector.rb +31 -5
- data/lib/swarm_sdk/log_stream.rb +37 -8
- data/lib/swarm_sdk/model_aliases.json +4 -1
- data/lib/swarm_sdk/node/agent_config.rb +33 -8
- data/lib/swarm_sdk/node/builder.rb +39 -18
- data/lib/swarm_sdk/node_orchestrator.rb +293 -26
- data/lib/swarm_sdk/proc_helpers.rb +53 -0
- data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
- data/lib/swarm_sdk/restore_result.rb +65 -0
- data/lib/swarm_sdk/snapshot.rb +156 -0
- data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
- data/lib/swarm_sdk/state_restorer.rb +491 -0
- data/lib/swarm_sdk/state_snapshot.rb +369 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
- data/lib/swarm_sdk/swarm/builder.rb +208 -12
- data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
- data/lib/swarm_sdk/swarm.rb +338 -42
- data/lib/swarm_sdk/swarm_loader.rb +145 -0
- data/lib/swarm_sdk/swarm_registry.rb +136 -0
- data/lib/swarm_sdk/tools/delegate.rb +92 -7
- data/lib/swarm_sdk/tools/read.rb +17 -5
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
- data/lib/swarm_sdk/utils.rb +18 -0
- data/lib/swarm_sdk/validation_result.rb +33 -0
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk.rb +40 -8
- data/swarm_cli.gemspec +1 -1
- data/swarm_memory.gemspec +2 -2
- data/swarm_sdk.gemspec +2 -2
- metadata +21 -13
- data/examples/learning-assistant/assistant.md +0 -7
- data/examples/learning-assistant/example-memories/concept-example.md +0 -90
- data/examples/learning-assistant/example-memories/experience-example.md +0 -66
- data/examples/learning-assistant/example-memories/fact-example.md +0 -76
- data/examples/learning-assistant/example-memories/memory-index.md +0 -78
- data/examples/learning-assistant/example-memories/skill-example.md +0 -168
- data/examples/learning-assistant/learning_assistant.rb +0 -34
- data/examples/learning-assistant/learning_assistant.yml +0 -20
- data/lib/swarm_sdk/mcp.rb +0 -16
- data/llm.v2.txt +0 -13407
- /data/docs/v2/guides/{MEMORY_DEFRAG_GUIDE.md → memory-defrag-guide.md} +0 -0
- /data/{llms.txt → llms.claude-swarm.txt} +0 -0
|
@@ -140,8 +140,8 @@ module SwarmMemory
|
|
|
140
140
|
# Read current content (this will raise ArgumentError if entry doesn't exist)
|
|
141
141
|
content = @storage.read(file_path: file_path)
|
|
142
142
|
|
|
143
|
-
# Enforce read-before-edit
|
|
144
|
-
unless Core::StorageReadTracker.entry_read?(@agent_name, file_path)
|
|
143
|
+
# Enforce read-before-edit with content verification
|
|
144
|
+
unless Core::StorageReadTracker.entry_read?(@agent_name, file_path, @storage)
|
|
145
145
|
return validation_error(
|
|
146
146
|
"Cannot edit memory entry without reading it first. " \
|
|
147
147
|
"You must use MemoryRead on 'memory://#{file_path}' before editing it. " \
|
|
@@ -64,12 +64,12 @@ module SwarmMemory
|
|
|
64
64
|
# @param file_path [String] Path to read from
|
|
65
65
|
# @return [String] JSON with content and metadata
|
|
66
66
|
def execute(file_path:)
|
|
67
|
-
# Register this read in the tracker
|
|
68
|
-
Core::StorageReadTracker.register_read(@agent_name, file_path)
|
|
69
|
-
|
|
70
67
|
# Read full entry with metadata
|
|
71
68
|
entry = @storage.read_entry(file_path: file_path)
|
|
72
69
|
|
|
70
|
+
# Register this read in the tracker with content digest
|
|
71
|
+
Core::StorageReadTracker.register_read(@agent_name, file_path, entry.content)
|
|
72
|
+
|
|
73
73
|
# Always return JSON format (metadata always exists - at minimum title)
|
|
74
74
|
format_as_json(entry)
|
|
75
75
|
rescue ArgumentError => e
|
data/lib/swarm_memory/version.rb
CHANGED
data/lib/swarm_memory.rb
CHANGED
|
@@ -31,6 +31,11 @@ loader = Zeitwerk::Loader.new
|
|
|
31
31
|
loader.tag = File.basename(__FILE__, ".rb")
|
|
32
32
|
loader.push_dir("#{__dir__}/swarm_memory", namespace: SwarmMemory)
|
|
33
33
|
loader.inflector = Zeitwerk::GemInflector.new(__FILE__)
|
|
34
|
+
loader.inflector.inflect(
|
|
35
|
+
"cli" => "CLI",
|
|
36
|
+
"dsl" => "DSL",
|
|
37
|
+
"sdk_plugin" => "SDKPlugin",
|
|
38
|
+
)
|
|
34
39
|
loader.setup
|
|
35
40
|
|
|
36
41
|
# Explicitly load DSL components and extensions to inject into SwarmSDK
|
|
@@ -24,6 +24,13 @@ module SwarmSDK
|
|
|
24
24
|
# Expose mcp_servers for tests
|
|
25
25
|
attr_reader :mcp_servers
|
|
26
26
|
|
|
27
|
+
# Get tools list as array for validation
|
|
28
|
+
#
|
|
29
|
+
# @return [Array<Symbol>] List of tools
|
|
30
|
+
def tools_list
|
|
31
|
+
@tools.to_a
|
|
32
|
+
end
|
|
33
|
+
|
|
27
34
|
def initialize(name)
|
|
28
35
|
@name = name
|
|
29
36
|
@description = nil
|
|
@@ -52,6 +59,7 @@ module SwarmSDK
|
|
|
52
59
|
@permissions_config = {}
|
|
53
60
|
@default_permissions = {} # Set by SwarmBuilder from all_agents
|
|
54
61
|
@memory_config = nil
|
|
62
|
+
@shared_across_delegations = nil # nil = not set (will default to false in Definition)
|
|
55
63
|
end
|
|
56
64
|
|
|
57
65
|
# Set/get agent model
|
|
@@ -267,6 +275,30 @@ module SwarmSDK
|
|
|
267
275
|
@permissions_config = PermissionsBuilder.build(&block)
|
|
268
276
|
end
|
|
269
277
|
|
|
278
|
+
# Configure delegation isolation mode
|
|
279
|
+
#
|
|
280
|
+
# @param enabled [Boolean] If true, allows sharing instances across delegations (old behavior)
|
|
281
|
+
# If false (default), creates isolated instances per delegation
|
|
282
|
+
# @return [self] Returns self for method chaining
|
|
283
|
+
#
|
|
284
|
+
# @example
|
|
285
|
+
# shared_across_delegations true # Allow sharing (old behavior)
|
|
286
|
+
def shared_across_delegations(enabled)
|
|
287
|
+
@shared_across_delegations = enabled
|
|
288
|
+
self
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Set permissions directly from hash (for YAML translation)
|
|
292
|
+
#
|
|
293
|
+
# This is intentionally separate from permissions() to keep the DSL clean.
|
|
294
|
+
# Called by Configuration when translating YAML permissions.
|
|
295
|
+
#
|
|
296
|
+
# @param hash [Hash] Permissions configuration hash
|
|
297
|
+
# @return [void]
|
|
298
|
+
def permissions_hash=(hash)
|
|
299
|
+
@permissions_config = hash || {}
|
|
300
|
+
end
|
|
301
|
+
|
|
270
302
|
# Check if model has been explicitly set (not default)
|
|
271
303
|
#
|
|
272
304
|
# Used by Swarm::Builder to determine if all_agents model should apply.
|
|
@@ -374,6 +406,7 @@ module SwarmSDK
|
|
|
374
406
|
agent_config[:permissions] = @permissions_config if @permissions_config.any?
|
|
375
407
|
agent_config[:default_permissions] = @default_permissions if @default_permissions.any?
|
|
376
408
|
agent_config[:memory] = @memory_config if @memory_config
|
|
409
|
+
agent_config[:shared_across_delegations] = @shared_across_delegations unless @shared_across_delegations.nil?
|
|
377
410
|
|
|
378
411
|
# Convert DSL hooks to HookDefinition format
|
|
379
412
|
agent_config[:hooks] = convert_hooks_to_definitions if @hooks.any?
|
|
@@ -13,6 +13,25 @@ module SwarmSDK
|
|
|
13
13
|
# - Check context warnings
|
|
14
14
|
#
|
|
15
15
|
# This is a stateful helper that's instantiated per Agent::Chat instance.
|
|
16
|
+
#
|
|
17
|
+
# ## Thread Safety and Fiber-Local Storage
|
|
18
|
+
#
|
|
19
|
+
# IMPORTANT: LogStream.emit calls in this class DO NOT explicitly pass
|
|
20
|
+
# swarm_id, parent_swarm_id, or execution_id. These values are automatically
|
|
21
|
+
# injected from Fiber-local storage (Fiber[:swarm_id], etc.) by LogStream.emit.
|
|
22
|
+
#
|
|
23
|
+
# Why: In threaded environments (Puma, Sidekiq), swarm/agent instances may be
|
|
24
|
+
# reused across multiple requests/jobs. If we explicitly pass @agent_context.swarm_id,
|
|
25
|
+
# callbacks would use STALE values from the first request, causing events to be
|
|
26
|
+
# lost or misattributed.
|
|
27
|
+
#
|
|
28
|
+
# By relying on Fiber-local storage, each request/job gets the correct context
|
|
29
|
+
# even when reusing the same swarm instance. Fiber storage is set at the start
|
|
30
|
+
# of Swarm#execute and inherited by child fibers (tool calls, delegations).
|
|
31
|
+
#
|
|
32
|
+
# This design works correctly in both:
|
|
33
|
+
# - Single-threaded environments (rails runner, console)
|
|
34
|
+
# - Multi-threaded environments (Puma, Sidekiq)
|
|
16
35
|
class ContextTracker
|
|
17
36
|
include LoggingHelpers
|
|
18
37
|
|
|
@@ -74,11 +93,20 @@ module SwarmSDK
|
|
|
74
93
|
# Mark threshold as hit and emit warning
|
|
75
94
|
@agent_context.hit_warning_threshold?(threshold)
|
|
76
95
|
|
|
96
|
+
# Emit context_threshold_hit event for snapshot reconstruction
|
|
97
|
+
LogStream.emit(
|
|
98
|
+
type: "context_threshold_hit",
|
|
99
|
+
agent: @agent_context.name,
|
|
100
|
+
threshold: threshold,
|
|
101
|
+
current_usage_percentage: current_percentage.round(2),
|
|
102
|
+
)
|
|
103
|
+
|
|
77
104
|
# Trigger automatic compression at 60% threshold
|
|
78
105
|
if threshold == Context::COMPRESSION_THRESHOLD
|
|
79
106
|
trigger_automatic_compression
|
|
80
107
|
end
|
|
81
108
|
|
|
109
|
+
# Emit legacy context_limit_warning for backwards compatibility
|
|
82
110
|
LogStream.emit(
|
|
83
111
|
type: "context_limit_warning",
|
|
84
112
|
agent: @agent_context.name,
|
|
@@ -107,6 +135,9 @@ module SwarmSDK
|
|
|
107
135
|
cumulative_input_tokens: @chat.cumulative_input_tokens,
|
|
108
136
|
cumulative_output_tokens: @chat.cumulative_output_tokens,
|
|
109
137
|
cumulative_total_tokens: @chat.cumulative_total_tokens,
|
|
138
|
+
cumulative_cached_tokens: @chat.cumulative_cached_tokens,
|
|
139
|
+
cumulative_cache_creation_tokens: @chat.cumulative_cache_creation_tokens,
|
|
140
|
+
effective_input_tokens: @chat.effective_input_tokens,
|
|
110
141
|
context_limit: @chat.context_limit,
|
|
111
142
|
tokens_used_percentage: "#{@chat.context_usage_percentage}%",
|
|
112
143
|
tokens_remaining: @chat.tokens_remaining,
|
|
@@ -118,6 +149,8 @@ module SwarmSDK
|
|
|
118
149
|
{
|
|
119
150
|
input_tokens: message.input_tokens,
|
|
120
151
|
output_tokens: message.output_tokens,
|
|
152
|
+
cached_tokens: message.cached_tokens,
|
|
153
|
+
cache_creation_tokens: message.cache_creation_tokens,
|
|
121
154
|
total_tokens: (message.input_tokens || 0) + (message.output_tokens || 0),
|
|
122
155
|
input_cost: cost_info[:input_cost],
|
|
123
156
|
output_cost: cost_info[:output_cost],
|
|
@@ -77,12 +77,15 @@ module SwarmSDK
|
|
|
77
77
|
# system reminders are handled.
|
|
78
78
|
#
|
|
79
79
|
# @param prompt [String] User prompt
|
|
80
|
-
# @param options [Hash] Additional options
|
|
80
|
+
# @param options [Hash] Additional options (may include source: "user" or "delegation")
|
|
81
81
|
# @return [RubyLLM::Message] LLM response
|
|
82
82
|
def ask(prompt, **options)
|
|
83
|
+
# Extract source for hook tracking (not passed to RubyLLM)
|
|
84
|
+
source = options.delete(:source) || "user"
|
|
85
|
+
|
|
83
86
|
# Trigger user_prompt hook before sending to LLM (can halt or modify prompt)
|
|
84
87
|
if @hook_executor
|
|
85
|
-
hook_result = trigger_user_prompt(prompt)
|
|
88
|
+
hook_result = trigger_user_prompt(prompt, source: source)
|
|
86
89
|
|
|
87
90
|
# Check if hook halted execution
|
|
88
91
|
if hook_result[:halted]
|
|
@@ -186,9 +189,13 @@ module SwarmSDK
|
|
|
186
189
|
def trigger_post_tool_use(result, tool_call:)
|
|
187
190
|
return result unless @hook_executor
|
|
188
191
|
|
|
192
|
+
# Extract tracking digest for Read/MemoryRead tools
|
|
193
|
+
metadata_with_digest = extract_tool_tracking_digest(tool_call, result)
|
|
194
|
+
|
|
189
195
|
context = build_hook_context(
|
|
190
196
|
event: :post_tool_use,
|
|
191
197
|
tool_result: wrap_tool_result(tool_call.id, tool_call.name, result),
|
|
198
|
+
metadata: metadata_with_digest,
|
|
192
199
|
)
|
|
193
200
|
|
|
194
201
|
agent_hooks = @hook_agent_hooks[:post_tool_use] || []
|
|
@@ -251,8 +258,9 @@ module SwarmSDK
|
|
|
251
258
|
# Can halt execution or append hook stdout to prompt.
|
|
252
259
|
#
|
|
253
260
|
# @param prompt [String] User's message/prompt
|
|
261
|
+
# @param source [String] Source of the prompt ("user" or "delegation")
|
|
254
262
|
# @return [Hash] { halted: bool, halt_message: String, modified_prompt: String }
|
|
255
|
-
def trigger_user_prompt(prompt)
|
|
263
|
+
def trigger_user_prompt(prompt, source: "user")
|
|
256
264
|
return { halted: false, modified_prompt: prompt } unless @hook_executor
|
|
257
265
|
|
|
258
266
|
# Filter out delegation tools from tools list
|
|
@@ -278,6 +286,7 @@ module SwarmSDK
|
|
|
278
286
|
provider: model.provider,
|
|
279
287
|
tools: actual_tools,
|
|
280
288
|
delegates_to: delegate_agents,
|
|
289
|
+
source: source,
|
|
281
290
|
timestamp: Time.now.utc.iso8601,
|
|
282
291
|
},
|
|
283
292
|
)
|
|
@@ -335,6 +344,43 @@ module SwarmSDK
|
|
|
335
344
|
)
|
|
336
345
|
end
|
|
337
346
|
|
|
347
|
+
# Extract tracking digest for Read/MemoryRead tools
|
|
348
|
+
#
|
|
349
|
+
# Queries the appropriate tracker after tool execution to get the digest
|
|
350
|
+
# that was calculated and stored during the read operation.
|
|
351
|
+
#
|
|
352
|
+
# @param tool_call [RubyLLM::ToolCall] Tool call with arguments
|
|
353
|
+
# @param result [Object] Tool execution result (to check for errors)
|
|
354
|
+
# @return [Hash] Metadata hash with digest if applicable
|
|
355
|
+
def extract_tool_tracking_digest(tool_call, result)
|
|
356
|
+
# Only add digest for successful Read/MemoryRead tool calls
|
|
357
|
+
return {} if result.is_a?(StandardError)
|
|
358
|
+
return {} unless ["Read", "MemoryRead"].include?(tool_call.name)
|
|
359
|
+
|
|
360
|
+
# Extract path from arguments
|
|
361
|
+
path = case tool_call.name
|
|
362
|
+
when "Read"
|
|
363
|
+
tool_call.arguments[:file_path] || tool_call.arguments["file_path"]
|
|
364
|
+
when "MemoryRead"
|
|
365
|
+
tool_call.arguments[:file_path] || tool_call.arguments["file_path"]
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
return {} unless path
|
|
369
|
+
|
|
370
|
+
# Query tracker for digest
|
|
371
|
+
digest = case tool_call.name
|
|
372
|
+
when "Read"
|
|
373
|
+
Tools::Stores::ReadTracker.get_read_files(@agent_context.name)[File.expand_path(path)]
|
|
374
|
+
when "MemoryRead"
|
|
375
|
+
# Only query if SwarmMemory is loaded (optional dependency)
|
|
376
|
+
if defined?(SwarmMemory::Core::StorageReadTracker)
|
|
377
|
+
SwarmMemory::Core::StorageReadTracker.get_read_entries(@agent_context.name)[path]
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
digest ? { read_digest: digest, read_path: path } : {}
|
|
382
|
+
end
|
|
383
|
+
|
|
338
384
|
# Wrap a tool result in our Hooks::ToolResult value object
|
|
339
385
|
#
|
|
340
386
|
# @param tool_call_id [String] Tool call ID
|
|
@@ -12,23 +12,6 @@ module SwarmSDK
|
|
|
12
12
|
#
|
|
13
13
|
# This class is stateless - it operates on the chat's message history.
|
|
14
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
15
|
# System reminder to inject AFTER the first user message
|
|
33
16
|
AFTER_FIRST_MESSAGE_REMINDER = <<~REMINDER.strip
|
|
34
17
|
<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>
|
|
@@ -51,16 +34,14 @@ module SwarmSDK
|
|
|
51
34
|
chat.messages.none? { |msg| msg.role == :user }
|
|
52
35
|
end
|
|
53
36
|
|
|
54
|
-
# Inject first message reminders
|
|
37
|
+
# Inject first message reminders
|
|
55
38
|
#
|
|
56
|
-
# This manually constructs the first message sequence with system reminders
|
|
57
|
-
# sandwiching the actual user prompt.
|
|
39
|
+
# This manually constructs the first message sequence with system reminders.
|
|
58
40
|
#
|
|
59
41
|
# Sequence:
|
|
60
|
-
# 1.
|
|
42
|
+
# 1. User's actual prompt
|
|
61
43
|
# 2. Toolset reminder (list of available tools)
|
|
62
|
-
# 3.
|
|
63
|
-
# 4. AFTER_FIRST_MESSAGE_REMINDER (todo list reminder)
|
|
44
|
+
# 3. AFTER_FIRST_MESSAGE_REMINDER (todo list reminder - only if TodoWrite available)
|
|
64
45
|
#
|
|
65
46
|
# @param chat [Agent::Chat] The chat instance
|
|
66
47
|
# @param prompt [String] The user's actual prompt
|
|
@@ -68,12 +49,15 @@ module SwarmSDK
|
|
|
68
49
|
def inject_first_message_reminders(chat, prompt)
|
|
69
50
|
# Build user message with embedded reminders
|
|
70
51
|
# Reminders are embedded in the content, not separate messages
|
|
71
|
-
|
|
52
|
+
parts = [
|
|
72
53
|
prompt,
|
|
73
|
-
BEFORE_FIRST_MESSAGE_REMINDER,
|
|
74
54
|
build_toolset_reminder(chat),
|
|
75
|
-
|
|
76
|
-
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
# Only include todo list reminder if agent has TodoWrite tool
|
|
58
|
+
parts << AFTER_FIRST_MESSAGE_REMINDER if chat.tools.key?("TodoWrite")
|
|
59
|
+
|
|
60
|
+
full_content = parts.join("\n\n")
|
|
77
61
|
|
|
78
62
|
# Extract reminders and add clean prompt to persistent history
|
|
79
63
|
reminders = chat.context_manager.extract_system_reminders(full_content)
|
data/lib/swarm_sdk/agent/chat.rb
CHANGED
|
@@ -150,6 +150,7 @@ module SwarmSDK
|
|
|
150
150
|
raise StateError, "Agent context not set. Call setup_context first." unless @agent_context
|
|
151
151
|
|
|
152
152
|
@context_tracker.setup_logging
|
|
153
|
+
inject_llm_instrumentation
|
|
153
154
|
end
|
|
154
155
|
|
|
155
156
|
# Emit model lookup warning if one occurred during initialization
|
|
@@ -164,6 +165,8 @@ module SwarmSDK
|
|
|
164
165
|
LogStream.emit(
|
|
165
166
|
type: "model_lookup_warning",
|
|
166
167
|
agent: agent_name,
|
|
168
|
+
swarm_id: @agent_context&.swarm_id,
|
|
169
|
+
parent_swarm_id: @agent_context&.parent_swarm_id,
|
|
167
170
|
model: @model_lookup_error[:model],
|
|
168
171
|
error_message: @model_lookup_error[:error_message],
|
|
169
172
|
suggestions: @model_lookup_error[:suggestions].map { |s| { id: s.id, name: s.name, context_window: s.context_window } },
|
|
@@ -221,6 +224,17 @@ module SwarmSDK
|
|
|
221
224
|
!@active_skill_path.nil?
|
|
222
225
|
end
|
|
223
226
|
|
|
227
|
+
# Clear conversation history
|
|
228
|
+
#
|
|
229
|
+
# Removes all messages from the conversation history and clears tool executions.
|
|
230
|
+
# Used by composable swarms when keep_context: false is specified.
|
|
231
|
+
#
|
|
232
|
+
# @return [void]
|
|
233
|
+
def clear_conversation
|
|
234
|
+
@messages.clear if @messages.respond_to?(:clear)
|
|
235
|
+
@context_manager&.clear_ephemeral
|
|
236
|
+
end
|
|
237
|
+
|
|
224
238
|
# Override ask to inject system reminders and periodic TodoWrite reminders
|
|
225
239
|
#
|
|
226
240
|
# Note: This is called BEFORE HookIntegration#ask (due to module include order),
|
|
@@ -230,63 +244,74 @@ module SwarmSDK
|
|
|
230
244
|
# @param options [Hash] Additional options to pass to complete
|
|
231
245
|
# @return [RubyLLM::Message] LLM response
|
|
232
246
|
def ask(prompt, **options)
|
|
233
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
# Serialize ask() calls to prevent message corruption from concurrent fibers
|
|
248
|
+
# Uses Async::Semaphore (not Mutex) because SwarmSDK runs in fiber context
|
|
249
|
+
# This protects against parallel delegation scenarios where multiple delegation
|
|
250
|
+
# instances call the same underlying primary agent (e.g., tester@frontend and
|
|
251
|
+
# tester@backend both calling database in parallel).
|
|
252
|
+
@ask_semaphore ||= Async::Semaphore.new(1)
|
|
253
|
+
|
|
254
|
+
@ask_semaphore.acquire do
|
|
255
|
+
# Check if this is the first user message
|
|
256
|
+
is_first = SystemReminderInjector.first_message?(self)
|
|
257
|
+
|
|
258
|
+
if is_first
|
|
259
|
+
# Collect plugin reminders first
|
|
260
|
+
plugin_reminders = collect_plugin_reminders(prompt, is_first_message: true)
|
|
261
|
+
|
|
262
|
+
# Build full prompt with embedded plugin reminders
|
|
263
|
+
full_prompt = prompt
|
|
264
|
+
plugin_reminders.each do |reminder|
|
|
265
|
+
full_prompt = "#{full_prompt}\n\n#{reminder}"
|
|
266
|
+
end
|
|
249
267
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
268
|
+
# Inject first message reminders (includes system reminders + toolset + after)
|
|
269
|
+
# SystemReminderInjector will embed all reminders in the prompt via add_message
|
|
270
|
+
SystemReminderInjector.inject_first_message_reminders(self, full_prompt)
|
|
271
|
+
|
|
272
|
+
# Trigger user_prompt hook manually since we're bypassing the normal ask flow
|
|
273
|
+
if @hook_executor
|
|
274
|
+
# Extract source from options if provided, default to "user"
|
|
275
|
+
source = options[:source] || "user"
|
|
276
|
+
hook_result = trigger_user_prompt(prompt, source: source)
|
|
277
|
+
|
|
278
|
+
# Check if hook halted execution
|
|
279
|
+
if hook_result[:halted]
|
|
280
|
+
# Return a halted message instead of calling LLM
|
|
281
|
+
return RubyLLM::Message.new(
|
|
282
|
+
role: :assistant,
|
|
283
|
+
content: hook_result[:halt_message],
|
|
284
|
+
model_id: model.id,
|
|
285
|
+
)
|
|
286
|
+
end
|
|
253
287
|
|
|
254
|
-
|
|
255
|
-
if hook_result[:halted]
|
|
256
|
-
# Return a halted message instead of calling LLM
|
|
257
|
-
return RubyLLM::Message.new(
|
|
258
|
-
role: :assistant,
|
|
259
|
-
content: hook_result[:halt_message],
|
|
260
|
-
model_id: model.id,
|
|
261
|
-
)
|
|
288
|
+
# NOTE: We ignore modified_prompt for first message since reminders already injected
|
|
262
289
|
end
|
|
263
290
|
|
|
264
|
-
#
|
|
265
|
-
|
|
291
|
+
# Call complete to get LLM response
|
|
292
|
+
complete(**options)
|
|
293
|
+
else
|
|
294
|
+
# Build prompt with embedded reminders (if needed)
|
|
295
|
+
full_prompt = prompt
|
|
296
|
+
|
|
297
|
+
# Add periodic TodoWrite reminder if needed (only if agent has TodoWrite tool)
|
|
298
|
+
if tools.key?("TodoWrite") && SystemReminderInjector.should_inject_todowrite_reminder?(self, @last_todowrite_message_index)
|
|
299
|
+
full_prompt = "#{full_prompt}\n\n#{SystemReminderInjector::TODOWRITE_PERIODIC_REMINDER}"
|
|
300
|
+
# Update tracking
|
|
301
|
+
@last_todowrite_message_index = SystemReminderInjector.find_last_todowrite_index(self)
|
|
302
|
+
end
|
|
266
303
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
# Add periodic TodoWrite reminder if needed
|
|
274
|
-
if SystemReminderInjector.should_inject_todowrite_reminder?(self, @last_todowrite_message_index)
|
|
275
|
-
full_prompt = "#{full_prompt}\n\n#{SystemReminderInjector::TODOWRITE_PERIODIC_REMINDER}"
|
|
276
|
-
# Update tracking
|
|
277
|
-
@last_todowrite_message_index = SystemReminderInjector.find_last_todowrite_index(self)
|
|
278
|
-
end
|
|
304
|
+
# Collect plugin reminders and embed them
|
|
305
|
+
plugin_reminders = collect_plugin_reminders(full_prompt, is_first_message: false)
|
|
306
|
+
plugin_reminders.each do |reminder|
|
|
307
|
+
full_prompt = "#{full_prompt}\n\n#{reminder}"
|
|
308
|
+
end
|
|
279
309
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
full_prompt
|
|
310
|
+
# Normal ask behavior for subsequent messages
|
|
311
|
+
# This calls super which goes to HookIntegration's ask override
|
|
312
|
+
# HookIntegration will call add_message, and we'll extract reminders there
|
|
313
|
+
super(full_prompt, **options)
|
|
284
314
|
end
|
|
285
|
-
|
|
286
|
-
# Normal ask behavior for subsequent messages
|
|
287
|
-
# This calls super which goes to HookIntegration's ask override
|
|
288
|
-
# HookIntegration will call add_message, and we'll extract reminders there
|
|
289
|
-
super(full_prompt, **options)
|
|
290
315
|
end
|
|
291
316
|
end
|
|
292
317
|
|
|
@@ -674,7 +699,15 @@ module SwarmSDK
|
|
|
674
699
|
# This is needed for setting agent_name and other provider-specific settings.
|
|
675
700
|
#
|
|
676
701
|
# @return [RubyLLM::Provider::Base] Provider instance
|
|
677
|
-
attr_reader :provider, :global_semaphore, :local_semaphore, :real_model_info, :context_tracker, :context_manager
|
|
702
|
+
attr_reader :provider, :global_semaphore, :local_semaphore, :real_model_info, :context_tracker, :context_manager, :agent_context, :last_todowrite_message_index, :active_skill_path
|
|
703
|
+
|
|
704
|
+
# Setters for snapshot/restore
|
|
705
|
+
attr_writer :last_todowrite_message_index, :active_skill_path
|
|
706
|
+
|
|
707
|
+
# Expose messages array (inherited from RubyLLM::Chat but not publicly accessible)
|
|
708
|
+
#
|
|
709
|
+
# @return [Array<RubyLLM::Message>] Conversation messages
|
|
710
|
+
attr_reader :messages
|
|
678
711
|
|
|
679
712
|
# Get context window limit for the current model
|
|
680
713
|
#
|
|
@@ -718,6 +751,37 @@ module SwarmSDK
|
|
|
718
751
|
messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.output_tokens || 0 }
|
|
719
752
|
end
|
|
720
753
|
|
|
754
|
+
# Calculate cumulative cached tokens across all assistant messages
|
|
755
|
+
#
|
|
756
|
+
# Cached tokens are portions of prompts served from the provider's cache.
|
|
757
|
+
# OpenAI reports this automatically for prompts >1024 tokens.
|
|
758
|
+
# Anthropic/Bedrock expose cache control via Content::Raw blocks.
|
|
759
|
+
#
|
|
760
|
+
# @return [Integer] Total cached tokens used in conversation
|
|
761
|
+
def cumulative_cached_tokens
|
|
762
|
+
messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.cached_tokens || 0 }
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
# Calculate cumulative cache creation tokens
|
|
766
|
+
#
|
|
767
|
+
# Cache creation tokens are written to the cache (Anthropic/Bedrock only).
|
|
768
|
+
# These are charged at the normal input rate when first created.
|
|
769
|
+
#
|
|
770
|
+
# @return [Integer] Total tokens written to cache
|
|
771
|
+
def cumulative_cache_creation_tokens
|
|
772
|
+
messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.cache_creation_tokens || 0 }
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# Calculate effective input tokens (excluding cache hits)
|
|
776
|
+
#
|
|
777
|
+
# This represents the actual tokens charged for input, excluding cached portions.
|
|
778
|
+
# Useful for accurate cost tracking when using prompt caching.
|
|
779
|
+
#
|
|
780
|
+
# @return [Integer] Actual input tokens charged (input minus cached)
|
|
781
|
+
def effective_input_tokens
|
|
782
|
+
cumulative_input_tokens - cumulative_cached_tokens
|
|
783
|
+
end
|
|
784
|
+
|
|
721
785
|
# Calculate total tokens used (input + output)
|
|
722
786
|
#
|
|
723
787
|
# @return [Integer] Total tokens used in conversation
|
|
@@ -777,6 +841,85 @@ module SwarmSDK
|
|
|
777
841
|
|
|
778
842
|
private
|
|
779
843
|
|
|
844
|
+
# Inject LLM instrumentation middleware for API request/response logging
|
|
845
|
+
#
|
|
846
|
+
# This middleware captures HTTP requests/responses to LLM providers and
|
|
847
|
+
# emits structured events via LogStream. Only injected when logging is enabled.
|
|
848
|
+
#
|
|
849
|
+
# @return [void]
|
|
850
|
+
def inject_llm_instrumentation
|
|
851
|
+
# Safety checks
|
|
852
|
+
return unless @provider
|
|
853
|
+
|
|
854
|
+
faraday_conn = @provider.connection&.connection
|
|
855
|
+
return unless faraday_conn
|
|
856
|
+
|
|
857
|
+
# Check if middleware is already present to prevent duplicates
|
|
858
|
+
return if @llm_instrumentation_injected
|
|
859
|
+
|
|
860
|
+
# Get provider name for logging
|
|
861
|
+
provider_name = @provider.class.name.split("::").last.downcase
|
|
862
|
+
|
|
863
|
+
# Inject middleware at beginning of stack (position 0)
|
|
864
|
+
# This ensures we capture raw requests before any transformations
|
|
865
|
+
# Use fully qualified name to ensure Zeitwerk loads it
|
|
866
|
+
faraday_conn.builder.insert(
|
|
867
|
+
0,
|
|
868
|
+
SwarmSDK::Agent::LLMInstrumentationMiddleware,
|
|
869
|
+
on_request: method(:handle_llm_api_request),
|
|
870
|
+
on_response: method(:handle_llm_api_response),
|
|
871
|
+
provider_name: provider_name,
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
# Mark as injected to prevent duplicates
|
|
875
|
+
@llm_instrumentation_injected = true
|
|
876
|
+
|
|
877
|
+
RubyLLM.logger.debug("SwarmSDK: Injected LLM instrumentation middleware for agent #{@agent_name}")
|
|
878
|
+
rescue StandardError => e
|
|
879
|
+
# Don't fail initialization if instrumentation fails
|
|
880
|
+
RubyLLM.logger.error("SwarmSDK: Failed to inject LLM instrumentation: #{e.message}")
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
# Handle LLM API request event
|
|
884
|
+
#
|
|
885
|
+
# Emits llm_api_request event via LogStream with request details.
|
|
886
|
+
#
|
|
887
|
+
# @param data [Hash] Request data from middleware
|
|
888
|
+
# @return [void]
|
|
889
|
+
def handle_llm_api_request(data)
|
|
890
|
+
return unless LogStream.emitter
|
|
891
|
+
|
|
892
|
+
LogStream.emit(
|
|
893
|
+
type: "llm_api_request",
|
|
894
|
+
agent: @agent_name,
|
|
895
|
+
swarm_id: @agent_context&.swarm_id,
|
|
896
|
+
parent_swarm_id: @agent_context&.parent_swarm_id,
|
|
897
|
+
**data,
|
|
898
|
+
)
|
|
899
|
+
rescue StandardError => e
|
|
900
|
+
RubyLLM.logger.error("SwarmSDK: Error emitting llm_api_request event: #{e.message}")
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
# Handle LLM API response event
|
|
904
|
+
#
|
|
905
|
+
# Emits llm_api_response event via LogStream with response details.
|
|
906
|
+
#
|
|
907
|
+
# @param data [Hash] Response data from middleware
|
|
908
|
+
# @return [void]
|
|
909
|
+
def handle_llm_api_response(data)
|
|
910
|
+
return unless LogStream.emitter
|
|
911
|
+
|
|
912
|
+
LogStream.emit(
|
|
913
|
+
type: "llm_api_response",
|
|
914
|
+
agent: @agent_name,
|
|
915
|
+
swarm_id: @agent_context&.swarm_id,
|
|
916
|
+
parent_swarm_id: @agent_context&.parent_swarm_id,
|
|
917
|
+
**data,
|
|
918
|
+
)
|
|
919
|
+
rescue StandardError => e
|
|
920
|
+
RubyLLM.logger.error("SwarmSDK: Error emitting llm_api_response event: #{e.message}")
|
|
921
|
+
end
|
|
922
|
+
|
|
780
923
|
# Call LLM with retry logic for transient failures
|
|
781
924
|
#
|
|
782
925
|
# Retries up to 10 times with fixed 10-second delays for:
|
|
@@ -802,10 +945,13 @@ module SwarmSDK
|
|
|
802
945
|
LogStream.emit(
|
|
803
946
|
type: "llm_retry_exhausted",
|
|
804
947
|
agent: @agent_name,
|
|
948
|
+
swarm_id: @agent_context&.swarm_id,
|
|
949
|
+
parent_swarm_id: @agent_context&.parent_swarm_id,
|
|
805
950
|
model: @model&.id,
|
|
806
951
|
attempts: attempts,
|
|
807
952
|
error_class: e.class.name,
|
|
808
953
|
error_message: e.message,
|
|
954
|
+
error_backtrace: e.backtrace,
|
|
809
955
|
)
|
|
810
956
|
raise
|
|
811
957
|
end
|
|
@@ -814,11 +960,14 @@ module SwarmSDK
|
|
|
814
960
|
LogStream.emit(
|
|
815
961
|
type: "llm_retry_attempt",
|
|
816
962
|
agent: @agent_name,
|
|
963
|
+
swarm_id: @agent_context&.swarm_id,
|
|
964
|
+
parent_swarm_id: @agent_context&.parent_swarm_id,
|
|
817
965
|
model: @model&.id,
|
|
818
966
|
attempt: attempts,
|
|
819
967
|
max_retries: max_retries,
|
|
820
968
|
error_class: e.class.name,
|
|
821
969
|
error_message: e.message,
|
|
970
|
+
error_backtrace: e.backtrace,
|
|
822
971
|
retry_delay: delay,
|
|
823
972
|
)
|
|
824
973
|
|