robot_lab 0.2.1 → 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 (236) 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 +185 -64
  8. data/Rakefile +28 -12
  9. data/docs/api/core/index.md +41 -14
  10. data/docs/api/core/memory.md +247 -29
  11. data/docs/api/core/network.md +285 -33
  12. data/docs/api/core/result.md +120 -32
  13. data/docs/api/core/robot.md +636 -60
  14. data/docs/api/core/state.md +87 -197
  15. data/docs/api/core/tool.md +165 -20
  16. data/docs/api/errors.md +152 -14
  17. data/docs/api/hooks.md +469 -0
  18. data/docs/api/index.md +83 -7
  19. data/docs/api/mcp/client.md +129 -35
  20. data/docs/api/mcp/index.md +164 -23
  21. data/docs/api/mcp/server.md +27 -3
  22. data/docs/api/mcp/transports.md +94 -22
  23. data/docs/api/messages/index.md +26 -3
  24. data/docs/api/messages/text-message.md +33 -11
  25. data/docs/api/messages/tool-call-message.md +27 -4
  26. data/docs/api/messages/tool-result-message.md +23 -4
  27. data/docs/api/messages/user-message.md +45 -8
  28. data/docs/api/skills.md +519 -0
  29. data/docs/api/streaming/context.md +28 -5
  30. data/docs/api/streaming/index.md +57 -11
  31. data/docs/api/support.md +846 -0
  32. data/docs/architecture/core-concepts.md +118 -30
  33. data/docs/architecture/index.md +86 -11
  34. data/docs/architecture/message-flow.md +66 -29
  35. data/docs/architecture/network-orchestration.md +153 -38
  36. data/docs/architecture/robot-execution.md +173 -91
  37. data/docs/architecture/state-management.md +31 -12
  38. data/docs/concepts.md +176 -21
  39. data/docs/examples/basic-chat.md +72 -19
  40. data/docs/examples/index.md +117 -31
  41. data/docs/examples/mcp-server.md +154 -45
  42. data/docs/examples/multi-robot-network.md +91 -21
  43. data/docs/examples/tool-usage.md +104 -37
  44. data/docs/getting-started/configuration.md +299 -93
  45. data/docs/getting-started/installation.md +53 -41
  46. data/docs/getting-started/quick-start.md +51 -6
  47. data/docs/guides/building-robots.md +296 -50
  48. data/docs/guides/creating-networks.md +220 -31
  49. data/docs/guides/hooks.md +1084 -0
  50. data/docs/guides/index.md +5 -0
  51. data/docs/guides/knowledge.md +37 -6
  52. data/docs/guides/mcp-integration.md +211 -44
  53. data/docs/guides/memory.md +103 -12
  54. data/docs/guides/observability.md +166 -51
  55. data/docs/guides/streaming.md +184 -125
  56. data/docs/guides/using-tools.md +300 -11
  57. data/docs/index.md +37 -5
  58. data/examples/01_simple_robot.rb +2 -2
  59. data/examples/02_tools.rb +14 -4
  60. data/examples/03_network.rb +23 -9
  61. data/examples/04_mcp.rb +11 -4
  62. data/examples/05_streaming.rb +8 -5
  63. data/examples/06_prompt_templates.rb +13 -9
  64. data/examples/07_network_memory.rb +5 -5
  65. data/examples/08_llm_config.rb +20 -15
  66. data/examples/09_chaining.rb +4 -4
  67. data/examples/11_network_introspection.rb +4 -4
  68. data/examples/12_message_bus.rb +2 -2
  69. data/examples/13_spawn.rb +2 -2
  70. data/examples/14_rusty_circuit/README.md +1 -0
  71. data/examples/14_rusty_circuit/comic.rb +7 -3
  72. data/examples/14_rusty_circuit/display.rb +14 -0
  73. data/examples/14_rusty_circuit/heckler.rb +8 -6
  74. data/examples/14_rusty_circuit/open_mic.rb +17 -6
  75. data/examples/14_rusty_circuit/scout.rb +17 -10
  76. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
  77. data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
  78. data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
  79. data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
  80. data/examples/16_writers_room/writer.rb +22 -22
  81. data/examples/16_writers_room/writers_room.rb +2 -0
  82. data/examples/17_skills.rb +14 -13
  83. data/examples/18_rails/README.md +20 -1
  84. data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
  85. data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
  86. data/examples/18_rails/app/robots/chat_robot.rb +11 -0
  87. data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
  88. data/examples/19_token_tracking.rb +25 -9
  89. data/examples/20_circuit_breaker.rb +10 -7
  90. data/examples/21_learning_loop.rb +42 -16
  91. data/examples/22_context_compression.rb +23 -23
  92. data/examples/23_convergence.rb +24 -17
  93. data/examples/24_structured_delegation.rb +13 -8
  94. data/examples/25_history_search.rb +12 -8
  95. data/examples/27_incident_response/incident_response.rb +31 -13
  96. data/examples/28_mcp_discovery.rb +17 -13
  97. data/examples/29_ractor_tools.rb +4 -2
  98. data/examples/30_ractor_network.rb +22 -17
  99. data/examples/31_launch_assessment.rb +20 -9
  100. data/examples/32_newsletter_reader.rb +7 -2
  101. data/examples/33_stock_predictor.rb +34 -13
  102. data/examples/34_agentskills.rb +7 -3
  103. data/examples/35_hooks.rb +266 -0
  104. data/examples/README.md +203 -42
  105. data/examples/common.rb +79 -7
  106. data/examples/xyzzy.rb +97 -0
  107. data/lib/robot_lab/agent_skill.rb +5 -4
  108. data/lib/robot_lab/budget/ledger.rb +98 -0
  109. data/lib/robot_lab/capabilities.rb +84 -0
  110. data/lib/robot_lab/config/defaults.yml +10 -0
  111. data/lib/robot_lab/config.rb +14 -9
  112. data/lib/robot_lab/error.rb +34 -4
  113. data/lib/robot_lab/errors.rb +45 -0
  114. data/lib/robot_lab/hook.rb +79 -0
  115. data/lib/robot_lab/hook_context.rb +194 -0
  116. data/lib/robot_lab/hook_registry.rb +55 -0
  117. data/lib/robot_lab/hooks.rb +87 -0
  118. data/lib/robot_lab/mcp/connection_poller.rb +2 -2
  119. data/lib/robot_lab/names.rb +402 -0
  120. data/lib/robot_lab/narrator.rb +87 -0
  121. data/lib/robot_lab/network.rb +77 -20
  122. data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
  123. data/lib/robot_lab/robot/budget.rb +89 -0
  124. data/lib/robot_lab/robot/bus_messaging.rb +90 -18
  125. data/lib/robot_lab/robot/hooking.rb +56 -0
  126. data/lib/robot_lab/robot/mcp_management.rb +8 -2
  127. data/lib/robot_lab/robot/template_rendering.rb +18 -5
  128. data/lib/robot_lab/robot.rb +233 -89
  129. data/lib/robot_lab/run_config.rb +24 -5
  130. data/lib/robot_lab/runnable.rb +51 -0
  131. data/lib/robot_lab/sandbox/null.rb +13 -0
  132. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  133. data/lib/robot_lab/sandbox.rb +52 -0
  134. data/lib/robot_lab/script_tool.rb +60 -2
  135. data/lib/robot_lab/task.rb +26 -20
  136. data/lib/robot_lab/tool.rb +52 -11
  137. data/lib/robot_lab/version.rb +1 -1
  138. data/lib/robot_lab.rb +70 -15
  139. data/mkdocs.yml +7 -1
  140. metadata +41 -99
  141. data/examples/temp.md +0 -51
  142. data/site/404.html +0 -2300
  143. data/site/api/core/index.html +0 -2706
  144. data/site/api/core/memory/index.html +0 -3793
  145. data/site/api/core/network/index.html +0 -3500
  146. data/site/api/core/robot/index.html +0 -4566
  147. data/site/api/core/state/index.html +0 -3390
  148. data/site/api/core/tool/index.html +0 -3843
  149. data/site/api/index.html +0 -2635
  150. data/site/api/mcp/client/index.html +0 -3435
  151. data/site/api/mcp/index.html +0 -2783
  152. data/site/api/mcp/server/index.html +0 -3252
  153. data/site/api/mcp/transports/index.html +0 -3352
  154. data/site/api/messages/index.html +0 -2641
  155. data/site/api/messages/text-message/index.html +0 -3087
  156. data/site/api/messages/tool-call-message/index.html +0 -3159
  157. data/site/api/messages/tool-result-message/index.html +0 -3252
  158. data/site/api/messages/user-message/index.html +0 -3212
  159. data/site/api/streaming/context/index.html +0 -3282
  160. data/site/api/streaming/events/index.html +0 -3347
  161. data/site/api/streaming/index.html +0 -2738
  162. data/site/architecture/core-concepts/index.html +0 -3757
  163. data/site/architecture/index.html +0 -2797
  164. data/site/architecture/message-flow/index.html +0 -3238
  165. data/site/architecture/network-orchestration/index.html +0 -3433
  166. data/site/architecture/robot-execution/index.html +0 -3140
  167. data/site/architecture/state-management/index.html +0 -3498
  168. data/site/assets/css/custom.css +0 -56
  169. data/site/assets/images/favicon.png +0 -0
  170. data/site/assets/images/robot_lab.jpg +0 -0
  171. data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
  172. data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
  173. data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
  174. data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
  175. data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
  176. data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
  177. data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
  178. data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
  179. data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
  180. data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
  181. data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
  182. data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
  183. data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
  184. data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
  185. data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
  186. data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
  187. data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
  188. data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
  189. data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
  190. data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
  191. data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
  192. data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
  193. data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
  194. data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
  195. data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
  196. data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
  197. data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
  198. data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
  199. data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
  200. data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
  201. data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
  202. data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
  203. data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
  204. data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
  205. data/site/assets/javascripts/lunr/tinyseg.js +0 -206
  206. data/site/assets/javascripts/lunr/wordcut.js +0 -6708
  207. data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
  208. data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
  209. data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
  210. data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
  211. data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
  212. data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
  213. data/site/concepts/index.html +0 -3455
  214. data/site/examples/basic-chat/index.html +0 -2880
  215. data/site/examples/index.html +0 -2907
  216. data/site/examples/mcp-server/index.html +0 -3018
  217. data/site/examples/multi-robot-network/index.html +0 -3131
  218. data/site/examples/rails-application/index.html +0 -3329
  219. data/site/examples/tool-usage/index.html +0 -3085
  220. data/site/getting-started/configuration/index.html +0 -3745
  221. data/site/getting-started/index.html +0 -2572
  222. data/site/getting-started/installation/index.html +0 -2981
  223. data/site/getting-started/quick-start/index.html +0 -2942
  224. data/site/guides/building-robots/index.html +0 -4290
  225. data/site/guides/creating-networks/index.html +0 -3858
  226. data/site/guides/index.html +0 -2586
  227. data/site/guides/mcp-integration/index.html +0 -3581
  228. data/site/guides/memory/index.html +0 -3586
  229. data/site/guides/rails-integration/index.html +0 -4019
  230. data/site/guides/streaming/index.html +0 -3157
  231. data/site/guides/using-tools/index.html +0 -3802
  232. data/site/index.html +0 -2671
  233. data/site/search/search_index.json +0 -1
  234. data/site/sitemap.xml +0 -183
  235. data/site/sitemap.xml.gz +0 -0
  236. data/site/tags.json +0 -1
@@ -25,11 +25,17 @@ robot = RobotLab.build(
25
25
  say so honestly.
26
26
  PROMPT
27
27
  local_tools: [OrderLookup, RefundProcessor], # RubyLLM::Tool subclasses
28
- mcp: :inherit, # Use network's MCP servers
28
+ mcp: :inherit, # Use the network's MCP servers
29
29
  temperature: 0.7 # Inference parameter
30
30
  )
31
31
  ```
32
32
 
33
+ The build-time `mcp: :inherit` above assumes this robot will be added to a
34
+ network whose `config:` supplies an `mcp:` list. The parent is resolved on every
35
+ run, so `:inherit` picks up whatever the enclosing network provides. For a robot
36
+ that will run **standalone**, `:inherit` resolves against the global default
37
+ `:none` and yields nothing — give it an explicit array instead.
38
+
33
39
  Or with a template:
34
40
 
35
41
  ```ruby
@@ -83,24 +89,32 @@ result = robot.run("What is the weather in Berlin?")
83
89
  puts result.last_text_content
84
90
  ```
85
91
 
86
- With runtime overrides:
92
+ `run` defaults to `mcp: :none, tools: :none`. Those defaults are *explicit* "send nothing this turn" values, not "unset" — so a plain `run` connects no MCP servers and sends the LLM zero tools even when `local_tools:`/`mcp:` were supplied at build time. Opt in per run:
93
+
94
+ ```ruby
95
+ robot.run("...", tools: :inherit) # send the attached local tools
96
+ robot.run("...", mcp: :inherit, tools: :inherit) # connect MCP servers and send their tools
97
+ ```
98
+
99
+ With other runtime overrides:
87
100
 
88
101
  ```ruby
89
102
  result = robot.run("Analyze this",
90
103
  memory: { data: report },
91
- mcp: :none,
92
- tools: :none
104
+ tools: :inherit
93
105
  )
94
106
  ```
95
107
 
96
- With streaming:
108
+ With streaming — the block receives a `RubyLLM::Chunk`, whose text is in `content`:
97
109
 
98
110
  ```ruby
99
- robot.run("Tell me a story") do |event|
100
- print event.text if event.respond_to?(:text)
111
+ robot.run("Tell me a story") do |chunk|
112
+ print chunk.content
101
113
  end
102
114
  ```
103
115
 
116
+ An `on_content:` callback (constructor kwarg or `RunConfig` field) fires on every run. When both an `on_content` callback and a block are supplied, both fire, stored callback first.
117
+
104
118
  ## Tool
105
119
 
106
120
  Tools give robots the ability to interact with external systems. There are two patterns for defining tools.
@@ -208,7 +222,7 @@ end
208
222
 
209
223
  | Key | Type | Description |
210
224
  |-----|------|-------------|
211
- | `:data` | `Hash` | Runtime data (accessible via `memory.data.key_name`) |
225
+ | `:data` | `StateProxy` | Runtime data (hash-style `memory.data[:key]` and method-style `memory.data.key_name`) |
212
226
  | `:results` | `Array` | Accumulated robot results |
213
227
  | `:messages` | `Array` | Conversation history |
214
228
  | `:session_id` | `String` | Session identifier |
@@ -227,7 +241,7 @@ memory.get(:sentiment) # => { score: 0.8 } or nil
227
241
 
228
242
  # Blocking read (waits until value exists)
229
243
  memory.get(:sentiment, wait: true) # Blocks indefinitely
230
- memory.get(:sentiment, wait: 30) # Blocks up to 30 seconds
244
+ memory.get(:sentiment, wait: 30) # Blocks up to 30s, then raises RobotLab::AwaitTimeout
231
245
 
232
246
  # Subscribe to changes
233
247
  memory.subscribe(:sentiment) do |change|
@@ -296,13 +310,21 @@ The output from a robot execution:
296
310
  result = robot.run("Hello!")
297
311
 
298
312
  result.robot_name # => "support_agent"
299
- result.output # => [TextMessage, ...]
300
- result.tool_calls # => [ToolResultMessage, ...]
301
- result.stop_reason # => "stop"
313
+ result.output # => [TextMessage] built from the final response text
314
+ result.tool_calls # => [] — see note below
315
+ result.stop_reason # => nil — always (see below)
302
316
  result.created_at # => Time
303
317
  result.id # => UUID string
318
+ result.input_tokens # => Integer
319
+ result.output_tokens # => Integer
320
+ result.duration # => Float, nil (set by Robot#call during pipeline execution)
321
+ result.checksum # => "sha256-hex"
304
322
  ```
305
323
 
324
+ `stop_reason` is always `nil` on a `Robot#run` result — `build_result` only copies it when the response responds to `stop_reason`, and `RubyLLM::Message` does not. The table above describes `Message::VALID_STOP_REASONS`, which applies to message objects you construct yourself, not to `RobotResult`. As a consequence `result.stopped?` reduces to `!result.has_tool_calls?`.
325
+
326
+ `output` is always a single `TextMessage` synthesized from the final response text — it is not a transcript of the turn. `tool_calls` is read off that final assistant message, which no longer carries tool calls once RubyLLM's tool loop has finished, so in practice it is empty; use the `on_tool_call`/`on_tool_result` callbacks or the tool hooks to observe tool usage.
327
+
306
328
  ### Accessing Response Content
307
329
 
308
330
  ```ruby
@@ -323,21 +345,44 @@ result.to_json # => JSON string
323
345
 
324
346
  ## Configuration
325
347
 
326
- RobotLab uses `MywayConfig` for configuration. There is no `RobotLab.configure` block. Configuration is loaded from:
348
+ Global configuration uses `MywayConfig`. Sources are layered lowest to highest:
327
349
 
328
350
  1. Bundled defaults (`lib/robot_lab/config/defaults.yml`)
329
- 2. Environment-specific overrides
330
- 3. XDG config files (`~/.config/robot_lab/config.yml`)
331
- 4. Project config (`./config/robot_lab.yml`)
332
- 5. Environment variables (`ROBOT_LAB_*` prefix)
351
+ 2. Environment-specific overrides (development / test / production)
352
+ 3. XDG config file (`~/.config/robot_lab/robot_lab.yml` — note the filename repeats the app name; `config.yml` is never read, and this loader does **not** run ERB)
353
+ 4. Project config (`./config/robot_lab.yml` — ERB is evaluated here)
354
+ 5. Environment variables (`ROBOT_LAB_*` prefix, double underscore for nesting)
355
+ 6. Constructor params
356
+
357
+ The two file layers treat top-level wrappers differently:
358
+
359
+ - **XDG file** (`~/.config/robot_lab/robot_lab.yml`) — a section named for the current environment **is** honoured. The loader checks `parsed.key?(env)` first (env comes from `Anyway::Settings.current_environment`, then `RAILS_ENV`, then `RACK_ENV`, defaulting to `"development"`) and falls back to the root when no such key exists. So `development:` works, a flat file works, and `production:` is simply skipped while you are in development. Only a `defaults:` wrapper is meaningless — it is not an environment name, so the whole hash is read as flat config and `defaults` is an unknown key.
360
+ - **Project file** (`./config/robot_lab.yml`) — outside Rails this must be flat; every wrapper, `defaults:` and environment names alike, is ignored. Inside Rails, `anyway_config` sets `current_environment` to `Rails.env`, which makes this file environmental: a flat project file is then ignored and keys must be nested under `development:` / `test:` / `production:`.
333
361
 
334
- Access via `RobotLab.config`:
362
+ Read via `RobotLab.config`, or set values with the `RobotLab.configure` block, which yields the same `Config` object:
335
363
 
336
364
  ```ruby
337
365
  RobotLab.config.ruby_llm.model # => "claude-sonnet-4"
338
366
  RobotLab.config.ruby_llm.request_timeout # => 120
367
+
368
+ RobotLab.configure do |c|
369
+ c.logger = Logger.new($stdout)
370
+ end
339
371
  ```
340
372
 
373
+ ### RunConfig
374
+
375
+ Global `Config` is distinct from `RunConfig`, the per-run settings object that flows `RobotLab.config → Network → Task → Robot → template front matter → constructor kwargs`. Its fields are:
376
+
377
+ - **LLM**: `model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop`
378
+ - **Capabilities** (`TOOL_FIELDS`): `mcp`, `tools`
379
+ - **Callbacks**: `on_tool_call`, `on_tool_result`, `on_content`
380
+ - **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`
381
+
382
+ `TOOL_FIELDS` is exactly `[:mcp, :tools]` — those are the two a network propagates. `max_tools` is an infrastructure field, so it is *not* inherited that way.
383
+
384
+ Per robot, template front matter is the **base**, a `config:` RunConfig merges over it, and constructor kwargs always win. A network-level `config:` propagates only `mcp` and `tools` down to member robots (and only when a robot opts in with `:inherit`); LLM fields and callbacks are never inherited from a network. `max_concurrent_robots` is the one field the network itself consumes.
385
+
341
386
  ## Configuration Hierarchy
342
387
 
343
388
  Tools and MCP servers use a cascading configuration system:
@@ -348,29 +393,32 @@ RobotLab.config (global)
348
393
  +-- mcp: [server1, server2]
349
394
  +-- tools: [tool1, tool2]
350
395
  |
351
- +-- Network
396
+ +-- Network config: (RunConfig)
352
397
  | |
353
398
  | +-- mcp: :inherit | :none | [servers]
354
399
  | +-- tools: :inherit | :none | [tools]
355
400
  | |
356
- | +-- Task (per-step config)
357
- | | +-- context: { department: "billing" }
358
- | | +-- mcp: :none | :inherit | [servers]
359
- | | +-- tools: :none | :inherit | [tools]
360
- | |
361
401
  | +-- Robot (build-time config)
362
402
  | |
363
403
  | +-- mcp: :inherit | :none | [servers]
364
404
  | +-- tools: :inherit | :none | [tools]
365
405
  | |
366
- | +-- run() call (runtime config)
367
- | +-- mcp: :none | [servers]
368
- | +-- tools: :none | [tools]
406
+ | +-- Task (per-task) / run() call -- the RUNTIME level
407
+ | +-- mcp: :none (default) | :inherit | [servers]
408
+ | +-- tools: :none (default) | :inherit | [tools]
369
409
  ```
370
410
 
371
- Resolution order: **runtime > robot build-time > task > network > global config**.
411
+ Resolution order: **task/runtime > robot build-time > network > global config**.
412
+
413
+ A `Task`'s `mcp:`/`tools:` are not a separate tier between network and robot. The task injects them into `run_params`, and `Robot#call` pulls them out and passes them to `run` — so they arrive as the *runtime* value and are resolved against the robot's build-time value.
372
414
 
373
- The `:inherit` value pulls from the parent level. `:none` explicitly disables.
415
+ The `:inherit` value pulls from the parent level. `:none` explicitly disables. An explicit array is a **filter over the already-attached tools**, not a local-vs-MCP switch.
416
+
417
+ Three consequences worth internalizing:
418
+
419
+ - `run()` defaults both to `:none`, so the runtime level is a deliberate "send nothing this turn" unless you override it. Pass `tools: :inherit` (and `mcp: :inherit`) to use what the robot was built with.
420
+ - Build-time `:inherit` depends on there being a parent to inherit from. The parent is recomputed on every run (`network_config&.tools || network_parent_config(network)&.tools || RobotLab.config.tools`), so inside a network whose `config:` sets `tools:`/`mcp:` it is exactly the right way to opt in. For a robot that runs **standalone** the parent is the global `:none`, so build-time `:inherit` matches nothing — leave `tools:` unset there and opt in at run time.
421
+ - An explicit array is compared with `tool.name.to_s`, and `Class#name` differs from `RubyLLM::Tool#name`. A tool attached as a class matches `[RefundTool]` (`"RefundTool"`); the same tool attached as an instance matches `%w[refund]`. Both forms work — but the array must be written in the same form the tool was attached in, or it filters everything out. Note that the **build-time** `tools:` kwarg is validated (`validate_tools_filter!`) and accepts only Strings/Symbols; the class form is available only at the task/runtime level, which is not validated. See [Network Orchestration](network-orchestration.md#task-configuration).
374
422
 
375
423
  ## Message Bus
376
424
 
@@ -378,7 +426,7 @@ The **Message Bus** provides bidirectional, cyclic communication between robots,
378
426
 
379
427
  ### How It Works
380
428
 
381
- Robots connect to a shared `TypedBus::MessageBus` via the `bus:` parameter. Each robot gets a typed channel (accepting only `RobotMessage` objects) named after its `name`. Messages are delivered asynchronously via the `async` gem's fiber scheduler.
429
+ Robots connect to a shared `TypedBus::MessageBus` via the `bus:` parameter. Each robot gets a typed channel (accepting only `RobotMessage` objects) named after its `name`. Delivery is routed through a shared `BusPoller`, which runs the handler **in the caller's execution context** (Async fiber or OS thread) rather than on a background thread of its own. A mutex serializes deliveries per robot: if a robot is already processing a message, later ones are queued and drained after the current one returns.
382
430
 
383
431
  ```ruby
384
432
  bus = TypedBus::MessageBus.new
@@ -415,6 +463,16 @@ end
415
463
 
416
464
  Block arity controls delivery handling: 1 argument auto-acks; 2 arguments give manual control over `delivery.ack!`/`delivery.nack!`.
417
465
 
466
+ `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 can't clobber each other — the bus is safe to send on from more than one thread at a time. (There is no poller thread doing the correlating: as described above, `BusPoller#enqueue` processes deliveries inline in the caller's context.)
467
+
468
+ 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:
469
+
470
+ ```ruby
471
+ bob.serve # equivalent to the on_message block above, running bob.run and replying automatically
472
+ ```
473
+
474
+ `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).
475
+
418
476
  ### Dynamic Spawning
419
477
 
420
478
  Robots can create new robots at runtime using `spawn`. The bus is created lazily — no upfront wiring required:
@@ -430,6 +488,8 @@ answer = helper.run("What is 2+2?").last_text_content
430
488
  helper.send_message(to: :dispatcher, content: answer)
431
489
  ```
432
490
 
491
+ 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.
492
+
433
493
  Robots can also join a bus after creation using `with_bus`:
434
494
 
435
495
  ```ruby
@@ -480,6 +540,34 @@ Networks provide:
480
540
  - **Per-task configuration** via the `Task` wrapper
481
541
  - **Broadcast messaging** for network-wide announcements
482
542
 
543
+ 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`.
544
+
545
+ ## Runnable Protocol
546
+
547
+ `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)`:
548
+
549
+ ```ruby
550
+ def summarize(runnable)
551
+ runnable.crew.each { |r| puts r.name }
552
+ puts "chief: #{runnable.chief.name}"
553
+ puts runnable.network? ? "network of #{runnable.robot_count}" : "single robot"
554
+ end
555
+
556
+ summarize(robot) # crew: [robot], chief: robot, "single robot"
557
+ summarize(network) # crew: network.robots.values, chief: crew.first, "network of N"
558
+ ```
559
+
560
+ | Method | Robot | Network |
561
+ |--------|-------|---------|
562
+ | `crew` | `[self]` | `robots.values` (pipeline order) |
563
+ | `chief` | `self` | `crew.first` |
564
+ | `robot_count` | `1` | `crew.size` |
565
+ | `network?` | `false` | `true` |
566
+ | `single?` | `true` | `false` |
567
+ | `run(message = nil, **opts)` | accepts a positional message (already did) | now also accepts a positional message, folded into `message:` — the keyword form still works |
568
+
569
+ `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).
570
+
483
571
  ## Next Steps
484
572
 
485
573
  - [Robot Execution](robot-execution.md) - Detailed execution flow
@@ -44,24 +44,31 @@ graph TB
44
44
  subgraph "RobotLab Core"
45
45
  B[Network]
46
46
  C[Task]
47
- D[Robot < RubyLLM::Agent]
47
+ D[Robot &lt; RubyLLM::Agent]
48
48
  E[Memory]
49
49
  F[RobotResult]
50
50
  end
51
51
 
52
52
  subgraph "Configuration"
53
- G[Config < MywayConfig::Base]
53
+ G[Config &lt; MywayConfig::Base]
54
+ R[RunConfig]
55
+ end
56
+
57
+ subgraph "Cross-Cutting"
58
+ HK[Hooks<br/>HookRegistry x3]
54
59
  end
55
60
 
56
61
  subgraph "Integration Layer"
57
62
  H[MCP Client]
58
- I[Tools < RubyLLM::Tool]
63
+ I[Tools &lt; RubyLLM::Tool]
59
64
  J[Templates / prompt_manager]
65
+ SK[AgentSkills + Sandbox]
60
66
  end
61
67
 
62
68
  subgraph "Execution Layer"
63
69
  K[SimpleFlow::Pipeline]
64
70
  L[RubyLLM Chat]
71
+ TB[TypedBus + BusPoller]
65
72
  end
66
73
 
67
74
  subgraph "Provider Layer"
@@ -77,20 +84,74 @@ graph TB
77
84
  C --> D
78
85
  B --> K
79
86
  B --> E
87
+ B --> TB
80
88
  D --> E
81
89
  D --> L
90
+ D --> TB
82
91
  D --> H
83
92
  D --> I
84
93
  D --> J
94
+ D --> SK
85
95
  D --> F
96
+ SK --> I
97
+ G --> R
98
+ R --> B
99
+ R --> C
100
+ R --> D
86
101
  G --> D
87
102
  G --> L
103
+ HK -.wraps.-> B
104
+ HK -.wraps.-> C
105
+ HK -.wraps.-> D
106
+ HK -.wraps.-> I
88
107
  L --> M
89
108
  L --> N
90
109
  L --> O
91
110
  H --> P
92
111
  ```
93
112
 
113
+ The dotted `wraps` edges are the hook system: `Hooks.run` brackets every network
114
+ run, task, robot run, LLM generation, tool call, compaction, and `learn` call. It
115
+ is the framework's extension seam — the extension gems (`robot_lab-audit`,
116
+ `robot_lab-durable`, …) attach here rather than subclassing core objects. See the
117
+ [Hooks API](../api/hooks.md).
118
+
119
+ ### Robot subsystems
120
+
121
+ A `Robot` composes several small collaborators, each documented on the
122
+ [Support](../api/support.md) and [Skills](../api/skills.md) API pages:
123
+
124
+ ```mermaid
125
+ graph LR
126
+ D[Robot]
127
+
128
+ D --> BM[BusMessaging<br/>RobotMessage envelopes]
129
+ BM --> BP[BusPoller<br/>per-robot serialization]
130
+ D --> BD[Budget::Ledger<br/>token_budget / cost_budget]
131
+ D --> DL[DoomLoopDetector<br/>always installed per run]
132
+ D --> HC[HistoryCompressor<br/>compress_history / auto_compact]
133
+ D --> DF[DelegationFuture<br/>delegate async: true]
134
+ D --> HS[HistorySearch<br/>search_history]
135
+ D --> ASM[AgentSkillMatching<br/>prepended around run]
136
+
137
+ ASM --> AS[AgentSkill<br/>SKILL.md bundle]
138
+ AS --> CAT[AgentSkillCatalog]
139
+ AS --> CAP[Capabilities]
140
+ AS --> ST[ScriptTool]
141
+ CAP --> SB[Sandbox<br/>Seatbelt or Null]
142
+ ST --> SB
143
+
144
+ HC --> TA[TextAnalysis<br/>TF / TF-IDF]
145
+ HS --> TA
146
+ CV[Convergence] --> TA
147
+ SD[MCP::ServerDiscovery] --> TA
148
+ ```
149
+
150
+ `TextAnalysis` is the shared floor under every similarity feature — history
151
+ compression, history search, convergence detection, and MCP server discovery all
152
+ route through it, which is why they share the one optional `classifier`
153
+ dependency.
154
+
94
155
  ## Core Components
95
156
 
96
157
  | Component | Description | Documentation |
@@ -100,30 +161,44 @@ graph TB
100
161
  | **Memory** | Reactive key-value store with pub/sub and blocking reads | [Memory Management](state-management.md) |
101
162
  | **Task** | Wraps a robot for pipeline execution with per-task config | [Network Orchestration](network-orchestration.md) |
102
163
  | **RobotResult** | Captures LLM output, tool calls, and metadata from a run | [Message Flow](message-flow.md) |
103
- | **Config** | MywayConfig-based configuration with env var and file support | [Configuration](#configuration) |
164
+ | **RunConfig** | Per-run settings object (LLM fields, `mcp`/`tools`, callbacks, infrastructure) that cascades global → network → task → robot | [Core Concepts](core-concepts.md) |
165
+ | **Config** | MywayConfig-based global configuration with env var and file support | [Configuration](#configuration) |
166
+ | **Hook** | Handler base class for the seven hook families — the framework's extension seam | [Hooks API](../api/hooks.md) |
167
+ | **AgentSkill** | A `SKILL.md` bundle whose instructions and `scripts/` become prompt text and tools | [Skills API](../api/skills.md) |
168
+ | **Sandbox** | Opt-in OS-level confinement (macOS Seatbelt) for skill scripts, derived from `Capabilities` | [Skills API](../api/skills.md) |
169
+ | **RobotMessage** | Immutable envelope for TypedBus inter-robot messaging, serialized per robot by `BusPoller` | [Support API](../api/support.md) |
170
+ | **Budget::Ledger** | Thread-safe reserve/reconcile ledger behind `token_budget` / `cost_budget` | [Support API](../api/support.md) |
104
171
 
105
172
  ## Configuration
106
173
 
107
- RobotLab uses MywayConfig (`Config < MywayConfig::Base`) instead of a `configure` block. Configuration is loaded from multiple sources in priority order:
174
+ Global configuration is a MywayConfig subclass (`Config < MywayConfig::Base`). It is loaded from multiple sources in priority order:
108
175
 
109
176
  1. **Bundled defaults** (`lib/robot_lab/config/defaults.yml`)
110
- 2. **Environment overrides** (development, test, production sections)
111
- 3. **XDG user config** (`~/.config/robot_lab/config.yml`)
112
- 4. **Project config** (`./config/robot_lab.yml`)
177
+ 2. **Environment overrides** (development, test, production)
178
+ 3. **XDG user config** (`~/.config/robot_lab/robot_lab.yml` — the filename repeats the app name; `config.yml` is never read)
179
+ 4. **Project config** (`./config/robot_lab.yml` — the only file with an ERB pass)
113
180
  5. **Environment variables** (`ROBOT_LAB_*` prefix, double underscore for nesting)
181
+ 6. **Constructor params**
182
+
183
+ Top-level wrappers behave differently per file. In the **XDG user config**, a section named for the current environment is honoured — the loader looks for `parsed.key?(env)` (`Anyway::Settings.current_environment`, else `RAILS_ENV`, else `RACK_ENV`, else `"development"`) and falls back to the root when absent — so both `development:` and a flat file work there. A `defaults:` wrapper is not an environment name, so it is ignored. The **project config** must be flat outside Rails (all wrappers ignored); under Rails, `anyway_config` sets `current_environment` to `Rails.env` and the project file becomes environmental, so a flat file is ignored and keys must sit under `development:` / `test:` / `production:`. See [Core Concepts](core-concepts.md#configuration) for details.
114
184
 
115
185
  ```ruby
116
186
  # Access configuration
117
187
  RobotLab.config.ruby_llm.model #=> "claude-sonnet-4"
118
188
  RobotLab.config.ruby_llm.request_timeout #=> 120
189
+
190
+ # Block form (yields the same Config object)
191
+ RobotLab.configure do |c|
192
+ c.logger = Logger.new($stdout)
193
+ end
119
194
  ```
120
195
 
121
196
  ## Data Flow
122
197
 
123
198
  1. **Input**: User calls `robot.run("message")` or `network.run(message: "...")`
124
199
  2. **Memory**: Robot resolves active memory (standalone or network-shared)
125
- 3. **MCP**: Robot resolves and initializes MCP clients from hierarchical config
126
- 4. **Tools**: Robot resolves and filters tools from hierarchical config
200
+ 3. **MCP**: Robot resolves MCP servers from hierarchical config and connects clients. `run` defaults to `mcp: :none`, so a plain `run` connects nothing
201
+ 4. **Tools**: Robot resolves and filters tools from hierarchical config. `run` defaults to `tools: :none`, so a plain `run` sends the LLM zero tools; pass `tools: :inherit` to send the robot's attached tools
127
202
  5. **Execution**: Robot delegates to `Agent#ask` which calls `@chat.ask` on RubyLLM
128
203
  6. **Tool Loop**: LLM may invoke tools; RubyLLM handles the tool call/result loop
129
204
  7. **Result**: Robot builds and returns a `RobotResult`
@@ -161,7 +236,7 @@ robot = RobotLab.build(name: "bot")
161
236
 
162
237
  ### Hierarchical Configuration
163
238
 
164
- Tools and MCP servers use hierarchical resolution: `runtime > robot build > network > global config`. Values can be `:none`, `:inherit`, or explicit arrays.
239
+ Tools and MCP servers use hierarchical resolution: `run()/task > robot build-time > network > global config`. Values can be `:none`, `:inherit`, or an explicit array (which filters the already-attached tools by name — entries must match how the tool was attached, class or instance). `run()` defaults both to `:none`, so the runtime level is an explicit "send nothing" unless you override it.
165
240
 
166
241
  ### SimpleFlow Pipeline
167
242
 
@@ -4,7 +4,7 @@ This page explains how messages move through RobotLab, from user input to LLM re
4
4
 
5
5
  ## Message Types
6
6
 
7
- RobotLab uses four primary message types:
7
+ `RobotLab::Message` is the abstract base for three conversation message classes. `ToolMessage` is a plain value object that they reference, and two further message classes — `UserMessage` and `RobotMessage` — live outside the hierarchy entirely:
8
8
 
9
9
  ```mermaid
10
10
  classDiagram
@@ -12,22 +12,30 @@ classDiagram
12
12
  <<abstract>>
13
13
  +type: String
14
14
  +role: String
15
- +content: String
15
+ +content
16
16
  +stop_reason: String
17
- }
18
-
19
- class TextMessage {
20
17
  +text?() bool
18
+ +tool_call?() bool
19
+ +tool_result?() bool
20
+ +system?() bool
21
21
  +user?() bool
22
22
  +assistant?() bool
23
- +system?() bool
23
+ +stopped?() bool
24
+ +tool_stop?() bool
25
+ +to_h() Hash
26
+ }
27
+
28
+ class TextMessage {
29
+ +content: String
24
30
  }
25
31
 
26
32
  class ToolMessage {
33
+ <<PORO>>
27
34
  +id: String
28
35
  +name: String
29
36
  +input: Hash
30
- +tool_call?() bool
37
+ +to_h() Hash
38
+ +to_json() String
31
39
  }
32
40
 
33
41
  class ToolCallMessage {
@@ -37,7 +45,10 @@ classDiagram
37
45
  class ToolResultMessage {
38
46
  +tool: ToolMessage
39
47
  +content: Hash
40
- +tool_result?() bool
48
+ +success?() bool
49
+ +error?() bool
50
+ +data()
51
+ +error()
41
52
  }
42
53
 
43
54
  Message <|-- TextMessage
@@ -47,6 +58,15 @@ classDiagram
47
58
  ToolMessage -- ToolResultMessage
48
59
  ```
49
60
 
61
+ `ToolMessage` does **not** inherit from `Message` — its superclass is `Object`. It has no `type`, `role`, or predicate methods; only `id`, `name`, `input`, `to_h`, and `to_json`. The `tool_call?` predicate lives on `Message`, so it answers for `ToolCallMessage`, not for `ToolMessage`.
62
+
63
+ The two classes outside the hierarchy:
64
+
65
+ | Class | Role |
66
+ |-------|------|
67
+ | `UserMessage` | Envelope for user input carrying `session_id`, an extra `system_prompt`, `metadata`, `id`, `created_at`. `UserMessage.from` normalizes a String/Hash/TextMessage; `#to_message` converts to a `TextMessage` |
68
+ | `RobotMessage` | Immutable `Data.define(:id, :from, :content, :in_reply_to)` envelope for robot-to-robot traffic on the message bus, with `#key` (`"from:id"`) and `#reply?` |
69
+
50
70
  ### TextMessage
51
71
 
52
72
  Regular text content from users or assistants:
@@ -66,16 +86,20 @@ TextMessage.new(
66
86
 
67
87
  ### ToolMessage
68
88
 
69
- Represents a tool invocation with its parameters:
89
+ A standalone value object (not a `Message`) representing a tool invocation and its parameters:
70
90
 
71
91
  ```ruby
72
- ToolMessage.new(
92
+ tool = ToolMessage.new(
73
93
  id: "tool_123",
74
94
  name: "get_weather",
75
95
  input: { location: "Paris" }
76
96
  )
97
+
98
+ tool.to_h #=> { type: "tool", id: "tool_123", name: "get_weather", input: { location: "Paris" } }
77
99
  ```
78
100
 
101
+ Note that the `type: "tool"` key appears only in `to_h`; there is no `type` reader and no predicate methods.
102
+
79
103
  ### ToolCallMessage
80
104
 
81
105
  LLM's request to execute one or more tools:
@@ -83,14 +107,17 @@ LLM's request to execute one or more tools:
83
107
  ```ruby
84
108
  ToolCallMessage.new(
85
109
  role: "assistant",
86
- content: nil,
87
- stop_reason: "tool",
88
110
  tools: [
89
111
  ToolMessage.new(id: "call_1", name: "get_weather", input: { location: "Paris" })
90
- ]
112
+ ],
113
+ stop_reason: "tool"
91
114
  )
92
115
  ```
93
116
 
117
+ The signature is `initialize(role:, tools:, stop_reason: nil)` — `role` and
118
+ `tools` are required and there is **no** `content:` keyword. Passing one raises
119
+ `ArgumentError`.
120
+
94
121
  ### ToolResultMessage
95
122
 
96
123
  Result from tool execution:
@@ -126,8 +153,8 @@ sequenceDiagram
126
153
  Robot->>Robot: ensure_mcp_clients
127
154
 
128
155
  Robot->>Tools: resolve_tools_hierarchy
129
- Tools-->>Robot: filtered tools
130
- Robot->>Chat: @chat.with_tools(...)
156
+ Tools-->>Robot: filtered + capped tools
157
+ Robot->>Chat: @chat.with_tools(*filtered, replace: true)
131
158
 
132
159
  Robot->>Agent: ask("message")
133
160
  Agent->>Chat: @chat.ask("message")
@@ -161,13 +188,13 @@ sequenceDiagram
161
188
 
162
189
  4. **Set Current Writer**: Sets `memory.current_writer = robot.name` so subscription callbacks know which robot wrote a value.
163
190
 
164
- 5. **Resolve MCP Hierarchy**: Resolves MCP server configuration through the hierarchy: `runtime > robot build > network > global config`.
191
+ 5. **Resolve MCP Hierarchy**: Resolves MCP server configuration through the hierarchy: `runtime (run/task) > robot build-time > network > global config`. `run` defaults `mcp:` to `:none`, so nothing is connected unless you pass `mcp: :inherit` or an explicit list.
165
192
 
166
- 6. **Ensure MCP Clients**: Initializes or updates MCP client connections. Discovers tools from connected MCP servers.
193
+ 6. **Ensure MCP Clients**: Initializes or updates MCP client connections and discovers tools from them. Connection failures are logged and recorded in `failed_mcp_server_names`, never raised.
167
194
 
168
- 7. **Resolve Tools Hierarchy**: Resolves which tools are available through the same hierarchy.
195
+ 7. **Resolve Tools Hierarchy**: Resolves which tools are available through the same hierarchy. `run` defaults `tools:` to `:none`, which means "send zero tools this turn" — pass `tools: :inherit` to send the robot's attached tools.
169
196
 
170
- 8. **Filter Tools**: Applies the resolved tool list to `@chat.with_tools(...)`.
197
+ 8. **Filter Tools**: Filters by the resolved allowlist, clamps to `max_tools` (128 by default), and applies the set with `@chat.with_tools(*filtered, replace: true)` so the persistent chat holds exactly this turn's tools.
171
198
 
172
199
  9. **Agent#ask**: Delegates to the parent class `RubyLLM::Agent#ask`, which calls `@chat.ask(message)`.
173
200
 
@@ -212,11 +239,11 @@ sequenceDiagram
212
239
 
213
240
  ### Key Points
214
241
 
215
- - **Network creates initial result**: `SimpleFlow::Result.new(run_context, context: { run_params: run_context })`
216
- - **Task wraps robot**: Each `Task` deep-merges its own context with the run params before delegating to the robot
217
- - **Robot extracts context**: `extract_run_context(result)` pulls the message, MCP, tools, and memory from the SimpleFlow result
242
+ - **Network creates initial result**: `SimpleFlow::Result.new(run_context, context: { run_params: run_context })`, after injecting `network_memory`, `network`, and (when non-empty) `network_config` into the run context, all wrapped in the `:network_run` hook
243
+ - **Task wraps robot**: Each `Task` runs the `:task` hook and deep-merges its own context, `mcp`, `tools`, `memory`, and `config` into the run params before delegating to the robot
244
+ - **Robot extracts context**: `extract_run_context(result)` pulls the message, MCP, tools, memory, network, and task out of the SimpleFlow result and passes them to `run` as keyword arguments
218
245
  - **Shared memory**: All robots use `network.memory` during network execution
219
- - **Result accumulation**: Each task stores its `RobotResult` in `result.context[:task_name]`
246
+ - **Result accumulation**: Each robot stores its `RobotResult` in `result.context[:robot_name]` — the key comes from `@name.to_sym` in `Robot#call`, so it is the **robot's** name, not the task name. They match only when the two are spelled the same way
220
247
 
221
248
  ## RobotResult
222
249
 
@@ -226,16 +253,24 @@ The return value of `robot.run("message")`:
226
253
  result = robot.run("What is Ruby?")
227
254
 
228
255
  result.last_text_content #=> "Ruby is a dynamic programming language..."
256
+ result.reply #=> alias for last_text_content
229
257
  result.has_tool_calls? #=> false
230
258
  result.robot_name #=> "assistant"
231
259
  result.output #=> [TextMessage(role: "assistant", content: "...")]
232
260
  result.tool_calls #=> []
233
- result.stop_reason #=> "stop"
261
+ result.stop_reason #=> nil (always — see below)
234
262
  result.created_at #=> Time
235
263
  result.id #=> "uuid"
236
264
  result.checksum #=> "sha256-hex"
265
+ result.input_tokens #=> 42
266
+ result.output_tokens #=> 128
267
+ result.duration #=> Float, nil
237
268
  ```
238
269
 
270
+ `stop_reason` is always `nil` on a `Robot#run` result: `build_result` reads it as `response.respond_to?(:stop_reason) ? response.stop_reason : nil`, and `RubyLLM::Message` does not define the method. It is consequently dropped from `export`, and `result.stopped?` reduces to `!result.has_tool_calls?`.
271
+
272
+ `output` always holds exactly one synthesized `TextMessage` built from the final response text — it is not a transcript of the turn, and it never contains `ToolCallMessage` or `ToolResultMessage` entries. `tool_calls` is read off the final assistant message, which carries no tool calls once RubyLLM's tool loop has finished, so it is effectively always empty. To observe tool activity, use the `on_tool_call` / `on_tool_result` callbacks or the tool hooks.
273
+
239
274
  ### Result Serialization
240
275
 
241
276
  ```ruby
@@ -254,7 +289,7 @@ result = RobotResult.from_hash(hash)
254
289
 
255
290
  ## Message Predicates
256
291
 
257
- Check message types:
292
+ These are defined on `Message`, so every `TextMessage`, `ToolCallMessage`, and `ToolResultMessage` responds to all of them. `ToolMessage` responds to none of them:
258
293
 
259
294
  ```ruby
260
295
  message.text? # Is it a TextMessage?
@@ -269,6 +304,8 @@ message.stopped? # Is stop_reason "stop"?
269
304
  message.tool_stop? # Is stop_reason "tool"?
270
305
  ```
271
306
 
307
+ Valid values are constrained at construction: `type` must be one of `text`, `tool_call`, `tool_result`; `role` one of `system`, `user`, `assistant`, `tool_result`; `stop_reason` one of `tool`, `stop`. Anything else raises `ArgumentError`.
308
+
272
309
  ## Creating Messages
273
310
 
274
311
  ### From Strings
@@ -317,11 +354,11 @@ robot = RobotLab.build(
317
354
 
318
355
  The template resolution process:
319
356
  1. `PM.parse(:helper)` loads the template file from the configured prompts directory
320
- 2. YAML front matter is extracted and applied to the chat (model, temperature, etc.)
321
- 3. The template body is rendered with the provided context
322
- 4. The rendered text is set as system instructions via `@chat.with_instructions(rendered)`
357
+ 2. Non-LLM front matter (`robot_name`, `description`, `tools`, `mcp`, `skills`) is applied to the robot, filling in only what the constructor did not provide
358
+ 3. The LLM front-matter keys become a `RunConfig` that the robot's own `@config` merges over — front matter is the base, constructor kwargs win — and `apply_to` dispatches `chat.with_<field>` for each field the chat supports. In practice only `model` and `temperature` reach the chat this way; `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, and `stop` are parsed and silently dropped because `RubyLLM::Chat` defines no `with_` method for them. Set those via constructor kwargs instead
359
+ 4. The template body is rendered with ERB (`<%= var %>`; `{{ var }}` is not interpolated) and set as system instructions via `@chat.with_instructions(rendered)`
323
360
 
324
- If both `template:` and `system_prompt:` are provided, the template is applied first, then the system prompt is appended via a second `@chat.with_instructions` call.
361
+ If both `template:` and `system_prompt:` are provided, the system prompt is appended to the rendered template, producing one combined system message — and it is re-appended on every template re-render, so supplying run-time context never silently drops it.
325
362
 
326
363
  ## Next Steps
327
364