robot_lab 0.2.6 → 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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +124 -64
  3. data/docs/api/core/index.md +41 -15
  4. data/docs/api/core/memory.md +247 -29
  5. data/docs/api/core/network.md +255 -33
  6. data/docs/api/core/result.md +120 -32
  7. data/docs/api/core/robot.md +551 -61
  8. data/docs/api/core/state.md +87 -197
  9. data/docs/api/core/tool.md +165 -20
  10. data/docs/api/errors.md +110 -17
  11. data/docs/api/hooks.md +469 -0
  12. data/docs/api/index.md +80 -7
  13. data/docs/api/mcp/client.md +129 -35
  14. data/docs/api/mcp/index.md +164 -23
  15. data/docs/api/mcp/server.md +27 -3
  16. data/docs/api/mcp/transports.md +94 -22
  17. data/docs/api/messages/index.md +26 -3
  18. data/docs/api/messages/text-message.md +33 -11
  19. data/docs/api/messages/tool-call-message.md +27 -4
  20. data/docs/api/messages/tool-result-message.md +23 -4
  21. data/docs/api/messages/user-message.md +45 -8
  22. data/docs/api/skills.md +519 -0
  23. data/docs/api/streaming/context.md +28 -5
  24. data/docs/api/streaming/index.md +57 -11
  25. data/docs/api/support.md +846 -0
  26. data/docs/architecture/core-concepts.md +79 -31
  27. data/docs/architecture/index.md +86 -11
  28. data/docs/architecture/message-flow.md +66 -29
  29. data/docs/architecture/network-orchestration.md +145 -38
  30. data/docs/architecture/robot-execution.md +172 -90
  31. data/docs/architecture/state-management.md +31 -12
  32. data/docs/concepts.md +176 -21
  33. data/docs/examples/basic-chat.md +72 -19
  34. data/docs/examples/index.md +117 -31
  35. data/docs/examples/mcp-server.md +154 -45
  36. data/docs/examples/multi-robot-network.md +91 -21
  37. data/docs/examples/tool-usage.md +104 -37
  38. data/docs/getting-started/configuration.md +284 -97
  39. data/docs/getting-started/installation.md +53 -41
  40. data/docs/getting-started/quick-start.md +51 -6
  41. data/docs/guides/building-robots.md +258 -50
  42. data/docs/guides/creating-networks.md +214 -30
  43. data/docs/guides/hooks.md +141 -54
  44. data/docs/guides/knowledge.md +35 -4
  45. data/docs/guides/mcp-integration.md +211 -44
  46. data/docs/guides/memory.md +103 -12
  47. data/docs/guides/observability.md +95 -47
  48. data/docs/guides/streaming.md +184 -125
  49. data/docs/guides/using-tools.md +237 -17
  50. data/docs/index.md +36 -4
  51. data/examples/01_simple_robot.rb +2 -2
  52. data/examples/02_tools.rb +14 -4
  53. data/examples/03_network.rb +12 -7
  54. data/examples/04_mcp.rb +11 -4
  55. data/examples/05_streaming.rb +8 -5
  56. data/examples/06_prompt_templates.rb +13 -9
  57. data/examples/07_network_memory.rb +5 -5
  58. data/examples/08_llm_config.rb +20 -15
  59. data/examples/09_chaining.rb +4 -4
  60. data/examples/11_network_introspection.rb +4 -4
  61. data/examples/12_message_bus.rb +2 -2
  62. data/examples/13_spawn.rb +2 -2
  63. data/examples/14_rusty_circuit/README.md +1 -0
  64. data/examples/14_rusty_circuit/comic.rb +7 -3
  65. data/examples/14_rusty_circuit/display.rb +14 -0
  66. data/examples/14_rusty_circuit/heckler.rb +8 -6
  67. data/examples/14_rusty_circuit/open_mic.rb +17 -6
  68. data/examples/14_rusty_circuit/scout.rb +17 -10
  69. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
  70. data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
  71. data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
  72. data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
  73. data/examples/16_writers_room/writer.rb +22 -22
  74. data/examples/16_writers_room/writers_room.rb +2 -0
  75. data/examples/17_skills.rb +14 -13
  76. data/examples/18_rails/README.md +20 -1
  77. data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
  78. data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
  79. data/examples/18_rails/app/robots/chat_robot.rb +11 -0
  80. data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
  81. data/examples/19_token_tracking.rb +25 -9
  82. data/examples/20_circuit_breaker.rb +10 -7
  83. data/examples/21_learning_loop.rb +42 -16
  84. data/examples/22_context_compression.rb +23 -23
  85. data/examples/23_convergence.rb +24 -17
  86. data/examples/24_structured_delegation.rb +13 -8
  87. data/examples/25_history_search.rb +12 -8
  88. data/examples/27_incident_response/incident_response.rb +31 -13
  89. data/examples/28_mcp_discovery.rb +17 -13
  90. data/examples/29_ractor_tools.rb +4 -2
  91. data/examples/30_ractor_network.rb +22 -17
  92. data/examples/31_launch_assessment.rb +20 -9
  93. data/examples/32_newsletter_reader.rb +7 -2
  94. data/examples/33_stock_predictor.rb +34 -13
  95. data/examples/34_agentskills.rb +7 -3
  96. data/examples/35_hooks.rb +18 -8
  97. data/examples/README.md +199 -45
  98. data/examples/common.rb +79 -11
  99. data/examples/xyzzy.rb +8 -1
  100. data/lib/robot_lab/config.rb +10 -5
  101. data/lib/robot_lab/names.rb +402 -0
  102. data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
  103. data/lib/robot_lab/robot/bus_messaging.rb +16 -8
  104. data/lib/robot_lab/robot/template_rendering.rb +16 -3
  105. data/lib/robot_lab/robot.rb +23 -2
  106. data/lib/robot_lab/version.rb +1 -1
  107. data/lib/robot_lab.rb +21 -15
  108. data/mkdocs.yml +6 -1
  109. metadata +7 -2
@@ -116,7 +116,10 @@ begin
116
116
  rescue RobotLab::BudgetExceeded => e
117
117
  puts e.message # "budget exceeded for cost: 0.51 > 0.5" — refused before spending
118
118
  rescue RobotLab::InferenceError => e
119
- puts e.message # "Cost budget exceeded: $0.5231 used, budget is $0.5" — this call pushed it over
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")
120
123
  end
121
124
  ```
122
125
 
@@ -143,12 +146,20 @@ robot = RobotLab.build(
143
146
  local_tools: [StepTool],
144
147
  max_tool_rounds: 10
145
148
  )
149
+ ```
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.
146
156
 
157
+ ```ruby
147
158
  begin
148
- robot.run("Run all steps.")
159
+ robot.run("Run all steps.", tools: :inherit)
149
160
  rescue RobotLab::ToolLoopError => e
150
- puts "Circuit breaker fired: #{e.message}"
151
- # => "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)"
152
163
  end
153
164
  ```
154
165
 
@@ -170,6 +181,8 @@ Error: tool_use ids were found without tool_result blocks immediately after
170
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:
171
182
 
172
183
  ```ruby
184
+ begin
185
+ robot.run("Keep calling the tool.", tools: :inherit)
173
186
  rescue RobotLab::ToolLoopError => e
174
187
  puts "Breaker fired: #{e.message}"
175
188
  end
@@ -191,7 +204,7 @@ unguarded = RobotLab.build(
191
204
  system_prompt: "Use the provided tool to answer questions.",
192
205
  local_tools: [DoubleTool]
193
206
  )
194
- result = unguarded.run("Double the number 21 using the tool.")
207
+ result = unguarded.run("Double the number 21 using the tool.", tools: :inherit)
195
208
  puts result.reply # "The result is 42."
196
209
  ```
197
210
 
@@ -205,16 +218,18 @@ puts result.reply # "The result is 42."
205
218
 
206
219
  ### doom_loop_threshold
207
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
+
208
223
  ```ruby
209
224
  robot = RobotLab.build(
210
225
  name: "runner",
211
226
  system_prompt: "Execute all steps.",
212
227
  local_tools: [StepTool],
213
- doom_loop_threshold: 3
228
+ doom_loop_threshold: 5 # tune the always-on detector; default is 3
214
229
  )
215
230
  ```
216
231
 
217
- Set `doom_loop_threshold:` to the number of repetitions after which the detector fires. It catches two patterns:
232
+ The detector catches two patterns:
218
233
 
219
234
  - **Consecutive repetition** — `[A, A, A]` (same tool called N times in a row)
220
235
  - **Cyclic repetition** — `[A, B, C, A, B, C, A, B, C]` (same sequence repeated N times)
@@ -252,27 +267,38 @@ Long-running robots accumulate conversation history. Eventually, the cumulative
252
267
 
253
268
  ### auto_compact
254
269
 
255
- 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:`:
256
271
 
257
272
  ```ruby
258
273
  # Compact when estimated token usage exceeds 80% of the model's context window
274
+ config = RobotLab::RunConfig.new(auto_compact: :context_window)
275
+
259
276
  robot = RobotLab.build(
260
277
  name: "analyst",
261
278
  system_prompt: "You are a research analyst.",
262
- auto_compact: :context_window
279
+ config: config
263
280
  )
264
281
  ```
265
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
+
266
288
  ### Tuning the Threshold
267
289
 
268
290
  `compact_threshold:` sets the fraction of the model's context window that triggers compaction. Defaults to `0.80` (80%):
269
291
 
270
292
  ```ruby
293
+ config = RobotLab::RunConfig.new(
294
+ auto_compact: :context_window,
295
+ compact_threshold: 0.70 # compact earlier, at 70%
296
+ )
297
+
271
298
  robot = RobotLab.build(
272
299
  name: "analyst",
273
300
  system_prompt: "You are a research analyst.",
274
- auto_compact: :context_window,
275
- compact_threshold: 0.70 # compact earlier, at 70%
301
+ config: config
276
302
  )
277
303
  ```
278
304
 
@@ -281,16 +307,20 @@ robot = RobotLab.build(
281
307
  Pass a `Proc` to take full control — the proc decides both when and how to compact:
282
308
 
283
309
  ```ruby
284
- robot = RobotLab.build(
285
- name: "analyst",
286
- system_prompt: "You are a research analyst.",
310
+ config = RobotLab::RunConfig.new(
287
311
  auto_compact: ->(r) {
288
312
  r.compress_history(recent_turns: 5) if r.chat.messages.size > 40
289
313
  }
290
314
  )
315
+
316
+ robot = RobotLab.build(
317
+ name: "analyst",
318
+ system_prompt: "You are a research analyst.",
319
+ config: config
320
+ )
291
321
  ```
292
322
 
293
- 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.
294
324
 
295
325
  ### Options
296
326
 
@@ -300,13 +330,6 @@ The proc receives the robot instance and is called before every `run()` when mes
300
330
  | `:context_window` | Compact when estimated token usage exceeds `compact_threshold` fraction of model's context window |
301
331
  | `Proc` | Called with the robot; application decides when and how to compact |
302
332
 
303
- Via `RunConfig`:
304
-
305
- ```ruby
306
- config = RobotLab::RunConfig.new(auto_compact: :context_window, compact_threshold: 0.75)
307
- robot = RobotLab.build(name: "analyst", system_prompt: "...", config: config)
308
- ```
309
-
310
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.
311
334
 
312
335
  ---
@@ -387,20 +410,33 @@ end
387
410
 
388
411
  After all three runs, `reviewer.learnings` contains up to three insights (fewer if any are subsets of others).
389
412
 
390
- ### 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.
391
416
 
392
- 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:
393
423
 
394
424
  ```ruby
395
- reviewer = RobotLab.build(
396
- name: "reviewer",
397
- system_prompt: "You are a Ruby code reviewer.",
398
- learn: true,
399
- learn_domain: "ruby_review" # optional namespace for the durable store
400
- )
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" })
401
437
  ```
402
438
 
403
- 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.
404
440
 
405
441
  ### Memory Persistence
406
442
 
@@ -522,29 +558,41 @@ Texts shorter than 30 characters always return `0.0`.
522
558
  | Same topic, different emphasis | 0.45 – 0.60 |
523
559
  | Unrelated | < 0.15 |
524
560
 
525
- ### Router Fast-Path Pattern
561
+ ### Reconciler Fast-Path Pattern
526
562
 
527
- 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:
528
564
 
529
565
  ```ruby
530
- router = ->(args) do
531
- a = args.context[:verifier_a]&.reply.to_s
532
- 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
533
571
 
534
- if RobotLab::Convergence.detected?(a, b)
535
- nil # both agree — network halts, no reconciler call
536
- else
537
- ["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
538
575
  end
539
576
  end
540
577
 
541
- network = RobotLab.create_network(
542
- name: "fact_check",
543
- robots: [verifier_a, verifier_b, reconciler],
544
- router: router
545
- )
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
546
587
  ```
547
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
+
548
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.
549
597
 
550
598
  ### Optional Dependency
@@ -662,10 +710,10 @@ Once registered, a run prints a line per event:
662
710
  ```
663
711
  · math_bot: thinking…
664
712
  · → calculate operation="add"
665
- · math_bot: thinking…
713
+ · calculate operation="multiply"
666
714
  ```
667
715
 
668
- - Before each LLM call: `"<robot name>: thinking…"`
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
669
717
  - Before each tool call: `"→ <tool name> <first arg>=<value>"` — only the *first* argument is shown, truncated to 80 characters
670
718
  - After a tool call: `" ✗ <error message>"` — printed only when the tool raised; silent on success
671
719