swarm_memory 2.1.1 → 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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/lib/claude_swarm/cli.rb +9 -11
  3. data/lib/claude_swarm/commands/ps.rb +1 -2
  4. data/lib/claude_swarm/configuration.rb +30 -7
  5. data/lib/claude_swarm/mcp_generator.rb +4 -10
  6. data/lib/claude_swarm/orchestrator.rb +43 -44
  7. data/lib/claude_swarm/system_utils.rb +4 -4
  8. data/lib/claude_swarm/version.rb +1 -1
  9. data/lib/claude_swarm.rb +5 -9
  10. data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
  11. data/lib/swarm_cli/commands/mcp_tools.rb +3 -3
  12. data/lib/swarm_cli/config_loader.rb +14 -13
  13. data/lib/swarm_cli/version.rb +1 -1
  14. data/lib/swarm_cli.rb +2 -0
  15. data/lib/swarm_memory/adapters/base.rb +4 -4
  16. data/lib/swarm_memory/adapters/filesystem_adapter.rb +0 -12
  17. data/lib/swarm_memory/core/storage.rb +66 -6
  18. data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
  19. data/lib/swarm_memory/integration/cli_registration.rb +3 -2
  20. data/lib/swarm_memory/integration/sdk_plugin.rb +24 -4
  21. data/lib/swarm_memory/optimization/defragmenter.rb +4 -0
  22. data/lib/swarm_memory/tools/memory_edit.rb +3 -2
  23. data/lib/swarm_memory/tools/memory_glob.rb +24 -1
  24. data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
  25. data/lib/swarm_memory/tools/memory_read.rb +3 -3
  26. data/lib/swarm_memory/tools/memory_write.rb +2 -2
  27. data/lib/swarm_memory/version.rb +1 -1
  28. data/lib/swarm_memory.rb +7 -0
  29. data/lib/swarm_sdk/agent/builder.rb +33 -0
  30. data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
  31. data/lib/swarm_sdk/agent/chat/hook_integration.rb +41 -0
  32. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
  33. data/lib/swarm_sdk/agent/chat.rb +199 -52
  34. data/lib/swarm_sdk/agent/context.rb +6 -2
  35. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  36. data/lib/swarm_sdk/agent/definition.rb +32 -23
  37. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
  38. data/lib/swarm_sdk/configuration.rb +420 -103
  39. data/lib/swarm_sdk/events_to_messages.rb +181 -0
  40. data/lib/swarm_sdk/log_collector.rb +31 -5
  41. data/lib/swarm_sdk/log_stream.rb +37 -8
  42. data/lib/swarm_sdk/model_aliases.json +4 -1
  43. data/lib/swarm_sdk/node/agent_config.rb +39 -9
  44. data/lib/swarm_sdk/node/builder.rb +158 -42
  45. data/lib/swarm_sdk/node_context.rb +75 -0
  46. data/lib/swarm_sdk/node_orchestrator.rb +492 -18
  47. data/lib/swarm_sdk/plugin.rb +73 -1
  48. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  49. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
  50. data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
  51. data/lib/swarm_sdk/restore_result.rb +65 -0
  52. data/lib/swarm_sdk/result.rb +32 -6
  53. data/lib/swarm_sdk/snapshot.rb +156 -0
  54. data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
  55. data/lib/swarm_sdk/state_restorer.rb +491 -0
  56. data/lib/swarm_sdk/state_snapshot.rb +369 -0
  57. data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
  58. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  59. data/lib/swarm_sdk/swarm/builder.rb +208 -11
  60. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  61. data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
  62. data/lib/swarm_sdk/swarm.rb +367 -90
  63. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  64. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  65. data/lib/swarm_sdk/tools/delegate.rb +94 -9
  66. data/lib/swarm_sdk/tools/read.rb +17 -5
  67. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
  68. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
  69. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
  70. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  71. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
  72. data/lib/swarm_sdk/tools/stores/storage.rb +4 -4
  73. data/lib/swarm_sdk/tools/think.rb +4 -1
  74. data/lib/swarm_sdk/tools/todo_write.rb +20 -8
  75. data/lib/swarm_sdk/utils.rb +18 -0
  76. data/lib/swarm_sdk/validation_result.rb +33 -0
  77. data/lib/swarm_sdk/version.rb +1 -1
  78. data/lib/swarm_sdk.rb +365 -28
  79. metadata +17 -5
@@ -95,22 +95,82 @@ module SwarmMemory
95
95
  )
96
96
  end
97
97
 
98
- # Read content from storage
98
+ # Read content from storage, automatically following stub redirects
99
99
  #
100
100
  # @param file_path [String] Path to read from
101
101
  # @return [String] Content at the path
102
102
  def read(file_path:)
103
- normalized_path = PathNormalizer.normalize(file_path)
104
- @adapter.read(file_path: normalized_path)
103
+ entry = read_entry(file_path: file_path)
104
+ entry.content
105
105
  end
106
106
 
107
- # Read full entry with metadata
107
+ # Read full entry with metadata, automatically following stub redirects
108
+ #
109
+ # Stub redirects are created by MemoryDefrag when merging/moving entries.
110
+ # This method transparently follows redirect chains up to 5 levels deep.
108
111
  #
109
112
  # @param file_path [String] Path to read from
113
+ # @param visited [Array<String>] Internal: tracks visited paths to detect circular redirects
110
114
  # @return [Entry] Full entry object
111
- def read_entry(file_path:)
115
+ # @raise [ArgumentError] If path not found, circular redirect detected, or too many redirects
116
+ def read_entry(file_path:, visited: [])
112
117
  normalized_path = PathNormalizer.normalize(file_path)
113
- @adapter.read_entry(file_path: normalized_path)
118
+
119
+ # Detect circular redirects immediately
120
+ if visited.include?(normalized_path)
121
+ cycle = visited + [normalized_path]
122
+ raise ArgumentError,
123
+ "Circular redirect detected in memory storage: #{cycle.join(" → ")}\n\n" \
124
+ "This indicates corrupted stub files. Please run MemoryDefrag to repair:\n " \
125
+ "MemoryDefrag(action: \"analyze\")"
126
+ end
127
+
128
+ # Check depth limit (prevent infinite chains)
129
+ if visited.size >= 5
130
+ chain = visited + [normalized_path]
131
+ raise ArgumentError,
132
+ "Memory redirect chain too deep (>5 redirects): #{chain.join(" → ")}\n\n" \
133
+ "This indicates fragmented memory storage. Please run maintenance:\n " \
134
+ "MemoryDefrag(action: \"full\", dry_run: true) # Preview first\n " \
135
+ "MemoryDefrag(action: \"full\", dry_run: false) # Execute"
136
+ end
137
+
138
+ # Read entry from adapter
139
+ begin
140
+ entry = @adapter.read_entry(file_path: normalized_path)
141
+ rescue ArgumentError
142
+ # If this is a redirect target that doesn't exist, provide helpful error
143
+ if visited.empty?
144
+ # Not a redirect, just re-raise original error
145
+ raise
146
+ else
147
+ original_path = visited.first
148
+ raise ArgumentError,
149
+ "memory://#{original_path} was redirected to memory://#{normalized_path}, but the target was not found.\n\n" \
150
+ "The original entry may have been merged or moved incorrectly. " \
151
+ "Run MemoryDefrag to identify and fix broken redirects:\n " \
152
+ "MemoryDefrag(action: \"analyze\")"
153
+ end
154
+ end
155
+
156
+ # Check if this is a stub redirect
157
+ if entry.metadata && entry.metadata["stub"] == true
158
+ redirect_target = entry.metadata["redirect_to"]
159
+
160
+ # Validate redirect target exists
161
+ if redirect_target.nil? || redirect_target.strip.empty?
162
+ raise ArgumentError,
163
+ "memory://#{normalized_path} is a stub with invalid redirect metadata.\n\n" \
164
+ "This should never happen (stubs are created by MemoryDefrag). " \
165
+ "The stub file may be corrupted. Please report this as a bug."
166
+ end
167
+
168
+ # Follow redirect recursively, tracking visited paths
169
+ return read_entry(file_path: redirect_target, visited: visited + [normalized_path])
170
+ end
171
+
172
+ # Not a stub, return the entry
173
+ entry
114
174
  end
115
175
 
116
176
  # Delete an entry
@@ -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"
@@ -207,6 +207,19 @@ module SwarmMemory
207
207
  agent_definition.memory_enabled?
208
208
  end
209
209
 
210
+ # Contribute to agent serialization
211
+ #
212
+ # Preserves memory configuration when agents are cloned (e.g., in NodeOrchestrator).
213
+ # This allows memory configuration to persist across node transitions.
214
+ #
215
+ # @param agent_definition [Agent::Definition] Agent definition
216
+ # @return [Hash] Memory config to include in to_h
217
+ def serialize_config(agent_definition:)
218
+ return {} unless agent_definition.memory
219
+
220
+ { memory: agent_definition.memory }
221
+ end
222
+
210
223
  # Lifecycle: Agent initialized
211
224
  #
212
225
  # Filters tools by mode (removing non-mode tools), registers LoadSkill,
@@ -237,9 +250,12 @@ module SwarmMemory
237
250
  :interactive # Default
238
251
  end
239
252
 
240
- # Store storage and mode for this agent
241
- @storages[agent_name] = storage
242
- @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
243
259
 
244
260
  # Get mode-specific tools
245
261
  allowed_tools = tools_for_mode(mode)
@@ -285,8 +301,12 @@ module SwarmMemory
285
301
  # @param is_first_message [Boolean] True if first message
286
302
  # @return [Array<String>] System reminders (0-2 reminders)
287
303
  def on_user_message(agent_name:, prompt:, is_first_message:)
288
- 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
+
289
308
  return [] unless storage&.semantic_index
309
+ return [] if prompt.nil? || prompt.empty?
290
310
 
291
311
  # Adaptive threshold based on query length
292
312
  # Short queries use lower threshold as they have less semantic richness
@@ -747,7 +747,11 @@ module SwarmMemory
747
747
  # @param to [String] Target path
748
748
  # @param reason [String] Reason (merged, moved)
749
749
  # @return [void]
750
+ # @raise [ArgumentError] If target path or reason is nil/empty
750
751
  def create_stub(from:, to:, reason:)
752
+ raise ArgumentError, "Cannot create stub without target path" if to.nil? || to.strip.empty?
753
+ raise ArgumentError, "Cannot create stub without reason" if reason.nil? || reason.strip.empty?
754
+
751
755
  stub_content = "# #{reason} → #{to}\n\nThis entry was #{reason} into #{to}."
752
756
 
753
757
  @adapter.write(
@@ -85,6 +85,7 @@ module SwarmMemory
85
85
 
86
86
  param :replace_all,
87
87
  desc: "Replace all occurrences of old_string (default false)",
88
+ type: :boolean,
88
89
  required: false
89
90
 
90
91
  # Initialize with storage instance and agent name
@@ -123,8 +124,8 @@ module SwarmMemory
123
124
  # Read current content (this will raise ArgumentError if entry doesn't exist)
124
125
  content = @storage.read(file_path: file_path)
125
126
 
126
- # Enforce read-before-edit
127
- 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)
128
129
  return validation_error(
129
130
  "Cannot edit memory entry without reading it first. " \
130
131
  "You must use MemoryRead on 'memory://#{file_path}' before editing it. " \
@@ -100,6 +100,8 @@ module SwarmMemory
100
100
  desc: "Glob pattern - target concept/, fact/, skill/, or experience/ only (e.g., 'skill/**', 'concept/ruby/*', 'fact/people/*.md')",
101
101
  required: true
102
102
 
103
+ MAX_RESULTS = 500 # Limit results to prevent overwhelming output
104
+
103
105
  # Initialize with storage instance
104
106
  #
105
107
  # @param storage [Core::Storage] Storage instance
@@ -124,6 +126,14 @@ module SwarmMemory
124
126
  return "No entries found matching pattern '#{pattern}'"
125
127
  end
126
128
 
129
+ # Limit results
130
+ if entries.count > MAX_RESULTS
131
+ entries = entries.take(MAX_RESULTS)
132
+ truncated = true
133
+ else
134
+ truncated = false
135
+ end
136
+
127
137
  result = []
128
138
  result << "Memory entries matching '#{pattern}' (#{entries.size} #{entries.size == 1 ? "entry" : "entries"}):"
129
139
 
@@ -131,7 +141,20 @@ module SwarmMemory
131
141
  result << " memory://#{entry[:path]} - \"#{entry[:title]}\" (#{format_bytes(entry[:size])})"
132
142
  end
133
143
 
134
- result.join("\n")
144
+ output = result.join("\n")
145
+
146
+ # Add system reminder if truncated
147
+ if truncated
148
+ output += <<~REMINDER
149
+
150
+ <system-reminder>
151
+ Results limited to first #{MAX_RESULTS} matches (sorted by most recently modified).
152
+ Consider using a more specific pattern to narrow your search.
153
+ </system-reminder>
154
+ REMINDER
155
+ end
156
+
157
+ output
135
158
  rescue ArgumentError => e
136
159
  validation_error(e.message)
137
160
  end
@@ -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
@@ -45,8 +45,8 @@ module SwarmMemory
45
45
  TAGS ARE CRITICAL: Think "What would I search for in 6 months?" For skills especially, be VERY comprehensive with tags - they're your search index.
46
46
 
47
47
  EXAMPLES:
48
- - For concept: tags: ['ruby', 'oop', 'classes', 'inheritance', 'methods']
49
- - For skill: tags: ['debugging', 'api', 'http', 'errors', 'trace', 'network', 'rest']
48
+ - For concept: tags: (JSON) "['ruby', 'oop', 'classes', 'inheritance', 'methods']"
49
+ - For skill: tags: (JSON) "['debugging', 'api', 'http', 'errors', 'trace', 'network', 'rest']"
50
50
  DESC
51
51
 
52
52
  param :file_path,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmMemory
4
- VERSION = "2.1.1"
4
+ VERSION = "2.1.3"
5
5
  end
data/lib/swarm_memory.rb CHANGED
@@ -28,7 +28,14 @@ require_relative "swarm_memory/version"
28
28
  # Setup Zeitwerk loader
29
29
  require "zeitwerk"
30
30
  loader = Zeitwerk::Loader.new
31
+ loader.tag = File.basename(__FILE__, ".rb")
31
32
  loader.push_dir("#{__dir__}/swarm_memory", namespace: SwarmMemory)
33
+ loader.inflector = Zeitwerk::GemInflector.new(__FILE__)
34
+ loader.inflector.inflect(
35
+ "cli" => "CLI",
36
+ "dsl" => "DSL",
37
+ "sdk_plugin" => "SDKPlugin",
38
+ )
32
39
  loader.setup
33
40
 
34
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)