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
@@ -20,12 +20,20 @@ puts result.last_text_content
20
20
 
21
21
  ### Name
22
22
 
23
- A unique identifier used for routing and logging. If omitted, an auto-generated name is used:
23
+ An identifier used for routing, logging, and as the key under which a robot's result is stored in a network's `result.context`. If omitted it defaults to the literal string `"robot"` — nothing is auto-generated, so two unnamed robots share the same name.
24
24
 
25
25
  ```ruby
26
26
  robot = RobotLab.build(name: "support_agent", system_prompt: "...")
27
+
28
+ RobotLab.build.name # => "robot"
27
29
  ```
28
30
 
31
+ > [!NOTE]
32
+ > The default is load-bearing. A robot records whether `name:` was supplied by
33
+ > comparing it against `"robot"`; front-matter `robot_name:` is applied **only**
34
+ > when the constructor left the name at its default. Passing `name: "robot"`
35
+ > explicitly therefore still counts as "not named", and front matter wins.
36
+
29
37
  ### Description
30
38
 
31
39
  Describes what the robot does (useful for routing decisions):
@@ -123,16 +131,31 @@ The following YAML front matter keys are applied to the robot's chat automatical
123
131
 
124
132
  **LLM Configuration:**
125
133
 
126
- | Key | Description |
127
- |-----|-------------|
128
- | `model` | Override the LLM model |
129
- | `temperature` | Controls randomness (0.0 - 1.0) |
130
- | `top_p` | Nucleus sampling threshold |
131
- | `top_k` | Top-k sampling |
132
- | `max_tokens` | Maximum tokens in response |
133
- | `presence_penalty` | Penalize based on presence |
134
- | `frequency_penalty` | Penalize based on frequency |
135
- | `stop` | Stop sequences |
134
+ | Key | Description | Applied from front matter? |
135
+ |-----|-------------|----------------------------|
136
+ | `model` | Override the LLM model | Yes |
137
+ | `temperature` | Controls randomness (0.0 - 1.0) | Yes |
138
+ | `top_p` | Nucleus sampling threshold | **No — silently dropped** |
139
+ | `top_k` | Top-k sampling | **No — silently dropped** |
140
+ | `max_tokens` | Maximum tokens in response | **No — silently dropped** |
141
+ | `presence_penalty` | Penalize based on presence | **No — silently dropped** |
142
+ | `frequency_penalty` | Penalize based on frequency | **No — silently dropped** |
143
+ | `stop` | Stop sequences | **No — silently dropped** |
144
+
145
+ > [!WARNING]
146
+ > Only `model` and `temperature` take effect from front matter. The other six are
147
+ > parsed into the robot's `RunConfig` and then dropped: `RunConfig#apply_to`
148
+ > dispatches `chat.with_<field>` guarded by `respond_to?`, and `RubyLLM::Chat`
149
+ > only implements `with_model` and `with_temperature`. A template declaring all
150
+ > eight leaves the chat's params hash empty — no warning, no error.
151
+ >
152
+ > The same six **do** work as constructor kwargs or via a `config:` `RunConfig`,
153
+ > which route through `with_params`:
154
+ >
155
+ > ```ruby
156
+ > RobotLab.build(name: "w", system_prompt: "...", top_p: 0.5, max_tokens: 1200)
157
+ > # chat params => {top_p: 0.5, max_tokens: 1200}
158
+ > ```
136
159
 
137
160
  **Robot Identity and Capabilities:**
138
161
 
@@ -161,15 +184,16 @@ Unlike `template:` on `build` (which renders a template as a robot's *system pro
161
184
 
162
185
  Templates can declare everything a robot needs — identity, tools, MCP servers, and LLM config — making the `.md` file a complete robot definition:
163
186
 
164
- ```markdown title="prompts/github_assistant.md"
187
+ ```markdown title="prompts/my_github_assistant.md"
165
188
  ---
166
189
  description: GitHub assistant with MCP tool access
167
190
  robot_name: github_bot
168
191
  mcp:
169
192
  - name: github
170
- transport: stdio
171
- command: npx
172
- args: ["-y", "@modelcontextprotocol/server-github"]
193
+ transport:
194
+ type: stdio
195
+ command: npx
196
+ args: ["-y", "@modelcontextprotocol/server-github"]
173
197
  model: claude-sonnet-4
174
198
  temperature: 0.3
175
199
  ---
@@ -181,9 +205,22 @@ Build the robot with minimal constructor arguments:
181
205
 
182
206
  ```ruby
183
207
  # Template provides name, description, MCP config, model, and temperature
184
- robot = RobotLab.build(template: :github_assistant)
208
+ robot = RobotLab.build(template: :my_github_assistant)
209
+
210
+ # MCP still has to be requested at run time — see the warning below
211
+ robot.run("What are the open issues?", mcp: :inherit, tools: :inherit)
185
212
  ```
186
213
 
214
+ > [!WARNING]
215
+ > `transport:` **must be a nested hash**. The shipped
216
+ > `examples/prompts/github_assistant.md` uses the flat form
217
+ > (`transport: stdio` with sibling `command:`/`args:` keys), which raises
218
+ > `NoMethodError: undefined method 'transform_keys' for an instance of String`
219
+ > internally. The error is swallowed and logged as a warning, the server lands in
220
+ > `robot.failed_mcp_server_names`, and the robot builds with **zero tools**. That
221
+ > shipped template also declares no `model:` or `temperature:`, so it does not
222
+ > demonstrate the full self-contained pattern shown here.
223
+
187
224
  ### Tools in Front Matter
188
225
 
189
226
  Declare tool classes by name in the `tools:` key. RobotLab resolves each string to a Ruby constant and instantiates it:
@@ -201,6 +238,9 @@ You help customers with order inquiries and refunds.
201
238
  ```ruby
202
239
  # Tools are loaded from frontmatter — no local_tools: needed
203
240
  robot = RobotLab.build(template: :order_support)
241
+
242
+ # ...but they are only sent to the model when the run asks for them
243
+ robot.run("Where is order 12345?", tools: :inherit)
204
244
  ```
205
245
 
206
246
  Tool classes must be defined and loaded before the robot is built. If a tool name cannot be resolved, it is skipped with a warning.
@@ -224,19 +264,29 @@ Declare MCP server configurations directly in the template:
224
264
  description: Developer assistant with filesystem access
225
265
  mcp:
226
266
  - name: filesystem
227
- transport: stdio
228
- command: mcp-server-filesystem
229
- args: ["--root", "/home/user/projects"]
267
+ transport:
268
+ type: stdio
269
+ command: mcp-server-filesystem
270
+ args: ["--root", "/home/user/projects"]
230
271
  ---
231
272
  You are a developer assistant with filesystem access.
232
273
  ```
233
274
 
234
275
  ```ruby
235
276
  robot = RobotLab.build(template: :developer)
277
+ robot.run("List the files in lib/", mcp: :inherit, tools: :inherit)
236
278
  ```
237
279
 
238
280
  Constructor `mcp:` overrides frontmatter `mcp:` when provided.
239
281
 
282
+ > [!WARNING]
283
+ > `transport:` takes a nested hash — `type:` plus the transport's own keys. A flat
284
+ > `transport: stdio` with sibling `command:`/`args:` keys fails silently (the
285
+ > `transform_keys` NoMethodError is swallowed) and the robot ends up with no MCP
286
+ > tools. Valid `type:` values are `stdio`, `sse`, `ws`, `websocket`,
287
+ > `streamable-http`, and `http`; the underscored `streamable_http` raises
288
+ > `ArgumentError`.
289
+
240
290
  ### Template with System Prompt
241
291
 
242
292
  You can combine a template and an inline system prompt. Both are applied to the chat -- the template first, then the system prompt is appended as additional instructions:
@@ -363,7 +413,6 @@ Skills can include LLM configuration in their front matter. Config cascades in p
363
413
  ---
364
414
  description: Enable creative responses
365
415
  temperature: 0.9
366
- top_p: 0.95
367
416
  ---
368
417
  Be creative and imaginative in your responses.
369
418
  ```
@@ -377,6 +426,12 @@ robot = RobotLab.build(
377
426
  # temperature is 0.9 from the skill (unless the main template or constructor overrides it)
378
427
  ```
379
428
 
429
+ > [!NOTE]
430
+ > Skill front matter is subject to the same limitation as template front matter:
431
+ > only `model` and `temperature` reach the chat. Adding `top_p: 0.95` to
432
+ > `creative_mode.md` would be parsed and then silently discarded. Set it as a
433
+ > constructor kwarg (`top_p: 0.95`) instead.
434
+
380
435
  The precedence order (highest wins):
381
436
 
382
437
  1. Constructor kwargs (`temperature: 0.3`)
@@ -427,8 +482,41 @@ robot = RobotLab.build(
427
482
  system_prompt: "You help customers with orders.",
428
483
  local_tools: [OrderLookup, InventoryCheck]
429
484
  )
485
+
486
+ result = robot.run("Where is order 12345?", tools: :inherit)
430
487
  ```
431
488
 
489
+ > [!WARNING]
490
+ > **`run` defaults to `tools: :none` and `mcp: :none`.** Attaching tools at build
491
+ > time is not enough — a plain `robot.run("...")` sends the model **zero** tools,
492
+ > because an explicit `:none` means "send no tools this turn" rather than "fall
493
+ > back to the attached set". Pass `tools: :inherit` on the call to send the
494
+ > attached tools, and `mcp: :inherit, tools: :inherit` to connect MCP servers and
495
+ > send their tools.
496
+ >
497
+ > For a **standalone** robot, do not pass `tools: :inherit` at *build* time: the
498
+ > parent level is the global config's `:none`, so it resolves to an allowlist of
499
+ > `["none"]`, which matches nothing. Leave `tools:` unset in the constructor.
500
+ >
501
+ > | build `tools:` | run `tools:` | tools sent |
502
+ > |---|---|---|
503
+ > | unset | `:none` (default) | none |
504
+ > | unset | `:inherit` | all attached — **the correct pattern** |
505
+ > | `:inherit` | `:inherit` | none — the standalone trap |
506
+ > | `:none` | `:inherit` | all attached |
507
+ >
508
+ > This table is for a robot run on its own. Inside a **network** whose `config:`
509
+ > sets `tools:`/`mcp:`, build-time `:inherit` is not a trap — it is exactly how a
510
+ > robot opts in to the network-level list, and the parent is that list rather
511
+ > than `:none`. See
512
+ > [Network-Wide Tool and MCP Defaults](creating-networks.md#network-wide-tool-and-mcp-defaults).
513
+ >
514
+ > An explicit array (`tools: [OrderLookup]`) is an **allowlist** over the attached
515
+ > tools; it selects from them and cannot add new ones. Entries must match how the
516
+ > tool was attached — a tool attached as a class matches its class name
517
+ > (`[OrderLookup]`), one attached as an instance matches RubyLLM's derived name
518
+ > (`%w[order_lookup]`). The two forms do not cross-match.
519
+
432
520
  See the [Using Tools](using-tools.md) guide for details on defining tools.
433
521
 
434
522
  ## MCP Configuration
@@ -456,6 +544,14 @@ MCP configuration supports hierarchical resolution:
456
544
  | `:inherit` | Use parent network/config MCP servers |
457
545
  | `[...]` | Explicit array of server configurations |
458
546
 
547
+ `run` also defaults to `mcp: :none`, so the servers configured above are not connected by a plain `run`:
548
+
549
+ ```ruby
550
+ robot.run("Read config/database.yml", mcp: :inherit, tools: :inherit)
551
+ ```
552
+
553
+ `robot.connect_mcp!` connects eagerly if you want the handshake to happen up front, but a later plain `run()` still sends no tools — `tools: :inherit` is what puts the MCP tools in the request. Connection failures are logged and recorded in `robot.failed_mcp_server_names`; they are never raised.
554
+
459
555
  See the [MCP Integration](mcp-integration.md) guide for transport types and advanced patterns.
460
556
 
461
557
  ## Chaining Configuration
@@ -474,24 +570,35 @@ result = robot
474
570
 
475
571
  ### Available Chain Methods
476
572
 
573
+ This is the complete set — the LLM-facing methods are delegated dynamically from `RubyLLM::Chat`, and `with_template` / `with_bus` are RobotLab's own:
574
+
477
575
  | Method | Description |
478
576
  |--------|-------------|
479
577
  | `with_model(id)` | Change the LLM model |
480
578
  | `with_instructions(text)` | Set system instructions |
481
579
  | `with_temperature(val)` | Set temperature |
482
- | `with_top_p(val)` | Set nucleus sampling |
483
- | `with_top_k(val)` | Set top-k sampling |
484
- | `with_max_tokens(val)` | Set max output tokens |
485
- | `with_presence_penalty(val)` | Set presence penalty |
486
- | `with_frequency_penalty(val)` | Set frequency penalty |
487
- | `with_stop(sequences)` | Set stop sequences |
580
+ | `with_params(**params)` | Set arbitrary provider params (`top_p`, `max_tokens`, …) |
581
+ | `with_context(ctx)` | Set the RubyLLM context |
582
+ | `with_headers(**headers)` | Set extra request headers |
488
583
  | `with_tool(tool)` | Add a single tool |
489
584
  | `with_tools(*tools)` | Add multiple tools |
490
- | `with_template(id, **ctx)` | Apply a prompt template |
491
585
  | `with_schema(schema)` | Set structured output schema |
492
586
  | `with_thinking(config)` | Enable extended thinking |
587
+ | `with_template(id, **ctx)` | Apply a prompt template |
493
588
  | `with_bus(bus)` | Connect to a message bus (creates one if nil) |
494
589
 
590
+ > [!WARNING]
591
+ > `with_top_p`, `with_top_k`, `with_max_tokens`, `with_presence_penalty`,
592
+ > `with_frequency_penalty`, and `with_stop` **do not exist** — calling any of them
593
+ > raises `NoMethodError`. `RubyLLM::Chat` exposes those knobs through
594
+ > `with_params`, so use either the constructor kwarg or `with_params`:
595
+ >
596
+ > ```ruby
597
+ > robot.with_params(max_tokens: 2000, top_p: 0.3).run("...")
598
+ > # or
599
+ > RobotLab.build(name: "bot", system_prompt: "...", max_tokens: 2000, top_p: 0.3)
600
+ > ```
601
+
495
602
  ## Running Robots
496
603
 
497
604
  ### Standalone
@@ -508,14 +615,36 @@ The `run` method returns a `RobotResult` with:
508
615
  ```ruby
509
616
  result.last_text_content # => "Hi there! How can I help?"
510
617
  result.reply # => alias for last_text_content
511
- result.output # => Array of output messages
512
- result.tool_calls # => Array of tool call results
618
+ result.output # => [TextMessage] built from the final response text
619
+ result.tool_calls # => Array of tool call results (see note)
513
620
  result.robot_name # => "assistant"
514
- result.stop_reason # => stop reason from the LLM
621
+ result.stop_reason # => always nil (see note)
622
+ result.input_tokens # => Integer
623
+ result.output_tokens # => Integer
515
624
  result.duration # => Float (elapsed seconds, set in pipeline execution)
516
625
  result.raw # => raw LLM response object
517
626
  ```
518
627
 
628
+ > [!NOTE]
629
+ > `result.tool_calls` is effectively always empty. It is read from the *final*
630
+ > assistant message, and by the time ruby_llm's tool loop has finished that
631
+ > message carries no tool calls. Use `:tool_call` [hooks](hooks.md) or the
632
+ > `on_tool_call:` / `on_tool_result:` callbacks to observe tool activity.
633
+ > Similarly, `result.output` holds only the final response text, not the full
634
+ > turn. There is no `result.content` and no `result.text?`.
635
+
636
+ > [!WARNING]
637
+ > `result.stop_reason` is **always `nil`**. `RubyLLM::Message` does not define
638
+ > `stop_reason`, and `build_result` fills the field with
639
+ > `response.respond_to?(:stop_reason) ? response.stop_reason : nil` — so no
640
+ > provider value ever lands there, and `.compact` drops the key from
641
+ > `result.export` entirely. Do not branch on `"end_turn"`, `"tool_use"`, or
642
+ > `"stop"`. Consequently `result.stopped?` is simply "this result has no tool
643
+ > calls".
644
+ >
645
+ > (`RobotLab::Message::VALID_STOP_REASONS` is `["tool", "stop"]`, but that
646
+ > constant governs the `Message` classes you build yourself, not `RobotResult`.)
647
+
519
648
  ### With Runtime Memory
520
649
 
521
650
  Inject memory values for a single run:
@@ -569,7 +698,7 @@ robot = RobotLab.build(
569
698
  robot.run("Tell me a story") { |chunk| stream_to_client(chunk.content) }
570
699
  ```
571
700
 
572
- The `on_content` callback participates in the RunConfig cascade, so it can be set at the config level and inherited by robots:
701
+ `on_content` is also a `RunConfig` field, so it can be supplied through a `config:` on the robot itself rather than as a constructor kwarg:
573
702
 
574
703
  ```ruby
575
704
  config = RobotLab::RunConfig.new(
@@ -578,6 +707,13 @@ config = RobotLab::RunConfig.new(
578
707
  robot = RobotLab.build(name: "bot", system_prompt: "...", config: config)
579
708
  ```
580
709
 
710
+ > [!WARNING]
711
+ > This only works for the robot's **own** config. A network-level `config:` does
712
+ > **not** supply `on_content` (or any other callback or LLM field) to its member
713
+ > robots — a network propagates only `mcp` and `tools`. Each robot reads
714
+ > `on_content` from its own config at construction time, so streaming callbacks
715
+ > must be set per robot.
716
+
581
717
  You can also monitor tool activity via callbacks:
582
718
 
583
719
  ```ruby
@@ -640,8 +776,12 @@ billing_specialist = RobotLab.build(
640
776
  context: { department: "billing" },
641
777
  local_tools: [InvoiceLookup, RefundProcessor]
642
778
  )
779
+
780
+ billing_specialist.run("Refund order 12345", tools: :inherit)
643
781
  ```
644
782
 
783
+ In a network, the equivalent opt-in is on the task: `task :billing, billing_specialist, tools: :inherit, depends_on: :optional`.
784
+
645
785
  ### Summarizer Robot
646
786
 
647
787
  Condense information:
@@ -710,7 +850,16 @@ bob.serve # every inbound task runs through bob.run and replies automatically
710
850
  alice.send_message(to: :bob, content: "Tell me a funny robot joke.")
711
851
  ```
712
852
 
713
- `serve` is shorthand for `respond_to_tasks(auto_reply: true) { |message| run(message.content).reply }`. Use `respond_to_tasks` directly when the reply shouldn't just be `run(...).reply` — e.g. post-processing the result first:
853
+ `serve` is shorthand for `respond_to_tasks(auto_reply: true) { |message| run(bus_task_content(message)).reply }`.
854
+
855
+ `bus_task_content` flattens the message payload for `run`: a `String` content is passed through via `to_s`, while a `Hash` content becomes one `"key: value"` line per entry. So a task sent as `{ topic: "robots", style: "dry" }` reaches the LLM as:
856
+
857
+ ```
858
+ topic: robots
859
+ style: dry
860
+ ```
861
+
862
+ Use `respond_to_tasks` directly when the reply shouldn't just be `run(...).reply` — e.g. to build the prompt yourself, or to post-process the result:
714
863
 
715
864
  ```ruby
716
865
  bob.respond_to_tasks do |message|
@@ -776,7 +925,7 @@ bot.with_bus(existing_bus) # now connected and can send/receive messages
776
925
 
777
926
  ## Context Window Compression
778
927
 
779
- Long-running robots accumulate conversation history that can grow to fill the context window. `compress_history` prunes old turns using TF-IDF cosine similarity against the most recent context, keeping turns that are still relevant and discarding or summarizing those that aren't.
928
+ Long-running robots accumulate conversation history that can grow to fill the context window. `compress_history` prunes old turns using stemmed term-frequency cosine similarity (term frequencies only — no IDF weighting) against the most recent context, keeping turns that are still relevant and discarding or summarizing those that aren't.
780
929
 
781
930
  ```ruby
782
931
  # Default settings: protect 3 most-recent turn pairs, drop anything below 0.2
@@ -813,7 +962,7 @@ gem "classifier", "~> 2.3"
813
962
 
814
963
  ## Convergence Detection
815
964
 
816
- `RobotLab::Convergence` uses TF-IDF cosine similarity to detect when two independent agents have reached the same conclusion. The primary use case is a network router that skips an expensive reconciler robot when two verifiers already agree.
965
+ `RobotLab::Convergence` uses stemmed term-frequency cosine similarity (not TF-IDF — on a two-document corpus, IDF suppresses exactly the shared terms that signal agreement) to detect when two independent agents have reached the same conclusion. The primary use case is skipping an expensive reconciler robot when two verifiers already agree. Texts shorter than 30 characters always score `0.0`.
817
966
 
818
967
  ```ruby
819
968
  # Check the similarity score directly (returns Float 0.0..1.0)
@@ -826,26 +975,41 @@ RobotLab::Convergence.detected?(result_a.reply, result_b.reply)
826
975
  RobotLab::Convergence.detected?(text_a, text_b, threshold: 0.75)
827
976
  ```
828
977
 
829
- Wire it into a network router for the reconciler fast-path:
978
+ Wire it into a network for the reconciler fast-path. There is no router object in RobotLab — the reconciler is declared `depends_on: :optional` and a gate robot activates it only when the verifiers disagree:
830
979
 
831
980
  ```ruby
832
981
  verifier_a = RobotLab.build(name: "verifier_a", system_prompt: "Verify the answer.")
833
982
  verifier_b = RobotLab.build(name: "verifier_b", system_prompt: "Independently verify the answer.")
834
983
  reconciler = RobotLab.build(name: "reconciler", system_prompt: "Reconcile conflicting answers.")
835
984
 
836
- router = lambda do |args|
837
- a = args.context[:verifier_a]&.reply.to_s
838
- b = args.context[:verifier_b]&.reply.to_s
985
+ class ConvergenceGate < RobotLab::Robot
986
+ def call(result)
987
+ a = result.context[:verifier_a]&.reply.to_s # keyed by ROBOT name
988
+ b = result.context[:verifier_b]&.reply.to_s
989
+
990
+ return result if RobotLab::Convergence.detected?(a, b) # agree — skip reconciler
839
991
 
840
- # Skip reconciler when verifiers agree
841
- RobotLab::Convergence.detected?(a, b) ? nil : ["reconciler"]
992
+ result.activate(:reconciler)
993
+ end
842
994
  end
843
995
 
844
- network = RobotLab.create_network(name: "verify", router: router) do
845
- # ...
996
+ network = RobotLab.create_network(name: "verify") do
997
+ task :verifier_a, verifier_a, depends_on: :none
998
+ task :verifier_b, verifier_b, depends_on: :none
999
+ task :gate, ConvergenceGate.new(name: "gate"), depends_on: %i[verifier_a verifier_b]
1000
+ task :reconciler, reconciler, depends_on: :optional
846
1001
  end
1002
+
1003
+ network.run(message: "Is the deployment healthy?").activated_steps
1004
+ # => [] when they agreed, [:reconciler] when they diverged
847
1005
  ```
848
1006
 
1007
+ > [!WARNING]
1008
+ > `RobotLab.create_network` accepts only `name:`, `concurrency:`, `config:`, and a
1009
+ > block. There are no `router:` or `robots:` keyword arguments — passing them
1010
+ > raises `ArgumentError: unknown keywords: :robots, :router` — and no `Router` or
1011
+ > `Router::Args` class exists anywhere in the library.
1012
+
849
1013
  Requires the `classifier` gem (`~> 2.3`).
850
1014
 
851
1015
  ## Structured Delegation
@@ -901,20 +1065,64 @@ result = f1.value(timeout: 30)
901
1065
 
902
1066
  ## Configuration
903
1067
 
904
- RobotLab uses `MywayConfig` for configuration. Access the config object directly -- there is no `RobotLab.configure` block:
1068
+ RobotLab uses `MywayConfig` for configuration. Read values off the config object directly:
905
1069
 
906
1070
  ```ruby
907
- RobotLab.config.ruby_llm.model # => "claude-sonnet-4"
1071
+ RobotLab.config.ruby_llm.model # => "claude-sonnet-4"
908
1072
  RobotLab.config.ruby_llm.request_timeout # => 120
909
1073
  ```
910
1074
 
911
- Configuration is loaded from:
1075
+ `RobotLab.configure` also exists, and yields the config object for imperative setup:
1076
+
1077
+ ```ruby
1078
+ RobotLab.configure do |config|
1079
+ config.logger = Logger.new($stdout)
1080
+ end
1081
+ ```
912
1082
 
913
- - Bundled defaults (`lib/robot_lab/config/defaults.yml`)
914
- - Environment-specific overrides (development, test, production)
915
- - XDG config files (`~/.config/robot_lab/config.yml`)
916
- - Project config (`./config/robot_lab.yml`)
917
- - Environment variables (`ROBOT_LAB_*` prefix)
1083
+ Configuration is layered, lowest precedence first:
1084
+
1085
+ 1. Bundled defaults (`lib/robot_lab/config/defaults.yml`)
1086
+ 2. Environment-specific overrides (development, test, production)
1087
+ 3. XDG user config (`~/.config/robot_lab/robot_lab.yml`)
1088
+ 4. Project config (`./config/robot_lab.yml`)
1089
+ 5. Environment variables (`ROBOT_LAB_*` prefix; `__` for nesting)
1090
+ 6. Constructor parameters
1091
+
1092
+ > [!WARNING]
1093
+ > The XDG file is `~/.config/robot_lab/**robot_lab.yml**` — the filename repeats
1094
+ > the app name. `~/.config/robot_lab/config.yml` is never read.
1095
+ >
1096
+ > A top-level `defaults:` wrapper is **always** ignored — that key means
1097
+ > something only inside the gem's own bundled `defaults.yml`. Write
1098
+ > `max_tool_rounds: 12`, not `defaults:\n max_tool_rounds: 12`.
1099
+ >
1100
+ > An **environment-named** wrapper is a different story, and the two config
1101
+ > files behave differently:
1102
+ >
1103
+ > | File | flat keys | `development:` / `test:` / `production:` wrapper |
1104
+ > |---|---|---|
1105
+ > | `~/.config/robot_lab/robot_lab.yml` | honored | **honored** for the current environment |
1106
+ > | `./config/robot_lab.yml` (no Rails) | honored | ignored |
1107
+ > | `./config/robot_lab.yml` (in Rails) | ignored | **required** — keys must be nested under `Rails.env` |
1108
+ >
1109
+ > The XDG loader checks for a section named for the current environment and only
1110
+ > falls back to the file root when there is none. Outside Rails the environment
1111
+ > defaults to `development` (or `RAILS_ENV` / `RACK_ENV` when set), so a
1112
+ > `development:` section in the XDG file takes effect while `test:` and
1113
+ > `production:` sections sit dormant. Verified with a `max_iterations: 777`
1114
+ > XDG file: flat → 777, `development:` → 777, `production:` → 10 (until
1115
+ > `RACK_ENV=production`, then 777), `defaults:` → 10.
1116
+ >
1117
+ > Inside Rails, `anyway_config` sets the current environment to `Rails.env`,
1118
+ > which makes the **project** file environmental too — a flat
1119
+ > `./config/robot_lab.yml` is then ignored.
1120
+ >
1121
+ > ERB is evaluated only in `./config/robot_lab.yml`. The XDG loader uses
1122
+ > `YAML.safe_load` with no ERB pass, so `<%= ENV['KEY'] %>` there stays a literal
1123
+ > string. Nested env vars also arrive as strings
1124
+ > (`ROBOT_LAB_RUBY_LLM__REQUEST_TIMEOUT=180` yields `"180"`); top-level keys are
1125
+ > type-coerced.
918
1126
 
919
1127
  ## Best Practices
920
1128