robot_lab 0.2.1 → 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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +1 -0
  3. data/.loki +60 -0
  4. data/.quality/reek_baseline.txt +43 -0
  5. data/.rubocop.yml +5 -167
  6. data/CHANGELOG.md +54 -0
  7. data/README.md +61 -0
  8. data/Rakefile +28 -12
  9. data/docs/api/core/index.md +1 -0
  10. data/docs/api/core/network.md +31 -1
  11. data/docs/api/core/robot.md +91 -5
  12. data/docs/api/errors.md +47 -2
  13. data/docs/api/index.md +3 -0
  14. data/docs/architecture/core-concepts.md +40 -0
  15. data/docs/architecture/network-orchestration.md +8 -0
  16. data/docs/architecture/robot-execution.md +1 -1
  17. data/docs/getting-started/configuration.md +20 -1
  18. data/docs/guides/building-robots.md +39 -1
  19. data/docs/guides/creating-networks.md +6 -1
  20. data/docs/guides/hooks.md +997 -0
  21. data/docs/guides/index.md +5 -0
  22. data/docs/guides/knowledge.md +2 -2
  23. data/docs/guides/observability.md +74 -7
  24. data/docs/guides/using-tools.md +69 -0
  25. data/docs/index.md +1 -1
  26. data/examples/03_network.rb +11 -2
  27. data/examples/34_agentskills.rb +0 -0
  28. data/examples/35_hooks.rb +256 -0
  29. data/examples/README.md +7 -0
  30. data/examples/common.rb +7 -3
  31. data/examples/xyzzy.rb +90 -0
  32. data/lib/robot_lab/agent_skill.rb +5 -4
  33. data/lib/robot_lab/budget/ledger.rb +98 -0
  34. data/lib/robot_lab/capabilities.rb +84 -0
  35. data/lib/robot_lab/config/defaults.yml +10 -0
  36. data/lib/robot_lab/config.rb +4 -4
  37. data/lib/robot_lab/error.rb +34 -4
  38. data/lib/robot_lab/errors.rb +45 -0
  39. data/lib/robot_lab/hook.rb +79 -0
  40. data/lib/robot_lab/hook_context.rb +194 -0
  41. data/lib/robot_lab/hook_registry.rb +55 -0
  42. data/lib/robot_lab/hooks.rb +87 -0
  43. data/lib/robot_lab/mcp/connection_poller.rb +2 -2
  44. data/lib/robot_lab/narrator.rb +87 -0
  45. data/lib/robot_lab/network.rb +77 -20
  46. data/lib/robot_lab/robot/budget.rb +89 -0
  47. data/lib/robot_lab/robot/bus_messaging.rb +76 -12
  48. data/lib/robot_lab/robot/hooking.rb +56 -0
  49. data/lib/robot_lab/robot/mcp_management.rb +8 -2
  50. data/lib/robot_lab/robot/template_rendering.rb +2 -2
  51. data/lib/robot_lab/robot.rb +211 -88
  52. data/lib/robot_lab/run_config.rb +24 -5
  53. data/lib/robot_lab/runnable.rb +51 -0
  54. data/lib/robot_lab/sandbox/null.rb +13 -0
  55. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  56. data/lib/robot_lab/sandbox.rb +52 -0
  57. data/lib/robot_lab/script_tool.rb +60 -2
  58. data/lib/robot_lab/task.rb +26 -20
  59. data/lib/robot_lab/tool.rb +52 -11
  60. data/lib/robot_lab/version.rb +1 -1
  61. data/lib/robot_lab.rb +49 -0
  62. data/mkdocs.yml +1 -0
  63. metadata +36 -99
  64. data/examples/temp.md +0 -51
  65. data/site/404.html +0 -2300
  66. data/site/api/core/index.html +0 -2706
  67. data/site/api/core/memory/index.html +0 -3793
  68. data/site/api/core/network/index.html +0 -3500
  69. data/site/api/core/robot/index.html +0 -4566
  70. data/site/api/core/state/index.html +0 -3390
  71. data/site/api/core/tool/index.html +0 -3843
  72. data/site/api/index.html +0 -2635
  73. data/site/api/mcp/client/index.html +0 -3435
  74. data/site/api/mcp/index.html +0 -2783
  75. data/site/api/mcp/server/index.html +0 -3252
  76. data/site/api/mcp/transports/index.html +0 -3352
  77. data/site/api/messages/index.html +0 -2641
  78. data/site/api/messages/text-message/index.html +0 -3087
  79. data/site/api/messages/tool-call-message/index.html +0 -3159
  80. data/site/api/messages/tool-result-message/index.html +0 -3252
  81. data/site/api/messages/user-message/index.html +0 -3212
  82. data/site/api/streaming/context/index.html +0 -3282
  83. data/site/api/streaming/events/index.html +0 -3347
  84. data/site/api/streaming/index.html +0 -2738
  85. data/site/architecture/core-concepts/index.html +0 -3757
  86. data/site/architecture/index.html +0 -2797
  87. data/site/architecture/message-flow/index.html +0 -3238
  88. data/site/architecture/network-orchestration/index.html +0 -3433
  89. data/site/architecture/robot-execution/index.html +0 -3140
  90. data/site/architecture/state-management/index.html +0 -3498
  91. data/site/assets/css/custom.css +0 -56
  92. data/site/assets/images/favicon.png +0 -0
  93. data/site/assets/images/robot_lab.jpg +0 -0
  94. data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
  95. data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
  96. data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
  97. data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
  98. data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
  99. data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
  100. data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
  101. data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
  102. data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
  103. data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
  104. data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
  105. data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
  106. data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
  107. data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
  108. data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
  109. data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
  110. data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
  111. data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
  112. data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
  113. data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
  114. data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
  115. data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
  116. data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
  117. data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
  118. data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
  119. data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
  120. data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
  121. data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
  122. data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
  123. data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
  124. data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
  125. data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
  126. data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
  127. data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
  128. data/site/assets/javascripts/lunr/tinyseg.js +0 -206
  129. data/site/assets/javascripts/lunr/wordcut.js +0 -6708
  130. data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
  131. data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
  132. data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
  133. data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
  134. data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
  135. data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
  136. data/site/concepts/index.html +0 -3455
  137. data/site/examples/basic-chat/index.html +0 -2880
  138. data/site/examples/index.html +0 -2907
  139. data/site/examples/mcp-server/index.html +0 -3018
  140. data/site/examples/multi-robot-network/index.html +0 -3131
  141. data/site/examples/rails-application/index.html +0 -3329
  142. data/site/examples/tool-usage/index.html +0 -3085
  143. data/site/getting-started/configuration/index.html +0 -3745
  144. data/site/getting-started/index.html +0 -2572
  145. data/site/getting-started/installation/index.html +0 -2981
  146. data/site/getting-started/quick-start/index.html +0 -2942
  147. data/site/guides/building-robots/index.html +0 -4290
  148. data/site/guides/creating-networks/index.html +0 -3858
  149. data/site/guides/index.html +0 -2586
  150. data/site/guides/mcp-integration/index.html +0 -3581
  151. data/site/guides/memory/index.html +0 -3586
  152. data/site/guides/rails-integration/index.html +0 -4019
  153. data/site/guides/streaming/index.html +0 -3157
  154. data/site/guides/using-tools/index.html +0 -3802
  155. data/site/index.html +0 -2671
  156. data/site/search/search_index.json +0 -1
  157. data/site/sitemap.xml +0 -183
  158. data/site/sitemap.xml.gz +0 -0
  159. data/site/tags.json +0 -1
@@ -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}")]
@@ -199,6 +199,20 @@ Default chat parameters applied to all robots unless overridden:
199
199
  | `chat.with_params.frequency_penalty` | `null` | Frequency penalty (-2.0 to 2.0) |
200
200
  | `chat.with_params.stop` | `null` | Stop sequences |
201
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
+
202
216
  ## Runtime-Only Attributes
203
217
 
204
218
  Some attributes can only be set at runtime, not through config files. Use direct assignment on `RobotLab.config` or the `RobotLab.configure` block:
@@ -374,7 +388,9 @@ effective.temperature #=> 0.9 (overridden)
374
388
  | **LLM** | `model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop` |
375
389
  | **Tools** | `mcp`, `tools` |
376
390
  | **Callbacks** | `on_tool_call`, `on_tool_result` |
377
- | **Infrastructure** | `bus`, `enable_cache`, `max_tool_rounds`, `token_budget`, `ractor_pool_size`, `max_concurrent_robots`, `doom_loop_threshold`, `auto_compact`, `compact_threshold` |
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).
378
394
 
379
395
  ### RunConfig vs RobotLab.config
380
396
 
@@ -425,10 +441,13 @@ robot = RobotLab.build(
425
441
  name: "calculator",
426
442
  system_prompt: "You solve math problems.",
427
443
  mcp: :none,
444
+ tools: :inherit, # required: without this, local_tools below is never sent
428
445
  local_tools: [Calculator]
429
446
  )
430
447
  ```
431
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
+
432
451
  ## Next Steps
433
452
 
434
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.