robot_lab 0.2.1 → 0.2.7

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 (236) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +1 -0
  3. data/.loki +60 -0
  4. data/.quality/reek_baseline.txt +43 -0
  5. data/.rubocop.yml +5 -167
  6. data/CHANGELOG.md +54 -0
  7. data/README.md +185 -64
  8. data/Rakefile +28 -12
  9. data/docs/api/core/index.md +41 -14
  10. data/docs/api/core/memory.md +247 -29
  11. data/docs/api/core/network.md +285 -33
  12. data/docs/api/core/result.md +120 -32
  13. data/docs/api/core/robot.md +636 -60
  14. data/docs/api/core/state.md +87 -197
  15. data/docs/api/core/tool.md +165 -20
  16. data/docs/api/errors.md +152 -14
  17. data/docs/api/hooks.md +469 -0
  18. data/docs/api/index.md +83 -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 +519 -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 +118 -30
  33. data/docs/architecture/index.md +86 -11
  34. data/docs/architecture/message-flow.md +66 -29
  35. data/docs/architecture/network-orchestration.md +153 -38
  36. data/docs/architecture/robot-execution.md +173 -91
  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 +299 -93
  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 +296 -50
  48. data/docs/guides/creating-networks.md +220 -31
  49. data/docs/guides/hooks.md +1084 -0
  50. data/docs/guides/index.md +5 -0
  51. data/docs/guides/knowledge.md +37 -6
  52. data/docs/guides/mcp-integration.md +211 -44
  53. data/docs/guides/memory.md +103 -12
  54. data/docs/guides/observability.md +166 -51
  55. data/docs/guides/streaming.md +184 -125
  56. data/docs/guides/using-tools.md +300 -11
  57. data/docs/index.md +37 -5
  58. data/examples/01_simple_robot.rb +2 -2
  59. data/examples/02_tools.rb +14 -4
  60. data/examples/03_network.rb +23 -9
  61. data/examples/04_mcp.rb +11 -4
  62. data/examples/05_streaming.rb +8 -5
  63. data/examples/06_prompt_templates.rb +13 -9
  64. data/examples/07_network_memory.rb +5 -5
  65. data/examples/08_llm_config.rb +20 -15
  66. data/examples/09_chaining.rb +4 -4
  67. data/examples/11_network_introspection.rb +4 -4
  68. data/examples/12_message_bus.rb +2 -2
  69. data/examples/13_spawn.rb +2 -2
  70. data/examples/14_rusty_circuit/README.md +1 -0
  71. data/examples/14_rusty_circuit/comic.rb +7 -3
  72. data/examples/14_rusty_circuit/display.rb +14 -0
  73. data/examples/14_rusty_circuit/heckler.rb +8 -6
  74. data/examples/14_rusty_circuit/open_mic.rb +17 -6
  75. data/examples/14_rusty_circuit/scout.rb +17 -10
  76. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
  77. data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
  78. data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
  79. data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
  80. data/examples/16_writers_room/writer.rb +22 -22
  81. data/examples/16_writers_room/writers_room.rb +2 -0
  82. data/examples/17_skills.rb +14 -13
  83. data/examples/18_rails/README.md +20 -1
  84. data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
  85. data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
  86. data/examples/18_rails/app/robots/chat_robot.rb +11 -0
  87. data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
  88. data/examples/19_token_tracking.rb +25 -9
  89. data/examples/20_circuit_breaker.rb +10 -7
  90. data/examples/21_learning_loop.rb +42 -16
  91. data/examples/22_context_compression.rb +23 -23
  92. data/examples/23_convergence.rb +24 -17
  93. data/examples/24_structured_delegation.rb +13 -8
  94. data/examples/25_history_search.rb +12 -8
  95. data/examples/27_incident_response/incident_response.rb +31 -13
  96. data/examples/28_mcp_discovery.rb +17 -13
  97. data/examples/29_ractor_tools.rb +4 -2
  98. data/examples/30_ractor_network.rb +22 -17
  99. data/examples/31_launch_assessment.rb +20 -9
  100. data/examples/32_newsletter_reader.rb +7 -2
  101. data/examples/33_stock_predictor.rb +34 -13
  102. data/examples/34_agentskills.rb +7 -3
  103. data/examples/35_hooks.rb +266 -0
  104. data/examples/README.md +203 -42
  105. data/examples/common.rb +79 -7
  106. data/examples/xyzzy.rb +97 -0
  107. data/lib/robot_lab/agent_skill.rb +5 -4
  108. data/lib/robot_lab/budget/ledger.rb +98 -0
  109. data/lib/robot_lab/capabilities.rb +84 -0
  110. data/lib/robot_lab/config/defaults.yml +10 -0
  111. data/lib/robot_lab/config.rb +14 -9
  112. data/lib/robot_lab/error.rb +34 -4
  113. data/lib/robot_lab/errors.rb +45 -0
  114. data/lib/robot_lab/hook.rb +79 -0
  115. data/lib/robot_lab/hook_context.rb +194 -0
  116. data/lib/robot_lab/hook_registry.rb +55 -0
  117. data/lib/robot_lab/hooks.rb +87 -0
  118. data/lib/robot_lab/mcp/connection_poller.rb +2 -2
  119. data/lib/robot_lab/names.rb +402 -0
  120. data/lib/robot_lab/narrator.rb +87 -0
  121. data/lib/robot_lab/network.rb +77 -20
  122. data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
  123. data/lib/robot_lab/robot/budget.rb +89 -0
  124. data/lib/robot_lab/robot/bus_messaging.rb +90 -18
  125. data/lib/robot_lab/robot/hooking.rb +56 -0
  126. data/lib/robot_lab/robot/mcp_management.rb +8 -2
  127. data/lib/robot_lab/robot/template_rendering.rb +18 -5
  128. data/lib/robot_lab/robot.rb +233 -89
  129. data/lib/robot_lab/run_config.rb +24 -5
  130. data/lib/robot_lab/runnable.rb +51 -0
  131. data/lib/robot_lab/sandbox/null.rb +13 -0
  132. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  133. data/lib/robot_lab/sandbox.rb +52 -0
  134. data/lib/robot_lab/script_tool.rb +60 -2
  135. data/lib/robot_lab/task.rb +26 -20
  136. data/lib/robot_lab/tool.rb +52 -11
  137. data/lib/robot_lab/version.rb +1 -1
  138. data/lib/robot_lab.rb +70 -15
  139. data/mkdocs.yml +7 -1
  140. metadata +41 -99
  141. data/examples/temp.md +0 -51
  142. data/site/404.html +0 -2300
  143. data/site/api/core/index.html +0 -2706
  144. data/site/api/core/memory/index.html +0 -3793
  145. data/site/api/core/network/index.html +0 -3500
  146. data/site/api/core/robot/index.html +0 -4566
  147. data/site/api/core/state/index.html +0 -3390
  148. data/site/api/core/tool/index.html +0 -3843
  149. data/site/api/index.html +0 -2635
  150. data/site/api/mcp/client/index.html +0 -3435
  151. data/site/api/mcp/index.html +0 -2783
  152. data/site/api/mcp/server/index.html +0 -3252
  153. data/site/api/mcp/transports/index.html +0 -3352
  154. data/site/api/messages/index.html +0 -2641
  155. data/site/api/messages/text-message/index.html +0 -3087
  156. data/site/api/messages/tool-call-message/index.html +0 -3159
  157. data/site/api/messages/tool-result-message/index.html +0 -3252
  158. data/site/api/messages/user-message/index.html +0 -3212
  159. data/site/api/streaming/context/index.html +0 -3282
  160. data/site/api/streaming/events/index.html +0 -3347
  161. data/site/api/streaming/index.html +0 -2738
  162. data/site/architecture/core-concepts/index.html +0 -3757
  163. data/site/architecture/index.html +0 -2797
  164. data/site/architecture/message-flow/index.html +0 -3238
  165. data/site/architecture/network-orchestration/index.html +0 -3433
  166. data/site/architecture/robot-execution/index.html +0 -3140
  167. data/site/architecture/state-management/index.html +0 -3498
  168. data/site/assets/css/custom.css +0 -56
  169. data/site/assets/images/favicon.png +0 -0
  170. data/site/assets/images/robot_lab.jpg +0 -0
  171. data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
  172. data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
  173. data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
  174. data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
  175. data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
  176. data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
  177. data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
  178. data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
  179. data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
  180. data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
  181. data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
  182. data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
  183. data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
  184. data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
  185. data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
  186. data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
  187. data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
  188. data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
  189. data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
  190. data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
  191. data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
  192. data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
  193. data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
  194. data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
  195. data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
  196. data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
  197. data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
  198. data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
  199. data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
  200. data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
  201. data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
  202. data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
  203. data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
  204. data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
  205. data/site/assets/javascripts/lunr/tinyseg.js +0 -206
  206. data/site/assets/javascripts/lunr/wordcut.js +0 -6708
  207. data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
  208. data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
  209. data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
  210. data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
  211. data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
  212. data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
  213. data/site/concepts/index.html +0 -3455
  214. data/site/examples/basic-chat/index.html +0 -2880
  215. data/site/examples/index.html +0 -2907
  216. data/site/examples/mcp-server/index.html +0 -3018
  217. data/site/examples/multi-robot-network/index.html +0 -3131
  218. data/site/examples/rails-application/index.html +0 -3329
  219. data/site/examples/tool-usage/index.html +0 -3085
  220. data/site/getting-started/configuration/index.html +0 -3745
  221. data/site/getting-started/index.html +0 -2572
  222. data/site/getting-started/installation/index.html +0 -2981
  223. data/site/getting-started/quick-start/index.html +0 -2942
  224. data/site/guides/building-robots/index.html +0 -4290
  225. data/site/guides/creating-networks/index.html +0 -3858
  226. data/site/guides/index.html +0 -2586
  227. data/site/guides/mcp-integration/index.html +0 -3581
  228. data/site/guides/memory/index.html +0 -3586
  229. data/site/guides/rails-integration/index.html +0 -4019
  230. data/site/guides/streaming/index.html +0 -3157
  231. data/site/guides/using-tools/index.html +0 -3802
  232. data/site/index.html +0 -2671
  233. data/site/search/search_index.json +0 -1
  234. data/site/sitemap.xml +0 -183
  235. data/site/sitemap.xml.gz +0 -0
  236. data/site/tags.json +0 -1
@@ -86,7 +86,7 @@ Memory has reserved keys with special behavior:
86
86
  | `:results` | Array | Accumulated robot results |
87
87
  | `:messages` | Array | Conversation history |
88
88
  | `:session_id` | String | Session identifier for history persistence |
89
- | `:cache` | SemanticCache | Semantic cache (read-only after init) |
89
+ | `:cache` | `RubyLLM::SemanticCache` module, or `nil` | Semantic cache (read-only after init). Set at construction time; `nil` when built with `enable_cache: false` |
90
90
 
91
91
  ### The Data Hash
92
92
 
@@ -143,6 +143,16 @@ You can also clear just the custom keys without resetting reserved keys:
143
143
  robot.memory.clear # Clears non-reserved keys only
144
144
  ```
145
145
 
146
+ > [!WARNING]
147
+ > `reset_memory` resets **only the key-value store**. It does not touch the
148
+ > robot's conversation history — the chat still holds every prior message, and
149
+ > the LLM will still see them. Clearing the transcript is a separate call:
150
+ >
151
+ > ```ruby
152
+ > robot.reset_memory # key-value store only
153
+ > robot.clear_messages(keep_system: true) # conversation history only
154
+ > ```
155
+
146
156
  ## Network Shared Memory
147
157
 
148
158
  When robots run in a network, they share the network's memory instead of using their own inherent memory. This allows robots to communicate through shared state:
@@ -190,11 +200,40 @@ results = memory.get(:sentiment, :entities, :keywords, wait: 60)
190
200
  # => { sentiment: {...}, entities: [...], keywords: [...] }
191
201
  ```
192
202
 
193
- Each blocking wait is backed by an `IO.pipe` pair (`Waiter` class). Calling `signal` writes one byte per waiting caller, so all threads blocked on `IO.select` wake immediately. This design works cleanly with Ruby's Async fiber scheduler — no mutex contention or spurious wakeups.
203
+ > [!WARNING]
204
+ > A blocking `get` that expires **raises `RobotLab::AwaitTimeout`** — it does
205
+ > not return `nil`. (A *non*-blocking `get`, the default, returns `nil` for a
206
+ > missing key.) Wrap it if a missing value is survivable:
207
+ >
208
+ > ```ruby
209
+ > begin
210
+ > memory.get(:sentiment, wait: 30)
211
+ > rescue RobotLab::AwaitTimeout => e
212
+ > # => "Timeout waiting for :sentiment after 30 seconds"
213
+ > nil
214
+ > end
215
+ > ```
216
+
217
+ > [!CAUTION]
218
+ > With multiple keys the timeout is applied **per missing key**, not to the call
219
+ > as a whole. `memory.get(:a, :b, :c, wait: 30)` awaits the missing keys
220
+ > sequentially, each with its own fresh 30-second budget.
221
+ >
222
+ > It does not, however, spend the whole 90 seconds before reporting: the first
223
+ > key whose wait expires **raises `AwaitTimeout` immediately**, aborting the call
224
+ > — so the keys that were already resolved are lost along with the ones not yet
225
+ > attempted. The 90 seconds is the worst case only for a call that *succeeds*
226
+ > (each key arriving just under its own deadline).
227
+
228
+ Each blocking wait is backed by an `IO.pipe` pair (`Waiter` class). The waiting
229
+ side calls `@read_io.wait_readable(timeout)`; `signal` writes one byte per
230
+ waiting caller so every blocked waiter wakes exactly once. `wait_readable`
231
+ yields to Ruby's Async fiber scheduler when one is installed — no mutex
232
+ contention or spurious wakeups.
194
233
 
195
234
  ### Subscriptions
196
235
 
197
- Subscribe to key changes with asynchronous callbacks:
236
+ Subscribe to key changes:
198
237
 
199
238
  ```ruby
200
239
  # Subscribe to a single key
@@ -214,6 +253,24 @@ memory.subscribe_pattern("analysis:*") do |change|
214
253
  end
215
254
  ```
216
255
 
256
+ > [!IMPORTANT]
257
+ > Subscription callbacks are dispatched through `Async { }`. Inside a running
258
+ > Async reactor that defers them; **outside one — which is the normal case for
259
+ > plain Ruby, Rails request threads, and tests — the block runs synchronously
260
+ > on the writer's thread**, before `memory[:key] = value` returns:
261
+ >
262
+ > ```ruby
263
+ > order = []
264
+ > memory.subscribe(:k) { |c| order << "callback" }
265
+ > order << "before-set"
266
+ > memory[:k] = 1
267
+ > order << "after-set"
268
+ > order # => ["before-set", "callback", "after-set"]
269
+ > ```
270
+ >
271
+ > Keep subscription callbacks fast, and never assume the writer has moved on by
272
+ > the time your callback runs.
273
+
217
274
  ### Unsubscribe
218
275
 
219
276
  ```ruby
@@ -243,6 +300,8 @@ Memory can be exported and reconstructed:
243
300
  # Export to hash
244
301
  hash = robot.memory.to_h
245
302
  # => { data: {...}, results: [...], messages: [...], session_id: "...", custom: {...} }
303
+ # to_h is compacted: nil entries are dropped, so an unset :session_id (and the
304
+ # :cache key, which is never exported) simply will not appear.
246
305
 
247
306
  # Export to JSON
248
307
  json = robot.memory.to_json
@@ -279,34 +338,60 @@ memory[:stage] = "response"
279
338
 
280
339
  ### Caching Expensive Operations
281
340
 
341
+ A tool reaches memory through its owning robot. Subclass `RobotLab::Tool` (which
342
+ has a `robot` accessor) and attach an **instance constructed with `robot:`** —
343
+ that is the only supported way for tool code to read and write robot memory:
344
+
282
345
  ```ruby
283
- class FetchUser < RubyLLM::Tool
346
+ class FetchUser < RobotLab::Tool
284
347
  description "Fetch user details by ID"
285
348
  param :user_id, type: :string, desc: "User ID"
286
349
 
287
350
  def execute(user_id:)
288
- cache_key = "cache:user:#{user_id}"
351
+ cache_key = :"cache:user:#{user_id}"
289
352
 
290
- # Check robot's memory for cached value
291
- # (In practice, you'd access memory through the robot's context)
292
- cached = Thread.current[:robot_memory]&.[](cache_key.to_sym)
353
+ cached = robot&.memory&.[](cache_key)
293
354
  return cached if cached
294
355
 
295
- # Fetch and cache
296
356
  user = User.find(user_id).to_h
297
- Thread.current[:robot_memory]&.[]=(cache_key.to_sym, user)
357
+ robot&.memory&.[]=(cache_key, user)
298
358
  user
299
359
  end
300
360
  end
361
+
362
+ robot = RobotLab.build(name: "support", system_prompt: "...")
363
+ robot.local_tools << FetchUser.new(robot: robot)
364
+
365
+ robot.run("Look up user 42", tools: :inherit)
301
366
  ```
302
367
 
368
+ > [!WARNING]
369
+ > There is **no thread-local memory handle** in RobotLab — nothing anywhere in
370
+ > the codebase ever assigns `Thread.current[:robot_memory]`. A tool that reads
371
+ > it will always see `nil` and silently cache nothing. Go through `robot.memory`
372
+ > as above, and remember that `FetchUser.new` without `robot:` leaves `robot`
373
+ > `nil`.
374
+
303
375
  ### Semantic Caching
304
376
 
305
- Memory includes a semantic cache for LLM response caching:
377
+ Memory exposes a semantic cache for LLM response caching. It is on by default
378
+ and becomes `nil` when you opt out with `enable_cache: false`:
379
+
380
+ ```ruby
381
+ RobotLab.create_memory.cache # => RubyLLM::SemanticCache
382
+ RobotLab.create_memory(enable_cache: false).cache # => nil
383
+
384
+ RobotLab.build(name: "x", system_prompt: "…").memory.cache
385
+ # => RubyLLM::SemanticCache
386
+ RobotLab.build(name: "x", system_prompt: "…", enable_cache: false).memory.cache
387
+ # => nil
388
+ ```
389
+
390
+ Guard for `nil` in any code that might run against a cache-disabled memory.
306
391
 
307
392
  ```ruby
308
393
  # Access the semantic cache
309
- cache = robot.memory.cache # => RubyLLM::SemanticCache
394
+ cache = robot.memory.cache
310
395
 
311
396
  # Use it to cache semantically similar queries
312
397
  response = cache.fetch("What is Ruby?") do
@@ -314,6 +399,12 @@ response = cache.fetch("What is Ruby?") do
314
399
  end
315
400
  ```
316
401
 
402
+ > [!NOTE]
403
+ > `memory.cache` is the `RubyLLM::SemanticCache` **module itself**, not a
404
+ > per-memory instance. Its cache store, vector store, and configuration are
405
+ > process-global — two memories with `enable_cache: true` share one cache. It
406
+ > also embeds every query, so `fetch` costs an embedding call.
407
+
317
408
  ## Best Practices
318
409
 
319
410
  ### 1. Use Descriptive Keys
@@ -10,6 +10,7 @@ Facilities that help you monitor, control, improve, and scale robot behaviour:
10
10
  - **Context Window Compression** — prune irrelevant history to stay within token budgets
11
11
  - **Convergence Detection** — detect when independent agents reach the same conclusion
12
12
  - **Structured Delegation** — synchronous inter-robot calls with duration and token metadata
13
+ - **Live Narration** — an opt-in console feed of what a robot is doing as it happens
13
14
 
14
15
  ---
15
16
 
@@ -91,6 +92,41 @@ result = fresh.run("Explain memoization.")
91
92
  puts result.input_tokens # smallest possible — no prior history
92
93
  ```
93
94
 
95
+ ### Budgets (Token & Cost)
96
+
97
+ Where token/cost tracking above is purely observational, `token_budget:` and `cost_budget:` make it enforceable — a `Robot` refuses to keep spending once a configured limit is reached:
98
+
99
+ ```ruby
100
+ robot = RobotLab.build(
101
+ name: "capped",
102
+ system_prompt: "You are a concise assistant.",
103
+ token_budget: 10_000, # cumulative input + output tokens
104
+ cost_budget: 0.50 # cumulative $ across all runs
105
+ )
106
+ ```
107
+
108
+ Enforcement happens in two layers, backed by a thread-safe `RobotLab::Budget::Ledger`:
109
+
110
+ - **Before the call** — `run()` reserves whatever remains of each configured dimension. If a *prior* call already exhausted the budget, the reservation raises `RobotLab::BudgetExceeded` immediately, refusing the call outright before spending anything on it.
111
+ - **After the call** — actual usage (tokens from the result, cost from the response, when the provider reports pricing) replaces the reservation. If *this* call's actual usage pushes cumulative usage over budget, `RobotLab::InferenceError` is raised (unavoidable for the call that causes the overage, since totals aren't known until the response comes back) — the same error `token_budget` alone has always raised.
112
+
113
+ ```ruby
114
+ begin
115
+ robot.run("Another expensive task")
116
+ rescue RobotLab::BudgetExceeded => e
117
+ puts e.message # "budget exceeded for cost: 0.51 > 0.5" — refused before spending
118
+ rescue RobotLab::InferenceError => e
119
+ puts e.message
120
+ # "Cost budget exceeded: $0.523100 used, budget is $0.500000" — this call pushed it over
121
+ # (the cost message is formatted with %.6f; the token message is not formatted:
122
+ # "Token budget exceeded: 10412 tokens used, budget is 10000")
123
+ end
124
+ ```
125
+
126
+ A dimension with no configured limit (e.g. `cost_budget` when only `token_budget` is set) is treated as unlimited and never raises. Both fields are also available on `RunConfig` and cascade through the same global → network → robot hierarchy as other infrastructure fields (see [RunConfig](../getting-started/configuration.md#runconfig-shared-operational-defaults)).
127
+
128
+ This is a native alternative to the hand-rolled `BudgetHook` pattern in the [Hooks guide](hooks.md#cost-enforcement) for the common case of a per-robot token or dollar ceiling; reach for a hook instead when you need cross-robot session totals or custom accounting.
129
+
94
130
  ---
95
131
 
96
132
  ## Tool Loop Circuit Breaker
@@ -110,12 +146,20 @@ robot = RobotLab.build(
110
146
  local_tools: [StepTool],
111
147
  max_tool_rounds: 10
112
148
  )
149
+ ```
113
150
 
151
+ > [!WARNING]
152
+ > `run` defaults to `tools: :none`, so a plain `robot.run("...")` sends the LLM
153
+ > **no** tools — even when `local_tools:` were attached at build time — and the
154
+ > breaker can never fire. Pass `tools: :inherit` on the call to actually send the
155
+ > attached tools.
156
+
157
+ ```ruby
114
158
  begin
115
- robot.run("Run all steps.")
159
+ robot.run("Run all steps.", tools: :inherit)
116
160
  rescue RobotLab::ToolLoopError => e
117
- puts "Circuit breaker fired: #{e.message}"
118
- # => "Circuit breaker fired: Tool call limit of 10 exceeded"
161
+ puts e.message
162
+ # => "Circuit breaker triggered: 11 tool calls exceeded max_tool_rounds (10)"
119
163
  end
120
164
  ```
121
165
 
@@ -137,6 +181,8 @@ Error: tool_use ids were found without tool_result blocks immediately after
137
181
  Call `clear_messages` to flush the corrupted history before reusing the robot. The system prompt and all configuration (tools, `max_tool_rounds`, etc.) are preserved:
138
182
 
139
183
  ```ruby
184
+ begin
185
+ robot.run("Keep calling the tool.", tools: :inherit)
140
186
  rescue RobotLab::ToolLoopError => e
141
187
  puts "Breaker fired: #{e.message}"
142
188
  end
@@ -158,7 +204,7 @@ unguarded = RobotLab.build(
158
204
  system_prompt: "Use the provided tool to answer questions.",
159
205
  local_tools: [DoubleTool]
160
206
  )
161
- result = unguarded.run("Double the number 21 using the tool.")
207
+ result = unguarded.run("Double the number 21 using the tool.", tools: :inherit)
162
208
  puts result.reply # "The result is 42."
163
209
  ```
164
210
 
@@ -172,16 +218,18 @@ puts result.reply # "The result is 42."
172
218
 
173
219
  ### doom_loop_threshold
174
220
 
221
+ Doom loop detection is **always on** — the detector is installed unconditionally on every `run()`. `doom_loop_threshold:` does not enable it; it only tunes the number of repetitions after which it fires. The default is `3`.
222
+
175
223
  ```ruby
176
224
  robot = RobotLab.build(
177
225
  name: "runner",
178
226
  system_prompt: "Execute all steps.",
179
227
  local_tools: [StepTool],
180
- doom_loop_threshold: 3
228
+ doom_loop_threshold: 5 # tune the always-on detector; default is 3
181
229
  )
182
230
  ```
183
231
 
184
- Set `doom_loop_threshold:` to the number of repetitions after which the detector fires. It catches two patterns:
232
+ The detector catches two patterns:
185
233
 
186
234
  - **Consecutive repetition** — `[A, A, A]` (same tool called N times in a row)
187
235
  - **Cyclic repetition** — `[A, B, C, A, B, C, A, B, C]` (same sequence repeated N times)
@@ -219,27 +267,38 @@ Long-running robots accumulate conversation history. Eventually, the cumulative
219
267
 
220
268
  ### auto_compact
221
269
 
222
- Set `auto_compact:` to have the robot compress its history automatically before each `run()`:
270
+ `auto_compact` and `compact_threshold` are **`RunConfig` fields only** they are not constructor keyword arguments. Build a `RunConfig` and pass it as `config:`:
223
271
 
224
272
  ```ruby
225
273
  # Compact when estimated token usage exceeds 80% of the model's context window
274
+ config = RobotLab::RunConfig.new(auto_compact: :context_window)
275
+
226
276
  robot = RobotLab.build(
227
277
  name: "analyst",
228
278
  system_prompt: "You are a research analyst.",
229
- auto_compact: :context_window
279
+ config: config
230
280
  )
231
281
  ```
232
282
 
283
+ > [!WARNING]
284
+ > `Robot#initialize` has a closed keyword list — it takes no `**rest`. Passing
285
+ > `auto_compact:` or `compact_threshold:` directly to `RobotLab.build` raises
286
+ > `ArgumentError: unknown keyword`.
287
+
233
288
  ### Tuning the Threshold
234
289
 
235
290
  `compact_threshold:` sets the fraction of the model's context window that triggers compaction. Defaults to `0.80` (80%):
236
291
 
237
292
  ```ruby
293
+ config = RobotLab::RunConfig.new(
294
+ auto_compact: :context_window,
295
+ compact_threshold: 0.70 # compact earlier, at 70%
296
+ )
297
+
238
298
  robot = RobotLab.build(
239
299
  name: "analyst",
240
300
  system_prompt: "You are a research analyst.",
241
- auto_compact: :context_window,
242
- compact_threshold: 0.70 # compact earlier, at 70%
301
+ config: config
243
302
  )
244
303
  ```
245
304
 
@@ -248,16 +307,20 @@ robot = RobotLab.build(
248
307
  Pass a `Proc` to take full control — the proc decides both when and how to compact:
249
308
 
250
309
  ```ruby
251
- robot = RobotLab.build(
252
- name: "analyst",
253
- system_prompt: "You are a research analyst.",
310
+ config = RobotLab::RunConfig.new(
254
311
  auto_compact: ->(r) {
255
312
  r.compress_history(recent_turns: 5) if r.chat.messages.size > 40
256
313
  }
257
314
  )
315
+
316
+ robot = RobotLab.build(
317
+ name: "analyst",
318
+ system_prompt: "You are a research analyst.",
319
+ config: config
320
+ )
258
321
  ```
259
322
 
260
- The proc receives the robot instance and is called before every `run()` when messages are non-empty.
323
+ The proc receives the robot instance and is called once per `run()` when messages are non-empty.
261
324
 
262
325
  ### Options
263
326
 
@@ -267,13 +330,6 @@ The proc receives the robot instance and is called before every `run()` when mes
267
330
  | `:context_window` | Compact when estimated token usage exceeds `compact_threshold` fraction of model's context window |
268
331
  | `Proc` | Called with the robot; application decides when and how to compact |
269
332
 
270
- Via `RunConfig`:
271
-
272
- ```ruby
273
- config = RobotLab::RunConfig.new(auto_compact: :context_window, compact_threshold: 0.75)
274
- robot = RobotLab.build(name: "analyst", system_prompt: "...", config: config)
275
- ```
276
-
277
333
  Requires the `classifier` gem (`~> 2.3`) when using `:context_window`. Without it, a `RobotLab::DependencyError` is caught and logged rather than raised, so the robot continues running uncompressed.
278
334
 
279
335
  ---
@@ -354,20 +410,33 @@ end
354
410
 
355
411
  After all three runs, `reviewer.learnings` contains up to three insights (fewer if any are subsets of others).
356
412
 
357
- ### Durable Learning (learn: Constructor Shorthand)
413
+ ### Durable Learning (the `:learn` hook family)
414
+
415
+ Core RobotLab keeps learnings for the life of the process only. Cross-session persistence is supplied by the [`robot_lab-durable`](https://github.com/MadBomber/robot_lab-durable) gem, which registers a `RobotLab::Hook` on the `:learn` family — there is no `learn:` constructor shorthand.
358
416
 
359
- The `robot_lab-durable` gem adds automatic end-of-session learning promotion. Enable it with `learn: true` in the constructor:
417
+ > [!WARNING]
418
+ > `learn:` and `learn_domain:` are **not** constructor keyword arguments and do
419
+ > not exist anywhere in the codebase. `RobotLab.build(learn: true)` raises
420
+ > `ArgumentError: unknown keyword: :learn`.
421
+
422
+ The wiring is the ordinary hook registration described in the [Hooks guide](hooks.md) — an `on_learn` handler receives each learning after session-level deduplication and decides whether to persist it:
360
423
 
361
424
  ```ruby
362
- reviewer = RobotLab.build(
363
- name: "reviewer",
364
- system_prompt: "You are a Ruby code reviewer.",
365
- learn: true,
366
- learn_domain: "ruby_review" # optional namespace for the durable store
367
- )
425
+ class DurableLearnHook < RobotLab::Hook
426
+ self.namespace = :durable
427
+
428
+ def self.on_learn(ctx)
429
+ return unless ctx.stored
430
+
431
+ DurableStore.promote(text: ctx.text, robot: ctx.robot.name, domain: ctx.local.domain)
432
+ end
433
+ end
434
+
435
+ reviewer = RobotLab.build(name: "reviewer", system_prompt: "You are a Ruby code reviewer.")
436
+ reviewer.on(DurableLearnHook, context: { domain: "ruby_review" })
368
437
  ```
369
438
 
370
- At the end of each session, the robot reflects on its observations and promotes durable insights to a YAML-backed store that persists across process restarts. On the next run, those stored insights are automatically reloaded as learnings.
439
+ The extension promotes durable insights to a YAML-backed store that persists across process restarts; see the gem's own README for its registration entry point.
371
440
 
372
441
  ### Memory Persistence
373
442
 
@@ -489,29 +558,41 @@ Texts shorter than 30 characters always return `0.0`.
489
558
  | Same topic, different emphasis | 0.45 – 0.60 |
490
559
  | Unrelated | < 0.15 |
491
560
 
492
- ### Router Fast-Path Pattern
561
+ ### Reconciler Fast-Path Pattern
493
562
 
494
- Skip the reconciler when verifiers agree:
563
+ Skip the reconciler when verifiers agree. RobotLab has no router object — routing is done by declaring the optional branch as a task with `depends_on: :optional` and having a preceding robot call `result.activate(:task_name)` on it. Subclass `RobotLab::Robot` and override `#call` to make the decision:
495
564
 
496
565
  ```ruby
497
- router = ->(args) do
498
- a = args.context[:verifier_a]&.reply.to_s
499
- b = args.context[:verifier_b]&.reply.to_s
566
+ class ConvergenceGate < RobotLab::Robot
567
+ def call(result)
568
+ # result.context is keyed by ROBOT name, not task name
569
+ a = result.context[:verifier_a]&.reply.to_s
570
+ b = result.context[:verifier_b]&.reply.to_s
500
571
 
501
- if RobotLab::Convergence.detected?(a, b)
502
- nil # both agree — network halts, no reconciler call
503
- else
504
- ["reconciler"] # diverged — send to reconciler
572
+ return result if RobotLab::Convergence.detected?(a, b) # agree — reconciler stays dormant
573
+
574
+ result.activate(:reconciler) # diverged — activate the branch
505
575
  end
506
576
  end
507
577
 
508
- network = RobotLab.create_network(
509
- name: "fact_check",
510
- robots: [verifier_a, verifier_b, reconciler],
511
- router: router
512
- )
578
+ network = RobotLab.create_network(name: "fact_check") do
579
+ task :verifier_a, verifier_a, depends_on: :none
580
+ task :verifier_b, verifier_b, depends_on: :none
581
+ task :gate, ConvergenceGate.new(name: "gate"), depends_on: %i[verifier_a verifier_b]
582
+ task :reconciler, reconciler, depends_on: :optional
583
+ end
584
+
585
+ result = network.run(message: "Is the deployment healthy?")
586
+ result.activated_steps # => [] when they agreed, [:reconciler] when they diverged
513
587
  ```
514
588
 
589
+ The gate robot never calls the LLM — overriding `#call` replaces the default "run and continue" behaviour entirely, so the decision costs nothing.
590
+
591
+ > [!NOTE]
592
+ > `result.context` is keyed by the **robot's** name (`@name`), not the task name.
593
+ > The lookups above work because each verifier's `name:` matches its task label.
594
+ > If they differ, index by the robot name.
595
+
515
596
  Tune `threshold:` to control how strictly "agreement" is defined. A lower threshold (e.g., `0.6`) accepts more variation between verifiers; a higher threshold (e.g., `0.9`) only fast-paths near-identical responses.
516
597
 
517
598
  ### Optional Dependency
@@ -606,13 +687,47 @@ puts "#{analysis.robot_name} (#{analysis.duration.round(2)}s): #{analysis.reply}
606
687
 
607
688
  ---
608
689
 
690
+ ## Live Narration (`RobotLab::Narrator`)
691
+
692
+ `RobotLab::Narrator` is an opt-in [Hook](hooks.md) that narrates what a robot is doing as it happens, to `$stderr` (or any `IO`), so a run is never silent between events. It complements `RobotLab::Audit` (the `robot_lab-audit` gem, which records a persistent history for post-mortem analysis) with a live, human-facing console feed — the two are independent hooks and can both be registered at once.
693
+
694
+ Enable it globally (applies to every robot run, including networks):
695
+
696
+ ```ruby
697
+ RobotLab::Narrator.enable! # narrate to $stderr
698
+ RobotLab::Narrator.enable!(output: $stdout) # or any IO
699
+ ```
700
+
701
+ Or register it like any hook for a narrower scope:
702
+
703
+ ```ruby
704
+ robot.on(RobotLab::Narrator)
705
+ network.on(RobotLab::Narrator)
706
+ ```
707
+
708
+ Once registered, a run prints a line per event:
709
+
710
+ ```
711
+ · math_bot: thinking…
712
+ · → calculate operation="add"
713
+ · → calculate operation="multiply"
714
+ ```
715
+
716
+ - Once per `run`: `"<robot name>: thinking…"` — Narrator hooks `before_llm_generation`, which fires exactly once per `robot.run`; the provider's tool loop happens *inside* that hook, so the line is not repeated per LLM API call
717
+ - Before each tool call: `"→ <tool name> <first arg>=<value>"` — only the *first* argument is shown, truncated to 80 characters
718
+ - After a tool call: `" ✗ <error message>"` — printed only when the tool raised; silent on success
719
+
720
+ Narrator uses `IO#puts` rather than `Kernel#warn`, since `warn` is silenced whenever Ruby warnings are disabled (`$VERBOSE` is `nil`, the common case under `bundle exec`). All three hooks rescue internally, so a narration failure never breaks the underlying run.
721
+
722
+ ---
723
+
609
724
  ## See Also
610
725
 
611
- - [Robot API](../api/core/robot.md#token--cost-tracking)
612
- - [Example 19 — Token & Cost Tracking](../../examples/19_token_tracking.rb)
613
- - [Example 20 — Tool Loop Circuit Breaker](../../examples/20_circuit_breaker.rb)
614
- - [Example 21 — Learning Accumulation Loop](../../examples/21_learning_loop.rb)
615
- - [Example 22 — Context Window Compression](../../examples/22_context_compression.rb)
616
- - [Example 23 — Convergence Detection](../../examples/23_convergence.rb)
617
- - [Example 24 — Structured Delegation](../../examples/24_structured_delegation.rb)
726
+ - [Robot API](../api/core/robot.md#token-cost-tracking)
727
+ - [examples/19_token_tracking.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/19_token_tracking.rb) — Token & Cost Tracking
728
+ - [examples/20_circuit_breaker.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/20_circuit_breaker.rb) — Tool Loop Circuit Breaker
729
+ - [examples/21_learning_loop.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/21_learning_loop.rb) — Learning Accumulation Loop
730
+ - [examples/22_context_compression.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/22_context_compression.rb) — Context Window Compression
731
+ - [examples/23_convergence.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/23_convergence.rb) — Convergence Detection
732
+ - [examples/24_structured_delegation.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/24_structured_delegation.rb) — Structured Delegation
618
733
  - [RunConfig reference](../getting-started/configuration.md#runconfig-shared-operational-defaults)