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
@@ -85,16 +85,29 @@ weather_tool = RobotLab::Tool.create(
85
85
 
86
86
  `RobotLab::AskUser` lets a robot ask the user a question via the terminal. The LLM decides when it needs human input and calls the tool with a question, optional choices, and an optional default.
87
87
 
88
+ `AskUser` reads its IO from `robot.input` / `robot.output`, and it finds the
89
+ robot through its own `robot` accessor — which is only populated when you attach
90
+ an **instance constructed with `robot:`**. Build the robot first, then attach:
91
+
88
92
  ```ruby
89
93
  robot = RobotLab.build(
90
94
  name: "onboarding",
91
- system_prompt: "Walk the user through project setup. Ask questions to understand their needs.",
92
- local_tools: [RobotLab::AskUser]
95
+ system_prompt: "Walk the user through project setup. Ask questions to understand their needs."
93
96
  )
94
- robot.run("Help the user set up a new project")
97
+ robot.local_tools << RobotLab::AskUser.new(robot: robot)
98
+
99
+ robot.run("Help the user set up a new project", tools: :inherit)
95
100
  ```
96
101
 
97
- The tool displays the robot's name and question, then waits for terminal input:
102
+ > [!WARNING]
103
+ > Passing the bare class — `local_tools: [RobotLab::AskUser]` — leaves
104
+ > `tool.robot` as `nil`. The tool then ignores `robot.input`/`robot.output`,
105
+ > reads from the real `$stdin` (blocking your process, and hanging any test
106
+ > suite), and labels its prompt `[Robot]` instead of the robot's name.
107
+ > Note also the `tools: :inherit` above: a plain `run()` sends **zero** tools,
108
+ > so the LLM would never see `AskUser` at all.
109
+
110
+ The tool displays the robot's name and question, then waits for input:
98
111
 
99
112
  ```
100
113
  [onboarding] What programming language will you use?
@@ -110,13 +123,27 @@ Features:
110
123
  - **Multiple choice**: numbered options, user types the number or text
111
124
  - **Default value**: shown in the prompt, used when user presses Enter
112
125
 
113
- IO is sourced from `robot.input` / `robot.output` (defaulting to `$stdin` / `$stdout`), making it easy to test with `StringIO`:
126
+ Because the IO is sourced from the robot, a `StringIO` pair makes the tool
127
+ testable without a terminal:
114
128
 
115
129
  ```ruby
116
130
  robot.input = StringIO.new("2\n")
117
131
  robot.output = StringIO.new
132
+
133
+ # ... the LLM calls ask_user(question: "Which language?", choices: %w[Ruby Python Go])
134
+ robot.output.string
135
+ # => "\n[onboarding] Which language?\n 1. Ruby\n 2. Python\n 3. Go\n> "
118
136
  ```
119
137
 
138
+ > [!NOTE]
139
+ > The name the LLM sees is **`robot_lab--ask_user`**, not `ask_user`. RubyLLM
140
+ > derives tool names from the full class name including its namespace, so
141
+ > `RobotLab::AskUser` becomes `robot_lab--ask_user`. (A trailing `Tool` is
142
+ > stripped: a top-level `WeatherTool` is exposed as `weather`.) These derived
143
+ > names are what an allowlist must match **for tools attached as instances** —
144
+ > a tool attached as a class matches its class name instead. See
145
+ > [Runtime Tool Filtering](#runtime-tool-filtering).
146
+
120
147
  See the [AskUser API reference](../api/core/tool.md#built-in-askuser) for full details.
121
148
 
122
149
  ## Attaching Tools to Robots
@@ -165,13 +192,29 @@ Tool classes must be defined and loaded before building the robot. Unresolvable
165
192
 
166
193
  ### Via Chaining
167
194
 
168
- You can also add tools dynamically with chaining:
195
+ `with_tools` is delegated straight to the underlying `RubyLLM::Chat`, so it
196
+ writes tools onto the chat rather than onto the robot's `local_tools`:
169
197
 
170
198
  ```ruby
171
199
  robot = RobotLab.build(name: "assistant", system_prompt: "...")
172
200
  robot.with_tools(GetWeather, CalculatorTool)
173
201
  ```
174
202
 
203
+ > [!WARNING]
204
+ > Chained tools do not survive a `run`. Because `run` defaults to
205
+ > `tools: :none`, it replaces the chat's tool list with an empty one before
206
+ > asking the LLM — wiping whatever `with_tools` put there:
207
+ >
208
+ > ```ruby
209
+ > robot.with_tools(GetWeather, CalculatorTool)
210
+ > robot.chat.tools.keys # => [:get_weather, :calculator]
211
+ > robot.run("Hello")
212
+ > robot.chat.tools.keys # => [] <- cleared by the run
213
+ > ```
214
+ >
215
+ > For tools that should persist across runs, attach them with `local_tools:`
216
+ > and pass `tools: :inherit` on each `run`.
217
+
175
218
  ## Runtime Tool Filtering
176
219
 
177
220
  `tools:` (and `mcp:`) also work as a **per-run** override, passed to `run()` itself, on top of the build-time/network/global hierarchy described in [Hierarchical MCP and Tools](../getting-started/configuration.md#hierarchical-mcp-and-tools):
@@ -179,18 +222,72 @@ robot.with_tools(GetWeather, CalculatorTool)
179
222
  ```ruby
180
223
  robot.run("What's the weather?", tools: :inherit) # every attached tool
181
224
  robot.run("Just chat, no tools needed.", tools: :none) # zero tools this turn
182
- robot.run("Only use the calculator.", tools: %w[calculator]) # allowlist by name
225
+ # Allowlist. This robot attached its tools as CLASSES, so match the class names:
226
+ robot.run("Only use the calculator.", tools: %w[CalculatorTool])
183
227
  ```
184
228
 
185
229
  | Value | Meaning |
186
230
  |-------|---------|
187
- | `:inherit` | No filter use every tool attached at the robot/network/global level |
188
- | `:none`, `[]`, `nil` | Send **zero** tools this turn |
189
- | `["name", ...]` | Only these tool names, by exact match |
231
+ | `:inherit` | Propagate the parent level's allowlist (build-time / network / global) |
232
+ | `:none`, `[]` | Send **zero** tools this turn |
233
+ | `nil` | No filter at all — every attached tool, **discarding** any parent allowlist |
234
+ | `["name", ...]` | Only these tools, matched against how each was attached |
235
+
236
+ > [!CAUTION]
237
+ > `tools: nil` does **not** send zero tools, and it is **not** the same as
238
+ > `:inherit`. `:inherit` carries the parent's allowlist down; `nil` throws the
239
+ > filter away entirely. With a robot built `tools: %w[GetWeather]` holding
240
+ > `GetWeather` and `CalculatorTool`:
241
+ >
242
+ > ```
243
+ > run(tools: :inherit) -> [:get_weather] # parent allowlist honored
244
+ > run(tools: nil) -> [:get_weather, :calculator] # allowlist discarded
245
+ > ```
246
+ >
247
+ > If you mean zero tools, write `:none`.
248
+
249
+ > [!IMPORTANT]
250
+ > An allowlist entry must match **how the tool was attached**, because the
251
+ > comparison uses `tool.name` and `Class#name` differs from `RubyLLM::Tool#name`:
252
+ >
253
+ > | Attached as | Allowlist that matches |
254
+ > |---|---|
255
+ > | `local_tools: [CalculatorTool]` (class) | `[CalculatorTool]` or `%w[CalculatorTool]` |
256
+ > | `local_tools: [CalculatorTool.new]` (instance) | `%w[calculator]` |
257
+ >
258
+ > Note the constructor's `tools:` is validated and **rejects** classes and
259
+ > instances outright (`ArgumentError`); only task-level and `run()` values accept
260
+ > them.
190
261
 
191
262
  An explicit `:none`/`[]` is useful for a relevance filter that decided no tool is useful for the current message — it now genuinely sends zero tools for that turn (previously an empty allowlist was silently treated as "all tools," which could overflow small-context local models with the full tool set). Each turn's resolved tool set fully **replaces** the chat's tools rather than accumulating, so a later `:none` turn correctly clears whatever a prior turn attached.
192
263
 
193
- > **Watch the default.** Both `Robot.new`'s and `run()`'s `tools:`/`mcp:` parameters default to `:none`, not `:inherit`. If you build a robot with `local_tools:` and then call `robot.run(message)` with no `tools:` override at all, the current runtime default takes the explicit-`:none` path above — sending no tools for that turn. Pass `tools: :inherit` explicitly (at build time, per network task, or per `run()` call — wherever fits your call site) anywhere you need the robot's attached tools available.
264
+ > [!CAUTION]
265
+ > **Watch the default.** Both `Robot.new`'s and `run()`'s `tools:`/`mcp:`
266
+ > parameters default to `:none`, not `:inherit`. If you build a robot with
267
+ > `local_tools:` and then call `robot.run(message)` with no `tools:` override at
268
+ > all, the runtime default takes the explicit-`:none` path above — sending no
269
+ > tools for that turn. **Pass `tools: :inherit` on the `run()` call** (or on the
270
+ > network `task`, which is forwarded to `run()`) anywhere you need the robot's
271
+ > attached tools available.
272
+
273
+ > [!WARNING]
274
+ > For a **standalone** robot, do not pass `tools: :inherit` at *build* time as a
275
+ > way to turn tools on. Build-time `:inherit` resolves against the level above
276
+ > it, which for a standalone robot is the global `:none` — producing an
277
+ > allowlist of `["none"]` that matches nothing, and the empty result then
278
+ > carries into the runtime pass. Leave build-time `tools:` unset:
279
+ >
280
+ > ```ruby
281
+ > # build tools: unset + run(tools: :inherit) -> [:t1] correct
282
+ > # build tools: :inherit + run(tools: :inherit) -> [] the standalone trap
283
+ > # build tools: :none + run(tools: :inherit) -> [:t1] also fine
284
+ > ```
285
+ >
286
+ > This applies to a robot run on its own. Inside a **network** whose `config:`
287
+ > supplies `tools:`/`mcp:`, the parent level is that network list rather than
288
+ > `:none`, and build-time `:inherit` is *required* — it is the robot's opt-in to
289
+ > the network value. See
290
+ > [Network-Wide Tool and MCP Defaults](creating-networks.md#network-wide-tool-and-mcp-defaults).
194
291
 
195
292
  ### Tool Capping and Per-Turn Filtering
196
293
 
@@ -206,9 +303,22 @@ robot = RobotLab.build(
206
303
  ```
207
304
 
208
305
  - Default ceiling: **128** tools per turn (`RobotLab::Robot::DEFAULT_MAX_TOOLS`)
209
- - Override with `max_tools:` on `RunConfig` (or the `max_tools:` cascade field — see [Available Fields](../getting-started/configuration.md#available-fields)); `nil` or `<= 0` disables the cap
306
+ - Override with a **positive** `max_tools:` on `RunConfig` (or the `max_tools:` cascade field — see [Available Fields](../getting-started/configuration.md#available-fields))
210
307
  - When a turn's resolved tools exceed the cap, RobotLab logs a warning naming how many were dropped and sends the first `max_tools` entries
211
308
 
309
+ > [!WARNING]
310
+ > The cap **cannot be disabled.** `max_tools: nil`, `0`, or a negative number
311
+ > all fall back to the 128 default — only a positive integer changes the
312
+ > ceiling. If you attach more than 128 tools and set `max_tools: 0` expecting
313
+ > "unlimited", you get the first 128.
314
+ >
315
+ > The truncation is not silent, though — `cap_tools` logs it at `WARN` through
316
+ > `RobotLab.config.logger` every time it fires:
317
+ >
318
+ > ```
319
+ > [power_user] tool list (150) exceeds max_tools (128); sending 128, dropping 22
320
+ > ```
321
+
212
322
  ## Skill Scripts and Sandboxing
213
323
 
214
324
  A skill bundle (a directory with a `SKILL.md` plus `scripts/`, discovered via `AgentSkill`) can expose its scripts as tools (`ScriptTool`). Because those scripts run as real OS processes, each `SKILL.md` can declare the capabilities its scripts need directly in front matter, alongside `name`/`description`:
@@ -266,10 +376,22 @@ param :active, type: :boolean, desc: "Whether the user is active"
266
376
  param :tags, type: :array, desc: "List of tags"
267
377
  ```
268
378
 
269
- ### Enum
379
+ ### Enumerated Values
380
+
381
+ `param` has **no `enum:` option**. Its full signature is
382
+ `param(name, type: "string", desc: nil, description: nil, required: true)` —
383
+ anything else raises `ArgumentError: unknown keyword: :enum`. Express the
384
+ allowed values in the description instead, and validate in `execute`:
270
385
 
271
386
  ```ruby
272
- param :status, type: :string, desc: "Order status", enum: %w[pending active completed]
387
+ STATUSES = %w[pending active completed].freeze
388
+
389
+ param :status, type: :string, desc: "Order status — one of: pending, active, completed"
390
+
391
+ def execute(status:)
392
+ return { error: "status must be one of #{STATUSES.join(', ')}" } unless STATUSES.include?(status)
393
+ # ...
394
+ end
273
395
  ```
274
396
 
275
397
  ### Required vs Optional
@@ -416,7 +538,39 @@ result = tool.call({ "id" => "missing" })
416
538
  # => "Error (fetch_resource): connection refused"
417
539
  ```
418
540
 
419
- This applies to all `RobotLab::Tool` variants — subclasses, `Tool.create` factory tools, and MCP tools. Errors are also logged via `RobotLab.config.logger` at `:warn` level.
541
+ This applies to all `RobotLab::Tool` variants — subclasses, `Tool.create` factory tools, and MCP tools.
542
+
543
+ ### RobotLab::ToolError and Retryability
544
+
545
+ `RobotLab::ToolError` is handled on a separate path from ordinary
546
+ `StandardError`s. Raising one with `retryable: true` appends `" (retryable)"`
547
+ to the text the LLM sees, hinting that another attempt may succeed:
548
+
549
+ ```ruby
550
+ class FetchResource < RobotLab::Tool
551
+ description "Fetch a resource from an external API"
552
+ param :id, type: :string, desc: "Resource ID"
553
+
554
+ def execute(id:)
555
+ raise RobotLab::ToolError.new("upstream 503", retryable: true)
556
+ end
557
+ end
558
+
559
+ FetchResource.new.call({ "id" => "1" })
560
+ # => "Error (fetch_resource): upstream 503 (retryable)"
561
+
562
+ # Without retryable:, no suffix is appended:
563
+ # raise RobotLab::ToolError, "bad input"
564
+ # => "Error (fetch_resource): bad input"
565
+ ```
566
+
567
+ > [!WARNING]
568
+ > Only the ordinary `StandardError` path writes to the log
569
+ > (`RobotLab.config.logger.warn("Tool 'name' error: RuntimeError: …")`).
570
+ > A `RobotLab::ToolError` is turned into text for the LLM and logged
571
+ > **nowhere** — if you rely on `ToolError` for expected failures, add your own
572
+ > logging inside `execute`, or those failures leave no trace outside the
573
+ > transcript.
420
574
 
421
575
  ### Critical Tools (Opt-Out)
422
576
 
@@ -437,6 +591,24 @@ end
437
591
 
438
592
  `raise_on_error` is per-class and defaults to `false`. Setting it on one class does not affect others.
439
593
 
594
+ > [!WARNING]
595
+ > `raise_on_error` does **not** walk the inheritance chain. A subclass of a
596
+ > class that set it silently reverts to `false`:
597
+ >
598
+ > ```ruby
599
+ > class CriticalBase < RobotLab::Tool
600
+ > self.raise_on_error = true
601
+ > end
602
+ >
603
+ > class ChargeCard < CriticalBase; end
604
+ >
605
+ > CriticalBase.raise_on_error? # => true
606
+ > ChargeCard.raise_on_error? # => false <- NOT inherited
607
+ > ```
608
+ >
609
+ > Set `self.raise_on_error = true` on every class that needs it. (This differs
610
+ > from `ractor_safe`, below, which *does* consult the superclass.)
611
+
440
612
  ### Manual Error Handling
441
613
 
442
614
  You can still handle specific errors inside `execute` for domain-specific responses:
@@ -458,20 +630,68 @@ class FetchResource < RobotLab::Tool
458
630
  end
459
631
  ```
460
632
 
633
+ ## Ractor-Safe Tools
634
+
635
+ A tool class can declare itself safe to run inside a Ractor. When it is, and the
636
+ [`robot_lab-ractor`](https://github.com/MadBomber/robot_lab-ractor) extension
637
+ gem is loaded, `call` dispatches the work to `RobotLab.ractor_pool` instead of
638
+ running it inline on the calling thread — giving real CPU parallelism for
639
+ compute-bound tools.
640
+
641
+ ```ruby
642
+ class Fibonacci < RobotLab::Tool
643
+ ractor_safe true
644
+
645
+ description "Compute the nth Fibonacci number"
646
+ param :n, type: :integer, desc: "Which Fibonacci number to compute"
647
+
648
+ def execute(n:)
649
+ a, b = 0, 1
650
+ n.times { a, b = b, a + b }
651
+ a
652
+ end
653
+ end
654
+
655
+ Fibonacci.ractor_safe? # => true
656
+ ```
657
+
658
+ Dispatch to the pool happens only when **all three** hold:
659
+
660
+ 1. `ractor_safe?` is true
661
+ 2. the class has a resolvable name — anonymous classes (including every tool
662
+ built by `Tool.create`) fall back to the inline path
663
+ 3. the `robot_lab-ractor` extension is loaded
664
+
665
+ Otherwise `execute` runs inline, exactly as an ordinary tool would. Pool size
666
+ comes from the `ractor_pool_size` `RunConfig` field (`:auto` when unset).
667
+
668
+ > [!WARNING]
669
+ > A Ractor-safe tool must be genuinely stateless: it is instantiated **fresh
670
+ > inside the Ractor worker** for every call, so `robot`, captured closures, and
671
+ > mutable class-level state are not available to it, and the result comes back
672
+ > frozen. Do not set `ractor_safe true` on a tool that touches its robot.
673
+
674
+ Unlike `raise_on_error`, `ractor_safe` **does** walk the inheritance chain — a
675
+ subclass of a Ractor-safe tool is Ractor-safe unless it says otherwise.
676
+
461
677
  ## Tool Callbacks
462
678
 
463
- Robots support `on_tool_call` and `on_tool_result` callbacks for monitoring tool usage:
679
+ Robots support `on_tool_call` and `on_tool_result` callbacks for monitoring tool usage. Each receives exactly **one** argument — `on_tool_result` gets the result only, not the originating call:
464
680
 
465
681
  ```ruby
466
682
  robot = RobotLab.build(
467
683
  name: "assistant",
468
684
  system_prompt: "...",
469
685
  local_tools: [GetWeather],
470
- on_tool_call: ->(call) { puts "Calling: #{call}" },
686
+ on_tool_call: ->(tool_call) { puts "Calling: #{tool_call.name}" },
471
687
  on_tool_result: ->(result) { puts "Result: #{result}" }
472
688
  )
689
+
690
+ robot.run("What's the weather in Tokyo?", tools: :inherit)
473
691
  ```
474
692
 
693
+ See [Streaming](streaming.md#tool-callbacks) for details, including the RubyLLM deprecation warning these emit.
694
+
475
695
  ## RobotLab::Tool.create with Schema
476
696
 
477
697
  For dynamic tools via `Tool.create`, pass parameters as a JSON Schema hash:
data/docs/index.md CHANGED
@@ -142,16 +142,35 @@ Each robot is backed by a persistent LLM chat, configured with keyword arguments
142
142
 
143
143
  </div>
144
144
 
145
+ > [!NOTE]
146
+ > A few of the features above need a gem the core `robot_lab` gem does not install.
147
+ > **Knowledge & Retrieval** (`memory.store_document` / `memory.search_documents`)
148
+ > and **Runtime Skill Matching** (`AgentSkill` catalogs) both require
149
+ > `robot_lab-document_store`; the Redis backing for **Reactive Memory** requires
150
+ > `redis`; Ractor parallelism requires `robot_lab-ractor`; and
151
+ > `robot.compress_history` requires `classifier`. See
152
+ > [Optional Dependencies](getting-started/installation.md#optional-dependencies).
153
+
145
154
  ## Extension Gems
146
155
 
147
156
  These optional gems extend RobotLab with additional capabilities:
148
157
 
149
158
  | Gem | What it adds |
150
159
  |-----|-------------|
151
- | [robot_lab-rails](https://github.com/MadBomber/robot_lab-rails) | Rails Engine, generators, `RobotLab::Job` base class, Turbo Stream broadcasting |
152
- | [robot_lab-ractor](https://github.com/MadBomber/robot_lab-ractor) | CPU parallelism for `ractor_safe` tools and robot networks via Ruby Ractors |
153
- | [robot_lab-durable](https://github.com/MadBomber/robot_lab-durable) | Cross-session knowledge persistence robots accumulate and recall learned facts |
160
+ | [robot_lab-a2a](https://github.com/MadBomber/robot_lab-a2a) | Agent2Agent (A2A) protocol adapter over HTTP + SSE |
161
+ | [robot_lab-audit](https://github.com/MadBomber/robot_lab-audit) | SQLite-backed execution audit log, wired through the Hook system |
162
+ | [robot_lab-discovery](https://github.com/MadBomber/robot_lab-discovery) | Zero-configuration mDNS/DNS-SD robot discovery on the local network |
154
163
  | [robot_lab-document_store](https://github.com/MadBomber/robot_lab-document_store) | Embedding-based semantic document search powered by fastembed |
164
+ | [robot_lab-durable](https://github.com/MadBomber/robot_lab-durable) | HTM-backed long-term memory — robots accumulate and recall learned facts across sessions |
165
+ | [robot_lab-ractor](https://github.com/MadBomber/robot_lab-ractor) | CPU parallelism for `ractor_safe` tools and robot networks via Ruby Ractors |
166
+ | [robot_lab-rails](https://github.com/MadBomber/robot_lab-rails) | Rails Engine, generators, `RobotLab::Job` base class, Turbo Stream broadcasting |
167
+ | [robot_lab-to](https://github.com/MadBomber/robot_lab-to) | Autonomous overnight agent loop — iterate a robot toward an objective, committing each step |
168
+ | [robot_lab-web](https://github.com/MadBomber/robot_lab-web) | Rails-free Sinatra + HTMX browser console that streams a robot's run over SSE |
169
+
170
+ > [!NOTE]
171
+ > The Rails Engine, generators, `RobotLab::Job`, and Turbo broadcasting are
172
+ > **only** in `robot_lab-rails`. The core gem ships no Railtie and no generators.
173
+ > The former `robot_lab-acp` gem is retired and superseded by `robot_lab-a2a`.
155
174
 
156
175
  ## Quick Example
157
176
 
@@ -162,7 +181,10 @@ require "robot_lab"
162
181
  # Set API keys via env vars:
163
182
  # ROBOT_LAB_RUBY_LLM__ANTHROPIC_API_KEY=sk-ant-...
164
183
  #
165
- # Or place a config file at ~/.config/robot_lab/config.yml
184
+ # Or place a config file at ~/.config/robot_lab/robot_lab.yml
185
+ # (the filename repeats the app name; config.yml is never read). Keys go at the
186
+ # top level, or under a section named for the current environment
187
+ # (development:/test:/production:). Only a `defaults:` wrapper is ignored.
166
188
  # Access config values: RobotLab.config.ruby_llm.model #=> "claude-sonnet-4"
167
189
 
168
190
  # Create a robot with keyword arguments
@@ -188,6 +210,16 @@ puts result.last_text_content
188
210
  robot.with_instructions("Be extra concise.").with_temperature(0.3).run("Explain Ruby in one sentence.")
189
211
  ```
190
212
 
213
+ > [!IMPORTANT]
214
+ > `Robot#run` defaults to `tools: :none` and `mcp: :none`, so a plain
215
+ > `robot.run("...")` sends the model **no tools** even when you attached them
216
+ > with `local_tools:` or `mcp:` at build time. Ask for them on the run:
217
+ >
218
+ > ```ruby
219
+ > robot.run("...", tools: :inherit) # attached local tools
220
+ > robot.run("...", mcp: :inherit, tools: :inherit) # MCP servers and their tools
221
+ > ```
222
+
191
223
  ## Supported LLM Providers
192
224
 
193
225
  RobotLab supports multiple LLM providers through the [ruby_llm](https://github.com/crmne/ruby_llm) library:
@@ -6,13 +6,13 @@
6
6
  # Demonstrates creating and running a basic robot with a template.
7
7
  #
8
8
  # Usage:
9
- # ANTHROPIC_API_KEY=your_key ruby examples/01_simple_robot.rb
9
+ # ruby examples/01_simple_robot.rb
10
10
 
11
11
  require_relative "common"
12
12
 
13
13
  # Create a simple robot using a template
14
14
  robot = RobotLab.build(
15
- model: LLM[:default].model,
15
+ **llm_opts,
16
16
  name: "helper",
17
17
  template: :helper
18
18
  )
data/examples/02_tools.rb CHANGED
@@ -6,7 +6,7 @@
6
6
  # Demonstrates creating a robot with custom tools using RubyLLM::Tool.
7
7
  #
8
8
  # Usage:
9
- # ANTHROPIC_API_KEY=your_key ruby examples/02_tools.rb
9
+ # ruby examples/02_tools.rb
10
10
 
11
11
  require_relative "common"
12
12
 
@@ -78,7 +78,7 @@ end
78
78
 
79
79
  # Create robot with tools
80
80
  robot = RobotLab.build(
81
- model: LLM[:default].model,
81
+ **llm_opts,
82
82
  name: "assistant",
83
83
  template: :assistant,
84
84
  local_tools: [Calculator, FortuneCookie]
@@ -86,11 +86,21 @@ robot = RobotLab.build(
86
86
 
87
87
  banner "Robot with Tools"
88
88
 
89
- # Run the robot
90
- result = robot.run("What is 15 multiplied by 7? Also, give me a fortune about my career.")
89
+ # Run the robot.
90
+ #
91
+ # tools: :inherit is REQUIRED to actually offer the tools to the model.
92
+ # Robot#run defaults to tools: :none, which means "send zero tools this turn"
93
+ # — the robot still holds them in local_tools, but the provider never sees
94
+ # them and the LLM answers from memory instead of calling Calculator.
95
+ # :inherit means "no per-turn filter, use every attached tool".
96
+ result = robot.run(
97
+ "What is 15 multiplied by 7? Also, give me a fortune about my career.",
98
+ tools: :inherit
99
+ )
91
100
 
92
101
  # Display results
93
102
  puts "Robot: #{robot.name}"
103
+ puts "Tools offered: #{robot.local_tools.map(&:name).join(', ')}"
94
104
  puts "\nOutput:"
95
105
  result.output.each do |message|
96
106
  puts " #{message.content}" if message.respond_to?(:content)
@@ -7,7 +7,7 @@
7
7
  # using SimpleFlow's optional step activation.
8
8
  #
9
9
  # Usage:
10
- # ANTHROPIC_API_KEY=your_key ruby examples/03_network.rb
10
+ # ruby examples/03_network.rb
11
11
 
12
12
  require_relative "common"
13
13
 
@@ -38,32 +38,37 @@ class ClassifierRobot < RobotLab::Robot
38
38
  end
39
39
  end
40
40
 
41
- # Shared RunConfig — all robots in this network use the same model
42
- shared_config = RobotLab::RunConfig.new(model: LLM[:default].model)
41
+ # Shared RunConfig — operational defaults every robot in this network inherits.
42
+ #
43
+ # RunConfig has no `provider` field (see RunConfig::FIELDS), and an Ollama
44
+ # model is absent from RubyLLM's registry, so provider and model still travel
45
+ # together on each robot via **llm_opts. The RunConfig carries the settings
46
+ # that genuinely are shared.
47
+ shared_config = RobotLab::RunConfig.new(temperature: 0.3, max_tool_rounds: 5)
43
48
 
44
- # Create specialized robots (no model: needed — inherited from RunConfig)
45
49
  classifier = ClassifierRobot.new(
50
+ **llm_opts,
46
51
  name: "classifier",
47
52
  template: :classifier,
48
53
  config: shared_config
49
54
  )
50
55
 
51
56
  billing_robot = RobotLab.build(
52
- model: LLM[:default].model,
57
+ **llm_opts,
53
58
  name: "billing",
54
59
  template: :billing,
55
60
  config: shared_config
56
61
  )
57
62
 
58
63
  technical_robot = RobotLab.build(
59
- model: LLM[:default].model,
64
+ **llm_opts,
60
65
  name: "technical",
61
66
  template: :technical,
62
67
  config: shared_config
63
68
  )
64
69
 
65
70
  general_robot = RobotLab.build(
66
- model: LLM[:default].model,
71
+ **llm_opts,
67
72
  name: "general",
68
73
  template: :general,
69
74
  config: shared_config
data/examples/04_mcp.rb CHANGED
@@ -9,11 +9,10 @@
9
9
  # Prerequisites:
10
10
  # 1. Install the GitHub MCP server: brew install github-mcp-server
11
11
  # 2. Set environment variables:
12
- # - ANTHROPIC_API_KEY: Your Anthropic API key
13
12
  # - GITHUB_PERSONAL_ACCESS_TOKEN: Your GitHub personal access token
14
13
  #
15
14
  # Usage:
16
- # ANTHROPIC_API_KEY=your_key GITHUB_PERSONAL_ACCESS_TOKEN=your_token ruby examples/04_mcp.rb
15
+ # GITHUB_PERSONAL_ACCESS_TOKEN=your_token ruby examples/04_mcp.rb
17
16
  #
18
17
  # The GitHub MCP server provides tools for:
19
18
  # - Searching repositories, code, issues, and users
@@ -136,7 +135,7 @@ begin
136
135
  # connect_mcp! forces eager connection; without it MCP clients initialize
137
136
  # lazily on the first run() call, leaving mcp_clients empty until then.
138
137
  robot = RobotLab.build(
139
- model: LLM[:default].model,
138
+ **llm_opts,
140
139
  name: "github_assistant",
141
140
  template: :github_assistant,
142
141
  mcp_servers: [github_server]
@@ -164,7 +163,15 @@ begin
164
163
  puts "Query: 'What are the top 3 most starred Ruby web frameworks on GitHub?'"
165
164
  hr
166
165
 
167
- result = robot.run("What are the top 3 most starred Ruby web frameworks on GitHub? Just list their names and star counts.")
166
+ # mcp: :inherit and tools: :inherit are both required. Both keywords default
167
+ # to :none on run(), which resolves to "no MCP servers this turn" and "send
168
+ # zero tools this turn" — the discovered MCP tools would never reach the
169
+ # provider and the model would answer from its training data.
170
+ result = robot.run(
171
+ "What are the top 3 most starred Ruby web frameworks on GitHub? Just list their names and star counts.",
172
+ mcp: :inherit,
173
+ tools: :inherit
174
+ )
168
175
 
169
176
  puts
170
177
  puts "Robot Response:"
@@ -9,7 +9,7 @@
9
9
  # 3. Both together — stored fires first, then block
10
10
  #
11
11
  # Usage:
12
- # ANTHROPIC_API_KEY=your_key ruby examples/05_streaming.rb
12
+ # ruby examples/05_streaming.rb
13
13
 
14
14
  require_relative "common"
15
15
 
@@ -29,7 +29,7 @@ section "1. Stored Callback (on_content:)"
29
29
 
30
30
  chunks_received = 0
31
31
  robot = RobotLab.build(
32
- model: LLM[:default].model,
32
+ **llm_opts,
33
33
  name: "storyteller",
34
34
  system_prompt: "You are a concise storyteller. Keep responses under 3 sentences.",
35
35
  on_content: ->(chunk) {
@@ -52,7 +52,7 @@ section "2. Per-call Block"
52
52
 
53
53
  block_chunks = 0
54
54
  bare_robot = RobotLab.build(
55
- model: LLM[:default].model,
55
+ **llm_opts,
56
56
  name: "factbot",
57
57
  system_prompt: "You are concise. Answer in one sentence."
58
58
  )
@@ -76,7 +76,7 @@ stored_log = []
76
76
  block_log = []
77
77
 
78
78
  combo_robot = RobotLab.build(
79
- model: LLM[:default].model,
79
+ **llm_opts,
80
80
  name: "combo",
81
81
  system_prompt: "You are concise. Answer in one sentence.",
82
82
  on_content: ->(chunk) { stored_log << chunk.content }
@@ -99,6 +99,9 @@ puts ""
99
99
  section "4. Via RunConfig (config cascade)"
100
100
 
101
101
  config_chunks = 0
102
+ # RunConfig accepts only the fields in RunConfig::FIELDS — `provider` is not
103
+ # one of them, so it stays on the robot. on_content is a first-class config
104
+ # field and cascades normally.
102
105
  config = RobotLab::RunConfig.new(
103
106
  model: LLM[:default].model,
104
107
  on_content: ->(chunk) {
@@ -108,7 +111,7 @@ config = RobotLab::RunConfig.new(
108
111
  )
109
112
 
110
113
  config_robot = RobotLab.build(
111
- model: LLM[:default].model,
114
+ **llm_opts,
112
115
  name: "config_bot",
113
116
  system_prompt: "You are concise. Answer in one sentence.",
114
117
  config: config