robot_lab 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.github/workflows/deploy-github-pages.yml +52 -0
  4. data/.github/workflows/deploy-yard-docs.yml +52 -0
  5. data/CHANGELOG.md +55 -0
  6. data/COMMITS.md +196 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +332 -0
  9. data/Rakefile +67 -0
  10. data/docs/api/adapters/anthropic.md +121 -0
  11. data/docs/api/adapters/gemini.md +133 -0
  12. data/docs/api/adapters/index.md +104 -0
  13. data/docs/api/adapters/openai.md +134 -0
  14. data/docs/api/core/index.md +113 -0
  15. data/docs/api/core/memory.md +314 -0
  16. data/docs/api/core/network.md +291 -0
  17. data/docs/api/core/robot.md +273 -0
  18. data/docs/api/core/state.md +273 -0
  19. data/docs/api/core/tool.md +353 -0
  20. data/docs/api/history/active-record-adapter.md +195 -0
  21. data/docs/api/history/config.md +191 -0
  22. data/docs/api/history/index.md +132 -0
  23. data/docs/api/history/thread-manager.md +144 -0
  24. data/docs/api/index.md +82 -0
  25. data/docs/api/mcp/client.md +221 -0
  26. data/docs/api/mcp/index.md +111 -0
  27. data/docs/api/mcp/server.md +225 -0
  28. data/docs/api/mcp/transports.md +264 -0
  29. data/docs/api/messages/index.md +67 -0
  30. data/docs/api/messages/text-message.md +102 -0
  31. data/docs/api/messages/tool-call-message.md +144 -0
  32. data/docs/api/messages/tool-result-message.md +154 -0
  33. data/docs/api/messages/user-message.md +171 -0
  34. data/docs/api/streaming/context.md +174 -0
  35. data/docs/api/streaming/events.md +237 -0
  36. data/docs/api/streaming/index.md +108 -0
  37. data/docs/architecture/core-concepts.md +243 -0
  38. data/docs/architecture/index.md +138 -0
  39. data/docs/architecture/message-flow.md +320 -0
  40. data/docs/architecture/network-orchestration.md +216 -0
  41. data/docs/architecture/robot-execution.md +243 -0
  42. data/docs/architecture/state-management.md +323 -0
  43. data/docs/assets/css/custom.css +56 -0
  44. data/docs/assets/images/robot_lab.jpg +0 -0
  45. data/docs/concepts.md +216 -0
  46. data/docs/examples/basic-chat.md +193 -0
  47. data/docs/examples/index.md +129 -0
  48. data/docs/examples/mcp-server.md +290 -0
  49. data/docs/examples/multi-robot-network.md +312 -0
  50. data/docs/examples/rails-application.md +420 -0
  51. data/docs/examples/tool-usage.md +310 -0
  52. data/docs/getting-started/configuration.md +230 -0
  53. data/docs/getting-started/index.md +56 -0
  54. data/docs/getting-started/installation.md +179 -0
  55. data/docs/getting-started/quick-start.md +203 -0
  56. data/docs/guides/building-robots.md +376 -0
  57. data/docs/guides/creating-networks.md +366 -0
  58. data/docs/guides/history.md +359 -0
  59. data/docs/guides/index.md +68 -0
  60. data/docs/guides/mcp-integration.md +356 -0
  61. data/docs/guides/memory.md +309 -0
  62. data/docs/guides/rails-integration.md +432 -0
  63. data/docs/guides/streaming.md +314 -0
  64. data/docs/guides/using-tools.md +394 -0
  65. data/docs/index.md +160 -0
  66. data/examples/01_simple_robot.rb +38 -0
  67. data/examples/02_tools.rb +106 -0
  68. data/examples/03_network.rb +103 -0
  69. data/examples/04_mcp.rb +219 -0
  70. data/examples/05_streaming.rb +124 -0
  71. data/examples/06_prompt_templates.rb +324 -0
  72. data/examples/07_network_memory.rb +329 -0
  73. data/examples/prompts/assistant/system.txt.erb +2 -0
  74. data/examples/prompts/assistant/user.txt.erb +1 -0
  75. data/examples/prompts/billing/system.txt.erb +7 -0
  76. data/examples/prompts/billing/user.txt.erb +1 -0
  77. data/examples/prompts/classifier/system.txt.erb +4 -0
  78. data/examples/prompts/classifier/user.txt.erb +1 -0
  79. data/examples/prompts/entity_extractor/system.txt.erb +11 -0
  80. data/examples/prompts/entity_extractor/user.txt.erb +3 -0
  81. data/examples/prompts/escalation/system.txt.erb +35 -0
  82. data/examples/prompts/escalation/user.txt.erb +34 -0
  83. data/examples/prompts/general/system.txt.erb +4 -0
  84. data/examples/prompts/general/user.txt.erb +1 -0
  85. data/examples/prompts/github_assistant/system.txt.erb +6 -0
  86. data/examples/prompts/github_assistant/user.txt.erb +1 -0
  87. data/examples/prompts/helper/system.txt.erb +1 -0
  88. data/examples/prompts/helper/user.txt.erb +1 -0
  89. data/examples/prompts/keyword_extractor/system.txt.erb +8 -0
  90. data/examples/prompts/keyword_extractor/user.txt.erb +3 -0
  91. data/examples/prompts/order_support/system.txt.erb +27 -0
  92. data/examples/prompts/order_support/user.txt.erb +22 -0
  93. data/examples/prompts/product_support/system.txt.erb +30 -0
  94. data/examples/prompts/product_support/user.txt.erb +32 -0
  95. data/examples/prompts/sentiment_analyzer/system.txt.erb +9 -0
  96. data/examples/prompts/sentiment_analyzer/user.txt.erb +3 -0
  97. data/examples/prompts/synthesizer/system.txt.erb +14 -0
  98. data/examples/prompts/synthesizer/user.txt.erb +15 -0
  99. data/examples/prompts/technical/system.txt.erb +7 -0
  100. data/examples/prompts/technical/user.txt.erb +1 -0
  101. data/examples/prompts/triage/system.txt.erb +16 -0
  102. data/examples/prompts/triage/user.txt.erb +17 -0
  103. data/lib/generators/robot_lab/install_generator.rb +78 -0
  104. data/lib/generators/robot_lab/robot_generator.rb +55 -0
  105. data/lib/generators/robot_lab/templates/initializer.rb.tt +41 -0
  106. data/lib/generators/robot_lab/templates/migration.rb.tt +32 -0
  107. data/lib/generators/robot_lab/templates/result_model.rb.tt +52 -0
  108. data/lib/generators/robot_lab/templates/robot.rb.tt +46 -0
  109. data/lib/generators/robot_lab/templates/robot_test.rb.tt +32 -0
  110. data/lib/generators/robot_lab/templates/routing_robot.rb.tt +53 -0
  111. data/lib/generators/robot_lab/templates/thread_model.rb.tt +40 -0
  112. data/lib/robot_lab/adapters/anthropic.rb +163 -0
  113. data/lib/robot_lab/adapters/base.rb +85 -0
  114. data/lib/robot_lab/adapters/gemini.rb +193 -0
  115. data/lib/robot_lab/adapters/openai.rb +159 -0
  116. data/lib/robot_lab/adapters/registry.rb +81 -0
  117. data/lib/robot_lab/configuration.rb +143 -0
  118. data/lib/robot_lab/error.rb +32 -0
  119. data/lib/robot_lab/errors.rb +70 -0
  120. data/lib/robot_lab/history/active_record_adapter.rb +146 -0
  121. data/lib/robot_lab/history/config.rb +115 -0
  122. data/lib/robot_lab/history/thread_manager.rb +93 -0
  123. data/lib/robot_lab/mcp/client.rb +210 -0
  124. data/lib/robot_lab/mcp/server.rb +84 -0
  125. data/lib/robot_lab/mcp/transports/base.rb +56 -0
  126. data/lib/robot_lab/mcp/transports/sse.rb +117 -0
  127. data/lib/robot_lab/mcp/transports/stdio.rb +133 -0
  128. data/lib/robot_lab/mcp/transports/streamable_http.rb +139 -0
  129. data/lib/robot_lab/mcp/transports/websocket.rb +108 -0
  130. data/lib/robot_lab/memory.rb +882 -0
  131. data/lib/robot_lab/memory_change.rb +123 -0
  132. data/lib/robot_lab/message.rb +357 -0
  133. data/lib/robot_lab/network.rb +350 -0
  134. data/lib/robot_lab/rails/engine.rb +29 -0
  135. data/lib/robot_lab/rails/railtie.rb +42 -0
  136. data/lib/robot_lab/robot.rb +560 -0
  137. data/lib/robot_lab/robot_result.rb +205 -0
  138. data/lib/robot_lab/robotic_model.rb +324 -0
  139. data/lib/robot_lab/state_proxy.rb +188 -0
  140. data/lib/robot_lab/streaming/context.rb +144 -0
  141. data/lib/robot_lab/streaming/events.rb +95 -0
  142. data/lib/robot_lab/streaming/sequence_counter.rb +48 -0
  143. data/lib/robot_lab/task.rb +117 -0
  144. data/lib/robot_lab/tool.rb +223 -0
  145. data/lib/robot_lab/tool_config.rb +112 -0
  146. data/lib/robot_lab/tool_manifest.rb +234 -0
  147. data/lib/robot_lab/user_message.rb +118 -0
  148. data/lib/robot_lab/version.rb +5 -0
  149. data/lib/robot_lab/waiter.rb +73 -0
  150. data/lib/robot_lab.rb +195 -0
  151. data/mkdocs.yml +214 -0
  152. data/sig/robot_lab.rbs +4 -0
  153. metadata +442 -0
@@ -0,0 +1,882 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm/semantic_cache"
4
+
5
+ module RobotLab
6
+ # Raised when a blocking get times out
7
+ class AwaitTimeout < Error; end
8
+
9
+ # Unified memory system for Robot and Network execution
10
+ #
11
+ # Memory is a reactive key-value store backed by Redis (if available) or an
12
+ # internal Hash object. It provides persistent storage for runtime data,
13
+ # conversation history, and arbitrary user-defined values.
14
+ #
15
+ # == Reactive Features
16
+ #
17
+ # Memory supports pub/sub semantics where robots can subscribe to key changes
18
+ # and optionally block until values become available:
19
+ #
20
+ # - `set(key, value)` - Write a value and notify subscribers asynchronously
21
+ # - `get(key, wait: true)` - Read a value, blocking until it exists if needed
22
+ # - `subscribe(*keys)` - Register a callback for key changes
23
+ #
24
+ # Reserved keys with special accessors:
25
+ # - :data - runtime data (StateProxy for method-style access)
26
+ # - :results - accumulated robot results
27
+ # - :messages - conversation history
28
+ # - :session_id - conversation session identifier for history persistence
29
+ # - :cache - semantic cache instance (RubyLLM::SemanticCache)
30
+ #
31
+ # @example Basic usage
32
+ # memory = Memory.new
33
+ # memory.set(:user_id, 123)
34
+ # memory.get(:user_id) # => 123
35
+ #
36
+ # @example Blocking read
37
+ # # In robot A (writer)
38
+ # memory.set(:sentiment, { score: 0.8 })
39
+ #
40
+ # # In robot B (reader, may run concurrently)
41
+ # result = memory.get(:sentiment, wait: true) # Blocks until available
42
+ # result = memory.get(:sentiment, wait: 30) # Blocks up to 30 seconds
43
+ #
44
+ # @example Multiple keys
45
+ # results = memory.get(:sentiment, :entities, :keywords, wait: 60)
46
+ # # => { sentiment: {...}, entities: [...], keywords: [...] }
47
+ #
48
+ # @example Subscriptions (async callbacks)
49
+ # memory.subscribe(:raw_data) do |change|
50
+ # puts "#{change.key} changed by #{change.writer}"
51
+ # enriched = enrich(change.value)
52
+ # memory.set(:enriched, enriched)
53
+ # end
54
+ #
55
+ # @example Using reserved keys
56
+ # memory.data[:category] = "billing"
57
+ # memory.data.category # => "billing"
58
+ # memory.results # => []
59
+ # memory.cache # => RubyLLM::SemanticCache instance
60
+ #
61
+ class Memory
62
+ # Reserved keys that have special behavior
63
+ RESERVED_KEYS = %i[data results messages session_id cache].freeze
64
+
65
+ # @!attribute [r] network_name
66
+ # @return [String, nil] the network this memory belongs to
67
+ # @!attribute [rw] current_writer
68
+ # @return [String, nil] the name of the robot currently writing
69
+ attr_reader :network_name
70
+ attr_accessor :current_writer
71
+
72
+ # Creates a new Memory instance.
73
+ #
74
+ # @param data [Hash] initial runtime data
75
+ # @param results [Array<RobotResult>] pre-loaded robot results
76
+ # @param messages [Array<Message, Hash>] pre-loaded conversation messages
77
+ # @param session_id [String, nil] conversation session identifier
78
+ # @param backend [Symbol] storage backend (:auto, :redis, :hash)
79
+ # @param enable_cache [Boolean] whether to enable semantic caching (default: true)
80
+ # @param network_name [String, nil] the network this memory belongs to
81
+ #
82
+ # @example Basic memory with caching enabled
83
+ # Memory.new(data: { category: nil, resolved: false })
84
+ #
85
+ # @example Memory with caching disabled
86
+ # Memory.new(enable_cache: false)
87
+ #
88
+ # @example Network-owned memory
89
+ # Memory.new(network_name: "support_pipeline")
90
+ def initialize(data: {}, results: [], messages: [], session_id: nil, backend: :auto, enable_cache: true, network_name: nil)
91
+ @backend = select_backend(backend)
92
+ @mutex = Mutex.new
93
+ @enable_cache = enable_cache
94
+ @network_name = network_name
95
+ @current_writer = nil
96
+
97
+ # Initialize reserved keys
98
+ set_internal(:data, data.is_a?(Hash) ? data.transform_keys(&:to_sym) : data)
99
+ set_internal(:results, Array(results))
100
+ set_internal(:messages, Array(messages).map { |m| normalize_message(m) })
101
+ set_internal(:session_id, session_id)
102
+ set_internal(:cache, @enable_cache ? RubyLLM::SemanticCache : nil)
103
+
104
+ # Data proxy for method-style access
105
+ @data_proxy = nil
106
+
107
+ # Reactive infrastructure
108
+ @subscriptions = Hash.new { |h, k| h[k] = [] }
109
+ @pattern_subscriptions = []
110
+ @waiters = Hash.new { |h, k| h[k] = [] }
111
+ @subscription_mutex = Mutex.new
112
+ @waiter_mutex = Mutex.new
113
+ end
114
+
115
+ # Get value by key
116
+ #
117
+ # @param key [Symbol, String] the key to retrieve
118
+ # @return [Object] the stored value
119
+ #
120
+ def [](key)
121
+ key = key.to_sym
122
+ return send(key) if RESERVED_KEYS.include?(key) && key != :cache
123
+
124
+ get_internal(key)
125
+ end
126
+
127
+ # Set value by key
128
+ #
129
+ # For non-reserved keys, this delegates to {#set} which provides
130
+ # reactive notifications. For reserved keys, it bypasses notifications.
131
+ #
132
+ # @param key [Symbol, String] the key to set
133
+ # @param value [Object] the value to store
134
+ # @return [Object] the stored value
135
+ #
136
+ # @see #set
137
+ #
138
+ def []=(key, value)
139
+ key = key.to_sym
140
+
141
+ # Reserved keys have special handling (no notifications)
142
+ case key
143
+ when :data
144
+ @data_proxy = nil # Reset proxy
145
+ set_internal(:data, value.is_a?(Hash) ? value.transform_keys(&:to_sym) : value)
146
+ when :results
147
+ set_internal(:results, Array(value))
148
+ when :messages
149
+ set_internal(:messages, Array(value).map { |m| normalize_message(m) })
150
+ when :session_id
151
+ set_internal(:session_id, value)
152
+ when :cache
153
+ # Cache is read-only after initialization
154
+ raise ArgumentError, "Cannot reassign cache - it is initialized automatically"
155
+ else
156
+ # Non-reserved keys use reactive set
157
+ set(key, value)
158
+ end
159
+
160
+ value
161
+ end
162
+
163
+ # Access runtime data through StateProxy
164
+ #
165
+ # @return [StateProxy] proxy for method-style data access
166
+ #
167
+ def data
168
+ @data_proxy ||= StateProxy.new(get_internal(:data) || {})
169
+ end
170
+
171
+ # Get copy of results (immutable access)
172
+ #
173
+ # @return [Array<RobotResult>]
174
+ #
175
+ def results
176
+ (get_internal(:results) || []).dup
177
+ end
178
+
179
+ # Get copy of messages (immutable access)
180
+ #
181
+ # @return [Array<Message>]
182
+ #
183
+ def messages
184
+ (get_internal(:messages) || []).dup
185
+ end
186
+
187
+ # Get session identifier
188
+ #
189
+ # @return [String, nil]
190
+ #
191
+ def session_id
192
+ get_internal(:session_id)
193
+ end
194
+
195
+ # Set session identifier
196
+ #
197
+ # @param id [String, nil]
198
+ # @return [self]
199
+ #
200
+ def session_id=(id)
201
+ set_internal(:session_id, id)
202
+ self
203
+ end
204
+
205
+ # Get the semantic cache module
206
+ #
207
+ # The cache is always active and provides semantic similarity matching
208
+ # for LLM responses, reducing costs and latency by returning cached
209
+ # responses for semantically equivalent queries.
210
+ #
211
+ # @example Using the cache with fetch
212
+ # response = memory.cache.fetch("What is Ruby?") do
213
+ # RubyLLM.chat.ask("What is Ruby?")
214
+ # end
215
+ #
216
+ # @example Wrapping a chat instance
217
+ # chat = memory.cache.wrap(RubyLLM.chat(model: "gpt-4"))
218
+ # chat.ask("What is Ruby?") # Cached on semantic similarity
219
+ #
220
+ # @return [RubyLLM::SemanticCache] the semantic cache module
221
+ #
222
+ def cache
223
+ get_internal(:cache)
224
+ end
225
+
226
+ # =========================================================================
227
+ # Reactive Memory API
228
+ # =========================================================================
229
+
230
+ # Set a value and notify subscribers asynchronously.
231
+ #
232
+ # This is the primary write method for reactive memory. It stores the value,
233
+ # wakes any threads waiting for this key, and asynchronously notifies
234
+ # subscribers.
235
+ #
236
+ # @param key [Symbol, String] the key to set
237
+ # @param value [Object] the value to store
238
+ # @return [Object] the stored value
239
+ #
240
+ # @example Basic set
241
+ # memory.set(:sentiment, { score: 0.8, confidence: 0.95 })
242
+ #
243
+ # @example Set triggers notifications
244
+ # memory.subscribe(:status) { |change| puts "Status: #{change.value}" }
245
+ # memory.set(:status, "complete") # Subscriber callback fires async
246
+ #
247
+ def set(key, value)
248
+ key = key.to_sym
249
+ old_value = nil
250
+
251
+ # Store the value
252
+ @mutex.synchronize do
253
+ old_value = @backend[key]
254
+ @backend[key] = value
255
+ end
256
+
257
+ # Wake any threads waiting for this key (synchronous - they need the value)
258
+ wake_waiters(key, value)
259
+
260
+ # Notify subscribers asynchronously
261
+ notify_subscribers_async(key, value, old_value)
262
+
263
+ value
264
+ end
265
+
266
+ # Get one or more values, optionally waiting until they exist.
267
+ #
268
+ # @param keys [Array<Symbol, String>] one or more keys to retrieve
269
+ # @param wait [Boolean, Numeric] wait behavior:
270
+ # - `false` (default): return immediately, nil if missing
271
+ # - `true`: block indefinitely until value(s) exist
272
+ # - `Numeric`: block up to that many seconds, raise AwaitTimeout if exceeded
273
+ # @return [Object, Hash] single value for one key, hash for multiple keys
274
+ # @raise [AwaitTimeout] if timeout expires before value is available
275
+ #
276
+ # @example Immediate read
277
+ # memory.get(:sentiment) # => value or nil
278
+ #
279
+ # @example Blocking read
280
+ # memory.get(:sentiment, wait: true) # Blocks until available
281
+ #
282
+ # @example Blocking with timeout
283
+ # memory.get(:sentiment, wait: 30) # Blocks up to 30 seconds
284
+ #
285
+ # @example Multiple keys
286
+ # memory.get(:sentiment, :entities, :keywords, wait: 60)
287
+ # # => { sentiment: {...}, entities: [...], keywords: [...] }
288
+ #
289
+ def get(*keys, wait: false)
290
+ keys = keys.flatten.map(&:to_sym)
291
+
292
+ if keys.one?
293
+ get_single(keys.first, wait: wait)
294
+ else
295
+ get_multiple(keys, wait: wait)
296
+ end
297
+ end
298
+
299
+ # Subscribe to changes on one or more keys.
300
+ #
301
+ # The callback is invoked asynchronously whenever a subscribed key changes.
302
+ # The callback receives a MemoryChange object with details about the change.
303
+ #
304
+ # @param keys [Array<Symbol, String>] keys to subscribe to
305
+ # @yield [MemoryChange] callback invoked when a subscribed key changes
306
+ # @return [Object] subscription identifier (for unsubscribe)
307
+ #
308
+ # @example Subscribe to a single key
309
+ # memory.subscribe(:raw_data) do |change|
310
+ # puts "#{change.key} changed from #{change.previous} to #{change.value}"
311
+ # puts "Written by: #{change.writer}"
312
+ # end
313
+ #
314
+ # @example Subscribe to multiple keys
315
+ # memory.subscribe(:sentiment, :entities) do |change|
316
+ # update_dashboard(change.key, change.value)
317
+ # end
318
+ #
319
+ def subscribe(*keys, &block)
320
+ raise ArgumentError, "Block required for subscribe" unless block_given?
321
+
322
+ keys = keys.flatten.map(&:to_sym)
323
+ subscription_id = generate_subscription_id
324
+
325
+ @subscription_mutex.synchronize do
326
+ keys.each do |key|
327
+ @subscriptions[key] << { id: subscription_id, callback: block }
328
+ end
329
+ end
330
+
331
+ subscription_id
332
+ end
333
+
334
+ # Subscribe to keys matching a pattern.
335
+ #
336
+ # Pattern uses glob-style matching:
337
+ # - `*` matches any characters
338
+ # - `?` matches a single character
339
+ #
340
+ # @param pattern [String] glob pattern to match keys
341
+ # @yield [MemoryChange] callback invoked when a matching key changes
342
+ # @return [Object] subscription identifier (for unsubscribe)
343
+ #
344
+ # @example Subscribe to namespace
345
+ # memory.subscribe_pattern("analysis:*") do |change|
346
+ # puts "Analysis key #{change.key} updated"
347
+ # end
348
+ #
349
+ def subscribe_pattern(pattern, &block)
350
+ raise ArgumentError, "Block required for subscribe_pattern" unless block_given?
351
+
352
+ subscription_id = generate_subscription_id
353
+ regex = pattern_to_regex(pattern)
354
+
355
+ @subscription_mutex.synchronize do
356
+ @pattern_subscriptions << { id: subscription_id, pattern: regex, callback: block }
357
+ end
358
+
359
+ subscription_id
360
+ end
361
+
362
+ # Remove a subscription.
363
+ #
364
+ # @param subscription_id [Object] the subscription identifier from subscribe
365
+ # @return [Boolean] true if subscription was found and removed
366
+ #
367
+ def unsubscribe(subscription_id)
368
+ removed = false
369
+
370
+ @subscription_mutex.synchronize do
371
+ @subscriptions.each_value do |subs|
372
+ removed = true if subs.reject! { |s| s[:id] == subscription_id }
373
+ end
374
+
375
+ removed = true if @pattern_subscriptions.reject! { |s| s[:id] == subscription_id }
376
+ end
377
+
378
+ removed
379
+ end
380
+
381
+ # Remove all subscriptions for specific keys.
382
+ #
383
+ # @param keys [Array<Symbol, String>] keys to unsubscribe from
384
+ # @return [self]
385
+ #
386
+ def unsubscribe_keys(*keys)
387
+ keys = keys.flatten.map(&:to_sym)
388
+
389
+ @subscription_mutex.synchronize do
390
+ keys.each { |key| @subscriptions.delete(key) }
391
+ end
392
+
393
+ self
394
+ end
395
+
396
+ # Check if there are any subscribers for a key.
397
+ #
398
+ # @param key [Symbol, String] the key to check
399
+ # @return [Boolean]
400
+ #
401
+ def subscribed?(key)
402
+ key = key.to_sym
403
+
404
+ @subscription_mutex.synchronize do
405
+ return true if @subscriptions[key].any?
406
+
407
+ @pattern_subscriptions.any? { |s| s[:pattern].match?(key.to_s) }
408
+ end
409
+ end
410
+
411
+ # Append a robot result to history
412
+ #
413
+ # @param result [RobotResult]
414
+ # @return [self]
415
+ #
416
+ def append_result(result)
417
+ @mutex.synchronize do
418
+ results_array = @backend[:results] || []
419
+ results_array << result
420
+ @backend[:results] = results_array
421
+ end
422
+ self
423
+ end
424
+
425
+ # Set results (used when loading from persistence)
426
+ #
427
+ # @param results [Array<RobotResult>]
428
+ # @return [self]
429
+ #
430
+ def set_results(results)
431
+ set_internal(:results, Array(results))
432
+ self
433
+ end
434
+
435
+ # Get results from a specific index (for incremental save)
436
+ #
437
+ # @param start_index [Integer]
438
+ # @return [Array<RobotResult>]
439
+ #
440
+ def results_from(start_index)
441
+ (get_internal(:results) || [])[start_index..] || []
442
+ end
443
+
444
+ # Merge additional values into memory
445
+ #
446
+ # @param values [Hash] key-value pairs to merge
447
+ # @return [self]
448
+ #
449
+ def merge!(values)
450
+ values.each { |k, v| self[k] = v }
451
+ self
452
+ end
453
+
454
+ # Check if key exists
455
+ #
456
+ # @param key [Symbol, String]
457
+ # @return [Boolean]
458
+ #
459
+ def key?(key)
460
+ key = key.to_sym
461
+ return true if RESERVED_KEYS.include?(key)
462
+
463
+ @mutex.synchronize do
464
+ @backend.key?(key)
465
+ end
466
+ end
467
+ alias has_key? key?
468
+ alias include? key?
469
+
470
+ # Get all keys (excluding reserved keys)
471
+ #
472
+ # @return [Array<Symbol>]
473
+ #
474
+ def keys
475
+ @mutex.synchronize do
476
+ @backend.keys.map(&:to_sym) - RESERVED_KEYS
477
+ end
478
+ end
479
+
480
+ # Get all keys including reserved
481
+ #
482
+ # @return [Array<Symbol>]
483
+ #
484
+ def all_keys
485
+ @mutex.synchronize do
486
+ @backend.keys.map(&:to_sym)
487
+ end
488
+ end
489
+
490
+ # Delete a key
491
+ #
492
+ # @param key [Symbol, String]
493
+ # @return [Object] the deleted value
494
+ #
495
+ def delete(key)
496
+ key = key.to_sym
497
+ raise ArgumentError, "Cannot delete reserved key: #{key}" if RESERVED_KEYS.include?(key)
498
+
499
+ @mutex.synchronize do
500
+ @backend.delete(key)
501
+ end
502
+ end
503
+
504
+ # Clear all non-reserved keys
505
+ #
506
+ # @return [self]
507
+ #
508
+ def clear
509
+ @mutex.synchronize do
510
+ keys_to_delete = @backend.keys.map(&:to_sym) - RESERVED_KEYS
511
+ keys_to_delete.each { |k| @backend.delete(k) }
512
+ end
513
+ self
514
+ end
515
+
516
+ # Reset memory to initial state
517
+ #
518
+ # @return [self]
519
+ #
520
+ def reset
521
+ cached = get_internal(:cache) # Preserve cache instance
522
+ @mutex.synchronize do
523
+ @backend.clear
524
+ @backend[:data] = {}
525
+ @backend[:results] = []
526
+ @backend[:messages] = []
527
+ @backend[:session_id] = nil
528
+ @backend[:cache] = cached # Restore cache instance
529
+ end
530
+ @data_proxy = nil
531
+ self
532
+ end
533
+
534
+ # Format history for robot prompts
535
+ #
536
+ # Combines pre-loaded messages with formatted results.
537
+ #
538
+ # @param formatter [Proc, nil] custom result formatter
539
+ # @return [Array<Message>]
540
+ #
541
+ def format_history(formatter: nil)
542
+ formatter ||= default_formatter
543
+ messages + results.flat_map { |r| formatter.call(r) }
544
+ end
545
+
546
+ # Clone memory for isolated execution
547
+ #
548
+ # The semantic cache setting and network name are preserved in clones.
549
+ # Subscriptions are NOT cloned - the new memory starts with fresh subscriptions.
550
+ #
551
+ # @return [Memory]
552
+ #
553
+ def clone
554
+ cloned = Memory.new(
555
+ data: deep_dup(data.to_h),
556
+ results: results.dup,
557
+ messages: messages.dup,
558
+ session_id: session_id,
559
+ backend: @backend.is_a?(Hash) ? :hash : :auto,
560
+ enable_cache: @enable_cache,
561
+ network_name: @network_name
562
+ )
563
+ # Copy non-reserved keys (without triggering notifications)
564
+ keys.each { |k| cloned.send(:set_internal, k, deep_dup(get_internal(k))) }
565
+ cloned
566
+ end
567
+ alias dup clone
568
+
569
+ # Export memory to hash for serialization
570
+ #
571
+ # Note: The cache is not serialized as it is recreated on initialization.
572
+ #
573
+ # @return [Hash]
574
+ #
575
+ def to_h
576
+ {
577
+ data: data.to_h,
578
+ results: results.map(&:export),
579
+ messages: messages.map(&:to_h),
580
+ session_id: session_id,
581
+ custom: keys.each_with_object({}) { |k, h| h[k] = self[k] }
582
+ }.compact
583
+ end
584
+
585
+ # Convert to JSON
586
+ #
587
+ # @param args [Array] arguments passed to to_json
588
+ # @return [String]
589
+ #
590
+ def to_json(*args)
591
+ to_h.to_json(*args)
592
+ end
593
+
594
+ # Reconstruct memory from hash
595
+ #
596
+ # A new semantic cache instance is created automatically.
597
+ #
598
+ # @param hash [Hash]
599
+ # @return [Memory]
600
+ #
601
+ def self.from_hash(hash)
602
+ hash = hash.transform_keys(&:to_sym)
603
+ memory = new(
604
+ data: hash[:data] || {},
605
+ results: (hash[:results] || []).map { |r| RobotResult.from_hash(r) },
606
+ messages: (hash[:messages] || []).map { |m| Message.from_hash(m) },
607
+ session_id: hash[:session_id]
608
+ )
609
+
610
+ # Restore custom keys
611
+ (hash[:custom] || {}).each { |k, v| memory[k] = v }
612
+
613
+ memory
614
+ end
615
+
616
+ # Check if using Redis backend
617
+ #
618
+ # @return [Boolean]
619
+ #
620
+ def redis?
621
+ @backend.is_a?(RedisBackend)
622
+ end
623
+
624
+ private
625
+
626
+ def create_semantic_cache
627
+ RubyLLM::SemanticCache
628
+ end
629
+
630
+ def select_backend(preference)
631
+ case preference
632
+ when :redis
633
+ create_redis_backend || create_hash_backend
634
+ when :hash
635
+ create_hash_backend
636
+ else # :auto
637
+ create_redis_backend || create_hash_backend
638
+ end
639
+ end
640
+
641
+ def create_redis_backend
642
+ return nil unless redis_available?
643
+
644
+ RedisBackend.new
645
+ rescue StandardError
646
+ nil
647
+ end
648
+
649
+ def create_hash_backend
650
+ {}
651
+ end
652
+
653
+ def redis_available?
654
+ return false unless defined?(Redis)
655
+
656
+ # Check if Redis is configured in RobotLab
657
+ redis_config = RobotLab.configuration.respond_to?(:redis) ? RobotLab.configuration.redis : nil
658
+ redis_config || ENV["REDIS_URL"]
659
+ end
660
+
661
+ def get_internal(key)
662
+ @mutex.synchronize do
663
+ @backend[key.to_sym]
664
+ end
665
+ end
666
+
667
+ def set_internal(key, value)
668
+ @mutex.synchronize do
669
+ @backend[key.to_sym] = value
670
+ end
671
+ end
672
+
673
+ def normalize_message(msg)
674
+ case msg
675
+ when Message
676
+ msg
677
+ when Hash
678
+ Message.from_hash(msg)
679
+ else
680
+ raise ArgumentError, "Invalid message: must be Message or Hash"
681
+ end
682
+ end
683
+
684
+ def default_formatter
685
+ ->(result) { result.output + result.tool_calls }
686
+ end
687
+
688
+ def deep_dup(obj)
689
+ case obj
690
+ when Hash
691
+ obj.transform_values { |v| deep_dup(v) }
692
+ when Array
693
+ obj.map { |v| deep_dup(v) }
694
+ else
695
+ obj.dup rescue obj
696
+ end
697
+ end
698
+
699
+ # =========================================================================
700
+ # Reactive Memory Helpers
701
+ # =========================================================================
702
+
703
+ def get_single(key, wait:)
704
+ # Try immediate read
705
+ value = @mutex.synchronize { @backend[key] }
706
+ return value unless value.nil? && wait
707
+
708
+ # Need to wait
709
+ timeout = wait == true ? nil : wait
710
+ wait_for_key(key, timeout: timeout)
711
+ end
712
+
713
+ def get_multiple(keys, wait:)
714
+ results = {}
715
+ missing = []
716
+
717
+ @mutex.synchronize do
718
+ keys.each do |key|
719
+ if @backend.key?(key)
720
+ results[key] = @backend[key]
721
+ else
722
+ missing << key
723
+ end
724
+ end
725
+ end
726
+
727
+ return results if missing.empty? || !wait
728
+
729
+ # Wait for missing keys
730
+ timeout = wait == true ? nil : wait
731
+ missing.each do |key|
732
+ results[key] = wait_for_key(key, timeout: timeout)
733
+ end
734
+
735
+ results
736
+ end
737
+
738
+ def wait_for_key(key, timeout:)
739
+ waiter = Waiter.new
740
+
741
+ @waiter_mutex.synchronize do
742
+ # Double-check - value might have arrived while setting up
743
+ value = @mutex.synchronize { @backend[key] }
744
+ return value unless value.nil?
745
+
746
+ @waiters[key] << waiter
747
+ end
748
+
749
+ result = waiter.wait(timeout: timeout)
750
+
751
+ if result == :timeout
752
+ # Clean up the waiter
753
+ @waiter_mutex.synchronize { @waiters[key].delete(waiter) }
754
+ raise AwaitTimeout, "Timeout waiting for :#{key} after #{timeout} seconds"
755
+ end
756
+
757
+ result
758
+ end
759
+
760
+ def wake_waiters(key, value)
761
+ waiters = @waiter_mutex.synchronize { @waiters.delete(key) || [] }
762
+ waiters.each { |w| w.signal(value) }
763
+ end
764
+
765
+ def notify_subscribers_async(key, value, old_value)
766
+ # Collect all matching subscribers
767
+ callbacks = []
768
+
769
+ @subscription_mutex.synchronize do
770
+ # Exact key matches
771
+ callbacks.concat(@subscriptions[key].map { |s| s[:callback] })
772
+
773
+ # Pattern matches
774
+ key_str = key.to_s
775
+ @pattern_subscriptions.each do |sub|
776
+ callbacks << sub[:callback] if sub[:pattern].match?(key_str)
777
+ end
778
+ end
779
+
780
+ return if callbacks.empty?
781
+
782
+ # Build the change object
783
+ change = MemoryChange.new(
784
+ key: key,
785
+ value: value,
786
+ previous: old_value,
787
+ writer: @current_writer,
788
+ network_name: @network_name,
789
+ timestamp: Time.now
790
+ )
791
+
792
+ # Dispatch callbacks asynchronously
793
+ callbacks.each do |callback|
794
+ dispatch_async { callback.call(change) }
795
+ end
796
+ end
797
+
798
+ def dispatch_async(&block)
799
+ # Use Async if available (preferred for fiber-based concurrency)
800
+ if defined?(Async) && Async::Task.current?
801
+ Async { block.call }
802
+ else
803
+ # Fall back to Thread for basic async dispatch
804
+ Thread.new do
805
+ block.call
806
+ rescue StandardError => e
807
+ # Log but don't crash the notification system
808
+ warn "Memory subscription callback error: #{e.message}"
809
+ end
810
+ end
811
+ end
812
+
813
+ def generate_subscription_id
814
+ SecureRandom.uuid
815
+ end
816
+
817
+ def pattern_to_regex(pattern)
818
+ # Convert glob pattern to regex
819
+ regex_str = pattern
820
+ .gsub(".", "\\.")
821
+ .gsub("*", ".*")
822
+ .gsub("?", ".")
823
+
824
+ Regexp.new("\\A#{regex_str}\\z")
825
+ end
826
+ end
827
+
828
+ # Redis backend for Memory (optional, loaded when Redis is available)
829
+ #
830
+ # @api private
831
+ class RedisBackend
832
+ def initialize
833
+ @redis = create_redis_connection
834
+ @namespace = "robot_lab:memory:#{SecureRandom.uuid}"
835
+ end
836
+
837
+ def [](key)
838
+ value = @redis.get("#{@namespace}:#{key}")
839
+ value ? JSON.parse(value, symbolize_names: true) : nil
840
+ rescue JSON::ParserError
841
+ value
842
+ end
843
+
844
+ def []=(key, value)
845
+ serialized = value.is_a?(String) ? value : value.to_json
846
+ @redis.set("#{@namespace}:#{key}", serialized)
847
+ value
848
+ end
849
+
850
+ def key?(key)
851
+ @redis.exists?("#{@namespace}:#{key}")
852
+ end
853
+
854
+ def keys
855
+ @redis.keys("#{@namespace}:*").map { |k| k.sub("#{@namespace}:", "").to_sym }
856
+ end
857
+
858
+ def delete(key)
859
+ value = self[key]
860
+ @redis.del("#{@namespace}:#{key}")
861
+ value
862
+ end
863
+
864
+ def clear
865
+ keys.each { |k| delete(k) }
866
+ end
867
+
868
+ private
869
+
870
+ def create_redis_connection
871
+ redis_config = RobotLab.configuration.respond_to?(:redis) ? RobotLab.configuration.redis : nil
872
+
873
+ if redis_config.is_a?(Hash)
874
+ Redis.new(**redis_config)
875
+ elsif ENV["REDIS_URL"]
876
+ Redis.new(url: ENV["REDIS_URL"])
877
+ else
878
+ Redis.new
879
+ end
880
+ end
881
+ end
882
+ end