robot_lab 0.1.0 → 0.2.6

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 (180) hide show
  1. checksums.yaml +4 -4
  2. data/.architecture/AGENTS.md +32 -0
  3. data/.architecture/config.yml +8 -0
  4. data/.architecture/members.yml +60 -0
  5. data/.architecture/reviews/feature-free-will.md +490 -0
  6. data/.architecture/reviews/overall-codebase.md +427 -0
  7. data/.claude/settings.local.json +57 -0
  8. data/.codex/config.toml +2 -0
  9. data/.envrc +1 -0
  10. data/.irbrc +2 -2
  11. data/.loki +60 -0
  12. data/.quality/reek_baseline.txt +43 -0
  13. data/.rubocop.yml +10 -0
  14. data/CHANGELOG.md +126 -0
  15. data/CLAUDE.md +139 -0
  16. data/README.md +130 -73
  17. data/Rakefile +131 -9
  18. data/agent2agent_review.md +192 -0
  19. data/agentf_improvements.md +253 -0
  20. data/agents.md +14 -0
  21. data/docs/api/core/index.md +1 -0
  22. data/docs/api/core/network.md +31 -1
  23. data/docs/api/core/robot.md +91 -5
  24. data/docs/api/errors.md +47 -2
  25. data/docs/api/index.md +3 -0
  26. data/docs/architecture/core-concepts.md +40 -0
  27. data/docs/architecture/network-orchestration.md +8 -0
  28. data/docs/architecture/robot-execution.md +1 -1
  29. data/docs/examples/index.md +37 -2
  30. data/docs/getting-started/configuration.md +39 -7
  31. data/docs/guides/building-robots.md +39 -1
  32. data/docs/guides/creating-networks.md +6 -1
  33. data/docs/guides/hooks.md +997 -0
  34. data/docs/guides/index.md +19 -14
  35. data/docs/guides/knowledge.md +9 -3
  36. data/docs/guides/observability.md +206 -7
  37. data/docs/guides/using-tools.md +69 -0
  38. data/docs/index.md +31 -4
  39. data/docs/superpowers/plans/2026-05-06-agentskills.md +1303 -0
  40. data/docs/superpowers/specs/2026-05-06-agentskills-design.md +247 -0
  41. data/examples/.envrc +1 -0
  42. data/examples/01_simple_robot.rb +5 -9
  43. data/examples/02_tools.rb +5 -9
  44. data/examples/03_network.rb +18 -10
  45. data/examples/04_mcp.rb +21 -29
  46. data/examples/05_streaming.rb +12 -18
  47. data/examples/06_prompt_templates.rb +11 -19
  48. data/examples/07_network_memory.rb +16 -31
  49. data/examples/08_llm_config.rb +10 -22
  50. data/examples/09_chaining.rb +16 -27
  51. data/examples/10_memory.rb +12 -28
  52. data/examples/11_network_introspection.rb +15 -29
  53. data/examples/12_message_bus.rb +5 -12
  54. data/examples/13_spawn.rb +5 -10
  55. data/examples/14_rusty_circuit/.envrc +1 -0
  56. data/examples/14_rusty_circuit/comic.rb +2 -0
  57. data/examples/14_rusty_circuit/heckler.rb +1 -1
  58. data/examples/14_rusty_circuit/open_mic.rb +1 -3
  59. data/examples/14_rusty_circuit/scout.rb +2 -0
  60. data/examples/15_memory_network_and_bus/.envrc +1 -0
  61. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +6 -3
  62. data/examples/15_memory_network_and_bus/linux_writer.rb +1 -1
  63. data/examples/15_memory_network_and_bus/output/combined_article.md +6 -6
  64. data/examples/15_memory_network_and_bus/output/final_article.md +6 -8
  65. data/examples/15_memory_network_and_bus/output/linux_draft.md +4 -2
  66. data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
  67. data/examples/15_memory_network_and_bus/output/memory.json +6 -6
  68. data/examples/15_memory_network_and_bus/output/revision_1.md +10 -11
  69. data/examples/15_memory_network_and_bus/output/revision_2.md +6 -8
  70. data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
  71. data/examples/16_writers_room/.envrc +1 -0
  72. data/examples/16_writers_room/writers_room.rb +2 -4
  73. data/examples/17_skills.rb +8 -17
  74. data/examples/18_rails/Gemfile +1 -0
  75. data/examples/19_token_tracking.rb +9 -15
  76. data/examples/20_circuit_breaker.rb +10 -19
  77. data/examples/21_learning_loop.rb +11 -20
  78. data/examples/22_context_compression.rb +6 -13
  79. data/examples/23_convergence.rb +6 -17
  80. data/examples/24_structured_delegation.rb +11 -15
  81. data/examples/25_history_search.rb +5 -12
  82. data/examples/26_document_store.rb +6 -13
  83. data/examples/27_incident_response/incident_response.rb +4 -5
  84. data/examples/28_mcp_discovery.rb +8 -11
  85. data/examples/29_ractor_tools.rb +4 -9
  86. data/examples/30_ractor_network.rb +10 -19
  87. data/examples/31_launch_assessment.rb +10 -23
  88. data/examples/32_newsletter_reader.rb +188 -0
  89. data/examples/33_stock_generator.rb +80 -0
  90. data/examples/33_stock_predictor.rb +306 -0
  91. data/examples/34_agentskills.rb +72 -0
  92. data/examples/35_hooks.rb +256 -0
  93. data/examples/README.md +8 -1
  94. data/examples/common.rb +80 -0
  95. data/examples/ruboruby.md +423 -0
  96. data/examples/xyzzy.rb +90 -0
  97. data/lib/robot_lab/agent_skill.rb +64 -0
  98. data/lib/robot_lab/agent_skill_catalog.rb +74 -0
  99. data/lib/robot_lab/ask_user.rb +2 -2
  100. data/lib/robot_lab/budget/ledger.rb +98 -0
  101. data/lib/robot_lab/bus_poller.rb +12 -5
  102. data/lib/robot_lab/capabilities.rb +84 -0
  103. data/lib/robot_lab/config/defaults.yml +10 -0
  104. data/lib/robot_lab/config.rb +5 -16
  105. data/lib/robot_lab/delegation_future.rb +1 -1
  106. data/lib/robot_lab/doom_loop_detector.rb +98 -0
  107. data/lib/robot_lab/error.rb +34 -4
  108. data/lib/robot_lab/errors.rb +45 -0
  109. data/lib/robot_lab/history_compressor.rb +4 -10
  110. data/lib/robot_lab/hook.rb +79 -0
  111. data/lib/robot_lab/hook_context.rb +194 -0
  112. data/lib/robot_lab/hook_registry.rb +55 -0
  113. data/lib/robot_lab/hooks.rb +87 -0
  114. data/lib/robot_lab/mcp/client.rb +1 -2
  115. data/lib/robot_lab/mcp/connection_poller.rb +5 -5
  116. data/lib/robot_lab/mcp/server.rb +1 -1
  117. data/lib/robot_lab/mcp/server_discovery.rb +0 -2
  118. data/lib/robot_lab/memory.rb +32 -27
  119. data/lib/robot_lab/memory_change.rb +2 -2
  120. data/lib/robot_lab/message.rb +4 -4
  121. data/lib/robot_lab/narrator.rb +87 -0
  122. data/lib/robot_lab/network.rb +87 -25
  123. data/lib/robot_lab/robot/agent_skill_matching.rb +99 -0
  124. data/lib/robot_lab/robot/budget.rb +89 -0
  125. data/lib/robot_lab/robot/bus_messaging.rb +79 -33
  126. data/lib/robot_lab/robot/history_search.rb +4 -1
  127. data/lib/robot_lab/robot/hooking.rb +56 -0
  128. data/lib/robot_lab/robot/mcp_management.rb +12 -12
  129. data/lib/robot_lab/robot/template_rendering.rb +62 -42
  130. data/lib/robot_lab/robot.rb +482 -242
  131. data/lib/robot_lab/robot_result.rb +6 -5
  132. data/lib/robot_lab/run_config.rb +27 -14
  133. data/lib/robot_lab/runnable.rb +51 -0
  134. data/lib/robot_lab/sandbox/null.rb +13 -0
  135. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  136. data/lib/robot_lab/sandbox.rb +52 -0
  137. data/lib/robot_lab/script_tool.rb +134 -0
  138. data/lib/robot_lab/state_proxy.rb +7 -5
  139. data/lib/robot_lab/task.rb +26 -20
  140. data/lib/robot_lab/tool.rb +54 -13
  141. data/lib/robot_lab/tool_config.rb +1 -1
  142. data/lib/robot_lab/tool_manifest.rb +5 -7
  143. data/lib/robot_lab/user_message.rb +2 -2
  144. data/lib/robot_lab/version.rb +1 -1
  145. data/lib/robot_lab/waiter.rb +1 -1
  146. data/lib/robot_lab.rb +89 -51
  147. data/logfile +8 -0
  148. data/mkdocs.yml +3 -3
  149. data/robot_concurrency.md +38 -0
  150. data/simple_acp_review.md +298 -0
  151. data/temp.md +6 -0
  152. data/tool_manifest_plan.md +155 -0
  153. metadata +82 -83
  154. data/docs/examples/rails-application.md +0 -419
  155. data/docs/guides/ractor-parallelism.md +0 -364
  156. data/docs/guides/rails-integration.md +0 -681
  157. data/docs/superpowers/plans/2026-04-14-ractor-integration.md +0 -1538
  158. data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +0 -258
  159. data/lib/generators/robot_lab/install_generator.rb +0 -90
  160. data/lib/generators/robot_lab/job_generator.rb +0 -40
  161. data/lib/generators/robot_lab/robot_generator.rb +0 -55
  162. data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -42
  163. data/lib/generators/robot_lab/templates/job.rb.tt +0 -21
  164. data/lib/generators/robot_lab/templates/migration.rb.tt +0 -32
  165. data/lib/generators/robot_lab/templates/result_model.rb.tt +0 -52
  166. data/lib/generators/robot_lab/templates/robot.rb.tt +0 -31
  167. data/lib/generators/robot_lab/templates/robot_job.rb.tt +0 -18
  168. data/lib/generators/robot_lab/templates/robot_test.rb.tt +0 -34
  169. data/lib/generators/robot_lab/templates/routing_robot.rb.tt +0 -59
  170. data/lib/generators/robot_lab/templates/thread_model.rb.tt +0 -40
  171. data/lib/robot_lab/document_store.rb +0 -155
  172. data/lib/robot_lab/ractor_boundary.rb +0 -42
  173. data/lib/robot_lab/ractor_job.rb +0 -37
  174. data/lib/robot_lab/ractor_memory_proxy.rb +0 -85
  175. data/lib/robot_lab/ractor_network_scheduler.rb +0 -154
  176. data/lib/robot_lab/ractor_worker_pool.rb +0 -117
  177. data/lib/robot_lab/rails_integration/engine.rb +0 -29
  178. data/lib/robot_lab/rails_integration/job.rb +0 -158
  179. data/lib/robot_lab/rails_integration/railtie.rb +0 -51
  180. data/lib/robot_lab/rails_integration/turbo_stream_callbacks.rb +0 -72
@@ -56,6 +56,9 @@ result = network.run(
56
56
  **context
57
57
  )
58
58
  # => SimpleFlow::Result
59
+
60
+ # Runnable protocol: a positional message works too, just like Robot#run
61
+ result = network.run("Help me", customer_id: 123)
59
62
  ```
60
63
 
61
64
  Execute the network pipeline.
@@ -64,7 +67,7 @@ Execute the network pipeline.
64
67
 
65
68
  | Name | Type | Description |
66
69
  |------|------|-------------|
67
- | `message` | `String` | The input message |
70
+ | `message` | `String`, `nil` | The input message — as the `message:` keyword, or positionally (folded into `message:` when given) |
68
71
  | `**context` | `Hash` | Additional context passed to all robots |
69
72
 
70
73
  **Returns:** `SimpleFlow::Result`
@@ -108,6 +111,15 @@ network.add_robot(robot)
108
111
 
109
112
  Add a robot without creating a pipeline task. Useful for robots referenced by other tasks.
110
113
 
114
+ ### remove_robot
115
+
116
+ ```ruby
117
+ network.remove_robot("billing")
118
+ # => Robot, or nil if no robot by that name was present
119
+ ```
120
+
121
+ Remove a dynamically-added robot from the crew by name. Complements `add_robot`. Only removes the robot from `@robots` — it does **not** rewrite the pipeline, so don't remove a robot that is a pipeline task (a `depends_on` reference to a removed robot's task name would then fail).
122
+
111
123
  ### robot / []
112
124
 
113
125
  ```ruby
@@ -294,6 +306,24 @@ elsif result.context[:technical]
294
306
  end
295
307
  ```
296
308
 
309
+ ## Runnable Protocol
310
+
311
+ `Network` includes `RobotLab::Runnable`, the shared interface it has in common with `Robot` — see [Runnable Protocol](../../architecture/network-orchestration.md#runnable-protocol) for the full picture.
312
+
313
+ | Method | Returns |
314
+ |--------|---------|
315
+ | `crew` | `robots.values` — the constituent robots, as an `Array`, in pipeline order |
316
+ | `chief` | `crew.first` — the lead robot |
317
+ | `robot_count` | `crew.size` |
318
+ | `network?` | `true` |
319
+ | `single?` | `false` |
320
+
321
+ ```ruby
322
+ network.crew.map(&:name) # => ["classifier", "billing", "technical"]
323
+ network.chief # => the first robot
324
+ network.network? # => true
325
+ ```
326
+
297
327
  ## See Also
298
328
 
299
329
  - [Creating Networks Guide](../../guides/creating-networks.md)
@@ -35,6 +35,7 @@ Robot.new(
35
35
  skills: nil,
36
36
  max_tool_rounds: nil,
37
37
  token_budget: nil,
38
+ cost_budget: nil,
38
39
  temperature: nil,
39
40
  top_p: nil,
40
41
  top_k: nil,
@@ -60,7 +61,7 @@ Robot.new(
60
61
  | `provider` | `String`, `Symbol`, `nil` | `nil` | LLM provider for local providers (e.g., `:ollama`, `:gpustack`). Automatically sets `assume_model_exists: true` |
61
62
  | `mcp_servers` | `Array` | `[]` | Legacy MCP server configurations |
62
63
  | `mcp` | `Symbol`, `Array` | `:none` | Hierarchical MCP config (`:none`, `:inherit`, or server array) |
63
- | `tools` | `Symbol`, `Array` | `:none` | Hierarchical tools config (`:none`, `:inherit`, or tool name array) |
64
+ | `tools` | `Symbol`, `Array` | `:none` | Hierarchical tools config (`:none`, `:inherit`, or tool name **array**). Must be tool *names* (String/Symbol) — passing an instance or class raises `ArgumentError` telling you to use `local_tools:` instead. See [Runtime Tool Filtering](../../guides/using-tools.md#runtime-tool-filtering) |
64
65
  | `on_tool_call` | `Proc`, `nil` | `nil` | Callback invoked when a tool is called |
65
66
  | `on_tool_result` | `Proc`, `nil` | `nil` | Callback invoked when a tool returns a result |
66
67
  | `on_content` | `Proc`, `nil` | `nil` | Stored streaming callback invoked with each content chunk (see [Streaming](#streaming)) |
@@ -68,7 +69,8 @@ Robot.new(
68
69
  | `bus` | `TypedBus::MessageBus`, `nil` | `nil` | Optional message bus for inter-robot communication |
69
70
  | `skills` | `Symbol`, `Array<Symbol>`, `nil` | `nil` | Skill templates to prepend (see [Skills](#skills)) |
70
71
  | `max_tool_rounds` | `Integer`, `nil` | `nil` | Circuit breaker: raise `ToolLoopError` after this many tool calls in one `run()` (see [Tool Loop Circuit Breaker](#tool-loop-circuit-breaker)) |
71
- | `token_budget` | `Integer`, `nil` | `nil` | Raise `InferenceError` if cumulative input tokens exceed this limit |
72
+ | `token_budget` | `Integer`, `nil` | `nil` | Raise `InferenceError` if cumulative tokens exceed this limit after a call; raise `BudgetExceeded` up front if already exhausted (see [Budgets](#budgets)) |
73
+ | `cost_budget` | `Float`, `nil` | `nil` | Same enforcement as `token_budget`, tracked in cumulative dollar cost instead of tokens (requires provider pricing data) |
72
74
  | `config` | `RunConfig`, `nil` | `nil` | Shared config merged with explicit kwargs (see [RunConfig](#runconfig)) |
73
75
  | `temperature` | `Float`, `nil` | `nil` | Controls randomness (0.0-1.0) |
74
76
  | `top_p` | `Float`, `nil` | `nil` | Nucleus sampling threshold |
@@ -120,6 +122,7 @@ If `name` is omitted, it defaults to `"robot"`.
120
122
  | `total_input_tokens` | `Integer` | Cumulative input tokens sent across all `run()` calls |
121
123
  | `total_output_tokens` | `Integer` | Cumulative output tokens received across all `run()` calls |
122
124
  | `learnings` | `Array<String>` | Accumulated cross-run observations (see [Learning Accumulation](#learning-accumulation)) |
125
+ | `budget_ledger` | `RobotLab::Budget::Ledger`, `nil` | Reserve/reconcile ledger backing `token_budget`/`cost_budget`; `nil` when neither is configured (see [Budgets](#budgets)) |
123
126
 
124
127
  ## Attributes (Read-Write)
125
128
 
@@ -149,13 +152,15 @@ Primary execution method. Sends a message to the LLM with memory/MCP/tools resol
149
152
  | `network` | `NetworkRun`, `nil` | `nil` | Network context (passed internally) |
150
153
  | `network_memory` | `Memory`, `nil` | `nil` | Shared network memory |
151
154
  | `memory` | `Memory`, `Hash`, `nil` | `nil` | Runtime memory to merge |
152
- | `mcp` | `Symbol`, `Array` | `:none` | Runtime MCP override |
153
- | `tools` | `Symbol`, `Array` | `:none` | Runtime tools override |
155
+ | `mcp` | `Symbol`, `Array` | `:none` | Runtime MCP override — `:inherit` (all attached servers), `:none`/`[]` (zero this turn), or an explicit array |
156
+ | `tools` | `Symbol`, `Array` | `:none` | Runtime tools override — `:inherit` (all attached tools), `:none`/`[]` (zero this turn), or an explicit name array. See [Runtime Tool Filtering](../../guides/using-tools.md#runtime-tool-filtering) |
154
157
  | `**kwargs` | `Hash` | `{}` | Additional keyword arguments passed to `Agent#ask` |
155
158
  | `&block` | `Proc` | `nil` | Per-call streaming block, receives each content chunk |
156
159
 
157
160
  When both a stored `on_content` callback and a runtime block are provided, both fire (stored first, then runtime block).
158
161
 
162
+ Because `tools`/`mcp` default to `:none` here too, a bare `robot.run(message)` with no override sends **zero** tools/MCP servers for that call — pass `tools: :inherit` (and/or `mcp: :inherit`) explicitly to use what's attached. Each call's resolved tool set *replaces* the chat's tools rather than accumulating, so a subsequent `:none` call correctly clears whatever a prior call attached, and the fully-resolved set is clamped to `max_tools` (128 by default) right before being handed to the provider — see [Tool Capping](../../guides/using-tools.md#tool-capping-and-per-turn-filtering).
163
+
159
164
  **Returns:** `RobotResult`
160
165
 
161
166
  **Examples:**
@@ -271,7 +276,7 @@ message = robot.send_message(to: :bob, content: "Tell me a joke.")
271
276
  # => RobotMessage
272
277
  ```
273
278
 
274
- Publish a message to another robot's bus channel. Increments the internal message counter, creates a `RobotMessage`, tracks it in the outbox, and publishes to the target channel.
279
+ Publish a message to another robot's bus channel. Increments the internal message counter, creates a `RobotMessage`, tracks it in the outbox, and publishes to the target channel. The counter and outbox mutation are synchronized with an internal mutex, so concurrent `send_message`/`send_reply` calls from multiple threads and reply correlation on the poller thread never clobber each other.
275
280
 
276
281
  **Parameters:**
277
282
 
@@ -337,6 +342,49 @@ robot.on_message do |delivery, message|
337
342
  end
338
343
  ```
339
344
 
345
+ ### respond_to_tasks
346
+
347
+ ```ruby
348
+ robot.respond_to_tasks(auto_reply: true) { |message| "the reply content" }
349
+ # => self
350
+ ```
351
+
352
+ Auto-answer inbound (non-reply) bus tasks: run the block to produce a reply, and send it back to the sender. This is the symmetric counterpart to how a `robot_lab-cyborg` Cyborg answers its human — one call makes any bus member a first-class responder without hand-wiring `on_message` yourself.
353
+
354
+ **Parameters:**
355
+
356
+ | Name | Type | Default | Description |
357
+ |------|------|---------|-------------|
358
+ | `auto_reply` | `Boolean` | `true` | Send the block's result back to the sender via `send_reply` |
359
+ | `&responder` | `Proc` | **required** | Receives the inbound `message`; return the reply content (`nil` means no reply) |
360
+
361
+ **Returns:** `self`
362
+
363
+ Messages that are themselves replies (`message.reply?`) are ignored, so a two-way `respond_to_tasks` conversation between robots does not loop. The responder runs on the bus poller's drain thread, so deliveries to this robot are handled one at a time — a long-running responder delays the next inbound message.
364
+
365
+ ```ruby
366
+ bob.respond_to_tasks { |message| "handled: #{message.content}" }
367
+ alice.send_message(to: :bob, content: "ping")
368
+ # bob replies "handled: ping" back to alice automatically
369
+ ```
370
+
371
+ ### serve
372
+
373
+ ```ruby
374
+ robot.serve(auto_reply: true)
375
+ # => self
376
+ ```
377
+
378
+ The common case of `respond_to_tasks`: run every inbound task through this robot's own `#run` and reply with the result — the one-call way to make a Robot cooperate on the bus the way a Cyborg already does out of the box.
379
+
380
+ ```ruby
381
+ bob.serve
382
+ alice.send_message(to: :bob, content: "Tell me a joke.")
383
+ # bob runs "Tell me a joke." through its LLM and replies with the result
384
+ ```
385
+
386
+ Equivalent to `respond_to_tasks(auto_reply: auto_reply) { |message| run(message.content).reply }` (with Hash-content messages flattened to `"key: value"` lines first).
387
+
340
388
  ### spawn
341
389
 
342
390
  ```ruby
@@ -349,6 +397,8 @@ child = robot.spawn(
349
397
 
350
398
  Create a new robot on the same message bus. If the parent has no bus, one is created automatically and the parent is connected to it.
351
399
 
400
+ The spawned robot inherits its parent's `model` and `provider` (via `robot.model`/`robot.provider`) so a specialist runs on the same LLM as the robot that spawned it — a robot running on a local Ollama model, for instance, spawns specialists that also target that model rather than falling back to `RobotLab.config.ruby_llm.model` (the global default, typically a cloud model that would fail without credentials). Caller-supplied `model:`/`provider:` in `**options` still override.
401
+
352
402
  **Parameters:**
353
403
 
354
404
  | Name | Type | Default | Description |
@@ -813,6 +863,10 @@ robot = RobotLab.build(
813
863
  result = robot.run("Hello!")
814
864
  ```
815
865
 
866
+ `provider:` is threaded through on every re-application of the effective `RunConfig` — including when a template's front matter is re-rendered mid-run — so a local-provider robot (Ollama, GPUStack, LM Studio) doesn't fall back to RubyLLM's static model registry lookup on later turns and raise a spurious "model not found" error.
867
+
868
+ Some local/thinking-mode models (e.g. `qwen3` on Ollama) route all of their output through reasoning content rather than the normal response text. When `response.content` is `nil`, `result.reply` falls back first to `response.thinking.text` (RubyLLM's extended-thinking text), then to the most recent assistant text from later in *the current turn only* — never a stale reply left over from a previous turn.
869
+
816
870
  ### Robot with MCP
817
871
 
818
872
  ```ruby
@@ -958,6 +1012,26 @@ robot.reset_token_totals
958
1012
  puts robot.total_input_tokens # => 0
959
1013
  ```
960
1014
 
1015
+ ### Budgets
1016
+
1017
+ `token_budget:` and `cost_budget:` turn the counters above into enforceable ceilings, backed by a thread-safe `RobotLab::Budget::Ledger` (`robot.budget_ledger`, `nil` when neither is configured):
1018
+
1019
+ ```ruby
1020
+ robot = RobotLab.build(
1021
+ name: "capped",
1022
+ system_prompt: "...",
1023
+ token_budget: 10_000,
1024
+ cost_budget: 0.50
1025
+ )
1026
+ ```
1027
+
1028
+ Each `run()` reserves the remaining budget for every configured dimension before the LLM call, and reconciles the reservation with actual usage after:
1029
+
1030
+ - **`RobotLab::BudgetExceeded`** — raised up front when a *prior* call already exhausted a dimension; the new call is refused before it spends anything.
1031
+ - **`RobotLab::InferenceError`** — raised after the call when *this* call's actual usage (from `RobotResult#input_tokens`/`output_tokens`, and the response's reported cost when the provider supports pricing) pushes cumulative usage over budget. This is the same error `token_budget` alone has always raised; `cost_budget` uses the analogous message (`"Cost budget exceeded: $X used, budget is $Y"`).
1032
+
1033
+ See [Budgets](../../guides/observability.md#budgets-token--cost) for the full walkthrough.
1034
+
961
1035
  ## Tool Loop Circuit Breaker
962
1036
 
963
1037
  Set `max_tool_rounds:` to guard against a robot looping indefinitely through tool calls. After the limit is reached, `RobotLab::ToolLoopError` is raised.
@@ -1084,6 +1158,18 @@ robot.learn("avoid using puts and p in production code")
1084
1158
  # => broader learning replaces narrower; robot.learnings.size == 1
1085
1159
  ```
1086
1160
 
1161
+ ## Runnable Protocol
1162
+
1163
+ `Robot` includes `RobotLab::Runnable`, the shared interface it has in common with `Network` — see [Runnable Protocol](../../architecture/core-concepts.md#runnable-protocol) for the full picture. For a single robot:
1164
+
1165
+ | Method | Returns |
1166
+ |--------|---------|
1167
+ | `crew` | `[self]` — a robot is a crew of one |
1168
+ | `chief` | `self` |
1169
+ | `robot_count` | `1` |
1170
+ | `network?` | `false` |
1171
+ | `single?` | `true` |
1172
+
1087
1173
  ## See Also
1088
1174
 
1089
1175
  - [Building Robots Guide](../../guides/building-robots.md) (includes [Composable Skills](../../guides/building-robots.md#composable-skills))
data/docs/api/errors.md CHANGED
@@ -15,7 +15,8 @@ StandardError
15
15
  ├── RobotLab::MCPError
16
16
  ├── RobotLab::BusError
17
17
  ├── RobotLab::RactorBoundaryError
18
- └── RobotLab::ToolError
18
+ ├── RobotLab::ToolError
19
+ └── RobotLab::BudgetExceeded
19
20
  ```
20
21
 
21
22
  Additionally, `DelegationFuture` defines its own scoped error:
@@ -118,6 +119,8 @@ rescue RobotLab::MCPError => e
118
119
  end
119
120
  ```
120
121
 
122
+ `MCPError.new(message, retryable: true)` marks a specific instance as safe to retry (see [Retryable Errors](#retryable-errors) below). The connection-lost and timeout errors raised internally by `MCP::ConnectionPoller` are always constructed with `retryable: true`.
123
+
121
124
  ---
122
125
 
123
126
  ## RobotLab::BusError
@@ -160,6 +163,48 @@ rescue RobotLab::ToolError => e
160
163
  end
161
164
  ```
162
165
 
166
+ Like `MCPError`, `ToolError.new(message, retryable: true)` marks a specific instance as retryable. When a tool raises inside `Tool#call`, RobotLab appends `" (retryable)"` to the formatted error text returned to the LLM whenever `RobotLab::Errors.retryable?(error)` is true, so the model itself can see that another attempt is worth trying.
167
+
168
+ ---
169
+
170
+ ## RobotLab::BudgetExceeded
171
+
172
+ Raised when a Robot's configured `token_budget` or `cost_budget` is already exhausted **before** an LLM call is attempted (see [Budgets](../guides/observability.md#budgets-token--cost) and `RobotLab::Budget::Ledger`). This is distinct from the pre-existing `InferenceError` "Token budget exceeded" message, which covers a call that *completed* but pushed cumulative usage over budget — `BudgetExceeded` means the call was refused outright, before any tokens were spent.
173
+
174
+ ```ruby
175
+ robot = RobotLab.build(name: "capped", system_prompt: "...", cost_budget: 0.50)
176
+
177
+ begin
178
+ robot.run("Do the expensive thing")
179
+ rescue RobotLab::BudgetExceeded => e
180
+ puts e.message # "budget exceeded for cost: 0.62 > 0.5"
181
+ end
182
+ ```
183
+
184
+ ---
185
+
186
+ ## Retryable Errors
187
+
188
+ `RobotLab::Errors.retryable?(error)` classifies whether a host (an ActiveJob `retry_on` list, `robot_lab-to`'s takeover loop, a custom retry wrapper) should retry the operation that raised `error`:
189
+
190
+ | Error | Retryable? |
191
+ |-------|------------|
192
+ | `InferenceError` (and subclasses, except `ToolLoopError`) | Always |
193
+ | `ToolLoopError` | Never — it's a circuit breaker; retrying immediately re-triggers the same loop |
194
+ | `MCPError` / `ToolError` | Only when raised with `retryable: true` at the raise site |
195
+ | Everything else (`ConfigurationError`, `ToolNotFoundError`, `DependencyError`, `RactorBoundaryError`, `BusError`, `BudgetExceeded`, non-RobotLab errors) | Never |
196
+
197
+ ```ruby
198
+ begin
199
+ robot.run(task)
200
+ rescue RobotLab::Error => e
201
+ retry if RobotLab::Errors.retryable?(e)
202
+ raise
203
+ end
204
+ ```
205
+
206
+ `RobotLab::Errors.retryable_classes` returns `[RobotLab::InferenceError]` — an always-retryable allow-list for explicit ActiveJob-style `retry_on` declarations. It excludes `MCPError`/`ToolError` because their retryability is per-raise (via `retryable:`), not per-class.
207
+
163
208
  ---
164
209
 
165
210
  ## RobotLab::DelegationFuture::DelegationTimeout
@@ -181,5 +226,5 @@ end
181
226
  ## Related
182
227
 
183
228
  - [Building Robots](../guides/building-robots.md) — Tool loop circuit breaker, delegation
184
- - [Ractor Parallelism](../guides/ractor-parallelism.md) — Ractor boundary and tool errors
229
+ - [Observability & Safety](../guides/observability.md) — circuit breaker and tool loop errors
185
230
  - [MCP Integration](../guides/mcp-integration.md) — MCP connection errors
data/docs/api/index.md CHANGED
@@ -56,6 +56,9 @@ RobotLab.reload_config! # => reload from all sources
56
56
  RobotLab.build(name:, template:, system_prompt:, context:, **options)
57
57
  RobotLab.create_network(name:, concurrency:) { ... }
58
58
  RobotLab.create_memory(data:, enable_cache:, **options)
59
+
60
+ # Rendering a template to a String (not a robot) -- see Building Robots guide
61
+ RobotLab.render_template(name, **context) # => String
59
62
  ```
60
63
 
61
64
  See individual class documentation for detailed method references.
@@ -415,6 +415,16 @@ end
415
415
 
416
416
  Block arity controls delivery handling: 1 argument auto-acks; 2 arguments give manual control over `delivery.ack!`/`delivery.nack!`.
417
417
 
418
+ `send_message`/`send_reply` synchronize the per-robot message counter and outbox with an internal mutex, so concurrent sends from multiple threads and reply correlation on the poller thread can't clobber each other — the bus is safe to send on from more than one thread at a time.
419
+
420
+ For the common case of a robot that should simply answer whatever tasks arrive on the bus, `respond_to_tasks`/`serve` do the `on_message` wiring above in one call:
421
+
422
+ ```ruby
423
+ bob.serve # equivalent to the on_message block above, running bob.run and replying automatically
424
+ ```
425
+
426
+ `respond_to_tasks` takes a block instead when the reply needs post-processing, and both ignore inbound messages that are themselves replies, so two robots calling `serve` on each other don't loop. See [Auto-Responding to Bus Tasks](../guides/building-robots.md#auto-responding-to-bus-tasks).
427
+
418
428
  ### Dynamic Spawning
419
429
 
420
430
  Robots can create new robots at runtime using `spawn`. The bus is created lazily — no upfront wiring required:
@@ -430,6 +440,8 @@ answer = helper.run("What is 2+2?").last_text_content
430
440
  helper.send_message(to: :dispatcher, content: answer)
431
441
  ```
432
442
 
443
+ The spawned robot inherits its parent's `model`/`provider`, so a dispatcher running on a local provider (e.g. Ollama) spawns specialists targeting that same model instead of falling back to the global default (which would fail without cloud credentials). Explicit `model:`/`provider:` passed to `spawn` still override.
444
+
433
445
  Robots can also join a bus after creation using `with_bus`:
434
446
 
435
447
  ```ruby
@@ -480,6 +492,34 @@ Networks provide:
480
492
  - **Per-task configuration** via the `Task` wrapper
481
493
  - **Broadcast messaging** for network-wide announcements
482
494
 
495
+ Robots can be added to a network without a pipeline task via `add_robot`, and removed again with `remove_robot(name)` (returns the removed robot, or `nil` if absent). `remove_robot` only drops the robot from the crew — it doesn't rewrite the pipeline, so avoid removing a robot that's still referenced by a task's `depends_on`.
496
+
497
+ ## Runnable Protocol
498
+
499
+ `RobotLab::Runnable` is a shared interface implemented by both `Robot` and `Network`, so callers can treat either uniformly instead of branching on `is_a?(RobotLab::Network)`:
500
+
501
+ ```ruby
502
+ def summarize(runnable)
503
+ runnable.crew.each { |r| puts r.name }
504
+ puts "chief: #{runnable.chief.name}"
505
+ puts runnable.network? ? "network of #{runnable.robot_count}" : "single robot"
506
+ end
507
+
508
+ summarize(robot) # crew: [robot], chief: robot, "single robot"
509
+ summarize(network) # crew: network.robots.values, chief: crew.first, "network of N"
510
+ ```
511
+
512
+ | Method | Robot | Network |
513
+ |--------|-------|---------|
514
+ | `crew` | `[self]` | `robots.values` (pipeline order) |
515
+ | `chief` | `self` | `crew.first` |
516
+ | `robot_count` | `1` | `crew.size` |
517
+ | `network?` | `false` | `true` |
518
+ | `single?` | `true` | `false` |
519
+ | `run(message = nil, **opts)` | accepts a positional message (already did) | now also accepts a positional message, folded into `message:` — the keyword form still works |
520
+
521
+ `crew` is the only method implementers must define themselves; `chief`, `robot_count`, and `single?` all derive from it, and `network?` defaults to `false` unless overridden (as `Network` does).
522
+
483
523
  ## Next Steps
484
524
 
485
525
  - [Robot Execution](robot-execution.md) - Detailed execution flow
@@ -19,6 +19,10 @@ network = RobotLab.create_network(name: "customer_service") do
19
19
  end
20
20
  ```
21
21
 
22
+ ## Runnable Protocol
23
+
24
+ `Network` implements `RobotLab::Runnable` — the same interface `Robot` implements — so code that needs to run "a robot or a network" doesn't have to branch on `is_a?(RobotLab::Network)`. For a `Network`: `crew` returns `robots.values` (pipeline order), `chief` is `crew.first`, `robot_count` is `crew.size`, and `network?` is `true`. `run(message = nil, **opts)` accepts a positional message the same way `Robot#run` does — it's folded into `message:` — while the existing `run(message: ...)` keyword form still works unchanged. See [Runnable Protocol](../architecture/core-concepts.md#runnable-protocol) in Core Concepts for the full comparison against `Robot`.
25
+
22
26
  ## Creating Networks
23
27
 
24
28
  Networks are created via `RobotLab.create_network` with a block DSL:
@@ -352,6 +356,10 @@ network.available_robots #=> [Robot, Robot, ...]
352
356
  # Add a robot without a task
353
357
  network.add_robot(extra_robot)
354
358
 
359
+ # Remove a dynamically-added robot (returns it, or nil if absent).
360
+ # Only affects the crew (@robots) -- does not rewrite the pipeline.
361
+ network.remove_robot(:extra_robot)
362
+
355
363
  # Convert to hash
356
364
  network.to_h
357
365
  #=> { name: "support", robots: ["classifier", "billing"], tasks: [...], optional_tasks: [...] }
@@ -294,7 +294,7 @@ def call(result)
294
294
  .with_context(@name.to_sym, robot_result)
295
295
  .continue(robot_result)
296
296
  rescue Exception => e
297
- # Error is wrapped in a RobotResult with the elapsed duration
297
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
298
298
  error_result = RobotResult.new(
299
299
  robot_name: @name,
300
300
  output: [TextMessage.new(role: 'assistant', content: "Error: #{e.class}: #{e.message}")]
@@ -14,10 +14,11 @@ These examples show how to use RobotLab for common scenarios, from simple chatbo
14
14
  | [Multi-Robot Network](multi-robot-network.md) | Customer service with routing |
15
15
  | [Tool Usage](tool-usage.md) | External API integration |
16
16
  | [MCP Server](mcp-server.md) | Creating an MCP tool server |
17
- | [Rails Application](rails-application.md) | Full Rails integration |
18
17
  | [Message Bus](#message-bus) | Bidirectional robot communication with convergence |
19
18
  | [Spawning Robots](#spawning-robots) | Dynamic specialist creation at runtime |
20
19
 
20
+ > **Rails example** — see [robot_lab-rails](https://github.com/MadBomber/robot_lab-rails/blob/main/docs/examples/rails-application.md) for a full Rails integration example.
21
+
21
22
  ## Quick Links
22
23
 
23
24
  ### Simple Examples
@@ -158,7 +159,7 @@ puts result.last_text_content
158
159
 
159
160
  3. Run example:
160
161
  ```bash
161
- ruby examples/basic_chat.rb
162
+ ruby examples/01_simple_robot.rb
162
163
  ```
163
164
 
164
165
  Or use the provided rake tasks:
@@ -168,6 +169,40 @@ bundle exec rake examples:all # Run all examples
168
169
  bundle exec rake examples:run[1] # Run specific example by number
169
170
  ```
170
171
 
172
+ ## Shared Example Setup (`examples/common.rb`)
173
+
174
+ All numbered examples (`01_*.rb` through `34_*.rb`) begin with:
175
+
176
+ ```ruby
177
+ require_relative "common"
178
+ ```
179
+
180
+ `common.rb` handles the shared boilerplate so individual examples stay focused:
181
+
182
+ - **`LLM` hash** — frozen lookup of provider/model pairs accessible as `LLM[:default]`, `LLM[:local]`, `LLM[:anthropic]`. Each entry is a `LlmConfig = Data.define(:provider, :model)` value, so you access the model string as `LLM[:default].model`.
183
+ - **`RubyLLM.configure`** — sets a null logger and `LLM[:default].model` as the `default_model`.
184
+ - **`RobotLab.configure`** — sets a null logger.
185
+ - **Output helpers** — `banner(title)`, `section(title)`, `hr`, and `show_code(ruby_string, label:)` (Rouge-highlighted) for consistent terminal formatting.
186
+
187
+ ## Template Path via direnv
188
+
189
+ Examples that bundle their own `prompts/` directory ship with a `.envrc` file:
190
+
191
+ ```
192
+ examples/.envrc
193
+ examples/14_rusty_circuit/.envrc
194
+ examples/15_memory_network_and_bus/.envrc
195
+ examples/16_writers_room/.envrc
196
+ ```
197
+
198
+ Each sets `ROBOT_LAB_TEMPLATE_PATH` to the local `prompts/` directory when [direnv](https://direnv.net/) is active. `common.rb` also sets this variable as a fallback if `direnv` has not loaded the `.envrc`:
199
+
200
+ ```ruby
201
+ ENV["ROBOT_LAB_TEMPLATE_PATH"] ||= File.join(__dir__, "prompts")
202
+ ```
203
+
204
+ This means examples work correctly whether you run them from the project root with rake tasks or directly from inside the example's own directory.
205
+
171
206
  ## Message Bus
172
207
 
173
208
  Robots can communicate bidirectionally via a message bus, enabling convergence loops and negotiation patterns. This example demonstrates a comedy critic tasking a comedian to generate jokes until one passes:
@@ -31,8 +31,14 @@ RobotLab.config.streaming_enabled #=> true
31
31
  RobotLab.config.development? #=> true/false
32
32
  ```
33
33
 
34
- !!! warning "No configure block"
35
- RobotLab does **not** use a `RobotLab.configure do |config| ... end` pattern. All configuration comes from config files, environment variables, or direct assignment on `RobotLab.config`.
34
+ !!! tip "configure block"
35
+ `RobotLab.configure` yields the config object for block-style setup useful for setting runtime-only attributes like the logger. For static settings (API keys, timeouts, model defaults) prefer config files or environment variables.
36
+
37
+ ```ruby
38
+ RobotLab.configure do |c|
39
+ c.logger = Logger.new(File::NULL) # silence all RobotLab logging
40
+ end
41
+ ```
36
42
 
37
43
  ## Environment Variables
38
44
 
@@ -193,16 +199,37 @@ Default chat parameters applied to all robots unless overridden:
193
199
  | `chat.with_params.frequency_penalty` | `null` | Frequency penalty (-2.0 to 2.0) |
194
200
  | `chat.with_params.stop` | `null` | Stop sequences |
195
201
 
202
+ ### Skill-Script Sandboxing (`sandbox:` section)
203
+
204
+ Opt-in confinement for the scripts a [skill bundle](../guides/using-tools.md#skill-scripts-and-sandboxing) exposes as tools. Disabled by default — scripts run exactly as before until you turn it on:
205
+
206
+ | Key | Default | Description |
207
+ |-----|---------|-------------|
208
+ | `sandbox.enabled` | `false` | Turn on OS-level confinement for skill scripts |
209
+ | `sandbox.fs_read` | `["."]` | Ceiling: paths any skill script may read (relative to cwd) |
210
+ | `sandbox.fs_write` | `[]` | Ceiling: paths any skill script may write |
211
+ | `sandbox.network` | `false` | Ceiling: whether any skill script may use the network |
212
+ | `sandbox.timeout` | `60` | Ceiling: max seconds a skill script may run |
213
+
214
+ These are a **ceiling**, not a grant — the actual permissions a script runs with are the intersection of this ceiling and what the individual SKILL.md declares for itself. See [Skill Scripts and Sandboxing](../guides/using-tools.md#skill-scripts-and-sandboxing) for how the two combine and which platforms actually enforce confinement.
215
+
196
216
  ## Runtime-Only Attributes
197
217
 
198
- Some attributes can only be set at runtime, not through config files:
218
+ Some attributes can only be set at runtime, not through config files. Use direct assignment on `RobotLab.config` or the `RobotLab.configure` block:
199
219
 
200
220
  ```ruby
201
- # Logger (defaults to Rails.logger in Rails, or Logger.new($stdout) otherwise)
202
- RobotLab.config.logger = Logger.new(nil) # silence logging
203
- RobotLab.config.logger = Logger.new("robot.log") # log to file
221
+ # Direct assignment
222
+ RobotLab.config.logger = Logger.new(nil) # silence logging
223
+ RobotLab.config.logger = Logger.new("robot.log") # log to file
224
+
225
+ # Block-style configure (equivalent, useful when setting multiple values)
226
+ RobotLab.configure do |c|
227
+ c.logger = Logger.new(File::NULL)
228
+ end
204
229
  ```
205
230
 
231
+ `RobotLab.configure` yields the same `Config` object returned by `RobotLab.config`.
232
+
206
233
  ## Reloading Configuration
207
234
 
208
235
  To reload configuration from all sources:
@@ -361,7 +388,9 @@ effective.temperature #=> 0.9 (overridden)
361
388
  | **LLM** | `model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop` |
362
389
  | **Tools** | `mcp`, `tools` |
363
390
  | **Callbacks** | `on_tool_call`, `on_tool_result` |
364
- | **Infrastructure** | `bus`, `enable_cache`, `max_tool_rounds`, `token_budget`, `ractor_pool_size`, `max_concurrent_robots` |
391
+ | **Infrastructure** | `bus`, `enable_cache`, `max_tool_rounds`, `token_budget`, `cost_budget`, `max_tools`, `ractor_pool_size`, `max_concurrent_robots`, `doom_loop_threshold`, `auto_compact`, `compact_threshold` |
392
+
393
+ `cost_budget` mirrors `token_budget` for cumulative dollar spend — see [Budgets](../guides/observability.md#budgets-token--cost). `max_tools` overrides the default 128-tool ceiling enforced on every turn before tools are handed to the provider — see [Tool Capping](../guides/using-tools.md#tool-capping-and-per-turn-filtering).
365
394
 
366
395
  ### RunConfig vs RobotLab.config
367
396
 
@@ -412,10 +441,13 @@ robot = RobotLab.build(
412
441
  name: "calculator",
413
442
  system_prompt: "You solve math problems.",
414
443
  mcp: :none,
444
+ tools: :inherit, # required: without this, local_tools below is never sent
415
445
  local_tools: [Calculator]
416
446
  )
417
447
  ```
418
448
 
449
+ > **`tools:` defaults to `:none` at every level**, including `run()` itself. Attaching tools via `local_tools:` does not by itself make them available — an explicit `:none` (or an unset runtime override, which is equivalent) now sends **zero** tools for that call. Pass `tools: :inherit` wherever you want a robot's attached tools to actually be used. See [Runtime Tool Filtering](../guides/using-tools.md#runtime-tool-filtering) for the full `:inherit`/`:none`/array semantics.
450
+
419
451
  ## Next Steps
420
452
 
421
453
  - [Building Robots](../guides/building-robots.md) - Create custom robots
@@ -63,7 +63,9 @@ robot = RobotLab.build(
63
63
  )
64
64
  ```
65
65
 
66
- When `provider:` is set, `assume_model_exists: true` is automatically applied. The provider is available via `robot.provider`.
66
+ When `provider:` is set, `assume_model_exists: true` is automatically applied. The provider is available via `robot.provider`. This context is preserved across every re-application of the robot's config — including when a template's front matter re-renders mid-run — so a local-provider robot doesn't fall back to RubyLLM's static model registry (and raise a "model not found" error) on later turns.
67
+
68
+ Some local models route their entire response through reasoning/thinking content instead of the normal response text (e.g. `qwen3` on Ollama). When that happens, `result.reply` falls back to the thinking text automatically — see [Robot with Local Provider](../api/core/robot.md#robot-with-local-provider) for details.
67
69
 
68
70
  ### System Prompt
69
71
 
@@ -144,6 +146,17 @@ The following YAML front matter keys are applied to the robot's chat automatical
144
146
 
145
147
  Constructor-provided values always take precedence over frontmatter values.
146
148
 
149
+ ### Rendering a Template to a String
150
+
151
+ `RobotLab.render_template` renders a named template directly to a `String` — using the same configured template library (`prompts_dir` / `ROBOT_LAB_TEMPLATE_PATH`) as `template:` on `RobotLab.build` — without constructing a robot. Front-matter parameters are supplied as keyword arguments:
152
+
153
+ ```ruby
154
+ RobotLab.render_template(:objective, topic: "commit messages")
155
+ # => "<rendered body of prompts/objective.md, with topic substituted>"
156
+ ```
157
+
158
+ Unlike `template:` on `build` (which renders a template as a robot's *system prompt*), this returns the plain text — useful as a one-off task message, an evaluation rubric, or any other place you want a parameterized `.md` template's body without the overhead of a robot.
159
+
147
160
  ### Self-Contained Templates
148
161
 
149
162
  Templates can declare everything a robot needs — identity, tools, MCP servers, and LLM config — making the `.md` file a complete robot definition:
@@ -686,6 +699,30 @@ The `on_message` block arity controls delivery handling:
686
699
 
687
700
  See [Message Bus](../architecture/core-concepts.md#message-bus) for details.
688
701
 
702
+ ### Auto-Responding to Bus Tasks
703
+
704
+ The `Comedian`/`ComedyCritic` example above hand-wires `on_message` to run and reply. For the common case — run every inbound task through the robot and reply with the result — `respond_to_tasks`/`serve` do it in one call:
705
+
706
+ ```ruby
707
+ bob = RobotLab.build(name: "bob", template: :comedian, bus: bus)
708
+ bob.serve # every inbound task runs through bob.run and replies automatically
709
+
710
+ alice.send_message(to: :bob, content: "Tell me a funny robot joke.")
711
+ ```
712
+
713
+ `serve` is shorthand for `respond_to_tasks(auto_reply: true) { |message| run(message.content).reply }`. Use `respond_to_tasks` directly when the reply shouldn't just be `run(...).reply` — e.g. post-processing the result first:
714
+
715
+ ```ruby
716
+ bob.respond_to_tasks do |message|
717
+ joke = run(message.content.to_s).reply.strip
718
+ joke.end_with?("!") ? joke : "#{joke}!"
719
+ end
720
+ ```
721
+
722
+ Both ignore messages that are themselves replies (`message.reply?`), so two robots calling `serve`/`respond_to_tasks` on each other don't loop.
723
+
724
+ See [Message Bus](../architecture/core-concepts.md#message-bus) for details.
725
+
689
726
  ### Spawning Robots Dynamically
690
727
 
691
728
  Create new robots at runtime using `spawn`. The bus is created lazily — no upfront wiring required:
@@ -728,6 +765,7 @@ Key features of `spawn`:
728
765
  - Creates a bus lazily if the parent doesn't have one
729
766
  - Spawned robots can immediately send and receive messages
730
767
  - Multiple robots with the same name enable fan-out messaging
768
+ - The child inherits the parent's `model`/`provider` (caller-supplied `model:`/`provider:` still override) — a dispatcher running on a local Ollama model spawns specialists that also target that model, instead of falling back to the global default model/provider
731
769
 
732
770
  Robots can also join a bus after creation:
733
771
 
@@ -105,7 +105,7 @@ network = RobotLab.create_network(name: "launch_assessment", config: config) do
105
105
  end
106
106
  ```
107
107
 
108
- `nil` (the default) means unlimited — identical to pre-existing behavior. For Rails deployments, size the cap to match your database connection pool and API rate tier. See [Example 31](../../examples/31_launch_assessment.rb) for a working demo.
108
+ `nil` (the default) means unlimited — identical to pre-existing behavior. For Rails deployments, size the cap to match your database connection pool and API rate tier. See [examples/31_launch_assessment.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/31_launch_assessment.rb) for a working demo.
109
109
 
110
110
  ### Optional Tasks
111
111
 
@@ -441,8 +441,13 @@ network["billing"] # => Robot instance (alias)
441
441
  network.available_robots # => Array of Robot instances
442
442
  network.memory # => Memory instance (shared)
443
443
  network.to_h # => Hash representation
444
+
445
+ network.add_robot(extra_robot) # add without a pipeline task -> self
446
+ network.remove_robot(:extra_robot) # remove by name -> the removed Robot, or nil
444
447
  ```
445
448
 
449
+ `remove_robot` only drops the robot from the crew — it doesn't touch the pipeline, so don't remove a robot that's still a `depends_on` target of a task.
450
+
446
451
  ## Configuration Inheritance
447
452
 
448
453
  Networks accept a `config:` parameter that establishes default LLM settings for all member robots. This is useful when you want consistent behavior across a pipeline without configuring each robot individually.