lex-agentic-memory 0.1.21 → 0.1.22
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/CHANGELOG.md +7 -0
- data/lib/legion/extensions/agentic/memory/trace/actors/decay.rb +4 -0
- data/lib/legion/extensions/agentic/memory/trace/helpers/hot_tier.rb +26 -11
- data/lib/legion/extensions/agentic/memory/trace/helpers/postgres_store.rb +20 -8
- data/lib/legion/extensions/agentic/memory/trace/helpers/store.rb +121 -27
- data/lib/legion/extensions/agentic/memory/trace/helpers/trace.rb +7 -1
- data/lib/legion/extensions/agentic/memory/trace/runners/consolidation.rb +57 -2
- data/lib/legion/extensions/agentic/memory/trace/runners/traces.rb +10 -0
- data/lib/legion/extensions/agentic/memory/trace.rb +36 -7
- data/lib/legion/extensions/agentic/memory/version.rb +1 -1
- data/spec/legion/extensions/agentic/memory/trace/actors/decay_spec.rb +6 -0
- data/spec/legion/extensions/agentic/memory/trace/helpers/hot_tier_spec.rb +16 -0
- data/spec/legion/extensions/agentic/memory/trace/helpers/postgres_store_spec.rb +13 -0
- data/spec/legion/extensions/agentic/memory/trace/helpers/store_spec.rb +26 -1
- data/spec/legion/extensions/agentic/memory/trace/local_persistence_spec.rb +73 -3
- data/spec/legion/extensions/agentic/memory/trace/memory_spec.rb +27 -0
- data/spec/legion/extensions/agentic/memory/trace/runners/consolidation_spec.rb +26 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be817722cc940ed946ce77beb9a7c33b5671ea3bf84d206229cdc507dbe85f0a
|
|
4
|
+
data.tar.gz: 73c42aea2da869ecce1de5c02346320531361fd4d563884d6724cd00ffbdb9c0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f35dce2fc9fd3d2a1fb7e2eb9e22b2656f4b637d24d5c9635dc737075015ef3b38237b570bb279d7a73e33cfeda5b49f8bbd4b75f5bf8b2f1cd2ee6ca74315f7
|
|
7
|
+
data.tar.gz: 381973ab2893d6b5f651e62b16ae6dbfe166e8b949243ca9f8f3cc717fe58d80012e6cca5a14fce9513eb0abac9b58f8d02978c6ce1707b75afdc34eb85b7b76
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.22] - 2026-04-03
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- Refactor `Store#save_to_local` into `snapshot_dirty_state`, `persist_dirty_traces`, `persist_dirty_associations`, and `clear_dirty_flags` helpers to reduce cyclomatic/perceived complexity
|
|
7
|
+
- Restructure `Consolidation#decay_cycle` early return to satisfy `RunnerReturnHash` cop
|
|
8
|
+
- Add exception capture and logging to `Consolidation#trace_count` rescue clause
|
|
9
|
+
|
|
3
10
|
## [0.1.21] - 2026-04-01
|
|
4
11
|
|
|
5
12
|
### Changed
|
|
@@ -12,24 +12,24 @@ module Legion
|
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
14
|
# Cache a trace in the Redis hot tier.
|
|
15
|
-
def cache_trace(trace, tenant_id: nil)
|
|
15
|
+
def cache_trace(trace, tenant_id: nil, agent_id: nil)
|
|
16
16
|
return unless available?
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
key = trace_key(
|
|
18
|
+
scope = cache_scope_id(trace, tenant_id: tenant_id, agent_id: agent_id)
|
|
19
|
+
key = trace_key(scope, trace[:trace_id])
|
|
20
20
|
data = serialize_trace(trace)
|
|
21
21
|
Legion::Cache::RedisHash.hset(key, data)
|
|
22
22
|
Legion::Cache::RedisHash.expire(key, HOT_TTL)
|
|
23
23
|
|
|
24
|
-
index_key = "legion:tier:hot:#{
|
|
24
|
+
index_key = "legion:tier:hot:#{scope}"
|
|
25
25
|
Legion::Cache::RedisHash.zadd(index_key, Time.now.to_f, trace[:trace_id])
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
# Fetch a trace from the hot tier. Returns a deserialized trace hash or nil on miss.
|
|
29
|
-
def fetch_trace(trace_id, tenant_id: nil)
|
|
29
|
+
def fetch_trace(trace_id, tenant_id: nil, agent_id: nil)
|
|
30
30
|
return nil unless available?
|
|
31
31
|
|
|
32
|
-
key = trace_key(tenant_id, trace_id)
|
|
32
|
+
key = trace_key(scope_id(tenant_id: tenant_id, agent_id: agent_id), trace_id)
|
|
33
33
|
data = Legion::Cache::RedisHash.hgetall(key)
|
|
34
34
|
return nil if data.nil? || data.empty?
|
|
35
35
|
|
|
@@ -37,13 +37,14 @@ module Legion
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
# Evict a trace from the hot tier and remove it from the sorted-set index.
|
|
40
|
-
def evict_trace(trace_id, tenant_id: nil)
|
|
40
|
+
def evict_trace(trace_id, tenant_id: nil, agent_id: nil)
|
|
41
41
|
return unless available?
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
scope = scope_id(tenant_id: tenant_id, agent_id: agent_id)
|
|
44
|
+
key = trace_key(scope, trace_id)
|
|
44
45
|
Legion::Cache.delete(key)
|
|
45
46
|
|
|
46
|
-
index_key = "legion:tier:hot:#{
|
|
47
|
+
index_key = "legion:tier:hot:#{scope}"
|
|
47
48
|
Legion::Cache::RedisHash.zrem(index_key, trace_id)
|
|
48
49
|
end
|
|
49
50
|
|
|
@@ -56,8 +57,22 @@ module Legion
|
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
# Build the namespaced Redis key for a trace.
|
|
59
|
-
def trace_key(
|
|
60
|
-
"legion:trace:#{
|
|
60
|
+
def trace_key(scope_id, trace_id)
|
|
61
|
+
"legion:trace:#{scope_id}:#{trace_id}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def scope_id(tenant_id: nil, agent_id: nil)
|
|
65
|
+
return tenant_id if tenant_id && agent_id.nil?
|
|
66
|
+
return agent_id if agent_id && tenant_id.nil?
|
|
67
|
+
|
|
68
|
+
[tenant_id, agent_id].compact.join(':')
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def cache_scope_id(trace, tenant_id: nil, agent_id: nil)
|
|
72
|
+
return scope_id(tenant_id: tenant_id, agent_id: agent_id) if agent_id
|
|
73
|
+
return tenant_id if tenant_id
|
|
74
|
+
|
|
75
|
+
trace[:partition_id]
|
|
61
76
|
end
|
|
62
77
|
|
|
63
78
|
# Serialize a trace hash to a string-only flat hash suitable for Redis HSET.
|
|
@@ -10,7 +10,7 @@ module Legion
|
|
|
10
10
|
module Helpers
|
|
11
11
|
# Write-through durable store backed by Legion::Data (PostgreSQL or MySQL).
|
|
12
12
|
# All writes go directly to the database — no in-memory dirty tracking, no flush.
|
|
13
|
-
# Scoped by tenant_id so multiple agents can share the same DB tables safely.
|
|
13
|
+
# Scoped by tenant_id and agent_id so multiple agents can share the same DB tables safely.
|
|
14
14
|
class PostgresStore
|
|
15
15
|
TRACES_TABLE = :memory_traces
|
|
16
16
|
ASSOCIATIONS_TABLE = :memory_associations
|
|
@@ -32,19 +32,19 @@ module Legion
|
|
|
32
32
|
else
|
|
33
33
|
ds.insert_conflict(target: :trace_id, update: row.except(:trace_id)).insert(row)
|
|
34
34
|
end
|
|
35
|
-
HotTier.cache_trace(trace, tenant_id: @tenant_id) if HotTier.available?
|
|
35
|
+
HotTier.cache_trace(trace, tenant_id: @tenant_id, agent_id: @agent_id) if HotTier.available?
|
|
36
36
|
trace[:trace_id]
|
|
37
37
|
rescue StandardError => e
|
|
38
38
|
log_warn("store failed: #{e.message}")
|
|
39
39
|
nil
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
# Retrieve a single trace by trace_id (tenant
|
|
42
|
+
# Retrieve a single trace by trace_id (tenant/agent scoped).
|
|
43
43
|
# Checks the Redis hot tier first; falls through to DB on a miss and caches the result.
|
|
44
44
|
# Returns a trace hash or nil.
|
|
45
45
|
def retrieve(trace_id)
|
|
46
46
|
if HotTier.available?
|
|
47
|
-
cached = HotTier.fetch_trace(trace_id, tenant_id: @tenant_id)
|
|
47
|
+
cached = HotTier.fetch_trace(trace_id, tenant_id: @tenant_id, agent_id: @agent_id)
|
|
48
48
|
return cached if cached
|
|
49
49
|
end
|
|
50
50
|
|
|
@@ -52,13 +52,15 @@ module Legion
|
|
|
52
52
|
|
|
53
53
|
row = traces_ds.where(trace_id: trace_id).first
|
|
54
54
|
trace = row ? deserialize_trace(row) : nil
|
|
55
|
-
HotTier.cache_trace(trace, tenant_id: @tenant_id) if HotTier.available? && trace
|
|
55
|
+
HotTier.cache_trace(trace, tenant_id: @tenant_id, agent_id: @agent_id) if HotTier.available? && trace
|
|
56
56
|
trace
|
|
57
57
|
rescue StandardError => e
|
|
58
58
|
log_warn("retrieve failed: #{e.message}")
|
|
59
59
|
nil
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
alias get retrieve
|
|
63
|
+
|
|
62
64
|
# Retrieve traces by type, ordered by strength descending.
|
|
63
65
|
def retrieve_by_type(type, limit: 100, min_strength: 0.0)
|
|
64
66
|
return [] unless db_ready?
|
|
@@ -105,7 +107,7 @@ module Legion
|
|
|
105
107
|
|
|
106
108
|
# Delete a trace and its association rows.
|
|
107
109
|
def delete(trace_id)
|
|
108
|
-
HotTier.evict_trace(trace_id, tenant_id: @tenant_id) if HotTier.available?
|
|
110
|
+
HotTier.evict_trace(trace_id, tenant_id: @tenant_id, agent_id: @agent_id) if HotTier.available?
|
|
109
111
|
return unless db_ready?
|
|
110
112
|
|
|
111
113
|
db[ASSOCIATIONS_TABLE].where(trace_id_a: trace_id).delete
|
|
@@ -121,7 +123,7 @@ module Legion
|
|
|
121
123
|
return unless db_ready?
|
|
122
124
|
|
|
123
125
|
db[TRACES_TABLE].where(trace_id: trace_id).update(map_update_fields(fields))
|
|
124
|
-
HotTier.evict_trace(trace_id, tenant_id: @tenant_id) if HotTier.available?
|
|
126
|
+
HotTier.evict_trace(trace_id, tenant_id: @tenant_id, agent_id: @agent_id) if HotTier.available?
|
|
125
127
|
rescue StandardError => e
|
|
126
128
|
log_warn("update failed: #{e.message}")
|
|
127
129
|
end
|
|
@@ -247,6 +249,15 @@ module Legion
|
|
|
247
249
|
retrieve_by_type(:firmware)
|
|
248
250
|
end
|
|
249
251
|
|
|
252
|
+
def count
|
|
253
|
+
return 0 unless db_ready?
|
|
254
|
+
|
|
255
|
+
traces_ds.count
|
|
256
|
+
rescue StandardError => e
|
|
257
|
+
log_warn("count failed: #{e.message}")
|
|
258
|
+
0
|
|
259
|
+
end
|
|
260
|
+
|
|
250
261
|
# No-op — this store is write-through; nothing to flush.
|
|
251
262
|
def flush; end
|
|
252
263
|
|
|
@@ -272,9 +283,10 @@ module Legion
|
|
|
272
283
|
'default'
|
|
273
284
|
end
|
|
274
285
|
|
|
275
|
-
# Dataset for memory_traces scoped by tenant_id (if set).
|
|
286
|
+
# Dataset for memory_traces scoped by agent_id and tenant_id (if set).
|
|
276
287
|
def traces_ds
|
|
277
288
|
ds = db[TRACES_TABLE]
|
|
289
|
+
ds = ds.where(agent_id: @agent_id)
|
|
278
290
|
@tenant_id ? ds.where(tenant_id: @tenant_id) : ds
|
|
279
291
|
end
|
|
280
292
|
|
|
@@ -13,27 +13,41 @@ module Legion
|
|
|
13
13
|
class Store
|
|
14
14
|
attr_reader :traces, :associations
|
|
15
15
|
|
|
16
|
-
def initialize
|
|
16
|
+
def initialize(partition_id: nil)
|
|
17
17
|
@mutex = Mutex.new
|
|
18
18
|
@traces = {}
|
|
19
19
|
@associations = Hash.new { |h, k| h[k] = Hash.new(0) }
|
|
20
|
+
@partition_id = partition_id || resolve_partition_id
|
|
21
|
+
@traces_dirty = false
|
|
22
|
+
@associations_dirty = false
|
|
23
|
+
@persisted_trace_rows = {}
|
|
20
24
|
load_from_local
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def store(trace)
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
persisted_trace = trace.dup
|
|
29
|
+
persisted_trace[:partition_id] ||= @partition_id
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
@traces_dirty = true if @traces[persisted_trace[:trace_id]] != persisted_trace
|
|
32
|
+
@traces[persisted_trace[:trace_id]] = persisted_trace
|
|
33
|
+
end
|
|
34
|
+
persisted_trace[:trace_id]
|
|
26
35
|
end
|
|
27
36
|
|
|
28
37
|
def get(trace_id)
|
|
29
|
-
@traces[trace_id]
|
|
38
|
+
@mutex.synchronize { @traces[trace_id] }
|
|
30
39
|
end
|
|
31
40
|
|
|
32
41
|
def delete(trace_id)
|
|
33
42
|
@mutex.synchronize do
|
|
34
|
-
@traces.delete(trace_id)
|
|
35
|
-
@
|
|
43
|
+
removed_trace = @traces.delete(trace_id)
|
|
44
|
+
@traces.each_value { |trace| trace[:associated_traces]&.delete(trace_id) }
|
|
45
|
+
|
|
46
|
+
removed_links = @associations.delete(trace_id)
|
|
36
47
|
@associations.each_value { |links| links.delete(trace_id) }
|
|
48
|
+
|
|
49
|
+
@traces_dirty = true if removed_trace
|
|
50
|
+
@associations_dirty = true if removed_trace || removed_links
|
|
37
51
|
end
|
|
38
52
|
end
|
|
39
53
|
|
|
@@ -68,9 +82,11 @@ module Legion
|
|
|
68
82
|
@mutex.synchronize do
|
|
69
83
|
@associations[trace_id_a][trace_id_b] += 1
|
|
70
84
|
@associations[trace_id_b][trace_id_a] += 1
|
|
85
|
+
@associations_dirty = true
|
|
71
86
|
|
|
72
87
|
threshold = Helpers::Trace::COACTIVATION_THRESHOLD
|
|
73
|
-
|
|
88
|
+
@traces_dirty = true if @associations[trace_id_a][trace_id_b] >= threshold &&
|
|
89
|
+
link_traces(trace_id_a, trace_id_b)
|
|
74
90
|
end
|
|
75
91
|
end
|
|
76
92
|
|
|
@@ -80,7 +96,7 @@ module Legion
|
|
|
80
96
|
end
|
|
81
97
|
|
|
82
98
|
def count
|
|
83
|
-
@traces.size
|
|
99
|
+
@mutex.synchronize { @traces.size }
|
|
84
100
|
end
|
|
85
101
|
|
|
86
102
|
def synchronize(&) = @mutex.synchronize(&)
|
|
@@ -89,6 +105,28 @@ module Legion
|
|
|
89
105
|
retrieve_by_type(:firmware)
|
|
90
106
|
end
|
|
91
107
|
|
|
108
|
+
def flush
|
|
109
|
+
save_to_local
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def restore_traces(traces)
|
|
113
|
+
snapshot = Array(traces).each_with_object({}) do |trace, memo|
|
|
114
|
+
next unless trace.is_a?(Hash) && trace[:trace_id]
|
|
115
|
+
|
|
116
|
+
restored = trace.dup
|
|
117
|
+
restored[:partition_id] ||= @partition_id
|
|
118
|
+
memo[restored[:trace_id]] = restored
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
@mutex.synchronize do
|
|
122
|
+
@traces = snapshot
|
|
123
|
+
@associations = Hash.new { |h, k| h[k] = Hash.new(0) }
|
|
124
|
+
@traces_dirty = true
|
|
125
|
+
@associations_dirty = true
|
|
126
|
+
end
|
|
127
|
+
flush
|
|
128
|
+
end
|
|
129
|
+
|
|
92
130
|
def walk_associations(start_id:, max_hops: 12, min_strength: 0.1)
|
|
93
131
|
snapshot = @mutex.synchronize { @traces.dup }
|
|
94
132
|
return [] unless snapshot.key?(start_id)
|
|
@@ -123,49 +161,96 @@ module Legion
|
|
|
123
161
|
return unless Legion::Data::Local.connection.table_exists?(:memory_traces)
|
|
124
162
|
|
|
125
163
|
db = Legion::Data::Local.connection
|
|
126
|
-
|
|
164
|
+
snapshots = snapshot_dirty_state
|
|
165
|
+
return unless snapshots
|
|
127
166
|
|
|
128
|
-
traces_snapshot
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
167
|
+
traces_snapshot, associations_snapshot, trace_rows_snapshot, traces_dirty, associations_dirty = snapshots
|
|
168
|
+
scoped_trace_ids = db[:memory_traces].where(partition_id: @partition_id).select_map(:trace_id)
|
|
169
|
+
memory_trace_ids = traces_snapshot.keys
|
|
170
|
+
stale_ids = persist_dirty_traces(db, trace_rows_snapshot, scoped_trace_ids, memory_trace_ids, traces_dirty)
|
|
171
|
+
persist_dirty_associations(db, associations_snapshot, scoped_trace_ids, memory_trace_ids, stale_ids, associations_dirty)
|
|
172
|
+
clear_dirty_flags(trace_rows_snapshot)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def snapshot_dirty_state
|
|
176
|
+
traces_snapshot, associations_snapshot, trace_rows_snapshot, traces_dirty, associations_dirty = @mutex.synchronize do
|
|
177
|
+
ts = @traces.transform_values(&:dup)
|
|
178
|
+
as = @associations.each_with_object({}) { |(tid, targets), memo| memo[tid] = targets.dup }
|
|
179
|
+
trs = ts.transform_values { |trace| serialize_trace_for_db(trace) }
|
|
180
|
+
[ts, as, trs, @traces_dirty || trs != @persisted_trace_rows, @associations_dirty]
|
|
181
|
+
end
|
|
182
|
+
return nil unless traces_dirty || associations_dirty
|
|
183
|
+
|
|
184
|
+
[traces_snapshot, associations_snapshot, trace_rows_snapshot, traces_dirty, associations_dirty]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def persist_dirty_traces(db, trace_rows_snapshot, scoped_trace_ids, memory_trace_ids, traces_dirty)
|
|
188
|
+
return [] unless traces_dirty
|
|
189
|
+
|
|
190
|
+
trace_rows_snapshot.each do |trace_id, row|
|
|
191
|
+
if db[:memory_traces].where(trace_id: trace_id).first
|
|
192
|
+
db[:memory_traces].where(trace_id: trace_id).update(row)
|
|
133
193
|
else
|
|
134
194
|
db[:memory_traces].insert(row)
|
|
135
195
|
end
|
|
136
196
|
end
|
|
137
|
-
|
|
138
|
-
db_trace_ids = db[:memory_traces].select_map(:trace_id)
|
|
139
|
-
memory_trace_ids = traces_snapshot.keys
|
|
140
|
-
stale_ids = db_trace_ids - memory_trace_ids
|
|
197
|
+
stale_ids = scoped_trace_ids - memory_trace_ids
|
|
141
198
|
db[:memory_traces].where(trace_id: stale_ids).delete unless stale_ids.empty?
|
|
199
|
+
stale_ids
|
|
200
|
+
end
|
|
142
201
|
|
|
143
|
-
|
|
144
|
-
|
|
202
|
+
def persist_dirty_associations(db, associations_snapshot, scoped_trace_ids, memory_trace_ids, stale_ids, dirty)
|
|
203
|
+
assoc_scope_ids = (scoped_trace_ids + memory_trace_ids).uniq
|
|
204
|
+
return unless (dirty || !stale_ids.empty?) && !assoc_scope_ids.empty?
|
|
205
|
+
|
|
206
|
+
db[:memory_associations].where(trace_id_a: assoc_scope_ids).delete
|
|
207
|
+
db[:memory_associations].where(trace_id_b: assoc_scope_ids).delete
|
|
208
|
+
associations_snapshot.each do |id_a, targets|
|
|
145
209
|
targets.each do |id_b, count|
|
|
146
210
|
db[:memory_associations].insert(trace_id_a: id_a, trace_id_b: id_b, coactivation_count: count)
|
|
147
211
|
end
|
|
148
212
|
end
|
|
149
213
|
end
|
|
150
214
|
|
|
215
|
+
def clear_dirty_flags(trace_rows_snapshot)
|
|
216
|
+
@mutex.synchronize do
|
|
217
|
+
@traces_dirty = false
|
|
218
|
+
@associations_dirty = false
|
|
219
|
+
@persisted_trace_rows = trace_rows_snapshot
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
151
223
|
def load_from_local
|
|
152
224
|
return unless defined?(Legion::Data::Local) && Legion::Data::Local.connected?
|
|
153
225
|
return unless Legion::Data::Local.connection.table_exists?(:memory_traces)
|
|
154
226
|
|
|
155
227
|
db = Legion::Data::Local.connection
|
|
156
228
|
|
|
157
|
-
db[:memory_traces].each do |row|
|
|
229
|
+
db[:memory_traces].where(partition_id: @partition_id).each do |row|
|
|
158
230
|
@traces[row[:trace_id]] = deserialize_trace_from_db(row)
|
|
159
231
|
end
|
|
160
232
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
233
|
+
trace_ids = @traces.keys
|
|
234
|
+
unless trace_ids.empty?
|
|
235
|
+
db[:memory_associations].where(trace_id_a: trace_ids).each do |row|
|
|
236
|
+
@associations[row[:trace_id_a]] ||= {}
|
|
237
|
+
@associations[row[:trace_id_a]][row[:trace_id_b]] = row[:coactivation_count]
|
|
238
|
+
end
|
|
164
239
|
end
|
|
240
|
+
|
|
241
|
+
@persisted_trace_rows = @traces.transform_values { |trace| serialize_trace_for_db(trace) }
|
|
242
|
+
@traces_dirty = false
|
|
243
|
+
@associations_dirty = false
|
|
165
244
|
end
|
|
166
245
|
|
|
167
246
|
private
|
|
168
247
|
|
|
248
|
+
def resolve_partition_id
|
|
249
|
+
Legion::Settings.dig(:agent, :id) || 'default'
|
|
250
|
+
rescue StandardError => _e
|
|
251
|
+
'default'
|
|
252
|
+
end
|
|
253
|
+
|
|
169
254
|
def serialize_trace_for_db(trace)
|
|
170
255
|
payload = trace[:content_payload] || trace[:content]
|
|
171
256
|
{
|
|
@@ -253,10 +338,19 @@ module Legion
|
|
|
253
338
|
return unless trace_a && trace_b
|
|
254
339
|
|
|
255
340
|
max = Helpers::Trace::MAX_ASSOCIATIONS
|
|
256
|
-
|
|
257
|
-
|
|
341
|
+
changed = false
|
|
342
|
+
|
|
343
|
+
unless trace_a[:associated_traces].include?(id_b) || trace_a[:associated_traces].size >= max
|
|
344
|
+
trace_a[:associated_traces] << id_b
|
|
345
|
+
changed = true
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
unless trace_b[:associated_traces].include?(id_a) || trace_b[:associated_traces].size >= max
|
|
349
|
+
trace_b[:associated_traces] << id_a
|
|
350
|
+
changed = true
|
|
351
|
+
end
|
|
258
352
|
|
|
259
|
-
|
|
353
|
+
changed
|
|
260
354
|
end
|
|
261
355
|
end
|
|
262
356
|
end
|
|
@@ -79,7 +79,7 @@ module Legion
|
|
|
79
79
|
reinforcement_count: imprint_active ? 1 : 0,
|
|
80
80
|
confidence: confidence || (type == :firmware ? 1.0 : 0.5),
|
|
81
81
|
storage_tier: :hot,
|
|
82
|
-
partition_id: partition_id,
|
|
82
|
+
partition_id: partition_id || default_partition_id,
|
|
83
83
|
encryption_key_id: nil,
|
|
84
84
|
associated_traces: [],
|
|
85
85
|
parent_trace_id: nil,
|
|
@@ -97,6 +97,12 @@ module Legion
|
|
|
97
97
|
|
|
98
98
|
true
|
|
99
99
|
end
|
|
100
|
+
|
|
101
|
+
def default_partition_id
|
|
102
|
+
Legion::Settings.dig(:agent, :id) || 'default'
|
|
103
|
+
rescue StandardError => _e
|
|
104
|
+
'default'
|
|
105
|
+
end
|
|
100
106
|
end
|
|
101
107
|
end
|
|
102
108
|
end
|
|
@@ -24,15 +24,22 @@ module Legion
|
|
|
24
24
|
trace[:reinforcement_count] += 1
|
|
25
25
|
|
|
26
26
|
store.store(trace)
|
|
27
|
+
persist_store(store)
|
|
27
28
|
|
|
28
29
|
log.debug("[memory] reinforced #{trace_id[0..7]} strength=#{new_strength.round(3)}#{' (imprint 3x)' if imprint_active}")
|
|
29
30
|
{ found: true, reinforced: true, trace_id: trace_id, new_strength: new_strength }
|
|
30
31
|
end
|
|
31
32
|
|
|
32
|
-
def decay_cycle(store: nil, tick_count: 1, **)
|
|
33
|
+
def decay_cycle(store: nil, tick_count: 1, maintenance: true, **)
|
|
33
34
|
store ||= default_store
|
|
35
|
+
unless maintenance
|
|
36
|
+
deferred = deferred_decay_summary(store)
|
|
37
|
+
return { **deferred }
|
|
38
|
+
end
|
|
39
|
+
|
|
34
40
|
decayed = 0
|
|
35
41
|
pruned = 0
|
|
42
|
+
total = trace_count(store)
|
|
36
43
|
|
|
37
44
|
store.all_traces.each do |trace|
|
|
38
45
|
next if trace[:base_decay_rate].zero?
|
|
@@ -55,8 +62,20 @@ module Legion
|
|
|
55
62
|
end
|
|
56
63
|
end
|
|
57
64
|
|
|
65
|
+
persist_store(store) if decayed.positive? || pruned.positive?
|
|
66
|
+
|
|
67
|
+
remaining = trace_count(store)
|
|
68
|
+
summary = {
|
|
69
|
+
decayed: decayed,
|
|
70
|
+
pruned: pruned,
|
|
71
|
+
total: total,
|
|
72
|
+
remaining: remaining,
|
|
73
|
+
maintained_at: Time.now.utc
|
|
74
|
+
}
|
|
75
|
+
Legion::Extensions::Agentic::Memory::Trace.record_maintenance_summary(summary.dup)
|
|
76
|
+
|
|
58
77
|
log.debug("[memory] decay cycle: decayed=#{decayed} pruned=#{pruned}")
|
|
59
|
-
|
|
78
|
+
summary
|
|
60
79
|
end
|
|
61
80
|
|
|
62
81
|
def migrate_tier(store: nil, **)
|
|
@@ -73,6 +92,7 @@ module Legion
|
|
|
73
92
|
migrated += 1
|
|
74
93
|
end
|
|
75
94
|
|
|
95
|
+
persist_store(store) if migrated.positive?
|
|
76
96
|
log.debug("[memory] tier migration: migrated=#{migrated}")
|
|
77
97
|
{ migrated: migrated }
|
|
78
98
|
end
|
|
@@ -82,6 +102,7 @@ module Legion
|
|
|
82
102
|
|
|
83
103
|
store ||= default_store
|
|
84
104
|
store.record_coactivation(trace_id_a, trace_id_b)
|
|
105
|
+
persist_store(store)
|
|
85
106
|
log.debug("[memory] hebbian link #{trace_id_a[0..7]} <-> #{trace_id_b[0..7]}")
|
|
86
107
|
{ linked: true }
|
|
87
108
|
end
|
|
@@ -99,6 +120,7 @@ module Legion
|
|
|
99
120
|
traces = store.retrieve_by_type(type, min_strength: 0.0, limit: 100_000)
|
|
100
121
|
count = traces.size
|
|
101
122
|
traces.each { |t| store.delete(t[:trace_id]) }
|
|
123
|
+
persist_store(store) if count.positive?
|
|
102
124
|
log.info("[memory] erased #{count} traces of type=#{type}")
|
|
103
125
|
{ erased: count, type: type }
|
|
104
126
|
end
|
|
@@ -108,12 +130,45 @@ module Legion
|
|
|
108
130
|
traces = store.all_traces.select { |t| t[:partition_id] == partition_id }
|
|
109
131
|
count = traces.size
|
|
110
132
|
traces.each { |t| store.delete(t[:trace_id]) }
|
|
133
|
+
persist_store(store) if count.positive?
|
|
111
134
|
log.info("[memory] erased #{count} traces for partition=#{partition_id}")
|
|
112
135
|
{ erased: count, partition_id: partition_id }
|
|
113
136
|
end
|
|
114
137
|
|
|
115
138
|
private
|
|
116
139
|
|
|
140
|
+
def deferred_decay_summary(store)
|
|
141
|
+
summary = Legion::Extensions::Agentic::Memory::Trace.last_maintenance_summary || {}
|
|
142
|
+
current_count = trace_count(store)
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
decayed: summary[:decayed] || 0,
|
|
146
|
+
pruned: summary[:pruned] || 0,
|
|
147
|
+
total: summary[:total] || current_count,
|
|
148
|
+
remaining: summary[:remaining] || current_count,
|
|
149
|
+
maintained_at: summary[:maintained_at],
|
|
150
|
+
deferred: true,
|
|
151
|
+
reason: :background_decay_actor
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def trace_count(store)
|
|
156
|
+
if store.respond_to?(:count)
|
|
157
|
+
store.count
|
|
158
|
+
else
|
|
159
|
+
store.all_traces.size
|
|
160
|
+
end
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
log.debug("[memory] trace_count fallback: #{e.message}")
|
|
163
|
+
store.all_traces.size
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def persist_store(store)
|
|
167
|
+
store.flush if store.respond_to?(:flush)
|
|
168
|
+
rescue StandardError => e
|
|
169
|
+
log.debug("[memory] persist_store skipped: #{e.message}")
|
|
170
|
+
end
|
|
171
|
+
|
|
117
172
|
def default_store
|
|
118
173
|
@default_store ||= Legion::Extensions::Agentic::Memory::Trace.shared_store
|
|
119
174
|
end
|
|
@@ -11,6 +11,7 @@ module Legion
|
|
|
11
11
|
store ||= default_store
|
|
12
12
|
trace = Helpers::Trace.new_trace(type: type.to_sym, content_payload: content_payload, **)
|
|
13
13
|
store.store(trace)
|
|
14
|
+
persist_store(store)
|
|
14
15
|
log.debug("[memory] stored trace #{trace[:trace_id][0..7]} type=#{trace[:trace_type]} strength=#{trace[:strength].round(2)}")
|
|
15
16
|
{ trace_id: trace[:trace_id], trace_type: trace[:trace_type], strength: trace[:strength] }
|
|
16
17
|
end
|
|
@@ -68,6 +69,7 @@ module Legion
|
|
|
68
69
|
def delete_trace(trace_id:, store: nil, **)
|
|
69
70
|
store ||= default_store
|
|
70
71
|
store.delete(trace_id)
|
|
72
|
+
persist_store(store)
|
|
71
73
|
log.debug("[memory] deleted trace #{trace_id[0..7]}")
|
|
72
74
|
{ deleted: true, trace_id: trace_id }
|
|
73
75
|
end
|
|
@@ -85,12 +87,20 @@ module Legion
|
|
|
85
87
|
store.store(trace)
|
|
86
88
|
end
|
|
87
89
|
|
|
90
|
+
persist_store(store) unless top.empty?
|
|
91
|
+
|
|
88
92
|
log.debug("[memory] retrieve_and_reinforce: retrieved=#{top.size} from=#{all.size} total")
|
|
89
93
|
{ count: top.size, traces: top }
|
|
90
94
|
end
|
|
91
95
|
|
|
92
96
|
private
|
|
93
97
|
|
|
98
|
+
def persist_store(store)
|
|
99
|
+
store.flush if store.respond_to?(:flush)
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
log.debug("[memory] persist_store skipped: #{e.message}")
|
|
102
|
+
end
|
|
103
|
+
|
|
94
104
|
def default_store
|
|
95
105
|
@default_store ||= Legion::Extensions::Agentic::Memory::Trace.shared_store
|
|
96
106
|
end
|
|
@@ -17,33 +17,62 @@ module Legion
|
|
|
17
17
|
module Memory
|
|
18
18
|
module Trace
|
|
19
19
|
class << self
|
|
20
|
-
# Process-wide
|
|
21
|
-
# traces written by one component
|
|
22
|
-
#
|
|
23
|
-
#
|
|
20
|
+
# Process-wide default trace store. All memory runners delegate here so
|
|
21
|
+
# traces written by one component remain visible to the rest of the
|
|
22
|
+
# current agent runtime. Raw trace storage prefers agent-local durable
|
|
23
|
+
# state and only falls back to shared stores when explicitly requested
|
|
24
|
+
# or when local persistence is unavailable.
|
|
24
25
|
def shared_store
|
|
25
26
|
@shared_store ||= create_store
|
|
26
27
|
end
|
|
27
28
|
|
|
29
|
+
def last_maintenance_summary
|
|
30
|
+
@maintenance_summary
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def record_maintenance_summary(summary)
|
|
34
|
+
@maintenance_summary = summary
|
|
35
|
+
end
|
|
36
|
+
|
|
28
37
|
def reset_store!
|
|
29
38
|
@shared_store = nil
|
|
39
|
+
@maintenance_summary = nil
|
|
30
40
|
end
|
|
31
41
|
|
|
32
42
|
private
|
|
33
43
|
|
|
34
44
|
def create_store
|
|
35
|
-
if
|
|
45
|
+
if local_store_available? && configured_trace_store != :shared
|
|
46
|
+
Legion::Logging.debug '[memory] Using agent-local Store (Data::Local-backed)'
|
|
47
|
+
Helpers::Store.new(partition_id: resolve_agent_id)
|
|
48
|
+
elsif postgres_available?
|
|
36
49
|
Legion::Logging.debug '[memory] Using shared PostgresStore (write-through)'
|
|
37
50
|
Helpers::PostgresStore.new(tenant_id: resolve_tenant_id, agent_id: resolve_agent_id)
|
|
38
51
|
elsif defined?(Legion::Cache) && Legion::Cache.respond_to?(:connected?) && Legion::Cache.connected?
|
|
39
52
|
Legion::Logging.debug '[memory] Using shared CacheStore (memcached)'
|
|
40
53
|
Helpers::CacheStore.new
|
|
41
54
|
else
|
|
42
|
-
Legion::Logging.debug '[memory] Using
|
|
43
|
-
Helpers::Store.new
|
|
55
|
+
Legion::Logging.debug '[memory] Using agent-local in-memory Store'
|
|
56
|
+
Helpers::Store.new(partition_id: resolve_agent_id)
|
|
44
57
|
end
|
|
45
58
|
end
|
|
46
59
|
|
|
60
|
+
def configured_trace_store
|
|
61
|
+
Legion::Settings.dig(:memory, :trace_store)&.to_sym
|
|
62
|
+
rescue StandardError => _e
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def local_store_available?
|
|
67
|
+
defined?(Legion::Data::Local) &&
|
|
68
|
+
Legion::Data::Local.respond_to?(:connected?) &&
|
|
69
|
+
Legion::Data::Local.connected? &&
|
|
70
|
+
Legion::Data::Local.connection&.table_exists?(:memory_traces) &&
|
|
71
|
+
Legion::Data::Local.connection.table_exists?(:memory_associations)
|
|
72
|
+
rescue StandardError => _e
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
|
|
47
76
|
def postgres_available?
|
|
48
77
|
defined?(Legion::Data) &&
|
|
49
78
|
Legion::Data.respond_to?(:connection) &&
|
|
@@ -30,6 +30,12 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Actor::Decay do
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
describe '#args' do
|
|
34
|
+
it 'runs full maintenance when scheduled in the background' do
|
|
35
|
+
expect(actor.args).to eq({ maintenance: true })
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
33
39
|
describe '#time' do
|
|
34
40
|
it 'returns 60 seconds' do
|
|
35
41
|
expect(actor.time).to eq(60)
|
|
@@ -97,6 +97,16 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::HotTier do
|
|
|
97
97
|
end
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
+
describe '.scope_id' do
|
|
101
|
+
it 'uses tenant scope when agent_id is not provided' do
|
|
102
|
+
expect(mod.scope_id(tenant_id: 'tenant-1')).to eq('tenant-1')
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'uses combined tenant and agent scope when both are provided' do
|
|
106
|
+
expect(mod.scope_id(tenant_id: 'tenant-1', agent_id: 'agent-1')).to eq('tenant-1:agent-1')
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
100
110
|
# --- serialize_trace / deserialize_trace round-trip ---
|
|
101
111
|
|
|
102
112
|
describe '.serialize_trace' do
|
|
@@ -239,6 +249,12 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::HotTier do
|
|
|
239
249
|
expect(Legion::Cache::RedisHash).to receive(:hset).with(key, anything)
|
|
240
250
|
mod.cache_trace(trace)
|
|
241
251
|
end
|
|
252
|
+
|
|
253
|
+
it 'uses a combined tenant and agent scope when both are provided' do
|
|
254
|
+
key = mod.trace_key('tenant-abc:agent-1', trace_id)
|
|
255
|
+
expect(Legion::Cache::RedisHash).to receive(:hset).with(key, anything)
|
|
256
|
+
mod.cache_trace(trace, tenant_id: tenant_id, agent_id: 'agent-1')
|
|
257
|
+
end
|
|
242
258
|
end
|
|
243
259
|
end
|
|
244
260
|
|
|
@@ -179,6 +179,12 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::PostgresStor
|
|
|
179
179
|
expect(other_store.retrieve(semantic_trace[:trace_id])).to be_nil
|
|
180
180
|
end
|
|
181
181
|
|
|
182
|
+
it 'scopes retrieve to the correct agent within the same tenant' do
|
|
183
|
+
store.store(semantic_trace)
|
|
184
|
+
other_store = described_class.new(tenant_id: tenant_id, agent_id: 'other-agent')
|
|
185
|
+
expect(other_store.retrieve(semantic_trace[:trace_id])).to be_nil
|
|
186
|
+
end
|
|
187
|
+
|
|
182
188
|
it 'returns nil when db not ready' do
|
|
183
189
|
db.drop_table(:memory_traces)
|
|
184
190
|
expect(store.retrieve(semantic_trace[:trace_id])).to be_nil
|
|
@@ -301,6 +307,13 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::PostgresStor
|
|
|
301
307
|
expect(store.all_traces.size).to eq(1)
|
|
302
308
|
end
|
|
303
309
|
|
|
310
|
+
it 'does not include traces from another agent in the same tenant' do
|
|
311
|
+
store.store(semantic_trace)
|
|
312
|
+
other = described_class.new(tenant_id: tenant_id, agent_id: 'other-agent')
|
|
313
|
+
other.store(episodic_trace)
|
|
314
|
+
expect(store.all_traces.map { |trace| trace[:trace_id] }).to eq([semantic_trace[:trace_id]])
|
|
315
|
+
end
|
|
316
|
+
|
|
304
317
|
it 'returns empty array when db not ready' do
|
|
305
318
|
db.drop_table(:memory_traces)
|
|
306
319
|
expect(store.all_traces).to eq([])
|
|
@@ -15,6 +15,14 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::Store do
|
|
|
15
15
|
expect(result[:trace_type]).to eq(:semantic)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
it 'assigns the store partition_id when a trace does not already have one' do
|
|
19
|
+
semantic_trace[:partition_id] = nil
|
|
20
|
+
|
|
21
|
+
store.store(semantic_trace)
|
|
22
|
+
result = store.get(semantic_trace[:trace_id])
|
|
23
|
+
expect(result[:partition_id]).to eq('default')
|
|
24
|
+
end
|
|
25
|
+
|
|
18
26
|
it 'returns nil for unknown trace_id' do
|
|
19
27
|
expect(store.get('nonexistent')).to be_nil
|
|
20
28
|
end
|
|
@@ -194,7 +202,7 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::Store do
|
|
|
194
202
|
end
|
|
195
203
|
|
|
196
204
|
it 'filters by min_strength and does not traverse beyond filtered nodes' do
|
|
197
|
-
trace_b[:strength] = 0.05
|
|
205
|
+
store.traces[trace_b[:trace_id]][:strength] = 0.05
|
|
198
206
|
results = store.walk_associations(start_id: trace_a[:trace_id], min_strength: 0.1)
|
|
199
207
|
found_ids = results.map { |r| r[:trace_id] }
|
|
200
208
|
expect(found_ids).not_to include(trace_b[:trace_id])
|
|
@@ -214,4 +222,21 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::Store do
|
|
|
214
222
|
expect(results).to eq([])
|
|
215
223
|
end
|
|
216
224
|
end
|
|
225
|
+
|
|
226
|
+
describe '#restore_traces' do
|
|
227
|
+
it 'replaces existing traces and clears stale associations' do
|
|
228
|
+
store.store(semantic_trace)
|
|
229
|
+
store.store(episodic_trace)
|
|
230
|
+
|
|
231
|
+
threshold = Legion::Extensions::Agentic::Memory::Trace::Helpers::Trace::COACTIVATION_THRESHOLD
|
|
232
|
+
threshold.times { store.record_coactivation(semantic_trace[:trace_id], episodic_trace[:trace_id]) }
|
|
233
|
+
|
|
234
|
+
replacement = trace_helper.new_trace(type: :semantic, content_payload: { fact: 'replacement' })
|
|
235
|
+
store.restore_traces([replacement])
|
|
236
|
+
|
|
237
|
+
expect(store.count).to eq(1)
|
|
238
|
+
expect(store.get(semantic_trace[:trace_id])).to be_nil
|
|
239
|
+
expect(store.associations).to be_empty
|
|
240
|
+
end
|
|
241
|
+
end
|
|
217
242
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'sequel'
|
|
4
4
|
require 'sequel/extensions/migration'
|
|
5
|
+
require 'logger'
|
|
6
|
+
require 'stringio'
|
|
5
7
|
|
|
6
8
|
# Minimal stub for Legion::Data::Local used in persistence specs.
|
|
7
9
|
# Does not require the full legion-data gem.
|
|
@@ -35,9 +37,9 @@ module Legion
|
|
|
35
37
|
private
|
|
36
38
|
|
|
37
39
|
def run_memory_migrations
|
|
38
|
-
migration_path = File.
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
migration_path = File.expand_path(
|
|
41
|
+
'../../../../../../lib/legion/extensions/agentic/memory/trace/local_migrations',
|
|
42
|
+
__dir__
|
|
41
43
|
)
|
|
42
44
|
::Sequel::TimestampMigrator.new(@connection, migration_path).run
|
|
43
45
|
end
|
|
@@ -159,6 +161,51 @@ RSpec.describe 'lex-memory local SQLite persistence' do
|
|
|
159
161
|
expect(second_count).to eq(first_count)
|
|
160
162
|
end
|
|
161
163
|
|
|
164
|
+
it 'does not rewrite association rows when only trace fields change' do
|
|
165
|
+
store.store(semantic_trace)
|
|
166
|
+
store.store(episodic_trace)
|
|
167
|
+
|
|
168
|
+
threshold = Legion::Extensions::Agentic::Memory::Trace::Helpers::Trace::COACTIVATION_THRESHOLD
|
|
169
|
+
threshold.times { store.record_coactivation(semantic_trace[:trace_id], episodic_trace[:trace_id]) }
|
|
170
|
+
store.save_to_local
|
|
171
|
+
|
|
172
|
+
io = StringIO.new
|
|
173
|
+
logger = Logger.new(io)
|
|
174
|
+
logger.level = Logger::DEBUG
|
|
175
|
+
Legion::Data::Local.connection.loggers << logger
|
|
176
|
+
|
|
177
|
+
store.traces[semantic_trace[:trace_id]][:strength] = 0.77
|
|
178
|
+
store.save_to_local
|
|
179
|
+
|
|
180
|
+
sql = io.string
|
|
181
|
+
expect(sql).not_to match(/DELETE FROM [`"]memory_associations[`"]/)
|
|
182
|
+
expect(sql).not_to match(/INSERT INTO [`"]memory_associations[`"]/)
|
|
183
|
+
ensure
|
|
184
|
+
Legion::Data::Local.connection.loggers.delete(logger) if logger
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'only deletes stale rows for the current partition' do
|
|
188
|
+
other_partition = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new(partition_id: 'agent-2')
|
|
189
|
+
agent_two_trace = trace_helper.new_trace(
|
|
190
|
+
type: :episodic,
|
|
191
|
+
content_payload: { event: 'agent 2 boot' },
|
|
192
|
+
domain_tags: ['boot'],
|
|
193
|
+
partition_id: 'agent-2'
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
store.store(semantic_trace)
|
|
197
|
+
other_partition.store(agent_two_trace)
|
|
198
|
+
other_partition.save_to_local
|
|
199
|
+
store.save_to_local
|
|
200
|
+
|
|
201
|
+
store.delete(semantic_trace[:trace_id])
|
|
202
|
+
store.save_to_local
|
|
203
|
+
|
|
204
|
+
db = Legion::Data::Local.connection
|
|
205
|
+
expect(db[:memory_traces].where(partition_id: 'agent-2').count).to eq(1)
|
|
206
|
+
expect(db[:memory_traces].where(trace_id: agent_two_trace[:trace_id]).count).to eq(1)
|
|
207
|
+
end
|
|
208
|
+
|
|
162
209
|
it 'is a no-op when Local is not connected' do
|
|
163
210
|
allow(Legion::Data::Local).to receive(:connected?).and_return(false)
|
|
164
211
|
expect { store.save_to_local }.not_to raise_error
|
|
@@ -189,6 +236,29 @@ RSpec.describe 'lex-memory local SQLite persistence' do
|
|
|
189
236
|
expect(fresh.associations[semantic_trace[:trace_id]]).not_to be_empty
|
|
190
237
|
end
|
|
191
238
|
|
|
239
|
+
it 'loads only traces for the requested partition' do
|
|
240
|
+
other_partition = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new(partition_id: 'agent-2')
|
|
241
|
+
agent_two_trace = trace_helper.new_trace(
|
|
242
|
+
type: :episodic,
|
|
243
|
+
content_payload: { event: 'agent 2 boot' },
|
|
244
|
+
domain_tags: ['boot'],
|
|
245
|
+
partition_id: 'agent-2'
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
store.store(semantic_trace)
|
|
249
|
+
other_partition.store(agent_two_trace)
|
|
250
|
+
store.save_to_local
|
|
251
|
+
other_partition.save_to_local
|
|
252
|
+
|
|
253
|
+
fresh = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new(partition_id: 'default')
|
|
254
|
+
other = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new(partition_id: 'agent-2')
|
|
255
|
+
|
|
256
|
+
expect(fresh.get(semantic_trace[:trace_id])).not_to be_nil
|
|
257
|
+
expect(fresh.get(agent_two_trace[:trace_id])).to be_nil
|
|
258
|
+
expect(other.get(agent_two_trace[:trace_id])).not_to be_nil
|
|
259
|
+
expect(other.get(semantic_trace[:trace_id])).to be_nil
|
|
260
|
+
end
|
|
261
|
+
|
|
192
262
|
it 'is a no-op when Local is not connected' do
|
|
193
263
|
allow(Legion::Data::Local).to receive(:connected?).and_return(false)
|
|
194
264
|
expect { store.load_from_local }.not_to raise_error
|
|
@@ -8,4 +8,31 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace do
|
|
|
8
8
|
it 'has a version that is a string' do
|
|
9
9
|
expect(Legion::Extensions::Agentic::Memory::Trace::VERSION).to be_a(String)
|
|
10
10
|
end
|
|
11
|
+
|
|
12
|
+
describe '.shared_store' do
|
|
13
|
+
before { described_class.reset_store! }
|
|
14
|
+
after { described_class.reset_store! }
|
|
15
|
+
|
|
16
|
+
it 'prefers the agent-local store when local persistence is available' do
|
|
17
|
+
allow(described_class).to receive(:local_store_available?).and_return(true)
|
|
18
|
+
allow(described_class).to receive(:configured_trace_store).and_return(nil)
|
|
19
|
+
allow(described_class).to receive(:resolve_agent_id).and_return('agent-1')
|
|
20
|
+
allow(described_class::Helpers::Store).to receive(:new).with(partition_id: 'agent-1').and_return(:local_store)
|
|
21
|
+
|
|
22
|
+
expect(described_class.shared_store).to eq(:local_store)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'uses the shared Postgres store when trace_store is explicitly shared' do
|
|
26
|
+
allow(described_class).to receive(:local_store_available?).and_return(true)
|
|
27
|
+
allow(described_class).to receive(:configured_trace_store).and_return(:shared)
|
|
28
|
+
allow(described_class).to receive(:postgres_available?).and_return(true)
|
|
29
|
+
allow(described_class).to receive(:resolve_agent_id).and_return('agent-1')
|
|
30
|
+
allow(described_class).to receive(:resolve_tenant_id).and_return('tenant-1')
|
|
31
|
+
allow(described_class::Helpers::PostgresStore).to receive(:new)
|
|
32
|
+
.with(tenant_id: 'tenant-1', agent_id: 'agent-1')
|
|
33
|
+
.and_return(:shared_store)
|
|
34
|
+
|
|
35
|
+
expect(described_class.shared_store).to eq(:shared_store)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
11
38
|
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require 'legion/extensions/agentic/memory/trace/client'
|
|
4
4
|
|
|
5
5
|
RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Runners::Consolidation do
|
|
6
|
+
before(:each) { Legion::Extensions::Agentic::Memory::Trace.reset_store! }
|
|
7
|
+
|
|
6
8
|
let(:client) { Legion::Extensions::Agentic::Memory::Trace::Client.new }
|
|
7
9
|
|
|
8
10
|
describe '#reinforce' do
|
|
@@ -51,6 +53,30 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Runners::Consolidatio
|
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
describe '#decay_cycle' do
|
|
56
|
+
it 'defers Gaia heartbeat decay work to the background actor when maintenance is false' do
|
|
57
|
+
client.store_trace(type: :semantic, content_payload: {})
|
|
58
|
+
|
|
59
|
+
result = client.decay_cycle(maintenance: false)
|
|
60
|
+
expect(result).to include(
|
|
61
|
+
decayed: 0,
|
|
62
|
+
pruned: 0,
|
|
63
|
+
total: 1,
|
|
64
|
+
remaining: 1,
|
|
65
|
+
deferred: true,
|
|
66
|
+
reason: :background_decay_actor
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'reuses the latest maintenance summary when decay is deferred' do
|
|
71
|
+
client.store_trace(type: :semantic, content_payload: {})
|
|
72
|
+
client.decay_cycle(tick_count: 100)
|
|
73
|
+
|
|
74
|
+
result = client.decay_cycle(maintenance: false)
|
|
75
|
+
expect(result[:deferred]).to be true
|
|
76
|
+
expect(result[:total]).to be >= result[:remaining]
|
|
77
|
+
expect(result[:maintained_at]).not_to be_nil
|
|
78
|
+
end
|
|
79
|
+
|
|
54
80
|
it 'decays non-firmware traces' do
|
|
55
81
|
client.store_trace(type: :semantic, content_payload: {})
|
|
56
82
|
client.store_trace(type: :firmware, content_payload: { directive_text: 'protect' })
|