swarm_memory 2.1.2 → 2.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/lib/claude_swarm/configuration.rb +28 -4
  3. data/lib/claude_swarm/mcp_generator.rb +4 -10
  4. data/lib/claude_swarm/version.rb +1 -1
  5. data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
  6. data/lib/swarm_cli/config_loader.rb +3 -3
  7. data/lib/swarm_cli/version.rb +1 -1
  8. data/lib/swarm_memory/adapters/base.rb +4 -4
  9. data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
  10. data/lib/swarm_memory/integration/cli_registration.rb +3 -2
  11. data/lib/swarm_memory/integration/sdk_plugin.rb +11 -5
  12. data/lib/swarm_memory/tools/memory_edit.rb +2 -2
  13. data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
  14. data/lib/swarm_memory/tools/memory_read.rb +3 -3
  15. data/lib/swarm_memory/version.rb +1 -1
  16. data/lib/swarm_memory.rb +5 -0
  17. data/lib/swarm_sdk/agent/builder.rb +33 -0
  18. data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
  19. data/lib/swarm_sdk/agent/chat/hook_integration.rb +41 -0
  20. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
  21. data/lib/swarm_sdk/agent/chat.rb +198 -51
  22. data/lib/swarm_sdk/agent/context.rb +6 -2
  23. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  24. data/lib/swarm_sdk/agent/definition.rb +15 -22
  25. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
  26. data/lib/swarm_sdk/configuration.rb +420 -103
  27. data/lib/swarm_sdk/events_to_messages.rb +181 -0
  28. data/lib/swarm_sdk/log_collector.rb +31 -5
  29. data/lib/swarm_sdk/log_stream.rb +37 -8
  30. data/lib/swarm_sdk/model_aliases.json +4 -1
  31. data/lib/swarm_sdk/node/agent_config.rb +33 -8
  32. data/lib/swarm_sdk/node/builder.rb +39 -18
  33. data/lib/swarm_sdk/node_orchestrator.rb +293 -26
  34. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  35. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
  36. data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
  37. data/lib/swarm_sdk/restore_result.rb +65 -0
  38. data/lib/swarm_sdk/snapshot.rb +156 -0
  39. data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
  40. data/lib/swarm_sdk/state_restorer.rb +491 -0
  41. data/lib/swarm_sdk/state_snapshot.rb +369 -0
  42. data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
  43. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  44. data/lib/swarm_sdk/swarm/builder.rb +208 -12
  45. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  46. data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
  47. data/lib/swarm_sdk/swarm.rb +367 -90
  48. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  49. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  50. data/lib/swarm_sdk/tools/delegate.rb +92 -7
  51. data/lib/swarm_sdk/tools/read.rb +17 -5
  52. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
  53. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
  54. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
  55. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  56. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
  57. data/lib/swarm_sdk/tools/stores/storage.rb +4 -4
  58. data/lib/swarm_sdk/tools/think.rb +4 -1
  59. data/lib/swarm_sdk/tools/todo_write.rb +20 -8
  60. data/lib/swarm_sdk/utils.rb +18 -0
  61. data/lib/swarm_sdk/validation_result.rb +33 -0
  62. data/lib/swarm_sdk/version.rb +1 -1
  63. data/lib/swarm_sdk.rb +362 -21
  64. metadata +17 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b83e33e8fe0bbfda5910aa6e3cb0da9815166741b6590e22d0321d31d1c4aa39
4
- data.tar.gz: 6c72c5174ec20553d1db8c9677b8a48e7bf475b0a689ccdea4c8755eedf8971e
3
+ metadata.gz: 8422c40d79fb0c2adcd9e1de9a4cfdf289ced49ef27507969ea797c4187a0ee1
4
+ data.tar.gz: 875f1429c37f2485d32641a7d96c9cae5db226a5bc2fc3e2f8c1443946e021b9
5
5
  SHA512:
6
- metadata.gz: 53ce80b29bad1e1d3c8e86a629d87f092fb005508127b57e90a90acc9e3fa4130bdd8b7946810aa0801a1cfef014d6a789e5458fab2e9034aa589cc768df2d0e
7
- data.tar.gz: f2c7a903a638748eee87f40a05bad00d8bcd0fd267bc2f1ca2cad667f489d9fec6887934373b29505f97b97cc46d9eba6ab20c9de23d6522d86a38a595d9265a
6
+ metadata.gz: 298c0506a2f486aab1ad29c9f6e4605fe3fbb91fb17f7e3ee913b046f97a9d347312afd686e4bf26d28d6329c8974d421d39ee488e04bf2bc4cde22fea8cdc8c
7
+ data.tar.gz: c472ec8754b18a2a8d0ab1ae1a0dc391b2f4fffc2de670831272938d81e51a6716c81343b174afa8833d256e28a728fdd198e0bb2b6d92e13adc085c786d2abc
@@ -69,19 +69,43 @@ module ClaudeSwarm
69
69
  validate_directories unless has_before_commands?
70
70
  end
71
71
 
72
- def interpolate_env_vars!(obj)
72
+ def interpolate_env_vars!(obj, path = [])
73
73
  case obj
74
74
  when String
75
- interpolate_env_string(obj)
75
+ # Skip interpolation for any values inside MCP configurations
76
+ # Check if we're inside an mcps array element (path like: [..., "instances", <name>, "mcps", <index>, ...])
77
+ if in_mcp_config?(path)
78
+ obj
79
+ else
80
+ interpolate_env_string(obj)
81
+ end
76
82
  when Hash
77
- obj.transform_values! { |v| interpolate_env_vars!(v) }
83
+ obj.each do |key, value|
84
+ obj[key] = interpolate_env_vars!(value, path + [key])
85
+ end
86
+ obj
78
87
  when Array
79
- obj.map! { |v| interpolate_env_vars!(v) }
88
+ obj.map!.with_index { |v, i| interpolate_env_vars!(v, path + [i]) }
80
89
  else
81
90
  obj
82
91
  end
83
92
  end
84
93
 
94
+ def in_mcp_config?(path)
95
+ # Check if we're inside an MCP configuration
96
+ # Pattern: [..., "instances", instance_name, "mcps", index, ...]
97
+ return false if path.size < 4
98
+
99
+ # Find the position of "mcps" in the path
100
+ mcps_index = path.rindex("mcps")
101
+ return false unless mcps_index
102
+
103
+ # Check if this is under instances and followed by an array index
104
+ return false if mcps_index < 2
105
+
106
+ path[mcps_index - 2] == "instances" && path[mcps_index + 1].is_a?(Integer)
107
+ end
108
+
85
109
  def interpolate_env_string(str)
86
110
  str.gsub(ENV_VAR_WITH_DEFAULT_PATTERN) do |_match|
87
111
  env_var = Regexp.last_match(1)
@@ -183,21 +183,17 @@ module ClaudeSwarm
183
183
  args.push("--claude-session-id", claude_session_id) if claude_session_id
184
184
  end
185
185
 
186
- # Capture environment variables needed for Ruby and Bundler to work properly
187
- # This includes both BUNDLE_* variables and Ruby-specific variables
186
+ # Capture environment variables needed for running claude-swarm
187
+ # We intentionally exclude Bundler variables to ensure we use the system-installed gem
188
188
  required_env = {}
189
189
 
190
- # Bundle-specific variables
191
- ENV.each do |k, v|
192
- required_env[k] = v if k.start_with?("BUNDLE_")
193
- end
194
-
195
- # Claude Swarm-specific variables
190
+ # Claude Swarm-specific variables (always needed)
196
191
  ENV.each do |k, v|
197
192
  required_env[k] = v if k.start_with?("CLAUDE_SWARM_")
198
193
  end
199
194
 
200
195
  # Ruby-specific variables that MCP servers need
196
+ # Exclude RUBYOPT and RUBYLIB to avoid Bundler interference
201
197
  [
202
198
  "RUBY_ROOT",
203
199
  "RUBY_ENGINE",
@@ -205,8 +201,6 @@ module ClaudeSwarm
205
201
  "GEM_ROOT",
206
202
  "GEM_HOME",
207
203
  "GEM_PATH",
208
- "RUBYOPT",
209
- "RUBYLIB",
210
204
  "PATH",
211
205
  ].each do |key|
212
206
  required_env[key] = ENV[key] if ENV[key]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeSwarm
4
- VERSION = "1.0.5"
4
+ VERSION = "1.0.6"
5
5
  end
@@ -29,7 +29,7 @@ module SwarmCLI
29
29
 
30
30
  # Validate the swarm configuration
31
31
  begin
32
- SwarmSDK::Swarm.load(config_path)
32
+ SwarmSDK.load_file(config_path)
33
33
  rescue SwarmSDK::ConfigurationError => e
34
34
  $stderr.puts "Error: Invalid swarm configuration: #{e.message}"
35
35
  exit(1)
@@ -92,7 +92,7 @@ module SwarmCLI
92
92
 
93
93
  define_method(:call) do |task:, description: nil, thinking_budget: nil|
94
94
  # Load swarm for each execution (ensures fresh state)
95
- swarm = SwarmSDK::Swarm.load(self.class.config_path)
95
+ swarm = SwarmSDK.load_file(self.class.config_path)
96
96
 
97
97
  # Build prompt with thinking budget if provided
98
98
  prompt = task
@@ -4,7 +4,7 @@ module SwarmCLI
4
4
  # ConfigLoader handles loading swarm configurations from both YAML and Ruby DSL files.
5
5
  #
6
6
  # Supports:
7
- # - YAML files (.yml, .yaml) - loaded via SwarmSDK::Swarm.load
7
+ # - YAML files (.yml, .yaml) - loaded via SwarmSDK.load_file
8
8
  # - Ruby DSL files (.rb) - executed and expected to return a SwarmSDK::Swarm or SwarmSDK::NodeOrchestrator instance
9
9
  #
10
10
  # @example Load YAML config
@@ -18,7 +18,7 @@ module SwarmCLI
18
18
  # Load a swarm configuration from file (YAML or Ruby DSL)
19
19
  #
20
20
  # Detects file type by extension:
21
- # - .yml, .yaml -> Load as YAML using SwarmSDK::Swarm.load
21
+ # - .yml, .yaml -> Load as YAML using SwarmSDK.load_file
22
22
  # - .rb -> Execute as Ruby DSL and expect SwarmSDK::Swarm or SwarmSDK::NodeOrchestrator instance
23
23
  #
24
24
  # @param path [String, Pathname] Path to configuration file
@@ -50,7 +50,7 @@ module SwarmCLI
50
50
  # @param path [Pathname] Path to YAML file
51
51
  # @return [SwarmSDK::Swarm] Configured swarm instance
52
52
  def load_yaml(path)
53
- SwarmSDK::Swarm.load(path.to_s)
53
+ SwarmSDK.load_file(path.to_s)
54
54
  rescue SwarmSDK::ConfigurationError => e
55
55
  # Re-raise with CLI context
56
56
  raise ConfigurationError, "Configuration error in #{path}: #{e.message}"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmCLI
4
- VERSION = "2.1.1"
4
+ VERSION = "2.1.2"
5
5
  end
@@ -7,11 +7,11 @@ module SwarmMemory
7
7
  # Subclasses must implement all public methods to provide
8
8
  # different storage backends (filesystem, Redis, SQLite, etc.)
9
9
  class Base
10
- # Maximum size per entry (1MB)
11
- MAX_ENTRY_SIZE = 1_000_000
10
+ # Maximum size per entry (3MB)
11
+ MAX_ENTRY_SIZE = 3_000_000
12
12
 
13
- # Maximum total storage size (100MB)
14
- MAX_TOTAL_SIZE = 100_000_000
13
+ # Maximum total storage size (100GB)
14
+ MAX_TOTAL_SIZE = 100_000_000_000
15
15
 
16
16
  # Write content to storage
17
17
  #
@@ -2,40 +2,77 @@
2
2
 
3
3
  module SwarmMemory
4
4
  module Core
5
- # StorageReadTracker manages read-entry tracking for all agents
5
+ # StorageReadTracker manages read-entry tracking for all agents with content digest verification
6
6
  #
7
7
  # This module maintains a global registry of which memory entries each agent
8
- # has read during their conversation. This enables enforcement of the
9
- # "read-before-edit" rule that ensures agents have context before modifying entries.
8
+ # has read during their conversation along with SHA256 digests of the content.
9
+ # This enables enforcement of the "read-before-edit" rule that ensures agents
10
+ # have context before modifying entries, AND prevents editing entries that have
11
+ # changed externally since being read.
10
12
  #
11
- # Each agent maintains an independent set of read entries, keyed by agent identifier.
13
+ # Each agent maintains an independent map of read entries to content digests.
12
14
  module StorageReadTracker
13
- @read_entries = {}
15
+ @read_entries = {} # { agent_id => { entry_path => sha256_digest } }
14
16
  @mutex = Mutex.new
15
17
 
16
18
  class << self
17
- # Register that an agent has read a storage entry
19
+ # Register that an agent has read a storage entry with content digest
18
20
  #
19
21
  # @param agent_id [Symbol] The agent identifier
20
22
  # @param entry_path [String] The storage entry path
21
- # @return [void]
22
- def register_read(agent_id, entry_path)
23
+ # @param content [String] Entry content (for digest calculation)
24
+ # @return [String] The calculated SHA256 digest
25
+ def register_read(agent_id, entry_path, content)
23
26
  @mutex.synchronize do
24
- @read_entries[agent_id] ||= Set.new
25
- @read_entries[agent_id] << entry_path
27
+ @read_entries[agent_id] ||= {}
28
+ digest = Digest::SHA256.hexdigest(content)
29
+ @read_entries[agent_id][entry_path] = digest
30
+ digest
26
31
  end
27
32
  end
28
33
 
29
- # Check if an agent has read a storage entry
34
+ # Check if an agent has read an entry AND content hasn't changed
30
35
  #
31
36
  # @param agent_id [Symbol] The agent identifier
32
37
  # @param entry_path [String] The storage entry path
33
- # @return [Boolean] true if the agent has read this entry
34
- def entry_read?(agent_id, entry_path)
38
+ # @param storage [Storage] Storage instance to read current content
39
+ # @return [Boolean] true if agent read entry and content matches
40
+ def entry_read?(agent_id, entry_path, storage)
35
41
  @mutex.synchronize do
36
42
  return false unless @read_entries[agent_id]
37
43
 
38
- @read_entries[agent_id].include?(entry_path)
44
+ stored_digest = @read_entries[agent_id][entry_path]
45
+ return false unless stored_digest
46
+
47
+ # Check if entry still matches stored digest
48
+ begin
49
+ current_content = storage.read(file_path: entry_path)
50
+ current_digest = Digest::SHA256.hexdigest(current_content)
51
+ current_digest == stored_digest
52
+ rescue StandardError
53
+ false # Entry deleted or inaccessible
54
+ end
55
+ end
56
+ end
57
+
58
+ # Get all read entries with digests for snapshot
59
+ #
60
+ # @param agent_id [Symbol] The agent identifier
61
+ # @return [Hash] { entry_path => digest }
62
+ def get_read_entries(agent_id)
63
+ @mutex.synchronize do
64
+ @read_entries[agent_id]&.dup || {}
65
+ end
66
+ end
67
+
68
+ # Restore read entries with digests from snapshot
69
+ #
70
+ # @param agent_id [Symbol] The agent identifier
71
+ # @param entries_with_digests [Hash] { entry_path => digest }
72
+ # @return [void]
73
+ def restore_read_entries(agent_id, entries_with_digests)
74
+ @mutex.synchronize do
75
+ @read_entries[agent_id] = entries_with_digests.dup
39
76
  end
40
77
  end
41
78
 
@@ -13,8 +13,9 @@ module SwarmMemory
13
13
  #
14
14
  # @return [void]
15
15
  def register!
16
- # Only register if SwarmCLI is present
17
- return unless defined?(SwarmCLI)
16
+ # Only register if SwarmCLI::CommandRegistry is available
17
+ # Check for the specific class, not just the module
18
+ return unless defined?(SwarmCLI::CommandRegistry)
18
19
 
19
20
  # Load CLI commands explicitly (Zeitwerk might not have loaded it yet)
20
21
  require_relative "../cli/commands"
@@ -250,9 +250,12 @@ module SwarmMemory
250
250
  :interactive # Default
251
251
  end
252
252
 
253
- # Store storage and mode for this agent
254
- @storages[agent_name] = storage
255
- @modes[agent_name] = mode
253
+ # V7.0: Extract base name for storage tracking (delegation instances share storage)
254
+ base_name = agent_name.to_s.split("@").first.to_sym
255
+
256
+ # Store storage and mode using BASE NAME
257
+ @storages[base_name] = storage # ← Changed from agent_name to base_name
258
+ @modes[base_name] = mode # ← Changed from agent_name to base_name
256
259
 
257
260
  # Get mode-specific tools
258
261
  allowed_tools = tools_for_mode(mode)
@@ -298,9 +301,12 @@ module SwarmMemory
298
301
  # @param is_first_message [Boolean] True if first message
299
302
  # @return [Array<String>] System reminders (0-2 reminders)
300
303
  def on_user_message(agent_name:, prompt:, is_first_message:)
301
- storage = @storages[agent_name]
304
+ # V7.0: Extract base name for storage lookup (delegation instances share storage)
305
+ base_name = agent_name.to_s.split("@").first.to_sym
306
+ storage = @storages[base_name] # ← Changed from agent_name to base_name
307
+
302
308
  return [] unless storage&.semantic_index
303
- return [] if prompt.empty?
309
+ return [] if prompt.nil? || prompt.empty?
304
310
 
305
311
  # Adaptive threshold based on query length
306
312
  # Short queries use lower threshold as they have less semantic richness
@@ -124,8 +124,8 @@ module SwarmMemory
124
124
  # Read current content (this will raise ArgumentError if entry doesn't exist)
125
125
  content = @storage.read(file_path: file_path)
126
126
 
127
- # Enforce read-before-edit
128
- unless Core::StorageReadTracker.entry_read?(@agent_name, file_path)
127
+ # Enforce read-before-edit with content verification
128
+ unless Core::StorageReadTracker.entry_read?(@agent_name, file_path, @storage)
129
129
  return validation_error(
130
130
  "Cannot edit memory entry without reading it first. " \
131
131
  "You must use MemoryRead on 'memory://#{file_path}' before editing it. " \
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmMemory
4
- VERSION = "2.1.2"
4
+ VERSION = "2.1.3"
5
5
  end
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],
@@ -186,9 +186,13 @@ module SwarmSDK
186
186
  def trigger_post_tool_use(result, tool_call:)
187
187
  return result unless @hook_executor
188
188
 
189
+ # Extract tracking digest for Read/MemoryRead tools
190
+ metadata_with_digest = extract_tool_tracking_digest(tool_call, result)
191
+
189
192
  context = build_hook_context(
190
193
  event: :post_tool_use,
191
194
  tool_result: wrap_tool_result(tool_call.id, tool_call.name, result),
195
+ metadata: metadata_with_digest,
192
196
  )
193
197
 
194
198
  agent_hooks = @hook_agent_hooks[:post_tool_use] || []
@@ -335,6 +339,43 @@ module SwarmSDK
335
339
  )
336
340
  end
337
341
 
342
+ # Extract tracking digest for Read/MemoryRead tools
343
+ #
344
+ # Queries the appropriate tracker after tool execution to get the digest
345
+ # that was calculated and stored during the read operation.
346
+ #
347
+ # @param tool_call [RubyLLM::ToolCall] Tool call with arguments
348
+ # @param result [Object] Tool execution result (to check for errors)
349
+ # @return [Hash] Metadata hash with digest if applicable
350
+ def extract_tool_tracking_digest(tool_call, result)
351
+ # Only add digest for successful Read/MemoryRead tool calls
352
+ return {} if result.is_a?(StandardError)
353
+ return {} unless ["Read", "MemoryRead"].include?(tool_call.name)
354
+
355
+ # Extract path from arguments
356
+ path = case tool_call.name
357
+ when "Read"
358
+ tool_call.arguments[:file_path] || tool_call.arguments["file_path"]
359
+ when "MemoryRead"
360
+ tool_call.arguments[:file_path] || tool_call.arguments["file_path"]
361
+ end
362
+
363
+ return {} unless path
364
+
365
+ # Query tracker for digest
366
+ digest = case tool_call.name
367
+ when "Read"
368
+ Tools::Stores::ReadTracker.get_read_files(@agent_context.name)[File.expand_path(path)]
369
+ when "MemoryRead"
370
+ # Only query if SwarmMemory is loaded (optional dependency)
371
+ if defined?(SwarmMemory::Core::StorageReadTracker)
372
+ SwarmMemory::Core::StorageReadTracker.get_read_entries(@agent_context.name)[path]
373
+ end
374
+ end
375
+
376
+ digest ? { read_digest: digest, read_path: path } : {}
377
+ end
378
+
338
379
  # Wrap a tool result in our Hooks::ToolResult value object
339
380
  #
340
381
  # @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 (before + after user message)
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. BEFORE_FIRST_MESSAGE_REMINDER (general reminders)
42
+ # 1. User's actual prompt
61
43
  # 2. Toolset reminder (list of available tools)
62
- # 3. User's actual prompt
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
- full_content = [
52
+ parts = [
72
53
  prompt,
73
- BEFORE_FIRST_MESSAGE_REMINDER,
74
54
  build_toolset_reminder(chat),
75
- AFTER_FIRST_MESSAGE_REMINDER,
76
- ].join("\n\n")
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)