htm 0.0.14 → 0.0.15
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 +33 -0
- data/README.md +269 -79
- data/db/migrate/00003_create_file_sources.rb +5 -0
- data/db/migrate/00004_create_nodes.rb +17 -0
- data/db/migrate/00005_create_tags.rb +7 -0
- data/db/migrate/00006_create_node_tags.rb +2 -0
- data/db/migrate/00007_create_robot_nodes.rb +7 -0
- data/db/schema.sql +41 -29
- data/docs/api/yard/HTM/Configuration.md +54 -0
- data/docs/api/yard/HTM/Database.md +13 -10
- data/docs/api/yard/HTM/EmbeddingService.md +5 -1
- data/docs/api/yard/HTM/LongTermMemory.md +18 -277
- data/docs/api/yard/HTM/PropositionError.md +18 -0
- data/docs/api/yard/HTM/PropositionService.md +66 -0
- data/docs/api/yard/HTM/QueryCache.md +88 -0
- data/docs/api/yard/HTM/RobotGroup.md +481 -0
- data/docs/api/yard/HTM/SqlBuilder.md +108 -0
- data/docs/api/yard/HTM/TagService.md +4 -0
- data/docs/api/yard/HTM/Telemetry/NullInstrument.md +13 -0
- data/docs/api/yard/HTM/Telemetry/NullMeter.md +15 -0
- data/docs/api/yard/HTM/Telemetry.md +109 -0
- data/docs/api/yard/HTM/WorkingMemoryChannel.md +176 -0
- data/docs/api/yard/HTM.md +11 -23
- data/docs/api/yard/index.csv +102 -25
- data/docs/api/yard-reference.md +8 -0
- data/docs/assets/images/multi-provider-failover.svg +51 -0
- data/docs/assets/images/robot-group-architecture.svg +65 -0
- data/docs/database/README.md +3 -3
- data/docs/database/public.file_sources.svg +29 -21
- data/docs/database/public.node_tags.md +2 -0
- data/docs/database/public.node_tags.svg +53 -41
- data/docs/database/public.nodes.md +2 -0
- data/docs/database/public.nodes.svg +52 -40
- data/docs/database/public.robot_nodes.md +2 -0
- data/docs/database/public.robot_nodes.svg +30 -22
- data/docs/database/public.robots.svg +16 -12
- data/docs/database/public.tags.md +3 -0
- data/docs/database/public.tags.svg +41 -33
- data/docs/database/schema.json +66 -0
- data/docs/database/schema.svg +60 -48
- data/docs/development/index.md +13 -0
- data/docs/development/rake-tasks.md +1068 -0
- data/docs/getting-started/quick-start.md +144 -155
- data/docs/guides/adding-memories.md +2 -3
- data/docs/guides/context-assembly.md +185 -184
- data/docs/guides/getting-started.md +154 -148
- data/docs/guides/index.md +7 -0
- data/docs/guides/long-term-memory.md +60 -92
- data/docs/guides/mcp-server.md +617 -0
- data/docs/guides/multi-robot.md +249 -345
- data/docs/guides/recalling-memories.md +153 -163
- data/docs/guides/robot-groups.md +604 -0
- data/docs/guides/search-strategies.md +61 -58
- data/docs/guides/working-memory.md +103 -136
- data/docs/index.md +30 -26
- data/examples/robot_groups/robot_worker.rb +1 -2
- data/examples/robot_groups/same_process.rb +1 -4
- data/lib/htm/robot_group.rb +721 -0
- data/lib/htm/version.rb +1 -1
- data/lib/htm/working_memory_channel.rb +250 -0
- data/lib/htm.rb +2 -0
- data/mkdocs.yml +2 -0
- metadata +18 -9
- data/db/migrate/00009_add_working_memory_to_robot_nodes.rb +0 -12
- data/db/migrate/00010_add_soft_delete_to_associations.rb +0 -29
- data/db/migrate/00011_add_performance_indexes.rb +0 -21
- data/db/migrate/00012_add_tags_trigram_index.rb +0 -18
- data/db/migrate/00013_enable_lz4_compression.rb +0 -43
- data/examples/robot_groups/lib/robot_group.rb +0 -419
- data/examples/robot_groups/lib/working_memory_channel.rb +0 -140
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
# examples/robot_groups/lib/htm/robot_group.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
class HTM
|
|
5
|
+
# Coordinates multiple robots with shared working memory and automatic failover.
|
|
6
|
+
#
|
|
7
|
+
# RobotGroup provides application-level coordination for multiple HTM robots,
|
|
8
|
+
# enabling them to share a common working memory context. Key capabilities include:
|
|
9
|
+
#
|
|
10
|
+
# - **Shared Working Memory**: All group members have access to the same context
|
|
11
|
+
# - **Active/Passive Roles**: Active robots participate in conversations; passive
|
|
12
|
+
# robots maintain synchronized context for instant failover
|
|
13
|
+
# - **Real-time Sync**: PostgreSQL LISTEN/NOTIFY enables immediate synchronization
|
|
14
|
+
# - **Failover**: When an active robot fails, a passive robot takes over instantly
|
|
15
|
+
# - **Dynamic Scaling**: Add or remove robots at runtime
|
|
16
|
+
#
|
|
17
|
+
# @example High-availability customer support setup
|
|
18
|
+
# group = HTM::RobotGroup.new(
|
|
19
|
+
# name: 'customer-support',
|
|
20
|
+
# active: ['primary-agent'],
|
|
21
|
+
# passive: ['standby-agent'],
|
|
22
|
+
# max_tokens: 8000
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# # Add shared context
|
|
26
|
+
# group.remember('Customer prefers email communication.')
|
|
27
|
+
# group.remember('Open ticket #789 regarding billing issue.')
|
|
28
|
+
#
|
|
29
|
+
# # Query shared memory
|
|
30
|
+
# results = group.recall('billing', limit: 5)
|
|
31
|
+
#
|
|
32
|
+
# # Simulate failover
|
|
33
|
+
# group.failover! # Promotes standby to active
|
|
34
|
+
#
|
|
35
|
+
# # Cleanup
|
|
36
|
+
# group.shutdown
|
|
37
|
+
#
|
|
38
|
+
# @see HTM::WorkingMemoryChannel Low-level pub/sub mechanism
|
|
39
|
+
#
|
|
40
|
+
class RobotGroup
|
|
41
|
+
# Name of the robot group
|
|
42
|
+
# @return [String]
|
|
43
|
+
attr_reader :name
|
|
44
|
+
|
|
45
|
+
# Maximum token budget for working memory
|
|
46
|
+
# @return [Integer]
|
|
47
|
+
attr_reader :max_tokens
|
|
48
|
+
|
|
49
|
+
# The pub/sub channel used for real-time synchronization
|
|
50
|
+
# @return [HTM::WorkingMemoryChannel]
|
|
51
|
+
attr_reader :channel
|
|
52
|
+
|
|
53
|
+
# Creates a new robot group with optional initial members.
|
|
54
|
+
#
|
|
55
|
+
# Initializes the group, sets up the PostgreSQL pub/sub channel for real-time
|
|
56
|
+
# synchronization, and registers initial active and passive robots.
|
|
57
|
+
#
|
|
58
|
+
# @param name [String] Unique name for this robot group
|
|
59
|
+
# @param active [Array<String>] Names of robots to add as active members
|
|
60
|
+
# @param passive [Array<String>] Names of robots to add as passive (standby) members
|
|
61
|
+
# @param max_tokens [Integer] Maximum token budget for shared working memory
|
|
62
|
+
# @param db_config [Hash, nil] PostgreSQL connection config (defaults to HTM::Database.default_config)
|
|
63
|
+
#
|
|
64
|
+
# @example Create a group with one active and one passive robot
|
|
65
|
+
# group = HTM::RobotGroup.new(
|
|
66
|
+
# name: 'support-team',
|
|
67
|
+
# active: ['agent-1'],
|
|
68
|
+
# passive: ['agent-2'],
|
|
69
|
+
# max_tokens: 4000
|
|
70
|
+
# )
|
|
71
|
+
#
|
|
72
|
+
# @example Create an empty group and add members later
|
|
73
|
+
# group = HTM::RobotGroup.new(name: 'dynamic-team')
|
|
74
|
+
# group.add_active('agent-1')
|
|
75
|
+
# group.add_passive('agent-2')
|
|
76
|
+
#
|
|
77
|
+
def initialize(name:, active: [], passive: [], max_tokens: 4000, db_config: nil)
|
|
78
|
+
@name = name
|
|
79
|
+
@max_tokens = max_tokens
|
|
80
|
+
@active_robots = {} # name => HTM instance
|
|
81
|
+
@passive_robots = {} # name => HTM instance
|
|
82
|
+
@sync_stats = { nodes_synced: 0, evictions_synced: 0 }
|
|
83
|
+
@mutex = Mutex.new
|
|
84
|
+
|
|
85
|
+
# Setup pub/sub channel for real-time sync
|
|
86
|
+
@db_config = db_config || HTM::Database.default_config
|
|
87
|
+
@channel = HTM::WorkingMemoryChannel.new(name, @db_config)
|
|
88
|
+
|
|
89
|
+
# Subscribe to working memory changes
|
|
90
|
+
setup_sync_listener
|
|
91
|
+
|
|
92
|
+
# Start listening for notifications
|
|
93
|
+
@channel.start_listening
|
|
94
|
+
|
|
95
|
+
# Initialize robots
|
|
96
|
+
active.each { |robot_name| add_active(robot_name) }
|
|
97
|
+
passive.each { |robot_name| add_passive(robot_name) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Shuts down the group by stopping the listener thread.
|
|
101
|
+
#
|
|
102
|
+
# Should be called when the group is no longer needed to release resources
|
|
103
|
+
# and close the PostgreSQL listener connection.
|
|
104
|
+
#
|
|
105
|
+
# @return [void]
|
|
106
|
+
#
|
|
107
|
+
# @example
|
|
108
|
+
# group.shutdown
|
|
109
|
+
#
|
|
110
|
+
def shutdown
|
|
111
|
+
@channel.stop_listening
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @!group Membership Management
|
|
115
|
+
|
|
116
|
+
# Adds a robot as an active member of the group.
|
|
117
|
+
#
|
|
118
|
+
# Active robots can add memories and respond to queries. The new robot
|
|
119
|
+
# is automatically synchronized with existing shared working memory.
|
|
120
|
+
#
|
|
121
|
+
# @param robot_name [String] Unique name for the robot
|
|
122
|
+
# @return [Integer] The robot's database ID
|
|
123
|
+
# @raise [ArgumentError] if robot_name is already a member
|
|
124
|
+
#
|
|
125
|
+
# @example
|
|
126
|
+
# robot_id = group.add_active('new-agent')
|
|
127
|
+
# puts "Added robot with ID: #{robot_id}"
|
|
128
|
+
#
|
|
129
|
+
def add_active(robot_name)
|
|
130
|
+
raise ArgumentError, "#{robot_name} is already a member" if member?(robot_name)
|
|
131
|
+
|
|
132
|
+
htm = HTM.new(robot_name: robot_name, working_memory_size: @max_tokens)
|
|
133
|
+
@active_robots[robot_name] = htm
|
|
134
|
+
|
|
135
|
+
# Sync existing shared working memory to new member
|
|
136
|
+
sync_robot(robot_name) if member_ids.length > 1
|
|
137
|
+
|
|
138
|
+
htm.robot_id
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Adds a robot as a passive (standby) member of the group.
|
|
142
|
+
#
|
|
143
|
+
# Passive robots maintain synchronized working memory but don't actively
|
|
144
|
+
# participate in conversations. They serve as warm standbys for failover.
|
|
145
|
+
#
|
|
146
|
+
# @param robot_name [String] Unique name for the robot
|
|
147
|
+
# @return [Integer] The robot's database ID
|
|
148
|
+
# @raise [ArgumentError] if robot_name is already a member
|
|
149
|
+
#
|
|
150
|
+
# @example
|
|
151
|
+
# robot_id = group.add_passive('standby-agent')
|
|
152
|
+
#
|
|
153
|
+
def add_passive(robot_name)
|
|
154
|
+
raise ArgumentError, "#{robot_name} is already a member" if member?(robot_name)
|
|
155
|
+
|
|
156
|
+
htm = HTM.new(robot_name: robot_name, working_memory_size: @max_tokens)
|
|
157
|
+
@passive_robots[robot_name] = htm
|
|
158
|
+
|
|
159
|
+
# Sync existing shared working memory to new member
|
|
160
|
+
sync_robot(robot_name) if member_ids.length > 1
|
|
161
|
+
|
|
162
|
+
htm.robot_id
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Removes a robot from the group.
|
|
166
|
+
#
|
|
167
|
+
# Clears the robot's working memory flags in the database. The robot can
|
|
168
|
+
# be either active or passive.
|
|
169
|
+
#
|
|
170
|
+
# @param robot_name [String] Name of the robot to remove
|
|
171
|
+
# @return [void]
|
|
172
|
+
#
|
|
173
|
+
# @example
|
|
174
|
+
# group.remove('departing-agent')
|
|
175
|
+
#
|
|
176
|
+
def remove(robot_name)
|
|
177
|
+
htm = @active_robots.delete(robot_name) || @passive_robots.delete(robot_name)
|
|
178
|
+
return unless htm
|
|
179
|
+
|
|
180
|
+
# Clear working memory flags for this robot
|
|
181
|
+
HTM::Models::RobotNode
|
|
182
|
+
.where(robot_id: htm.robot_id, working_memory: true)
|
|
183
|
+
.update_all(working_memory: false)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Promotes a passive robot to active status.
|
|
187
|
+
#
|
|
188
|
+
# The robot retains its synchronized working memory and becomes eligible
|
|
189
|
+
# to handle queries and add memories.
|
|
190
|
+
#
|
|
191
|
+
# @param robot_name [String] Name of the passive robot to promote
|
|
192
|
+
# @return [void]
|
|
193
|
+
# @raise [ArgumentError] if robot_name is not a passive member
|
|
194
|
+
#
|
|
195
|
+
# @example
|
|
196
|
+
# group.promote('standby-agent')
|
|
197
|
+
# group.active?('standby-agent') # => true
|
|
198
|
+
#
|
|
199
|
+
def promote(robot_name)
|
|
200
|
+
raise ArgumentError, "#{robot_name} is not a passive member" unless passive?(robot_name)
|
|
201
|
+
|
|
202
|
+
htm = @passive_robots.delete(robot_name)
|
|
203
|
+
@active_robots[robot_name] = htm
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Demotes an active robot to passive status.
|
|
207
|
+
#
|
|
208
|
+
# The robot retains its working memory but stops handling queries.
|
|
209
|
+
# Cannot demote the last active robot.
|
|
210
|
+
#
|
|
211
|
+
# @param robot_name [String] Name of the active robot to demote
|
|
212
|
+
# @return [void]
|
|
213
|
+
# @raise [ArgumentError] if robot_name is not an active member
|
|
214
|
+
# @raise [ArgumentError] if this is the last active robot
|
|
215
|
+
#
|
|
216
|
+
# @example
|
|
217
|
+
# group.demote('primary-agent')
|
|
218
|
+
# group.passive?('primary-agent') # => true
|
|
219
|
+
#
|
|
220
|
+
def demote(robot_name)
|
|
221
|
+
raise ArgumentError, "#{robot_name} is not an active member" unless active?(robot_name)
|
|
222
|
+
raise ArgumentError, 'Cannot demote last active robot' if @active_robots.length == 1
|
|
223
|
+
|
|
224
|
+
htm = @active_robots.delete(robot_name)
|
|
225
|
+
@passive_robots[robot_name] = htm
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Checks if a robot is a member of this group.
|
|
229
|
+
#
|
|
230
|
+
# @param robot_name [String] Name of the robot to check
|
|
231
|
+
# @return [Boolean] true if the robot is an active or passive member
|
|
232
|
+
#
|
|
233
|
+
# @example
|
|
234
|
+
# group.member?('agent-1') # => true
|
|
235
|
+
# group.member?('unknown') # => false
|
|
236
|
+
#
|
|
237
|
+
def member?(robot_name)
|
|
238
|
+
@active_robots.key?(robot_name) || @passive_robots.key?(robot_name)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Checks if a robot is an active member of this group.
|
|
242
|
+
#
|
|
243
|
+
# @param robot_name [String] Name of the robot to check
|
|
244
|
+
# @return [Boolean] true if the robot is an active member
|
|
245
|
+
#
|
|
246
|
+
# @example
|
|
247
|
+
# group.active?('primary-agent') # => true
|
|
248
|
+
#
|
|
249
|
+
def active?(robot_name)
|
|
250
|
+
@active_robots.key?(robot_name)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Checks if a robot is a passive member of this group.
|
|
254
|
+
#
|
|
255
|
+
# @param robot_name [String] Name of the robot to check
|
|
256
|
+
# @return [Boolean] true if the robot is a passive member
|
|
257
|
+
#
|
|
258
|
+
# @example
|
|
259
|
+
# group.passive?('standby-agent') # => true
|
|
260
|
+
#
|
|
261
|
+
def passive?(robot_name)
|
|
262
|
+
@passive_robots.key?(robot_name)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Returns database IDs of all group members.
|
|
266
|
+
#
|
|
267
|
+
# @return [Array<Integer>] Array of robot IDs (both active and passive)
|
|
268
|
+
#
|
|
269
|
+
# @example
|
|
270
|
+
# group.member_ids # => [1, 2, 3]
|
|
271
|
+
#
|
|
272
|
+
def member_ids
|
|
273
|
+
all_robots.values.map(&:robot_id)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Returns names of all active robots.
|
|
277
|
+
#
|
|
278
|
+
# @return [Array<String>] Array of active robot names
|
|
279
|
+
#
|
|
280
|
+
# @example
|
|
281
|
+
# group.active_robot_names # => ['primary-agent', 'secondary-agent']
|
|
282
|
+
#
|
|
283
|
+
def active_robot_names
|
|
284
|
+
@active_robots.keys
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Returns names of all passive robots.
|
|
288
|
+
#
|
|
289
|
+
# @return [Array<String>] Array of passive robot names
|
|
290
|
+
#
|
|
291
|
+
# @example
|
|
292
|
+
# group.passive_robot_names # => ['standby-agent']
|
|
293
|
+
#
|
|
294
|
+
def passive_robot_names
|
|
295
|
+
@passive_robots.keys
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# @!endgroup
|
|
299
|
+
|
|
300
|
+
# @!group Shared Working Memory Operations
|
|
301
|
+
|
|
302
|
+
# Adds content to shared working memory for all group members.
|
|
303
|
+
#
|
|
304
|
+
# The memory is created by the specified originator (or first active robot)
|
|
305
|
+
# and automatically synchronized to all other members via database and
|
|
306
|
+
# real-time notifications.
|
|
307
|
+
#
|
|
308
|
+
# @param content [String] The content to remember
|
|
309
|
+
# @param originator [String, nil] Name of the robot creating the memory (optional)
|
|
310
|
+
# @param options [Hash] Additional options passed to HTM#remember
|
|
311
|
+
# @return [Integer] The node ID of the created memory
|
|
312
|
+
# @raise [RuntimeError] if no active robots exist in the group
|
|
313
|
+
#
|
|
314
|
+
# @example Add memory with default originator
|
|
315
|
+
# node_id = group.remember('Customer prefers morning appointments.')
|
|
316
|
+
#
|
|
317
|
+
# @example Add memory with specific originator
|
|
318
|
+
# node_id = group.remember(
|
|
319
|
+
# 'Escalated to billing department.',
|
|
320
|
+
# originator: 'agent-2'
|
|
321
|
+
# )
|
|
322
|
+
#
|
|
323
|
+
def remember(content, originator: nil, **options)
|
|
324
|
+
raise 'No active robots in group' if @active_robots.empty?
|
|
325
|
+
|
|
326
|
+
# Use first active robot (or specified originator) to create the memory
|
|
327
|
+
primary = if originator && all_robots[originator]
|
|
328
|
+
all_robots[originator]
|
|
329
|
+
else
|
|
330
|
+
@active_robots.values.first
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
node_id = primary.remember(content, **options)
|
|
334
|
+
|
|
335
|
+
# Sync to database (robot_nodes table) for all other members
|
|
336
|
+
sync_node_to_members(node_id, exclude: primary.robot_id)
|
|
337
|
+
|
|
338
|
+
# Notify all listeners via PostgreSQL NOTIFY (triggers in-memory sync)
|
|
339
|
+
@channel.notify(:added, node_id: node_id, robot_id: primary.robot_id)
|
|
340
|
+
|
|
341
|
+
node_id
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Recalls memories from shared working memory.
|
|
345
|
+
#
|
|
346
|
+
# Uses the first active robot to perform the query against the shared
|
|
347
|
+
# working memory context.
|
|
348
|
+
#
|
|
349
|
+
# @param query [String] The search query
|
|
350
|
+
# @param options [Hash] Additional options passed to HTM#recall
|
|
351
|
+
# @option options [Integer] :limit Maximum number of results
|
|
352
|
+
# @option options [Symbol] :strategy Search strategy (:vector, :fulltext, :hybrid)
|
|
353
|
+
# @return [Array] Array of matching memories
|
|
354
|
+
# @raise [RuntimeError] if no active robots exist in the group
|
|
355
|
+
#
|
|
356
|
+
# @example
|
|
357
|
+
# results = group.recall('billing issue', limit: 5, strategy: :fulltext)
|
|
358
|
+
#
|
|
359
|
+
def recall(query, **options)
|
|
360
|
+
raise 'No active robots in group' if @active_robots.empty?
|
|
361
|
+
|
|
362
|
+
primary = @active_robots.values.first
|
|
363
|
+
primary.recall(query, **options)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Returns all nodes currently in shared working memory.
|
|
367
|
+
#
|
|
368
|
+
# Queries the database for the union of all members' working memory,
|
|
369
|
+
# returning nodes sorted by creation date (newest first).
|
|
370
|
+
#
|
|
371
|
+
# @return [ActiveRecord::Relation<HTM::Models::Node>] Collection of nodes
|
|
372
|
+
#
|
|
373
|
+
# @example
|
|
374
|
+
# nodes = group.working_memory_contents
|
|
375
|
+
# nodes.each { |n| puts n.content }
|
|
376
|
+
#
|
|
377
|
+
def working_memory_contents
|
|
378
|
+
node_ids = HTM::Models::RobotNode
|
|
379
|
+
.where(robot_id: member_ids, working_memory: true)
|
|
380
|
+
.distinct
|
|
381
|
+
.pluck(:node_id)
|
|
382
|
+
|
|
383
|
+
HTM::Models::Node.where(id: node_ids).order(created_at: :desc)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Clears shared working memory for all group members.
|
|
387
|
+
#
|
|
388
|
+
# Updates database flags and notifies all members to clear their
|
|
389
|
+
# in-memory caches.
|
|
390
|
+
#
|
|
391
|
+
# @return [Integer] Number of robot_node records updated
|
|
392
|
+
#
|
|
393
|
+
# @example
|
|
394
|
+
# cleared_count = group.clear_working_memory
|
|
395
|
+
# puts "Cleared #{cleared_count} working memory entries"
|
|
396
|
+
#
|
|
397
|
+
def clear_working_memory
|
|
398
|
+
count = HTM::Models::RobotNode
|
|
399
|
+
.where(robot_id: member_ids, working_memory: true)
|
|
400
|
+
.update_all(working_memory: false)
|
|
401
|
+
|
|
402
|
+
# Clear in-memory working memory for primary robot
|
|
403
|
+
primary = @active_robots.values.first || @passive_robots.values.first
|
|
404
|
+
return 0 unless primary
|
|
405
|
+
|
|
406
|
+
primary.clear_working_memory
|
|
407
|
+
|
|
408
|
+
# Notify all listeners (will clear other in-memory caches via callback)
|
|
409
|
+
@channel.notify(:cleared, node_id: nil, robot_id: primary.robot_id)
|
|
410
|
+
|
|
411
|
+
count
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# @!endgroup
|
|
415
|
+
|
|
416
|
+
# @!group Synchronization
|
|
417
|
+
|
|
418
|
+
# Synchronizes a specific robot to match the group's shared working memory.
|
|
419
|
+
#
|
|
420
|
+
# Copies working memory flags from other members to the specified robot,
|
|
421
|
+
# ensuring it has access to all shared context.
|
|
422
|
+
#
|
|
423
|
+
# @param robot_name [String] Name of the robot to synchronize
|
|
424
|
+
# @return [Integer] Number of nodes synchronized
|
|
425
|
+
# @raise [ArgumentError] if robot_name is not a member
|
|
426
|
+
#
|
|
427
|
+
# @example
|
|
428
|
+
# synced = group.sync_robot('new-agent')
|
|
429
|
+
# puts "Synchronized #{synced} nodes"
|
|
430
|
+
#
|
|
431
|
+
def sync_robot(robot_name)
|
|
432
|
+
htm = all_robots[robot_name]
|
|
433
|
+
raise ArgumentError, "#{robot_name} is not a member" unless htm
|
|
434
|
+
|
|
435
|
+
# Get all node_ids currently in any member's working memory
|
|
436
|
+
shared_node_ids = HTM::Models::RobotNode
|
|
437
|
+
.where(robot_id: member_ids, working_memory: true)
|
|
438
|
+
.where.not(robot_id: htm.robot_id)
|
|
439
|
+
.distinct
|
|
440
|
+
.pluck(:node_id)
|
|
441
|
+
|
|
442
|
+
synced = 0
|
|
443
|
+
shared_node_ids.each do |node_id|
|
|
444
|
+
# Create or update robot_node with working_memory=true
|
|
445
|
+
robot_node = HTM::Models::RobotNode.find_or_initialize_by(
|
|
446
|
+
robot_id: htm.robot_id,
|
|
447
|
+
node_id: node_id
|
|
448
|
+
)
|
|
449
|
+
next if robot_node.working_memory?
|
|
450
|
+
|
|
451
|
+
robot_node.working_memory = true
|
|
452
|
+
robot_node.save!
|
|
453
|
+
synced += 1
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
synced
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Synchronizes all members to a consistent state.
|
|
460
|
+
#
|
|
461
|
+
# Ensures every member has access to all shared working memory nodes.
|
|
462
|
+
#
|
|
463
|
+
# @return [Hash] Sync results with :synced_nodes and :members_updated counts
|
|
464
|
+
#
|
|
465
|
+
# @example
|
|
466
|
+
# result = group.sync_all
|
|
467
|
+
# puts "Synced #{result[:synced_nodes]} nodes to #{result[:members_updated]} members"
|
|
468
|
+
#
|
|
469
|
+
def sync_all
|
|
470
|
+
members_updated = 0
|
|
471
|
+
total_synced = 0
|
|
472
|
+
|
|
473
|
+
all_robots.each_key do |robot_name|
|
|
474
|
+
synced = sync_robot(robot_name)
|
|
475
|
+
if synced > 0
|
|
476
|
+
members_updated += 1
|
|
477
|
+
total_synced += synced
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
{ synced_nodes: total_synced, members_updated: members_updated }
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Checks if all members have identical working memory.
|
|
485
|
+
#
|
|
486
|
+
# Compares the set of working memory node IDs across all members.
|
|
487
|
+
#
|
|
488
|
+
# @return [Boolean] true if all members have the same working memory nodes
|
|
489
|
+
#
|
|
490
|
+
# @example
|
|
491
|
+
# if group.in_sync?
|
|
492
|
+
# puts "All robots synchronized"
|
|
493
|
+
# else
|
|
494
|
+
# group.sync_all
|
|
495
|
+
# end
|
|
496
|
+
#
|
|
497
|
+
def in_sync?
|
|
498
|
+
return true if member_ids.length <= 1
|
|
499
|
+
|
|
500
|
+
# Get working memory node_ids for each robot
|
|
501
|
+
working_memories = member_ids.map do |robot_id|
|
|
502
|
+
HTM::Models::RobotNode
|
|
503
|
+
.where(robot_id: robot_id, working_memory: true)
|
|
504
|
+
.pluck(:node_id)
|
|
505
|
+
.sort
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# All should be identical
|
|
509
|
+
working_memories.uniq.length == 1
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# @!endgroup
|
|
513
|
+
|
|
514
|
+
# @!group Failover
|
|
515
|
+
|
|
516
|
+
# Transfers working memory from one robot to another.
|
|
517
|
+
#
|
|
518
|
+
# Copies all working memory node references from the source robot to the
|
|
519
|
+
# target robot, optionally clearing the source.
|
|
520
|
+
#
|
|
521
|
+
# @param from_robot [String] Name of the source robot
|
|
522
|
+
# @param to_robot [String] Name of the destination robot
|
|
523
|
+
# @param clear_source [Boolean] Whether to clear source's working memory after transfer
|
|
524
|
+
# @return [Integer] Number of nodes transferred
|
|
525
|
+
# @raise [ArgumentError] if either robot is not a member
|
|
526
|
+
#
|
|
527
|
+
# @example Transfer with source clearing
|
|
528
|
+
# transferred = group.transfer_working_memory('failing-agent', 'backup-agent')
|
|
529
|
+
#
|
|
530
|
+
# @example Transfer without clearing source
|
|
531
|
+
# transferred = group.transfer_working_memory(
|
|
532
|
+
# 'agent-1', 'agent-2',
|
|
533
|
+
# clear_source: false
|
|
534
|
+
# )
|
|
535
|
+
#
|
|
536
|
+
def transfer_working_memory(from_robot, to_robot, clear_source: true)
|
|
537
|
+
from_htm = all_robots[from_robot]
|
|
538
|
+
to_htm = all_robots[to_robot]
|
|
539
|
+
|
|
540
|
+
raise ArgumentError, "#{from_robot} is not a member" unless from_htm
|
|
541
|
+
raise ArgumentError, "#{to_robot} is not a member" unless to_htm
|
|
542
|
+
|
|
543
|
+
# Get source's working memory nodes
|
|
544
|
+
source_node_ids = HTM::Models::RobotNode
|
|
545
|
+
.where(robot_id: from_htm.robot_id, working_memory: true)
|
|
546
|
+
.pluck(:node_id)
|
|
547
|
+
|
|
548
|
+
transferred = 0
|
|
549
|
+
source_node_ids.each do |node_id|
|
|
550
|
+
robot_node = HTM::Models::RobotNode.find_or_initialize_by(
|
|
551
|
+
robot_id: to_htm.robot_id,
|
|
552
|
+
node_id: node_id
|
|
553
|
+
)
|
|
554
|
+
robot_node.working_memory = true
|
|
555
|
+
robot_node.save!
|
|
556
|
+
transferred += 1
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Clear source's working memory if requested
|
|
560
|
+
if clear_source
|
|
561
|
+
HTM::Models::RobotNode
|
|
562
|
+
.where(robot_id: from_htm.robot_id, working_memory: true)
|
|
563
|
+
.update_all(working_memory: false)
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
transferred
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# Performs automatic failover to the first passive robot.
|
|
570
|
+
#
|
|
571
|
+
# Promotes the first passive robot to active status. The promoted robot
|
|
572
|
+
# already has synchronized working memory and can immediately handle requests.
|
|
573
|
+
#
|
|
574
|
+
# @return [String] Name of the promoted robot
|
|
575
|
+
# @raise [RuntimeError] if no passive robots are available
|
|
576
|
+
#
|
|
577
|
+
# @example
|
|
578
|
+
# promoted = group.failover!
|
|
579
|
+
# puts "#{promoted} is now active"
|
|
580
|
+
#
|
|
581
|
+
def failover!
|
|
582
|
+
raise 'No passive robots available for failover' if @passive_robots.empty?
|
|
583
|
+
|
|
584
|
+
# Get first passive robot
|
|
585
|
+
standby_name = @passive_robots.keys.first
|
|
586
|
+
|
|
587
|
+
# Promote it
|
|
588
|
+
promote(standby_name)
|
|
589
|
+
|
|
590
|
+
puts " ⚡ Failover: #{standby_name} promoted to active"
|
|
591
|
+
standby_name
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# @!endgroup
|
|
595
|
+
|
|
596
|
+
# @!group Status & Health
|
|
597
|
+
|
|
598
|
+
# Returns comprehensive status information about the group.
|
|
599
|
+
#
|
|
600
|
+
# @return [Hash] Status hash with the following keys:
|
|
601
|
+
# @option return [String] :name Group name
|
|
602
|
+
# @option return [Array<String>] :active Names of active robots
|
|
603
|
+
# @option return [Array<String>] :passive Names of passive robots
|
|
604
|
+
# @option return [Integer] :total_members Total number of members
|
|
605
|
+
# @option return [Integer] :working_memory_nodes Number of nodes in shared memory
|
|
606
|
+
# @option return [Integer] :working_memory_tokens Total tokens in shared memory
|
|
607
|
+
# @option return [Integer] :max_tokens Maximum token budget
|
|
608
|
+
# @option return [Float] :token_utilization Ratio of used to max tokens (0.0-1.0)
|
|
609
|
+
# @option return [Boolean] :in_sync Whether all members are synchronized
|
|
610
|
+
#
|
|
611
|
+
# @example
|
|
612
|
+
# status = group.status
|
|
613
|
+
# puts "Group: #{status[:name]}"
|
|
614
|
+
# puts "Active: #{status[:active].join(', ')}"
|
|
615
|
+
# puts "Utilization: #{(status[:token_utilization] * 100).round(1)}%"
|
|
616
|
+
#
|
|
617
|
+
def status
|
|
618
|
+
wm_contents = working_memory_contents
|
|
619
|
+
token_count = wm_contents.sum { |n| n.token_count || 0 }
|
|
620
|
+
|
|
621
|
+
{
|
|
622
|
+
name: @name,
|
|
623
|
+
active: active_robot_names,
|
|
624
|
+
passive: passive_robot_names,
|
|
625
|
+
total_members: member_ids.length,
|
|
626
|
+
working_memory_nodes: wm_contents.count,
|
|
627
|
+
working_memory_tokens: token_count,
|
|
628
|
+
max_tokens: @max_tokens,
|
|
629
|
+
token_utilization: @max_tokens > 0 ? (token_count.to_f / @max_tokens).round(2) : 0,
|
|
630
|
+
in_sync: in_sync?
|
|
631
|
+
}
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# Returns statistics about real-time synchronization.
|
|
635
|
+
#
|
|
636
|
+
# @return [Hash] Stats hash with :nodes_synced and :evictions_synced counts
|
|
637
|
+
#
|
|
638
|
+
# @example
|
|
639
|
+
# stats = group.sync_stats
|
|
640
|
+
# puts "Nodes synced: #{stats[:nodes_synced]}"
|
|
641
|
+
# puts "Evictions synced: #{stats[:evictions_synced]}"
|
|
642
|
+
#
|
|
643
|
+
def sync_stats
|
|
644
|
+
@mutex.synchronize { @sync_stats.dup }
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# @!endgroup
|
|
648
|
+
|
|
649
|
+
private
|
|
650
|
+
|
|
651
|
+
def all_robots
|
|
652
|
+
@active_robots.merge(@passive_robots)
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def setup_sync_listener
|
|
656
|
+
@channel.on_change do |event, node_id, origin_robot_id|
|
|
657
|
+
handle_sync_notification(event, node_id, origin_robot_id)
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def handle_sync_notification(event, node_id, origin_robot_id)
|
|
662
|
+
@mutex.synchronize do
|
|
663
|
+
case event
|
|
664
|
+
when :added
|
|
665
|
+
sync_node_to_in_memory_caches(node_id, origin_robot_id)
|
|
666
|
+
@sync_stats[:nodes_synced] += 1
|
|
667
|
+
when :evicted
|
|
668
|
+
evict_from_in_memory_caches(node_id, origin_robot_id)
|
|
669
|
+
@sync_stats[:evictions_synced] += 1
|
|
670
|
+
when :cleared
|
|
671
|
+
clear_all_in_memory_caches(origin_robot_id)
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def sync_node_to_in_memory_caches(node_id, origin_robot_id)
|
|
677
|
+
node = HTM::Models::Node.find_by(id: node_id)
|
|
678
|
+
return unless node
|
|
679
|
+
|
|
680
|
+
all_robots.each do |_name, htm|
|
|
681
|
+
next if htm.robot_id == origin_robot_id
|
|
682
|
+
|
|
683
|
+
htm.working_memory.add_from_sync(
|
|
684
|
+
id: node.id,
|
|
685
|
+
content: node.content,
|
|
686
|
+
token_count: node.token_count || 0,
|
|
687
|
+
created_at: node.created_at
|
|
688
|
+
)
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
def evict_from_in_memory_caches(node_id, origin_robot_id)
|
|
693
|
+
all_robots.each do |_name, htm|
|
|
694
|
+
next if htm.robot_id == origin_robot_id
|
|
695
|
+
|
|
696
|
+
htm.working_memory.remove_from_sync(node_id)
|
|
697
|
+
end
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def clear_all_in_memory_caches(origin_robot_id)
|
|
701
|
+
all_robots.each do |_name, htm|
|
|
702
|
+
next if htm.robot_id == origin_robot_id
|
|
703
|
+
|
|
704
|
+
htm.working_memory.clear_from_sync
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def sync_node_to_members(node_id, exclude: nil)
|
|
709
|
+
member_ids.each do |robot_id|
|
|
710
|
+
next if robot_id == exclude
|
|
711
|
+
|
|
712
|
+
robot_node = HTM::Models::RobotNode.find_or_initialize_by(
|
|
713
|
+
robot_id: robot_id,
|
|
714
|
+
node_id: node_id
|
|
715
|
+
)
|
|
716
|
+
robot_node.working_memory = true
|
|
717
|
+
robot_node.save!
|
|
718
|
+
end
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
end
|