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
@@ -12,11 +12,22 @@ RubyLLM::Agent
12
12
 
13
13
  `Robot` inherits from `RubyLLM::Agent`, which creates a persistent `@chat` on initialization. The robot adds template-based prompts, shared memory, hierarchical MCP configuration, and SimpleFlow pipeline integration on top of the base agent.
14
14
 
15
+ `Robot` also includes `RobotLab::Runnable` and the mixins
16
+ `Robot::TemplateRendering`, `Robot::MCPManagement`, `Robot::BusMessaging`,
17
+ `Robot::HistorySearch`, `Robot::Budget`, and `Robot::Hooking`, and prepends
18
+ `Robot::AgentSkillMatching`.
19
+
20
+ ## Constants
21
+
22
+ | Constant | Value | Description |
23
+ |----------|-------|-------------|
24
+ | `Robot::DEFAULT_MAX_TOOLS` | `128` | Ceiling on the number of tools handed to the provider per turn. Override per robot with `RunConfig#max_tools`; a nil, zero, or negative `max_tools` falls back to this default, so the cap cannot be disabled |
25
+
15
26
  ## Constructor
16
27
 
17
28
  ```ruby
18
29
  Robot.new(
19
- name:,
30
+ name:, # required
20
31
  template: nil,
21
32
  system_prompt: nil,
22
33
  context: {},
@@ -33,9 +44,6 @@ Robot.new(
33
44
  enable_cache: true,
34
45
  bus: nil,
35
46
  skills: nil,
36
- max_tool_rounds: nil,
37
- token_budget: nil,
38
- cost_budget: nil,
39
47
  temperature: nil,
40
48
  top_p: nil,
41
49
  top_k: nil,
@@ -43,15 +51,27 @@ Robot.new(
43
51
  presence_penalty: nil,
44
52
  frequency_penalty: nil,
45
53
  stop: nil,
54
+ max_tool_rounds: nil,
55
+ token_budget: nil,
56
+ cost_budget: nil,
57
+ doom_loop_threshold: nil,
58
+ mcp_discovery: false,
46
59
  config: nil
47
60
  )
48
61
  ```
49
62
 
63
+ !!! warning "The keyword list is closed"
64
+ `Robot#initialize` has no `**rest`. Any keyword not listed above raises
65
+ `ArgumentError`. In particular `auto_compact:`, `compact_threshold:`,
66
+ `ractor_pool_size:`, `max_concurrent_robots:`, and `max_tools:` are
67
+ **`RunConfig` fields only** — pass them via `config:`, not as constructor
68
+ kwargs. There is no `memory:`, `learn:`, or `learn_domain:` keyword.
69
+
50
70
  ### Parameters
51
71
 
52
72
  | Name | Type | Default | Description |
53
73
  |------|------|---------|-------------|
54
- | `name` | `String` | **required** | Unique identifier for the robot |
74
+ | `name` | `String` | **required** | Identifier for the robot. `RobotLab.build` defaults it to the literal string `"robot"`; that default is load-bearing — front-matter `robot_name:` is applied only when the constructor name is still `"robot"` |
55
75
  | `template` | `Symbol`, `nil` | `nil` | Prompt template (e.g., `:assistant` loads `prompts/assistant.md`) |
56
76
  | `system_prompt` | `String`, `nil` | `nil` | Inline system prompt (appended after template if both given) |
57
77
  | `context` | `Hash`, `Proc` | `{}` | Variables passed to the template |
@@ -61,7 +81,7 @@ Robot.new(
61
81
  | `provider` | `String`, `Symbol`, `nil` | `nil` | LLM provider for local providers (e.g., `:ollama`, `:gpustack`). Automatically sets `assume_model_exists: true` |
62
82
  | `mcp_servers` | `Array` | `[]` | Legacy MCP server configurations |
63
83
  | `mcp` | `Symbol`, `Array` | `:none` | Hierarchical MCP config (`:none`, `:inherit`, or server 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) |
84
+ | `tools` | `Symbol`, `Array` | `:none` | Hierarchical tools config (`:none`, `:inherit`, or tool name **array**). Must be tool *names* (String/Symbol) — `validate_tools_filter!` raises `ArgumentError` for an instance or class, telling you to use `local_tools:` instead. **For a standalone robot, leave this unset at build time**: `tools: :inherit` here resolves against the global parent `:none` and yields an allowlist that matches nothing. Inside a network whose `config:` sets `tools:`, build-time `:inherit` is the correct way to opt in. See [Runtime Tool Filtering](../../guides/using-tools.md#runtime-tool-filtering) |
65
85
  | `on_tool_call` | `Proc`, `nil` | `nil` | Callback invoked when a tool is called |
66
86
  | `on_tool_result` | `Proc`, `nil` | `nil` | Callback invoked when a tool returns a result |
67
87
  | `on_content` | `Proc`, `nil` | `nil` | Stored streaming callback invoked with each content chunk (see [Streaming](#streaming)) |
@@ -71,17 +91,25 @@ Robot.new(
71
91
  | `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)) |
72
92
  | `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
93
  | `cost_budget` | `Float`, `nil` | `nil` | Same enforcement as `token_budget`, tracked in cumulative dollar cost instead of tokens (requires provider pricing data) |
94
+ | `doom_loop_threshold` | `Integer`, `nil` | `nil` | Tunes the always-on doom-loop detector (default threshold 3). See [Doom Loop Detection](#doom-loop-detection) |
95
+ | `mcp_discovery` | `Boolean` | `false` | When true, the first run narrows the configured MCP server list to those `MCP::ServerDiscovery` judges relevant to the message |
74
96
  | `config` | `RunConfig`, `nil` | `nil` | Shared config merged with explicit kwargs (see [RunConfig](#runconfig)) |
75
- | `temperature` | `Float`, `nil` | `nil` | Controls randomness (0.0-1.0) |
76
- | `top_p` | `Float`, `nil` | `nil` | Nucleus sampling threshold |
77
- | `top_k` | `Integer`, `nil` | `nil` | Top-k sampling |
78
- | `max_tokens` | `Integer`, `nil` | `nil` | Maximum tokens in response |
79
- | `presence_penalty` | `Float`, `nil` | `nil` | Penalize based on presence |
80
- | `frequency_penalty` | `Float`, `nil` | `nil` | Penalize based on frequency |
81
- | `stop` | `String`, `Array`, `nil` | `nil` | Stop sequences |
97
+ | `temperature` | `Float`, `nil` | `nil` | Controls randomness — applied via `chat.with_temperature` |
98
+ | `top_p` | `Float`, `nil` | `nil` | Nucleus sampling threshold — applied via `chat.with_params` |
99
+ | `top_k` | `Integer`, `nil` | `nil` | Top-k sampling — applied via `chat.with_params` |
100
+ | `max_tokens` | `Integer`, `nil` | `nil` | Maximum tokens in response — applied via `chat.with_params` |
101
+ | `presence_penalty` | `Float`, `nil` | `nil` | Penalize based on presence — applied via `chat.with_params` |
102
+ | `frequency_penalty` | `Float`, `nil` | `nil` | Penalize based on frequency — applied via `chat.with_params` |
103
+ | `stop` | `String`, `Array`, `nil` | `nil` | Stop sequences — applied via `chat.with_params` |
82
104
 
83
105
  When both `config:` and explicit kwargs (e.g., `temperature:`) are provided, explicit kwargs always win.
84
106
 
107
+ `model` and `temperature` are applied to the chat with dedicated `with_model` /
108
+ `with_temperature` calls. The remaining six LLM fields (`top_p`, `top_k`,
109
+ `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop`) are collected into
110
+ a single `chat.with_params(...)` call. This distinction matters for template
111
+ front matter — see [Templates](#templates).
112
+
85
113
  ## Factory Method
86
114
 
87
115
  ```ruby
@@ -93,12 +121,15 @@ robot = RobotLab.build(
93
121
  enable_cache: true,
94
122
  bus: nil, # Optional TypedBus::MessageBus
95
123
  skills: nil, # Optional skill templates
124
+ config: nil, # Optional RunConfig
96
125
  **options # All other Robot.new parameters
97
126
  )
98
127
  # => RobotLab::Robot
99
128
  ```
100
129
 
101
- If `name` is omitted, it defaults to `"robot"`.
130
+ If `name` is omitted, it defaults to the literal string `"robot"`. `**options`
131
+ is forwarded verbatim to `Robot.new`, whose keyword list is closed — an unknown
132
+ option raises `ArgumentError`.
102
133
 
103
134
  ## Attributes (Read-Only)
104
135
 
@@ -123,6 +154,7 @@ If `name` is omitted, it defaults to `"robot"`.
123
154
  | `total_output_tokens` | `Integer` | Cumulative output tokens received across all `run()` calls |
124
155
  | `learnings` | `Array<String>` | Accumulated cross-run observations (see [Learning Accumulation](#learning-accumulation)) |
125
156
  | `budget_ledger` | `RobotLab::Budget::Ledger`, `nil` | Reserve/reconcile ledger backing `token_budget`/`cost_budget`; `nil` when neither is configured (see [Budgets](#budgets)) |
157
+ | `hooks` | `RobotLab::HookRegistry` | This robot's own hook registry. Populated by [`robot.on`](#on); consulted alongside `RobotLab.hooks` and the network's registry on every run |
126
158
 
127
159
  ## Attributes (Read-Write)
128
160
 
@@ -138,7 +170,9 @@ Used by tools like [`AskUser`](tool.md#built-in-askuser) that need terminal IO.
138
170
  ### run
139
171
 
140
172
  ```ruby
141
- result = robot.run(message, **kwargs, &block)
173
+ result = robot.run(message = nil, network: nil, task: nil,
174
+ network_memory: nil, network_config: nil, memory: nil,
175
+ mcp: :none, tools: :none, hooks: nil, **kwargs, &block)
142
176
  # => RobotResult
143
177
  ```
144
178
 
@@ -148,35 +182,63 @@ Primary execution method. Sends a message to the LLM with memory/MCP/tools resol
148
182
 
149
183
  | Name | Type | Default | Description |
150
184
  |------|------|---------|-------------|
151
- | `message` | `String` | **required** | The user message to send |
152
- | `network` | `NetworkRun`, `nil` | `nil` | Network context (passed internally) |
153
- | `network_memory` | `Memory`, `nil` | `nil` | Shared network memory |
154
- | `memory` | `Memory`, `Hash`, `nil` | `nil` | Runtime memory to merge |
185
+ | `message` | `String`, `nil` | `nil` | The user message to send (positional, optional) |
186
+ | `network` | `Network`, `nil` | `nil` | Network context (passed internally by `Network#run`) |
187
+ | `task` | `Task`, `nil` | `nil` | Task wrapper for the current pipeline step (passed internally); surfaces on hook contexts |
188
+ | `network_memory` | `Memory`, `nil` | `nil` | Shared network memory (passed internally) |
189
+ | `network_config` | `RunConfig`, `nil` | `nil` | Network-level config used when resolving `:inherit` for `mcp`/`tools` (passed internally) |
190
+ | `memory` | `Memory`, `Hash`, `nil` | `nil` | A `Memory` replaces the active memory for this run; a `Hash` is merged into it |
155
191
  | `mcp` | `Symbol`, `Array` | `:none` | Runtime MCP override — `:inherit` (all attached servers), `:none`/`[]` (zero this turn), or an explicit array |
156
192
  | `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) |
157
- | `**kwargs` | `Hash` | `{}` | Additional keyword arguments passed to `Agent#ask` |
193
+ | `hooks` | `Array`, `nil` | `nil` | Per-run hook handler classes, active only for this call |
194
+ | `**kwargs` | `Hash` | `{}` | See below — **not** a passthrough to `Agent#ask` |
158
195
  | `&block` | `Proc` | `nil` | Per-call streaming block, receives each content chunk |
159
196
 
197
+ **What `**kwargs` actually does.** Only `:with` is forwarded to the underlying
198
+ `Agent#ask` (`kwargs.slice(:with)`). *Every other* keyword is treated as
199
+ template re-render context: `kwargs.except(:with)` is merged over the build-time
200
+ context and the template is re-rendered before the call. If the robot has no
201
+ `template:`, those extra keywords are simply ignored.
202
+
203
+ ```ruby
204
+ robot = RobotLab.build(name: "support", template: :support)
205
+ robot.run("Help me", company: "Acme") # re-renders the template with company: "Acme"
206
+ robot.run("Describe this", with: image) # forwarded to Agent#ask as attachments
207
+ ```
208
+
160
209
  When both a stored `on_content` callback and a runtime block are provided, both fire (stored first, then runtime block).
161
210
 
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).
211
+ !!! warning "`tools:`/`mcp:` default to `:none` here"
212
+ A bare `robot.run(message)` sends **zero** tools and connects **no** MCP
213
+ servers for that call, even when `local_tools:`/`mcp:` were supplied at
214
+ build time. Pass `tools: :inherit` (and/or `mcp: :inherit`) explicitly to
215
+ use what is attached. `mcp: :inherit` triggers the connection attempt;
216
+ `tools: :inherit` is additionally required for the MCP tools to be sent.
217
+
218
+ 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` (`DEFAULT_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
219
 
164
220
  **Returns:** `RobotResult`
165
221
 
166
222
  **Examples:**
167
223
 
168
224
  ```ruby
169
- # Simple message
225
+ # Simple message — sends no tools, connects no MCP servers
170
226
  result = robot.run("What is 2+2?")
171
227
 
228
+ # Send the tools attached via local_tools:
229
+ result = robot.run("What is 15 * 7?", tools: :inherit)
230
+
231
+ # Connect MCP servers and send their tools
232
+ result = robot.run("Search the repo", mcp: :inherit, tools: :inherit)
233
+
234
+ # Restrict this turn to a named subset
235
+ result = robot.run("Look it up", tools: %w[order_lookup])
236
+
172
237
  # With runtime memory
173
238
  result = robot.run("Summarize the data", memory: { data: report })
174
239
 
175
240
  # With per-call streaming block
176
241
  result = robot.run("Tell me a story") { |chunk| print chunk.content }
177
-
178
- # With runtime overrides
179
- result = robot.run("Help me", mcp: :none, tools: :none)
180
242
  ```
181
243
 
182
244
  ### model
@@ -187,6 +249,21 @@ robot.model # => "claude-sonnet-4" or nil
187
249
 
188
250
  Returns the model ID string. Resolves through the underlying chat object.
189
251
 
252
+ ### effective_config
253
+
254
+ ```ruby
255
+ robot.effective_config
256
+ # => { model: "claude-sonnet-4-20250514", temperature: 0.7, max_tokens: 4096 }
257
+ ```
258
+
259
+ Snapshot of the robot's merged `RunConfig` as a plain Hash, `.compact`ed so unset
260
+ fields are omitted. Reports exactly these keys when set: `model`, `temperature`,
261
+ `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop`,
262
+ `tools`, `mcp`, `max_tool_rounds`, `doom_loop_threshold`, `auto_compact`,
263
+ `compact_threshold`, `token_budget`, `cost_budget`.
264
+
265
+ This is a *view*, not the config object — use `robot.config` for the `RunConfig` itself.
266
+
190
267
  ### update
191
268
 
192
269
  ```ruby
@@ -203,29 +280,52 @@ robot.update(
203
280
 
204
281
  Reconfigure the robot after construction. Returns `self` for chaining.
205
282
 
283
+ The five named parameters are applied directly (`template` re-renders the prompt;
284
+ `system_prompt`, `model`, and `temperature` call the corresponding `with_*` on the chat).
285
+
286
+ !!! warning "`**kwargs` only reaches fields the chat exposes as `with_<key>`"
287
+ Each extra keyword is forwarded as `@chat.with_#{key}(value)` **only if
288
+ `@chat.respond_to?(:"with_#{key}")`**. `RubyLLM::Chat` has no
289
+ `with_max_tokens`, `with_top_p`, `with_top_k`, `with_stop`,
290
+ `with_presence_penalty`, or `with_frequency_penalty` — so
291
+ `robot.update(max_tokens: 4000)` silently does nothing. Use
292
+ `robot.with_params(max_tokens: 4000)` for those fields.
293
+
206
294
  ### with_* Methods (Chaining)
207
295
 
208
- All `with_*` methods delegate to the persistent `@chat` and return `self` for chaining:
296
+ `with_*` methods are discovered from `RubyLLM::Chat` at construction time and
297
+ defined as singleton methods that delegate to the persistent `@chat` and return
298
+ `self` for chaining. This is the **complete** set:
209
299
 
210
300
  | Method | Description |
211
301
  |--------|-------------|
212
302
  | `with_model(model_id)` | Change the LLM model |
213
303
  | `with_temperature(temp)` | Set temperature |
214
- | `with_top_p(value)` | Set nucleus sampling |
215
- | `with_top_k(value)` | Set top-k sampling |
216
- | `with_max_tokens(value)` | Set max response tokens |
217
- | `with_presence_penalty(value)` | Set presence penalty |
218
- | `with_frequency_penalty(value)` | Set frequency penalty |
219
- | `with_stop(sequences)` | Set stop sequences |
220
304
  | `with_instructions(prompt)` | Set system instructions |
221
305
  | `with_tool(tool)` | Add a single tool |
222
306
  | `with_tools(*tools)` | Add multiple tools |
223
- | `with_params(**params)` | Set additional parameters |
307
+ | `with_params(**params)` | Set arbitrary provider parameters |
224
308
  | `with_headers(**headers)` | Set custom headers |
225
309
  | `with_schema(schema)` | Set output schema |
226
310
  | `with_context(**ctx)` | Set context |
227
311
  | `with_thinking(opts)` | Enable extended thinking |
228
- | `with_bus(bus)` | Connect to a message bus (creates one if nil) |
312
+
313
+ Plus two defined by RobotLab itself:
314
+
315
+ | Method | Description |
316
+ |--------|-------------|
317
+ | `with_template(id, **context)` | Apply a prompt_manager template (see below) |
318
+ | `with_bus(bus = nil)` | Connect to a message bus (creates one if nil) |
319
+
320
+ !!! danger "These do not exist"
321
+ `with_max_tokens`, `with_top_p`, `with_top_k`, `with_stop`,
322
+ `with_presence_penalty`, and `with_frequency_penalty` are **not** defined
323
+ and raise `NoMethodError`. Set those fields with a constructor kwarg
324
+ (`max_tokens: 2000`) or with `with_params`:
325
+
326
+ ```ruby
327
+ robot.with_params(max_tokens: 2000, top_p: 0.3)
328
+ ```
229
329
 
230
330
  **Example:**
231
331
 
@@ -234,6 +334,7 @@ robot = RobotLab.build(name: "bot")
234
334
  robot
235
335
  .with_model("claude-sonnet-4")
236
336
  .with_temperature(0.7)
337
+ .with_params(max_tokens: 2000)
237
338
  .with_instructions("Be concise.")
238
339
  .run("Hello")
239
340
  ```
@@ -276,7 +377,7 @@ message = robot.send_message(to: :bob, content: "Tell me a joke.")
276
377
  # => RobotMessage
277
378
  ```
278
379
 
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.
380
+ 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 never clobber each other.
280
381
 
281
382
  **Parameters:**
282
383
 
@@ -360,7 +461,7 @@ Auto-answer inbound (non-reply) bus tasks: run the block to produce a reply, and
360
461
 
361
462
  **Returns:** `self`
362
463
 
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.
464
+ 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 **inline in the caller's context** — `BusPoller` has no background thread; its `enqueue` either processes the delivery immediately or queues it behind the one in flight and drains it when that finishes. Deliveries to a given robot are therefore handled one at a time, and a long-running responder blocks the sender as well as the next inbound message.
364
465
 
365
466
  ```ruby
366
467
  bob.respond_to_tasks { |message| "handled: #{message.content}" }
@@ -431,6 +532,69 @@ worker2 = bot.spawn(name: "worker", system_prompt: "Worker 2")
431
532
  # Messages sent to :worker are delivered to both
432
533
  ```
433
534
 
535
+ ### assign_bus_poller
536
+
537
+ ```ruby
538
+ robot.assign_bus_poller(poller, group: :default)
539
+ # => void — do not rely on the return value
540
+ ```
541
+
542
+ Adopt a shared [`BusPoller`](../support.md#robotlabbuspoller) — normally the
543
+ network's. `Network#task` calls this for every robot that responds to it, passing
544
+ the task's `poller_group:`. Any private poller the robot auto-created is dropped
545
+ first.
546
+
547
+ **Parameters:**
548
+
549
+ | Name | Type | Default | Description |
550
+ |------|------|---------|-------------|
551
+ | `poller` | `BusPoller` | **required** | The shared poller to adopt |
552
+ | `group` | `Symbol` | `:default` | Poller group label — informational only; groups share one drain mechanism |
553
+
554
+ You only call this directly when wiring robots onto a shared poller outside a
555
+ `Network`.
556
+
557
+ ### inherited_llm_settings
558
+
559
+ ```ruby
560
+ robot.inherited_llm_settings
561
+ # => { model: "llama3.2", provider: :ollama }
562
+ ```
563
+
564
+ The model/provider pair a [`spawn`](#spawn)ed child inherits from this robot.
565
+ Returns `{}` when neither is set — each key is included only when the
566
+ corresponding reader is truthy. Exposed so an application building children by
567
+ some route other than `spawn` can apply the same inheritance:
568
+
569
+ ```ruby
570
+ child = RobotLab.build(name: "helper", bus: parent.bus, **parent.inherited_llm_settings)
571
+ ```
572
+
573
+ ### rerender_template
574
+
575
+ ```ruby
576
+ robot.rerender_template(run_context) # internal — see the warning below
577
+ ```
578
+
579
+ Re-renders the robot's template with `run_context` merged over the build-time
580
+ context (skill bodies included, when `skills:` are in play) and reinstalls the
581
+ result as the system prompt, re-appending the inline `system_prompt`. `run` calls
582
+ it automatically when the robot has a `template:` and the call carried extra
583
+ keywords — every keyword except `:with` — which is the mechanism behind
584
+ `robot.run("Help me", company: "Acme")`.
585
+
586
+ `Robot::AgentSkillMatching` overrides it to re-prepend any matched AgentSkill
587
+ instructions afterward, because a re-render replaces the whole system prompt and
588
+ would otherwise discard them mid-run.
589
+
590
+ !!! warning "Public only by accident — treat it as internal"
591
+ `rerender_template` is `private` in `Robot::TemplateRendering`, but the
592
+ prepended `Robot::AgentSkillMatching` redefines it **above** its own
593
+ `private` keyword, so the effective method on `Robot` is public. That is an
594
+ artifact of the override, not a supported entry point: the return value is
595
+ unspecified, and it mutates the chat's system prompt for the rest of the
596
+ conversation. Pass template context to `run` instead.
597
+
434
598
  ### with_bus
435
599
 
436
600
  ```ruby
@@ -506,7 +670,18 @@ Inject pre-connected MCP clients and their tools into this robot. Used by host a
506
670
  ```ruby
507
671
  # Host app manages MCP connections
508
672
  clients = { "github" => github_client }
509
- tools = github_client.list_tools.map { |t| RobotLab::Tool.from_mcp(t) }
673
+
674
+ # There is no Tool.from_mcp — MCP wrappers are built with Tool.create,
675
+ # exactly as RobotLab's own discover_mcp_tools does.
676
+ tools = github_client.list_tools.map do |tool_def|
677
+ name = tool_def[:name]
678
+ RobotLab::Tool.create(
679
+ name: name,
680
+ description: tool_def[:description],
681
+ parameters: tool_def[:inputSchema],
682
+ mcp: "github"
683
+ ) { |args| github_client.call_tool(name, args) }
684
+ end
510
685
 
511
686
  robot.inject_mcp!(clients: clients, tools: tools)
512
687
  ```
@@ -573,6 +748,147 @@ saved = robot.messages.dup
573
748
  robot.replace_messages(saved)
574
749
  ```
575
750
 
751
+ ### compress_history
752
+
753
+ ```ruby
754
+ robot.compress_history(
755
+ recent_turns: 3,
756
+ keep_threshold: 0.6,
757
+ drop_threshold: 0.2,
758
+ summarizer: nil
759
+ )
760
+ # => self
761
+ ```
762
+
763
+ Shrink the conversation by scoring each older turn against the most recent
764
+ context and dropping or summarizing the least relevant ones. Internally builds a
765
+ `RobotLab::HistoryCompressor` and hands the result to `replace_messages`.
766
+
767
+ **Parameters:**
768
+
769
+ | Name | Type | Default | Description |
770
+ |------|------|---------|-------------|
771
+ | `recent_turns` | `Integer` | `3` | Turn pairs at the end that are always kept verbatim |
772
+ | `keep_threshold` | `Float` | `0.6` | Cosine score at or above this → kept verbatim |
773
+ | `drop_threshold` | `Float` | `0.2` | Cosine score below this → dropped |
774
+ | `summarizer` | `#call`, `nil` | `nil` | `callable(text) -> String` applied to the medium tier; `nil` drops the medium tier instead |
775
+
776
+ **Returns:** `self`
777
+
778
+ System messages and tool-call/tool-result messages are always preserved.
779
+
780
+ Scoring uses **term-frequency cosine similarity without IDF** (see
781
+ `RobotLab::Convergence`), so it is a lexical overlap measure, not a semantic one.
782
+
783
+ **Raises:** `RobotLab::DependencyError` when the optional `classifier` gem
784
+ (`~> 2.3`) is not installed.
785
+
786
+ ```ruby
787
+ robot.compress_history(recent_turns: 5, summarizer: ->(text) { text[0, 200] })
788
+ ```
789
+
790
+ `auto_compact: :context_window` on a `RunConfig` calls this automatically before
791
+ an LLM call once estimated tokens exceed `compact_threshold` (default `0.80`) of
792
+ the model's context window. When the `classifier` gem is missing there, the
793
+ `DependencyError` is caught, logged at `:warn`, and compaction is skipped.
794
+
795
+ ### delegate
796
+
797
+ ```ruby
798
+ result = robot.delegate(to:, task:, async: false, **run_kwargs)
799
+ # => RobotResult (async: false) | DelegationFuture (async: true)
800
+ ```
801
+
802
+ Hand a task to another robot and annotate the result with delegation metadata.
803
+
804
+ **Parameters:**
805
+
806
+ | Name | Type | Default | Description |
807
+ |------|------|---------|-------------|
808
+ | `to` | `Robot` | **required** | The robot to delegate to |
809
+ | `task` | `String` | **required** | The message to send |
810
+ | `async` | `Boolean` | `false` | When true, returns a `DelegationFuture` immediately |
811
+ | `**run_kwargs` | `Hash` | `{}` | Forwarded verbatim to the delegatee's `run` — including `tools:`/`mcp:`, which still default to `:none` |
812
+
813
+ **Synchronous** (default) blocks until the delegatee finishes and returns its
814
+ `RobotResult` with `duration` and `delegated_by` set.
815
+
816
+ **Asynchronous** (`async: true`) runs the delegatee on a new `Thread` and returns
817
+ a `RobotLab::DelegationFuture`. Call `future.value` to block, `future.value(timeout: N)`
818
+ to block with a deadline (raises `RobotLab::DelegationFuture::DelegationTimeout`),
819
+ or `future.resolved?` to poll. An exception in the delegatee is captured and
820
+ re-raised from `future.value`.
821
+
822
+ ```ruby
823
+ # Synchronous
824
+ result = manager.delegate(to: analyst, task: "What are the risks?")
825
+ result.reply
826
+ result.delegated_by # => "manager"
827
+ result.duration # => 1.43
828
+
829
+ # Async fan-out
830
+ f1 = manager.delegate(to: summarizer, task: "summarize ...", async: true)
831
+ f2 = manager.delegate(to: analyst, task: "analyze ...", async: true, tools: :inherit)
832
+ summary = f1.value
833
+ analysis = f2.value(timeout: 30)
834
+ ```
835
+
836
+ ### search_history
837
+
838
+ ```ruby
839
+ results = robot.search_history(query, limit: 5)
840
+ # => Array<RobotLab::Robot::HistorySearch::HistoryResult>
841
+ ```
842
+
843
+ Rank the robot's own conversation messages against a natural-language query
844
+ using stemmed term-frequency cosine similarity.
845
+
846
+ **Parameters:**
847
+
848
+ | Name | Type | Default | Description |
849
+ |------|------|---------|-------------|
850
+ | `query` | `String` | **required** | Natural-language search query |
851
+ | `limit` | `Integer` | `5` | Maximum results to return |
852
+
853
+ **Returns:** `Array<HistoryResult>` sorted by score descending. `HistoryResult`
854
+ is a `Data` type with members `text`, `role`, `score`, and `index`.
855
+
856
+ Messages shorter than `MIN_SCORE_LENGTH` (20 characters) are skipped, as are
857
+ messages that score zero.
858
+
859
+ **Raises:** `RobotLab::DependencyError` when the optional `classifier` gem is not installed.
860
+
861
+ ```ruby
862
+ robot.search_history("quarterly revenue", limit: 3).each do |r|
863
+ puts "[#{r.role}] (#{r.score.round(3)}) #{r.text}"
864
+ end
865
+ ```
866
+
867
+ ### on
868
+
869
+ ```ruby
870
+ robot.on(HandlerClass, context: nil)
871
+ # => the registration
872
+ ```
873
+
874
+ Register a hook handler on **this robot's** registry (`robot.hooks`). The robot's
875
+ registry is consulted on every run alongside `RobotLab.hooks` (global) and the
876
+ network's registry, in that order.
877
+
878
+ **Parameters:**
879
+
880
+ | Name | Type | Default | Description |
881
+ |------|------|---------|-------------|
882
+ | `handler_class` | `Class` | **required** | The hook handler class |
883
+ | `context` | `Object`, `nil` | `nil` | Optional per-registration context passed to the handler |
884
+
885
+ !!! note "Task hooks bypass robot registries"
886
+ The `:task` hook family resolves against `[RobotLab.hooks, network&.hooks]`
887
+ only. A handler registered with `robot.on` never fires for task hooks —
888
+ register it with `RobotLab.on` or `network.on` instead.
889
+
890
+ Handlers can also be scoped to a single call with `robot.run(msg, hooks: [HandlerClass])`.
891
+
576
892
  ### chat_provider
577
893
 
578
894
  ```ruby
@@ -615,7 +931,23 @@ robot.to_h
615
931
  # => Hash
616
932
  ```
617
933
 
618
- Returns a hash representation of the robot including name, description, template, skills, system_prompt, local_tools, mcp_tools, mcp_config, tools_config, mcp_servers, model, and bus (true if configured, omitted otherwise). Nil values are compacted out.
934
+ Returns a hash representation of the robot. Keys, in order: `name`,
935
+ `description`, `template`, `skills`, `system_prompt`, `local_tools` (tool names),
936
+ `mcp_tools` (tool names), `mcp_config`, `tools_config`, `mcp_servers` (connected
937
+ client names), `model`, `config` (the `RunConfig` as a JSON-safe hash, omitted
938
+ when the config is empty), and `bus` (`true` if configured, omitted otherwise).
939
+ The whole hash is `.compact`ed, so nil values are dropped.
940
+
941
+ ```ruby
942
+ RobotLab.build(name: "x", max_tokens: 100).to_h
943
+ # => { name: "x", local_tools: [], mcp_tools: [], mcp_config: :none,
944
+ # tools_config: :none, mcp_servers: [], model: "claude-sonnet-4-20250514",
945
+ # config: { max_tokens: 100, enable_cache: true } }
946
+ ```
947
+
948
+ The `config` value comes from `RunConfig#to_json_hash`, which omits the
949
+ non-serializable fields (`on_tool_call`, `on_tool_result`, `on_content`, `bus`,
950
+ `auto_compact`).
619
951
 
620
952
  ## Memory Behavior
621
953
 
@@ -640,20 +972,60 @@ Templates are `.md` files with optional YAML front matter, loaded via `prompt_ma
640
972
  robot = RobotLab.build(name: "bot", template: :assistant, context: { tone: "friendly" })
641
973
  ```
642
974
 
643
- Front matter supports two categories of keys:
975
+ Front matter supports two categories of keys.
976
+
977
+ **LLM Config:** `model`, `temperature`, `top_p`, `top_k`, `max_tokens`,
978
+ `presence_penalty`, `frequency_penalty`, `stop` are all *parsed* into a
979
+ `RunConfig`.
644
980
 
645
- **LLM Config:** `model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop` applied to the underlying chat.
981
+ !!! warning "Only `model` and `temperature` actually take effect from front matter"
982
+ Front-matter LLM fields are applied through `RunConfig#apply_to`, which
983
+ dispatches `chat.with_<field>` guarded by `respond_to?`. `RubyLLM::Chat`
984
+ defines only `with_model` and `with_temperature`, so `top_p`, `top_k`,
985
+ `max_tokens`, `presence_penalty`, `frequency_penalty`, and `stop` are parsed
986
+ and **silently dropped**. Set those six as constructor kwargs or on a
987
+ `config:` `RunConfig` instead — that path goes through `with_params` and
988
+ does work.
646
989
 
647
990
  **Robot Extras:** `robot_name`, `description`, `tools`, `mcp`, `skills` — applied to the robot's identity and capabilities. Constructor-provided values always take precedence.
648
991
 
649
992
  | Key | Type | Description |
650
993
  |-----|------|-------------|
651
- | `robot_name` | `String` | Override robot name (when constructor uses the default `"robot"`) |
652
- | `description` | `String` | Human-readable description |
653
- | `tools` | `Array<String>` | Tool class names resolved via `Object.const_get` |
654
- | `mcp` | `Array<Hash>` | MCP server configurations |
994
+ | `robot_name` | `String` | Override robot name — applied only when the constructor name is still the default `"robot"` |
995
+ | `description` | `String` | Human-readable description; applied only when the constructor passed no `description:` |
996
+ | `tools` | `Array` | Tool entries; applied only when `local_tools:` is empty (see below) |
997
+ | `mcp` | `Array<Hash>` | MCP server configurations; applied only when the constructor `mcp:` is `:none` |
655
998
  | `skills` | `Array<Symbol>` | Skill templates to prepend (recursive, with cycle detection) |
656
999
 
1000
+ Templates render with ERB — write `<%= var %>`. `{{ var }}` is not interpolated
1001
+ and passes through verbatim.
1002
+
1003
+ ### Front-matter `tools:` resolution
1004
+
1005
+ Front-matter `tools` entries are resolved by `resolve_frontmatter_tools`, and the
1006
+ result becomes `local_tools` (real tool objects), **not** the `tools_config`
1007
+ name allowlist. Three entry shapes are accepted:
1008
+
1009
+ | Entry | Behavior |
1010
+ |-------|----------|
1011
+ | `String` | Resolved with `Object.const_get`. If the constant is a `Class` that is `< RubyLLM::Tool`, it is **instantiated** (`const.new`); any other constant is used as-is |
1012
+ | `Class` | **Instantiated** (`name.new`) |
1013
+ | anything else | Used as-is (e.g. an already-built tool instance) |
1014
+
1015
+ An unresolvable name does **not** raise. It is logged at `:warn`
1016
+ (`"Robot '<name>': tool '<X>' not found, skipping"`) and skipped.
1017
+
1018
+ ```markdown
1019
+ ---
1020
+ tools:
1021
+ - OrderLookup # instantiated: OrderLookup.new
1022
+ - RefundProcessor
1023
+ ---
1024
+ ```
1025
+
1026
+ Because `run()` still defaults to `tools: :none`, front-matter tools are sent
1027
+ only when you pass `tools: :inherit` at run time.
1028
+
657
1029
  ## Skills
658
1030
 
659
1031
  Skills compose robot behaviors from reusable templates. Each skill is a standard `.md` template whose prompt body is prepended before the main template. Skills are expanded depth-first with automatic cycle detection.
@@ -704,7 +1076,45 @@ robot = RobotLab.build(
704
1076
  robot.config #=> RunConfig with model: "claude-sonnet-4", temperature: 0.9, ...
705
1077
  ```
706
1078
 
707
- RunConfig fields: `model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop`, `mcp`, `tools`, `on_tool_call`, `on_tool_result`, `on_content`, `bus`, `enable_cache`.
1079
+ `RunConfig::FIELDS` is the complete, authoritative list. Passing any other key to
1080
+ `RunConfig.new` raises `ArgumentError: Unknown RunConfig field: ...`.
1081
+
1082
+ | Group | Constant | Fields |
1083
+ |-------|----------|--------|
1084
+ | LLM | `LLM_FIELDS` | `model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop` |
1085
+ | Tools | `TOOL_FIELDS` | `mcp`, `tools` |
1086
+ | Callbacks | `CALLBACK_FIELDS` | `on_tool_call`, `on_tool_result`, `on_content` |
1087
+ | Infrastructure | `INFRA_FIELDS` | `bus`, `enable_cache`, `max_tool_rounds`, `token_budget`, `cost_budget`, `ractor_pool_size`, `max_concurrent_robots`, `doom_loop_threshold`, `auto_compact`, `compact_threshold`, `max_tools` |
1088
+
1089
+ Five of those infrastructure fields are `RunConfig`-only — they are **not**
1090
+ `Robot.new` keywords: `ractor_pool_size`, `max_concurrent_robots`,
1091
+ `auto_compact`, `compact_threshold`, `max_tools`. (`max_concurrent_robots` is
1092
+ consumed by `Network`, not by `Robot`; `ractor_pool_size` by the
1093
+ `robot_lab-ractor` extension.)
1094
+
1095
+ ```ruby
1096
+ config = RobotLab::RunConfig.new(auto_compact: :context_window, compact_threshold: 0.7, max_tools: 32)
1097
+ robot = RobotLab.build(name: "long_runner", system_prompt: "...", config: config)
1098
+ ```
1099
+
1100
+ Other `RunConfig` API:
1101
+
1102
+ | Method | Description |
1103
+ |--------|-------------|
1104
+ | `RunConfig.new(**kwargs) { \|c\| ... }` | Keyword construction plus an optional block DSL (`c.model "..."`) |
1105
+ | `#merge(other)` | Returns a **new** RunConfig; the other's non-nil values win |
1106
+ | `#to_h` | The explicitly-set fields |
1107
+ | `#to_json_hash` | `to_h` minus `NON_SERIALIZABLE_FIELDS` (`on_tool_call`, `on_tool_result`, `on_content`, `bus`, `auto_compact`) |
1108
+ | `#apply_to(chat, provider: nil, assume_model_exists: false)` | Applies `LLM_FIELDS` via `chat.with_<field>`, guarded by `respond_to?` |
1109
+ | `#empty?` / `#key?(field)` | Introspection |
1110
+ | `RunConfig.from_front_matter(metadata)` | Builds a RunConfig from a template's parsed metadata |
1111
+
1112
+ !!! note "A network-level `config:` only propagates `mcp` and `tools`"
1113
+ LLM fields and callbacks (`on_content`, `on_tool_call`, `on_tool_result`)
1114
+ are read from the robot's own config at construction time and are never
1115
+ inherited from a network. A member robot picks up the network's `mcp`/`tools`
1116
+ only when it opts in with `:inherit`. `max_concurrent_robots` is the one
1117
+ field the network itself consumes.
708
1118
 
709
1119
  See [Configuration: RunConfig](../../getting-started/configuration.md#runconfig-shared-operational-defaults) for full details.
710
1120
 
@@ -778,27 +1188,55 @@ robot.run("Tell me a story") { |chunk| stream_to_client(chunk.content) }
778
1188
 
779
1189
  ## Configuration Hierarchy
780
1190
 
781
- Tools and MCP servers use hierarchical resolution: **runtime > robot > network > global config**.
1191
+ Tools and MCP servers use hierarchical resolution: **runtime > robot > task > network > global config**.
782
1192
 
783
1193
  ```
784
1194
  RobotLab.config (global)
785
1195
  |
786
- +-- Network (config:)
1196
+ +-- Network (config:) -- propagates only mcp/tools to members
787
1197
  | |
788
- | +-- Task (config:)
1198
+ | +-- Task (config:) -- likewise only mcp/tools
789
1199
  | | |
790
1200
  | | +-- Robot (config: + build-time mcp:, tools:)
791
1201
  | | |
792
- | | +-- Template front matter
793
- | | |
794
- | | +-- run() call (runtime mcp:, tools:)
1202
+ | | +-- run() call (runtime mcp:, tools:) <- default :none
795
1203
  ```
796
1204
 
797
1205
  Values at each level:
798
1206
 
799
- - `:none` -- no tools/MCP at this level
800
- - `:inherit` -- inherit from parent level
801
- - `Array` -- explicit list of tool names or MCP server configs
1207
+ - `:none` -- no tools/MCP at this level (the default at every level)
1208
+ - `:inherit` -- inherit from the parent level
1209
+ - `Array` -- a filter over the already-attached tools, or a list of MCP server configs. Entries are matched against `tool.name.to_s`, so they must be written in the same form the tool was attached in: a class-attached tool matches `"RefundTool"`, an instance-attached one matches `"refund"`. (The constructor's `tools:` accepts only Strings/Symbols; the class form is usable at the task/`run` level, which is not validated.)
1210
+
1211
+ !!! danger "For a standalone robot, do not set `tools: :inherit` at build time"
1212
+ The parent is recomputed on every run as
1213
+ `network_config&.tools || network_parent_config(network)&.tools || RobotLab.config.tools`.
1214
+ For a **standalone** robot that resolves to the global `:none`, so a
1215
+ build-time `:inherit` produces the allowlist `["none"]`, which matches
1216
+ nothing. Leave `tools:` unset on the constructor and pass `tools: :inherit`
1217
+ on `run()` instead.
1218
+
1219
+ | build `tools:` | run `tools:` | tools sent |
1220
+ |---|---|---|
1221
+ | unset | `:none` (default) | none |
1222
+ | unset | `:inherit` | all attached — **the correct pattern** |
1223
+ | `:inherit` | `:inherit` | none — broken |
1224
+ | `:none` | `:inherit` | all attached |
1225
+
1226
+ This does **not** generalize to robots inside a network. When the network's
1227
+ `config:` sets `tools:`/`mcp:`, the parent resolved at run time is that
1228
+ network value, and a build-time `:inherit` is exactly how the robot opts
1229
+ into it. See [MCP in Networks](../mcp/index.md#mcp-in-networks).
1230
+
1231
+ ### Per-robot config cascade
1232
+
1233
+ For a single robot, least- to most-specific:
1234
+
1235
+ ```
1236
+ template front matter -> config: (RunConfig) -> constructor kwargs
1237
+ ```
1238
+
1239
+ Front matter is the **base**, not an override. Constructor kwargs always win.
802
1240
 
803
1241
  ## Examples
804
1242
 
@@ -848,9 +1286,14 @@ robot = RobotLab.build(
848
1286
  system_prompt: "You help with math.",
849
1287
  local_tools: [Calculator]
850
1288
  )
851
- result = robot.run("What is 15 * 7?")
1289
+
1290
+ # run() defaults to tools: :none — pass :inherit to actually send Calculator
1291
+ result = robot.run("What is 15 * 7?", tools: :inherit)
852
1292
  ```
853
1293
 
1294
+ Note that `param` accepts only `type:`, `desc:`/`description:`, and `required:` —
1295
+ there is no `enum:` option. See [Tool](tool.md#param).
1296
+
854
1297
  ### Robot with Local Provider
855
1298
 
856
1299
  ```ruby
@@ -880,10 +1323,15 @@ robot = RobotLab.build(
880
1323
  }
881
1324
  ]
882
1325
  )
883
- result = robot.run("Search for popular Ruby repos")
1326
+
1327
+ # mcp: :inherit triggers the connection; tools: :inherit sends the discovered tools
1328
+ result = robot.run("Search for popular Ruby repos", mcp: :inherit, tools: :inherit)
884
1329
  robot.disconnect
885
1330
  ```
886
1331
 
1332
+ `transport:` must be a nested hash. MCP connection failures are logged and
1333
+ recorded in `robot.failed_mcp_server_names` — they are not raised.
1334
+
887
1335
  ### Robot with Skills
888
1336
 
889
1337
  ```ruby
@@ -985,7 +1433,7 @@ Every `robot.run()` returns a `RobotResult` with token counts for that call. The
985
1433
 
986
1434
  ```ruby
987
1435
  robot.reset_token_totals
988
- # => nil
1436
+ # => the robot itself (returns self, so it chains)
989
1437
  ```
990
1438
 
991
1439
  Reset the cumulative accounting counters to zero. Useful when you want to measure cost for a specific task batch while keeping the robot alive for the next batch.
@@ -1030,7 +1478,7 @@ Each `run()` reserves the remaining budget for every configured dimension before
1030
1478
  - **`RobotLab::BudgetExceeded`** — raised up front when a *prior* call already exhausted a dimension; the new call is refused before it spends anything.
1031
1479
  - **`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
1480
 
1033
- See [Budgets](../../guides/observability.md#budgets-token--cost) for the full walkthrough.
1481
+ See [Budgets](../../guides/observability.md#budgets-token-cost) for the full walkthrough.
1034
1482
 
1035
1483
  ## Tool Loop Circuit Breaker
1036
1484
 
@@ -1058,7 +1506,13 @@ robot = RobotLab.build(name: "runner", system_prompt: "...", config: config)
1058
1506
 
1059
1507
  `RobotLab::ToolLoopError < RobotLab::InferenceError`
1060
1508
 
1061
- Raised when the number of tool calls in a single `run()` exceeds `max_tool_rounds`. The error message includes the limit that was exceeded.
1509
+ Raised when the number of tool calls in a single `run()` exceeds `max_tool_rounds`. The message reads:
1510
+
1511
+ ```
1512
+ Circuit breaker triggered: <N> tool calls exceeded max_tool_rounds (<M>)
1513
+ ```
1514
+
1515
+ where `N` is the call count that tripped the breaker and `M` is the configured limit.
1062
1516
 
1063
1517
  ### Recovery after ToolLoopError
1064
1518
 
@@ -1070,6 +1524,7 @@ After a `ToolLoopError`, the chat contains a dangling `tool_use` block with no m
1070
1524
  begin
1071
1525
  robot.run("Execute all steps.")
1072
1526
  rescue RobotLab::ToolLoopError => e
1527
+ # "Circuit breaker triggered: 11 tool calls exceeded max_tool_rounds (10)"
1073
1528
  puts "Circuit breaker fired: #{e.message}"
1074
1529
  end
1075
1530
 
@@ -1081,9 +1536,34 @@ puts robot.config.max_tool_rounds # still set — config unchanged
1081
1536
  result = robot.run("Something new.")
1082
1537
  ```
1083
1538
 
1539
+ ## Doom Loop Detection
1540
+
1541
+ Distinct from the circuit breaker, doom-loop detection is **always on**. Every
1542
+ `run()` unconditionally installs a `RobotLab::DoomLoopDetector` over the chat's
1543
+ `execute_tool`, and removes it again when the run ends. `doom_loop_threshold:`
1544
+ only *tunes* it; it cannot be disabled from the constructor.
1545
+
1546
+ ```ruby
1547
+ robot = RobotLab.build(name: "worker", system_prompt: "...", doom_loop_threshold: 5)
1548
+ ```
1549
+
1550
+ | | |
1551
+ |---|---|
1552
+ | Default threshold | `RobotLab::DoomLoopDetector::DEFAULT_THRESHOLD` (3) |
1553
+ | Set via | `doom_loop_threshold:` constructor kwarg or `RunConfig#doom_loop_threshold` |
1554
+
1555
+ When a consecutive or cyclic repetition of the same tool name exceeds the
1556
+ threshold, the detector does **not** raise. It appends a self-correction warning
1557
+ to that tool's result so the model can change strategy: a `String` result gets
1558
+ `"\n\n⚠️ <warning>"` appended, and a `Hash` result gains a `:_doom_loop_warning`
1559
+ key. The detector then resets.
1560
+
1084
1561
  ## Learning Accumulation
1085
1562
 
1086
- `robot.learn(text)` records a cross-run observation. On each subsequent `run()`, active learnings are automatically prepended to the user message as a `LEARNINGS FROM PREVIOUS RUNS:` block.
1563
+ `robot.learn(text)` records a cross-run observation. On each subsequent `run()`,
1564
+ **all** accumulated learnings are prepended to the user message as a
1565
+ `LEARNINGS FROM PREVIOUS RUNS:` block. There is no active/inactive distinction —
1566
+ every entry in `robot.learnings` is injected.
1087
1567
 
1088
1568
  ### learn
1089
1569
 
@@ -1097,7 +1577,17 @@ Add a learning to the robot's accumulated observations. Learnings are automatica
1097
1577
  - If the new text is a substring of an existing learning, it is dropped (the existing broader learning already covers it).
1098
1578
  - If an existing learning is a substring of the new text, the narrower one is replaced.
1099
1579
 
1100
- Learnings are persisted to `memory[:learnings]` and survive a robot rebuild when the same `Memory` object is reused.
1580
+ Learnings are written to the robot's own memory under `memory[:learnings]`.
1581
+
1582
+ !!! note "Learnings do not survive process restart on their own"
1583
+ `initialize_memory` always constructs a fresh `Memory.new`, and there is no
1584
+ `memory:` constructor keyword, so a newly built robot starts with an empty
1585
+ `:learnings` key. `learn` reads back whatever is already in `memory[:learnings]`
1586
+ at construction, which means persistence requires an external store — for
1587
+ example the `robot_lab-durable` extension — to repopulate it.
1588
+
1589
+ `learn` runs inside the `:learn` hook family (`before_learn` / `around_learn` /
1590
+ `after_learn`, plus `on_learn`), so a hook handler can observe or veto the write.
1101
1591
 
1102
1592
  **Parameters:**
1103
1593