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.
- checksums.yaml +4 -4
- data/lib/claude_swarm/cli.rb +9 -11
- data/lib/claude_swarm/commands/ps.rb +1 -2
- data/lib/claude_swarm/configuration.rb +30 -7
- data/lib/claude_swarm/mcp_generator.rb +4 -10
- data/lib/claude_swarm/orchestrator.rb +43 -44
- data/lib/claude_swarm/system_utils.rb +4 -4
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm.rb +5 -9
- data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
- data/lib/swarm_cli/commands/mcp_tools.rb +3 -3
- data/lib/swarm_cli/config_loader.rb +14 -13
- data/lib/swarm_cli/version.rb +1 -1
- data/lib/swarm_cli.rb +2 -0
- data/lib/swarm_memory/adapters/base.rb +4 -4
- data/lib/swarm_memory/adapters/filesystem_adapter.rb +0 -12
- data/lib/swarm_memory/core/storage.rb +66 -6
- 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 +24 -4
- data/lib/swarm_memory/optimization/defragmenter.rb +4 -0
- data/lib/swarm_memory/tools/memory_edit.rb +3 -2
- data/lib/swarm_memory/tools/memory_glob.rb +24 -1
- 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/tools/memory_write.rb +2 -2
- data/lib/swarm_memory/version.rb +1 -1
- data/lib/swarm_memory.rb +7 -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 +41 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
- data/lib/swarm_sdk/agent/chat.rb +199 -52
- 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 +32 -23
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
- data/lib/swarm_sdk/configuration.rb +420 -103
- 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 +39 -9
- data/lib/swarm_sdk/node/builder.rb +158 -42
- data/lib/swarm_sdk/node_context.rb +75 -0
- data/lib/swarm_sdk/node_orchestrator.rb +492 -18
- data/lib/swarm_sdk/plugin.rb +73 -1
- data/lib/swarm_sdk/proc_helpers.rb +53 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
- data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
- data/lib/swarm_sdk/restore_result.rb +65 -0
- data/lib/swarm_sdk/result.rb +32 -6
- 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 -11
- 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 +367 -90
- 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 +94 -9
- data/lib/swarm_sdk/tools/read.rb +17 -5
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
- 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/tools/stores/storage.rb +4 -4
- data/lib/swarm_sdk/tools/think.rb +4 -1
- data/lib/swarm_sdk/tools/todo_write.rb +20 -8
- 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 +365 -28
- 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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
9
|
-
# "read-before-edit" rule that ensures agents
|
|
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
|
|
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
|
-
# @
|
|
22
|
-
|
|
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] ||=
|
|
25
|
-
|
|
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
|
|
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
|
-
# @
|
|
34
|
-
|
|
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]
|
|
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
|
|
17
|
-
|
|
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
|
-
#
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
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,
|
data/lib/swarm_memory/version.rb
CHANGED
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
|
|
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)
|