robot_lab 0.2.6 → 0.2.8

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 +4 -4
  2. data/.envrc +4 -0
  3. data/.loki +5 -0
  4. data/Archspec.rb +44 -0
  5. data/CHANGELOG.md +17 -1
  6. data/README.md +124 -64
  7. data/Rakefile +6 -111
  8. data/_typos.toml +21 -0
  9. data/docs/api/core/index.md +41 -15
  10. data/docs/api/core/memory.md +247 -29
  11. data/docs/api/core/network.md +255 -33
  12. data/docs/api/core/result.md +120 -32
  13. data/docs/api/core/robot.md +551 -61
  14. data/docs/api/core/state.md +87 -197
  15. data/docs/api/core/tool.md +165 -20
  16. data/docs/api/errors.md +110 -17
  17. data/docs/api/hooks.md +469 -0
  18. data/docs/api/index.md +80 -7
  19. data/docs/api/mcp/client.md +129 -35
  20. data/docs/api/mcp/index.md +164 -23
  21. data/docs/api/mcp/server.md +27 -3
  22. data/docs/api/mcp/transports.md +94 -22
  23. data/docs/api/messages/index.md +26 -3
  24. data/docs/api/messages/text-message.md +33 -11
  25. data/docs/api/messages/tool-call-message.md +27 -4
  26. data/docs/api/messages/tool-result-message.md +23 -4
  27. data/docs/api/messages/user-message.md +45 -8
  28. data/docs/api/skills.md +423 -0
  29. data/docs/api/streaming/context.md +28 -5
  30. data/docs/api/streaming/index.md +57 -11
  31. data/docs/api/support.md +846 -0
  32. data/docs/architecture/core-concepts.md +79 -31
  33. data/docs/architecture/index.md +87 -11
  34. data/docs/architecture/message-flow.md +66 -29
  35. data/docs/architecture/network-orchestration.md +145 -38
  36. data/docs/architecture/robot-execution.md +172 -90
  37. data/docs/architecture/state-management.md +31 -12
  38. data/docs/concepts.md +176 -21
  39. data/docs/examples/basic-chat.md +72 -19
  40. data/docs/examples/index.md +117 -31
  41. data/docs/examples/mcp-server.md +154 -45
  42. data/docs/examples/multi-robot-network.md +91 -21
  43. data/docs/examples/tool-usage.md +104 -37
  44. data/docs/getting-started/configuration.md +288 -98
  45. data/docs/getting-started/installation.md +53 -41
  46. data/docs/getting-started/quick-start.md +51 -6
  47. data/docs/guides/building-robots.md +258 -50
  48. data/docs/guides/creating-networks.md +214 -30
  49. data/docs/guides/hooks.md +263 -54
  50. data/docs/guides/knowledge.md +35 -4
  51. data/docs/guides/mcp-integration.md +211 -44
  52. data/docs/guides/memory.md +103 -12
  53. data/docs/guides/observability.md +95 -47
  54. data/docs/guides/streaming.md +184 -125
  55. data/docs/guides/using-tools.md +247 -18
  56. data/docs/index.md +36 -4
  57. data/examples/01_simple_robot.rb +2 -2
  58. data/examples/02_tools.rb +14 -4
  59. data/examples/03_network.rb +12 -7
  60. data/examples/04_mcp.rb +11 -4
  61. data/examples/05_streaming.rb +8 -5
  62. data/examples/06_prompt_templates.rb +13 -9
  63. data/examples/07_network_memory.rb +5 -5
  64. data/examples/08_llm_config.rb +20 -15
  65. data/examples/09_chaining.rb +4 -4
  66. data/examples/11_network_introspection.rb +4 -4
  67. data/examples/12_message_bus.rb +2 -2
  68. data/examples/13_spawn.rb +2 -2
  69. data/examples/14_rusty_circuit/README.md +1 -0
  70. data/examples/14_rusty_circuit/comic.rb +7 -3
  71. data/examples/14_rusty_circuit/display.rb +14 -0
  72. data/examples/14_rusty_circuit/heckler.rb +8 -6
  73. data/examples/14_rusty_circuit/open_mic.rb +17 -6
  74. data/examples/14_rusty_circuit/scout.rb +17 -10
  75. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
  76. data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
  77. data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
  78. data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
  79. data/examples/16_writers_room/writer.rb +22 -22
  80. data/examples/16_writers_room/writers_room.rb +2 -0
  81. data/examples/17_skills.rb +14 -13
  82. data/examples/18_rails/README.md +20 -1
  83. data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
  84. data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
  85. data/examples/18_rails/app/robots/chat_robot.rb +11 -0
  86. data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
  87. data/examples/19_token_tracking.rb +25 -9
  88. data/examples/20_circuit_breaker.rb +10 -7
  89. data/examples/21_learning_loop.rb +42 -16
  90. data/examples/22_context_compression.rb +23 -23
  91. data/examples/23_convergence.rb +24 -17
  92. data/examples/24_structured_delegation.rb +13 -8
  93. data/examples/25_history_search.rb +12 -8
  94. data/examples/27_incident_response/incident_response.rb +31 -13
  95. data/examples/28_mcp_discovery.rb +17 -13
  96. data/examples/29_ractor_tools.rb +4 -2
  97. data/examples/30_ractor_network.rb +22 -17
  98. data/examples/31_launch_assessment.rb +20 -9
  99. data/examples/32_newsletter_reader.rb +7 -2
  100. data/examples/33_stock_predictor.rb +34 -13
  101. data/examples/34_agentskills.rb +7 -3
  102. data/examples/35_hooks.rb +18 -8
  103. data/examples/README.md +199 -45
  104. data/examples/common.rb +79 -11
  105. data/examples/xyzzy.rb +8 -1
  106. data/lib/robot_lab/agent_skill_catalog.rb +1 -0
  107. data/lib/robot_lab/ask_user.rb +2 -0
  108. data/lib/robot_lab/bus_poller.rb +2 -0
  109. data/lib/robot_lab/capabilities.rb +4 -0
  110. data/lib/robot_lab/config.rb +18 -5
  111. data/lib/robot_lab/doom_loop_detector.rb +6 -3
  112. data/lib/robot_lab/history_compressor.rb +5 -0
  113. data/lib/robot_lab/hook.rb +1 -0
  114. data/lib/robot_lab/hook_context.rb +4 -0
  115. data/lib/robot_lab/hook_registry.rb +1 -0
  116. data/lib/robot_lab/hooks.rb +6 -3
  117. data/lib/robot_lab/mcp/client.rb +2 -2
  118. data/lib/robot_lab/mcp/connection_poller.rb +16 -8
  119. data/lib/robot_lab/mcp/server_discovery.rb +1 -0
  120. data/lib/robot_lab/mcp/transports/sse.rb +3 -0
  121. data/lib/robot_lab/mcp/transports/stdio.rb +5 -0
  122. data/lib/robot_lab/mcp/transports/streamable_http.rb +4 -0
  123. data/lib/robot_lab/mcp/transports/websocket.rb +3 -0
  124. data/lib/robot_lab/memory.rb +23 -6
  125. data/lib/robot_lab/memory_change.rb +1 -0
  126. data/lib/robot_lab/message.rb +3 -0
  127. data/lib/robot_lab/names.rb +400 -0
  128. data/lib/robot_lab/network.rb +13 -6
  129. data/lib/robot_lab/robot/agent_skill_matching.rb +3 -3
  130. data/lib/robot_lab/robot/bus_messaging.rb +19 -8
  131. data/lib/robot_lab/robot/history_search.rb +2 -0
  132. data/lib/robot_lab/robot/hooking.rb +3 -0
  133. data/lib/robot_lab/robot/mcp_management.rb +11 -3
  134. data/lib/robot_lab/robot/template_rendering.rb +29 -7
  135. data/lib/robot_lab/robot.rb +81 -19
  136. data/lib/robot_lab/robot_result.rb +3 -0
  137. data/lib/robot_lab/run_config.rb +5 -0
  138. data/lib/robot_lab/script_tool.rb +20 -39
  139. data/lib/robot_lab/state_proxy.rb +1 -0
  140. data/lib/robot_lab/streaming/context.rb +1 -0
  141. data/lib/robot_lab/streaming/events.rb +1 -0
  142. data/lib/robot_lab/task.rb +2 -0
  143. data/lib/robot_lab/tool.rb +4 -0
  144. data/lib/robot_lab/user_message.rb +1 -0
  145. data/lib/robot_lab/utils.rb +2 -0
  146. data/lib/robot_lab/version.rb +1 -1
  147. data/lib/robot_lab/waiter.rb +3 -0
  148. data/lib/robot_lab.rb +27 -18
  149. data/mkdocs.yml +6 -1
  150. metadata +12 -7
  151. data/lib/robot_lab/sandbox/null.rb +0 -13
  152. data/lib/robot_lab/sandbox/seatbelt.rb +0 -104
  153. data/lib/robot_lab/sandbox.rb +0 -52
data/Rakefile CHANGED
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/gem_tasks"
4
- require "rake/testtask"
3
+ # Quality gates (quality, rubocop_check, flog_check, flay_check,
4
+ # archspec_check, ...), doc *serving*, and the gem lifecycle (build, install,
5
+ # release) live in asgard — see .loki and the shared dev/*.loki files it
6
+ # imports. This Rakefile keeps the tasks asgard delegates to (tests,
7
+ # integration, examples, docs:build).
5
8
 
6
- RUBOCOP_ENV = { "RUBOCOP_CACHE_ROOT" => "tmp/rubocop_cache" }.freeze
9
+ require "rake/testtask"
7
10
 
8
11
  Rake::TestTask.new(:test) do |t|
9
12
  t.libs << "test"
@@ -36,100 +39,6 @@ Rake::TestTask.new(:integration) do |t|
36
39
  t.ruby_opts << "-rtest_helper"
37
40
  end
38
41
 
39
- desc "Check code complexity with Flog (warn ≥20, fail ≥50)"
40
- task :flog_check do
41
- require 'flog'
42
-
43
- # Target to work toward; methods above this are warned but don't fail the gate.
44
- METHOD_WARN = 20.0
45
- # Current baseline floor — established from first run. Reduce incrementally.
46
- METHOD_FAIL = 50.0
47
-
48
- flogger = Flog.new(all: true)
49
- flogger.flog(*Dir.glob('lib/**/*.rb'))
50
-
51
- warnings = []
52
- failures = []
53
-
54
- flogger.each_by_score do |method, score|
55
- next if method.end_with?('#none') # skip file-level non-method code
56
- if score > METHOD_FAIL
57
- failures << "#{'%.1f' % score}: #{method}"
58
- elsif score > METHOD_WARN
59
- warnings << "#{'%.1f' % score}: #{method}"
60
- end
61
- end
62
-
63
- unless warnings.empty?
64
- puts "\nFlog warnings (#{METHOD_WARN}–#{METHOD_FAIL}) — target for future refactoring:"
65
- warnings.each { |v| puts " #{v}" }
66
- end
67
-
68
- if failures.empty?
69
- puts "\nFlog: no methods exceed the failure threshold (≥#{METHOD_FAIL})"
70
- else
71
- puts "\nFlog failures (≥#{METHOD_FAIL}) — must be refactored:"
72
- failures.each { |v| puts " #{v}" }
73
- abort "\nFlog quality gate failed: #{failures.size} method(s) exceed #{METHOD_FAIL}"
74
- end
75
- end
76
-
77
- desc "Check for structural code duplication with Flay (mass >= 50)"
78
- task :flay_check do
79
- require 'flay'
80
-
81
- mass_threshold = 50
82
-
83
- flay = Flay.new({ mass: mass_threshold, diff: false, verbose: false, summary: false, timeout: 60 })
84
- flay.process(*Dir.glob('lib/**/*.rb'))
85
- flay.analyze
86
-
87
- if flay.hashes.empty?
88
- puts "\nFlay: no structural duplication detected (mass >= #{mass_threshold})"
89
- else
90
- puts "\nFlay found structural duplication (mass >= #{mass_threshold}):"
91
- flay.report
92
- abort "\nFlay quality gate failed: #{flay.hashes.length} pattern(s) detected"
93
- end
94
- end
95
-
96
- desc "Run all quality checks: tests (with coverage), RuboCop, Flog, and Flay"
97
- task :quality do
98
- results = {}
99
-
100
- puts "\n#{'=' * 60}"
101
- puts "Quality Gate: Tests + Coverage"
102
- puts '=' * 60
103
- results[:tests] = system("bundle exec rake test") ? :pass : :fail
104
-
105
- puts "\n#{'=' * 60}"
106
- puts "Quality Gate: RuboCop"
107
- puts '=' * 60
108
- results[:rubocop] = system(RUBOCOP_ENV, "bundle exec rubocop") ? :pass : :fail
109
-
110
- puts "\n#{'=' * 60}"
111
- puts "Quality Gate: Flog Complexity"
112
- puts '=' * 60
113
- results[:flog] = system("bundle exec rake flog_check") ? :pass : :fail
114
-
115
- puts "\n#{'=' * 60}"
116
- puts "Quality Gate: Flay Duplication"
117
- puts '=' * 60
118
- results[:flay] = system("bundle exec rake flay_check") ? :pass : :fail
119
-
120
- puts "\n#{'=' * 60}"
121
- puts "Quality Summary"
122
- puts '=' * 60
123
- results.each do |gate, status|
124
- icon = status == :pass ? 'PASS' : 'FAIL'
125
- puts " [#{icon}] #{gate}"
126
- end
127
- puts '=' * 60
128
-
129
- abort "\nQuality gate failed" if results.values.any?(:fail)
130
- puts "\nAll quality gates passed."
131
- end
132
-
133
42
  namespace :examples do
134
43
  # Map of subdirectory-based demos to their entry point scripts
135
44
  SUBDIR_ENTRY_POINTS = {
@@ -277,22 +186,8 @@ namespace :docs do
277
186
  sh "yard doc"
278
187
  end
279
188
 
280
- namespace :yard do
281
- desc "Serve YARD documentation locally"
282
- task :serve do
283
- sh "yard server --reload"
284
- end
285
- end
286
-
287
189
  desc "Build MkDocs documentation"
288
190
  task :mkdocs do
289
191
  sh "mkdocs build"
290
192
  end
291
-
292
- namespace :mkdocs do
293
- desc "Serve MkDocs documentation locally"
294
- task :serve do
295
- sh "mkdocs serve"
296
- end
297
- end
298
193
  end
data/_typos.toml ADDED
@@ -0,0 +1,21 @@
1
+ # False-positive suppressions for the typos quality gate (asgard).
2
+ # Everything here was reviewed by hand — see git history for context.
3
+
4
+ [files]
5
+ extend-exclude = [
6
+ # Example transcripts / articles: prose quotes real names ("Damon Clark")
7
+ # and SQL-ish plurals ("SELECTs") that typos mis-parses.
8
+ "examples/ruboruby.md",
9
+ "examples/25_history_search/conversation.jsonl",
10
+ ]
11
+
12
+ [default.extend-identifiers]
13
+ # Robot names in lib/robot_lab/names.rb
14
+ BATs = "BATs"
15
+ Damon = "Damon"
16
+ Mata_Nui = "Mata_Nui"
17
+ Pris = "Pris"
18
+ S1MONE = "S1MONE"
19
+ # Test-local variable names
20
+ ba = "ba" # b→a similarity (convergence_test.rb)
21
+ thr = "thr" # thread handle (connection_poller_test.rb)
@@ -10,16 +10,28 @@ classDiagram
10
10
  +name: String
11
11
  +description: String
12
12
  +model: String
13
- +template: String
14
- +tools: Array~Tool~
15
- +run(message) Message
13
+ +template: Symbol
14
+ +local_tools: Array~Tool~
15
+ +mcp_tools: Array~Tool~
16
+ +tools_config: Symbol_or_Array
17
+ +run(message) RobotResult
18
+ }
19
+
20
+ class RobotResult {
21
+ +robot_name: String
22
+ +reply: String
23
+ +output: Array~TextMessage~
24
+ +input_tokens: Integer
25
+ +output_tokens: Integer
26
+ +export() Hash
16
27
  }
17
28
 
18
29
  class Network {
19
30
  +name: String
20
31
  +robots: Hash
21
32
  +config: RunConfig
22
- +run(message)
33
+ +memory: Memory
34
+ +run(message) SimpleFlow_Result
23
35
  }
24
36
 
25
37
  class RunConfig {
@@ -32,8 +44,9 @@ classDiagram
32
44
  class Tool {
33
45
  +name: String
34
46
  +description: String
35
- +parameters: Hash
36
- +handler: Proc
47
+ +robot: Robot
48
+ +mcp: String
49
+ +call(args)
37
50
  }
38
51
 
39
52
  class Memory {
@@ -60,22 +73,28 @@ classDiagram
60
73
  Robot --> RunConfig : uses
61
74
  Robot --> Tool : has
62
75
  Robot --> Memory : uses
76
+ Robot ..> RobotResult : returns
63
77
  Network --> Memory : uses
64
78
  Robot ..> RobotMessage : sends/receives
79
+ Memory --> StateProxy : data
65
80
  ```
66
81
 
67
82
  ## Classes
68
83
 
69
84
  | Class | Purpose |
70
85
  |-------|---------|
71
- | [Robot](robot.md) | LLM agent with personality, tools, and model configuration |
72
- | [Network](network.md) | Container for robots with routing and orchestration |
73
- | RunConfig | Shared configuration for LLM, tools, callbacks, and infrastructure |
74
- | [Tool](tool.md) | Callable function with parameters and handler |
86
+ | [Robot](robot.md) | LLM agent with templates, tools, memory, and model configuration |
87
+ | [RobotResult](result.md) | Value object returned by `Robot#run` |
88
+ | [Network](network.md) | Container for robots with DAG orchestration |
89
+ | [RunConfig](robot.md#runconfig) | Shared configuration for LLM, tools, callbacks, and infrastructure |
90
+ | [Tool](tool.md) | Callable function with parameters and an `execute` method |
75
91
  | [AskUser](tool.md#built-in-askuser) | Built-in tool for terminal-based user interaction |
76
92
  | [Memory](memory.md) | Reactive key-value store for sharing data |
93
+ | [StateProxy](state.md) | Hash/method-access wrapper returned by `memory.data` |
77
94
  | RobotMessage | Typed envelope for bus-based inter-robot communication |
78
- | `RobotLab::Runnable` | Shared interface (`crew`, `chief`, `run`) implemented by both `Robot` and `Network` — see [Runnable Protocol](../../architecture/core-concepts.md#runnable-protocol) |
95
+ | `RobotLab::Runnable` | Shared interface (`crew`, `chief`, `robot_count`, `network?`, `single?`) implemented by both `Robot` and `Network` — see [Runnable Protocol](../../architecture/core-concepts.md#runnable-protocol) |
96
+
97
+ There is no `RobotLab::State` class and no `RobotLab::NetworkRun` class.
79
98
 
80
99
  ## Quick Examples
81
100
 
@@ -89,18 +108,25 @@ robot = RobotLab.build(
89
108
  local_tools: [greet_tool]
90
109
  )
91
110
 
92
- result = robot.run("Hello!")
111
+ result = robot.run("Hello!") # no tools sent — run() defaults to tools: :none
112
+ result = robot.run("Hello!", tools: :inherit) # sends greet_tool
113
+ result.reply # => String
93
114
  ```
94
115
 
116
+ `Robot#run` returns a [`RobotLab::RobotResult`](result.md), not a `Message`.
117
+
95
118
  ### Network
96
119
 
120
+ The builder DSL method is `task` (there is no `step`):
121
+
97
122
  ```ruby
98
123
  network = RobotLab.create_network(name: "my_network") do
99
- step :analyzer, analyzer_robot, depends_on: :none
100
- step :writer, writer_robot, depends_on: [:analyzer]
124
+ task :analyzer, analyzer_robot, depends_on: :none
125
+ task :writer, writer_robot, depends_on: [:analyzer]
101
126
  end
102
127
 
103
- result = network.run(message: "Process this")
128
+ result = network.run(message: "Process this") # => SimpleFlow::Result
129
+ result.value # => RobotResult of the last task
104
130
  ```
105
131
 
106
132
  ### Memory
@@ -83,14 +83,33 @@ Retrieve one or more values, optionally waiting until they exist.
83
83
 
84
84
  **Parameters:**
85
85
 
86
- | Name | Type | Description |
87
- |------|------|-------------|
88
- | `keys` | `Symbol`, `String` | One or more keys to retrieve |
89
- | `wait` | `Boolean`, `Numeric` | `false`: immediate, `true`: block, `Numeric`: timeout |
90
-
91
- **Returns:** Single value for one key, `Hash` for multiple keys.
92
-
93
- **Raises:** `AwaitTimeout` if timeout expires.
86
+ | Name | Type | Default | Description |
87
+ |------|------|---------|-------------|
88
+ | `*keys` | `Symbol`, `String` | — | One or more keys to retrieve; flattened and symbolized |
89
+ | `wait` | `Boolean`, `Numeric` | `false` | `false`: return immediately (nil if missing). `true`: block indefinitely. `Numeric`: block up to that many seconds |
90
+
91
+ **Returns:** the single value for one key, a `Hash` keyed by symbol for multiple keys.
92
+
93
+ **Raises:** `RobotLab::AwaitTimeout` `"Timeout waiting for :<key> after <N> seconds"`.
94
+
95
+ !!! warning "Blocking semantics"
96
+ - On expiry `get` **raises `RobotLab::AwaitTimeout`**; it does not return nil.
97
+ Rescue it if a missing value is acceptable.
98
+ - With multiple keys the timeout is applied **per missing key**, not to the
99
+ call as a whole. `get(:a, :b, :c, wait: 60)` can block for up to 180
100
+ seconds if all three are missing.
101
+ - `wait: true` blocks with no deadline and can hang forever. Prefer a numeric
102
+ timeout in production.
103
+ - Waiting is implemented with a pipe (`Waiter`, using `IO#wait_readable`), so
104
+ a blocked reader does not spin.
105
+
106
+ ```ruby
107
+ value = begin
108
+ memory.get(:sentiment, wait: 30)
109
+ rescue RobotLab::AwaitTimeout
110
+ nil
111
+ end
112
+ ```
94
113
 
95
114
  ### key?
96
115
 
@@ -114,7 +133,16 @@ Remove a key. Cannot delete reserved keys.
114
133
  memory.keys # => Array<Symbol>
115
134
  ```
116
135
 
117
- Get all non-reserved keys.
136
+ All keys **excluding** the reserved ones.
137
+
138
+ ### all_keys
139
+
140
+ ```ruby
141
+ memory.all_keys # => Array<Symbol>
142
+ ```
143
+
144
+ All keys **including** the reserved ones (`:data`, `:results`, `:messages`,
145
+ `:session_id`, `:cache`).
118
146
 
119
147
  ### clear
120
148
 
@@ -130,7 +158,14 @@ Clear all non-reserved keys.
130
158
  memory.reset
131
159
  ```
132
160
 
133
- Reset memory to initial state (clears everything including reserved keys, preserves cache).
161
+ Reset memory to its initial state: clears the backend, restores `:data` to `{}`,
162
+ `:results` and `:messages` to `[]`, `:session_id` to `nil`, and re-installs the
163
+ existing cache object. The `StateProxy` returned by `data` is discarded and
164
+ rebuilt on next access.
165
+
166
+ This resets the **key-value store only**. It has nothing to do with a robot's
167
+ chat history — use `robot.clear_messages(keep_system: true)` for that. The two
168
+ are independent.
134
169
 
135
170
  ### subscribe
136
171
 
@@ -141,20 +176,41 @@ end
141
176
  ```
142
177
 
143
178
  Subscribe to changes on one or more keys. Callback receives a `MemoryChange` object.
179
+ Returns a subscription ID for [`unsubscribe`](#unsubscribe).
180
+
181
+ **Raises:** `ArgumentError` if no block is given.
182
+
183
+ Only [`set`](#set) notifies subscribers. Writing a reserved key
184
+ (`memory[:data] = ...`, `session_id=`, `append_result`) bypasses notification
185
+ entirely, as does mutating `memory.data`.
144
186
 
145
- **MemoryChange attributes:**
187
+ Callbacks are dispatched through `Async { }`. **Outside a running reactor the
188
+ callback runs synchronously on the writer's thread**, so a slow subscriber blocks
189
+ the `set` that triggered it.
190
+
191
+ **`RobotLab::MemoryChange` attributes:**
146
192
 
147
193
  | Attribute | Type | Description |
148
194
  |-----------|------|-------------|
149
195
  | `key` | `Symbol` | The changed key |
150
196
  | `value` | `Object` | New value |
151
- | `previous` | `Object` | Previous value |
152
- | `writer` | `String, nil` | Name of robot that wrote |
197
+ | `previous` | `Object, nil` | Previous value |
198
+ | `writer` | `String, nil` | `memory.current_writer` at the time of the write — set to the robot's name for the duration of each `run` |
153
199
  | `network_name` | `String, nil` | Network name |
154
200
  | `timestamp` | `Time` | When the change occurred |
155
- | `created?` | `Boolean` | Previous was nil |
156
- | `updated?` | `Boolean` | Previous was not nil |
157
- | `deleted?` | `Boolean` | New value is nil |
201
+ | `correlation_id` | `String, nil` | Optional tracing ID |
202
+
203
+ **Predicates** note that each tests *both* sides, so all three are false when
204
+ `previous` and `value` are both nil, and all three are false for a nil→nil write:
205
+
206
+ | Predicate | Exact definition |
207
+ |-----------|------------------|
208
+ | `created?` | `previous.nil? && !value.nil?` |
209
+ | `updated?` | `!previous.nil? && !value.nil?` |
210
+ | `deleted?` | `value.nil? && !previous.nil?` |
211
+
212
+ Also available: `to_h` (`.compact`ed, `timestamp` rendered as ISO-8601),
213
+ `to_json`, and `MemoryChange.from_hash`.
158
214
 
159
215
  ### subscribe_pattern
160
216
 
@@ -164,7 +220,8 @@ sub_id = memory.subscribe_pattern("analysis:*") do |change|
164
220
  end
165
221
  ```
166
222
 
167
- Subscribe to keys matching a glob pattern (`*` and `?` supported).
223
+ Subscribe to keys matching a glob pattern (`*` and `?` supported). Returns a
224
+ subscription ID. **Raises:** `ArgumentError` if no block is given.
168
225
 
169
226
  ### unsubscribe
170
227
 
@@ -172,15 +229,131 @@ Subscribe to keys matching a glob pattern (`*` and `?` supported).
172
229
  memory.unsubscribe(sub_id) # => Boolean
173
230
  ```
174
231
 
175
- Remove a subscription by its ID.
232
+ Remove a subscription by its ID (works for both `subscribe` and
233
+ `subscribe_pattern`). Returns `true` when something was removed.
234
+
235
+ ### unsubscribe_keys
236
+
237
+ ```ruby
238
+ memory.unsubscribe_keys(:status, :progress) # => self
239
+ ```
240
+
241
+ Drop **all** key subscriptions for the named keys at once, without needing their
242
+ IDs. Pattern subscriptions are unaffected.
243
+
244
+ ### subscribed?
245
+
246
+ ```ruby
247
+ memory.subscribed?(:status) # => Boolean
248
+ ```
249
+
250
+ Whether any subscriber — key-based or pattern-based — would be notified for `key`.
176
251
 
177
252
  ### merge!
178
253
 
179
254
  ```ruby
180
- memory.merge!(key1: "value1", key2: "value2")
255
+ memory.merge!(key1: "value1", key2: "value2") # => self
256
+ ```
257
+
258
+ Merge multiple key-value pairs into memory. Each pair is assigned through `[]=`,
259
+ so non-reserved keys go through the reactive `set` path and do notify subscribers.
260
+
261
+ ## Results and History
262
+
263
+ ### append_result
264
+
265
+ ```ruby
266
+ memory.append_result(robot_result) # => self
267
+ ```
268
+
269
+ Push a `RobotResult` onto the accumulated `:results` array. Bypasses subscriber
270
+ notification.
271
+
272
+ ### set_results
273
+
274
+ ```ruby
275
+ memory.set_results(array_of_results) # => self
276
+ ```
277
+
278
+ Replace the whole `:results` array (used when loading from persistence).
279
+
280
+ ### results_from
281
+
282
+ ```ruby
283
+ memory.results_from(5) # => Array<RobotResult>
181
284
  ```
182
285
 
183
- Merge multiple key-value pairs into memory.
286
+ Results from the given index onward — for incremental saves. Returns `[]` when
287
+ the index is past the end.
288
+
289
+ ### format_history
290
+
291
+ ```ruby
292
+ memory.format_history(formatter: nil) # => Array<Message>
293
+ ```
294
+
295
+ `messages` followed by every result flat-mapped through `formatter`. Pass a
296
+ `Proc` for `formatter:` to control how a `RobotResult` becomes messages; the
297
+ default formatter is used when omitted.
298
+
299
+ ## Backend
300
+
301
+ ### redis?
302
+
303
+ ```ruby
304
+ memory.redis? # => Boolean
305
+ ```
306
+
307
+ Whether this memory is backed by Redis rather than the in-process Hash. The
308
+ `backend: :auto` default tries Redis and falls back to a Hash; `backend: :hash`
309
+ forces the Hash.
310
+
311
+ ### network_name
312
+
313
+ ```ruby
314
+ memory.network_name # => String, nil
315
+ ```
316
+
317
+ The network this memory belongs to, set once at construction and read-only
318
+ thereafter. `Network` creates its shared memory as `Memory.new(network_name: name)`;
319
+ a standalone robot's inherent memory has `nil`. It is copied onto every
320
+ `MemoryChange` so a subscriber can tell which network a write came from, and it is
321
+ preserved by `clone`/`dup` — but **not** by `from_hash`.
322
+
323
+ ### current_writer / current_writer=
324
+
325
+ ```ruby
326
+ memory.current_writer # => String, nil
327
+ memory.current_writer = "bot"
328
+ ```
329
+
330
+ The name attributed to writes, surfaced as `MemoryChange#writer`. `Robot#run`
331
+ sets this to the robot's name for the duration of the run and restores the
332
+ previous value in an `ensure` block, so nested and concurrent runs attribute
333
+ correctly.
334
+
335
+ ## Document Store
336
+
337
+ These four methods require the **`robot_lab-document_store`** extension gem. Without
338
+ it every one of them raises
339
+ `RobotLab::DependencyError: document storage requires the robot_lab-document_store gem.`
340
+
341
+ | Method | Returns | Description |
342
+ |--------|---------|-------------|
343
+ | `store_document(key, text)` | `self` | Embed `text` and store it under `key` |
344
+ | `search_documents(query, limit: 5)` | `Array<Hash>` | Hits sorted by score descending; each hash has `:key`, `:text`, `:score` |
345
+ | `document_keys` | `Array<Symbol>` | Keys of all stored documents |
346
+ | `delete_document(key)` | `self` | Remove a document |
347
+
348
+ ```ruby
349
+ memory.store_document(:readme, File.read("README.md"))
350
+ memory.search_documents("how to configure redis", limit: 3).each do |hit|
351
+ puts "#{hit[:key]} (#{hit[:score].round(3)})"
352
+ end
353
+ ```
354
+
355
+ Documents live in a separate store, not in the key-value backend — they do not
356
+ appear in `keys`, `all_keys`, or `to_h`.
184
357
 
185
358
  ## Reserved Key Accessors
186
359
 
@@ -193,7 +366,9 @@ memory.data.user_id # Method access
193
366
  memory.data[:status] = "active"
194
367
  ```
195
368
 
196
- Runtime data accessed through `StateProxy` for method-style access.
369
+ Runtime data accessed through a [`StateProxy`](state.md) for method-style access.
370
+ Writes made through the proxy are **not** reactive — they bypass `set`, so they
371
+ wake no blocking readers and notify no subscribers.
197
372
 
198
373
  ### results
199
374
 
@@ -221,10 +396,22 @@ memory.session_id = "abc" # Set session identifier
221
396
  ### cache
222
397
 
223
398
  ```ruby
224
- memory.cache # => RubyLLM::SemanticCache
399
+ memory.cache # => RubyLLM::SemanticCache (the module itself), or nil
225
400
  ```
226
401
 
227
- Semantic cache module (read-only after initialization).
402
+ Read-only after initialization — assigning it raises
403
+ `ArgumentError: Cannot reassign cache - it is initialized automatically`.
404
+
405
+ !!! warning "`cache` is `nil` when caching is disabled"
406
+ The value stored is the `RubyLLM::SemanticCache` **module**, not an instance.
407
+ When constructed with `enable_cache: false` it is `nil`, so
408
+ `memory.cache.fetch(...)` raises `NoMethodError`. Guard on
409
+ `memory.cache` before use, or leave `enable_cache` at its `true` default.
410
+
411
+ ```ruby
412
+ RobotLab::Memory.new.cache # => RubyLLM::SemanticCache
413
+ RobotLab::Memory.new(enable_cache: false).cache # => nil
414
+ ```
228
415
 
229
416
  ## Serialization
230
417
 
@@ -235,27 +422,50 @@ memory.to_h
235
422
  # => { data: {...}, results: [...], messages: [...], session_id: "...", custom: {...} }
236
423
  ```
237
424
 
425
+ Keys: `data` (from the `StateProxy`), `results` (each via `RobotResult#export`),
426
+ `messages` (each via `to_h`), `session_id`, and `custom` (every non-reserved key).
427
+ `cache` is never serialized.
428
+
429
+ !!! warning "`to_h` is `.compact`ed"
430
+ Nil values are dropped, so `session_id` disappears entirely when unset:
431
+
432
+ ```ruby
433
+ RobotLab::Memory.new.to_h
434
+ # => { data: {}, results: [], messages: [], custom: {} } -- no :session_id
435
+ ```
436
+
437
+ Consumers must tolerate the missing key. `custom` is always present (it is
438
+ `{}` rather than nil when there are no custom keys).
439
+
238
440
  ### to_json
239
441
 
240
442
  ```ruby
241
443
  memory.to_json # => String
242
444
  ```
243
445
 
446
+ Serializes `to_h`.
447
+
244
448
  ### from_hash
245
449
 
246
450
  ```ruby
247
- memory = Memory.from_hash(hash)
451
+ memory = RobotLab::Memory.from_hash(hash)
248
452
  ```
249
453
 
250
- Reconstruct memory from a hash.
454
+ Reconstructs `data`, `results`, `messages`, and `session_id`, then re-applies
455
+ every entry from `custom`. A fresh cache is created; subscriptions, backend
456
+ choice, and `network_name` are **not** restored.
251
457
 
252
- ### clone
458
+ ### clone / dup
253
459
 
254
460
  ```ruby
255
461
  new_memory = memory.clone
462
+ new_memory = memory.dup # alias for clone
256
463
  ```
257
464
 
258
- Deep copy with fresh subscriptions (cache and network_name preserved).
465
+ Deep copy of `data` plus copies of `results`, `messages`, `session_id`, and all
466
+ non-reserved keys. The `enable_cache` setting and `network_name` are preserved.
467
+ Subscriptions are **not** copied — the clone starts with none. Copying custom
468
+ keys uses the internal non-reactive setter, so no notifications fire.
259
469
 
260
470
  ## Examples
261
471
 
@@ -297,11 +507,18 @@ network.memory.set(:sentiment, { score: 0.8, confidence: 0.95 })
297
507
 
298
508
  # In robot B (reader, may run concurrently)
299
509
  result = network.memory.get(:sentiment, wait: true) # Block indefinitely
300
- result = network.memory.get(:sentiment, wait: 30) # Block up to 30s
510
+ result = network.memory.get(:sentiment, wait: 30) # Block up to 30s, then raise
301
511
 
302
- # Multiple keys with timeout
512
+ # Multiple keys the timeout applies PER MISSING KEY, so this can block 180s
303
513
  results = network.memory.get(:sentiment, :entities, :keywords, wait: 60)
304
514
  # => { sentiment: {...}, entities: [...], keywords: [...] }
515
+
516
+ # Treat a timeout as "not available"
517
+ sentiment = begin
518
+ network.memory.get(:sentiment, wait: 30)
519
+ rescue RobotLab::AwaitTimeout
520
+ nil
521
+ end
305
522
  ```
306
523
 
307
524
  ### Reactive Subscriptions
@@ -357,4 +574,5 @@ memory = Memory.from_hash(data)
357
574
  ## See Also
358
575
 
359
576
  - [Memory Guide](../../guides/memory.md)
577
+ - [StateProxy](state.md) — the wrapper returned by `memory.data`
360
578
  - [State Management Architecture](../../architecture/state-management.md)