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
data/docs/concepts.md CHANGED
@@ -8,7 +8,7 @@ A **Robot** is an LLM-powered agent that inherits from `RubyLLM::Agent`. Each ro
8
8
 
9
9
  Each robot has:
10
10
 
11
- - **Name**: A unique identifier (auto-generated if omitted)
11
+ - **Name**: An identifier. Nothing is auto-generated if you omit `name:` the robot is literally named `"robot"`. The value is load-bearing: a robot still named `"robot"` is treated as "unnamed", which is what lets a template's `robot_name:` front matter key take effect. Give every robot an explicit name.
12
12
  - **Template**: A `.md` file with YAML front matter managed by prompt_manager, referenced by symbol
13
13
  - **System Prompt**: Inline instructions (can be used alone or combined with a template)
14
14
  - **Model**: The LLM model to use (defaults to `RobotLab.config.ruby_llm.model`)
@@ -28,6 +28,9 @@ robot = RobotLab.build(
28
28
  model: "claude-sonnet-4"
29
29
  )
30
30
 
31
+ # Attached tools are only sent when the run asks for them
32
+ robot.run("Where is order 4471?", tools: :inherit)
33
+
31
34
  # Robot with inline system prompt
32
35
  robot = RobotLab.build(
33
36
  name: "helper",
@@ -56,13 +59,14 @@ puts result.last_text_content # => "Your name is Alice."
56
59
 
57
60
  ## Configuration
58
61
 
59
- RobotLab uses `MywayConfig` for configuration. There is no `RobotLab.configure` block. Instead, configuration is loaded automatically from multiple sources in priority order:
62
+ RobotLab uses `MywayConfig` for configuration. Values are loaded automatically from multiple sources in priority order (lowest to highest):
60
63
 
61
64
  1. Bundled defaults (`lib/robot_lab/config/defaults.yml`)
62
65
  2. Environment-specific overrides (development, test, production)
63
- 3. XDG user config (`~/.config/robot_lab/config.yml`)
66
+ 3. XDG user config (`~/.config/robot_lab/robot_lab.yml`)
64
67
  4. Project config (`./config/robot_lab.yml`)
65
68
  5. Environment variables (`ROBOT_LAB_*` prefix)
69
+ 6. Constructor parameters
66
70
 
67
71
  ```ruby
68
72
  # Access configuration values
@@ -73,10 +77,25 @@ RobotLab.config.ruby_llm.request_timeout #=> 120
73
77
  # ROBOT_LAB_RUBY_LLM__ANTHROPIC_API_KEY=sk-ant-...
74
78
  # ROBOT_LAB_RUBY_LLM__OPENAI_API_KEY=sk-...
75
79
 
80
+ # A configure block also exists, for runtime-only attributes such as the logger
81
+ RobotLab.configure do |c|
82
+ c.logger = Logger.new(File::NULL)
83
+ end
84
+
76
85
  # Reload configuration
77
86
  RobotLab.reload_config!
78
87
  ```
79
88
 
89
+ > [!IMPORTANT]
90
+ > Two easy mistakes. First, the user config file is
91
+ > `~/.config/robot_lab/**robot_lab**.yml` — the filename repeats the app name, and
92
+ > `config.yml` is never read. Second, the `defaults:` wrapper used inside the gem's
93
+ > bundled `defaults.yml` is silently ignored in your own files; write keys flat, or
94
+ > under a section named for the current environment. The user file honours a
95
+ > `development:` / `test:` / `production:` section; `./config/robot_lab.yml` must be
96
+ > flat outside Rails but **must** be environment-sectioned under Rails. See
97
+ > [Configuration](getting-started/configuration.md) for the full matrix.
98
+
80
99
  ## Network
81
100
 
82
101
  A **Network** is a collection of robots orchestrated using [SimpleFlow](https://github.com/MadBomber/simple_flow) pipelines. Networks provide:
@@ -106,37 +125,75 @@ result = network.run(message: "I was charged twice for my subscription.")
106
125
  A **Task** wraps a robot for use in a network pipeline with per-task configuration:
107
126
 
108
127
  - **Context**: Task-specific context deep-merged with network run params
109
- - **MCP**: MCP servers available to this task (`:none`, `:inherit`, or array)
110
- - **Tools**: Tools available to this task (`:none`, `:inherit`, or array)
128
+ - **MCP**: MCP servers available to this task (`:none`, `:inherit`, or a name array)
129
+ - **Tools**: Tools available to this task (`:none`, `:inherit`, or a name array)
111
130
  - **Memory**: Task-specific memory
112
131
  - **Dependencies**: `:none`, `[:task1, :task2]`, or `:optional`
132
+ - **Config**: a `RunConfig` — but see the caveat below
133
+ - **Poller group**: `poller_group:` (defaults to `:default`)
113
134
 
114
135
  ```ruby
136
+ # The robot must already have the tools ATTACHED...
137
+ billing_robot = RobotLab.build(
138
+ name: "billing",
139
+ system_prompt: "You handle billing.",
140
+ local_tools: [RefundTool, InvoiceTool, AuditTool] # attached as CLASSES
141
+ )
142
+
115
143
  task :billing, billing_robot,
116
144
  context: { department: "billing", escalation_level: 2 },
117
- tools: [RefundTool, InvoiceTool],
145
+ tools: [RefundTool, InvoiceTool], # ...and this SELECTS from them
118
146
  depends_on: :optional
119
147
  ```
120
148
 
149
+ > [!WARNING]
150
+ > An explicit `tools:` array is a **name allowlist** over tools the robot already
151
+ > has attached — it is not a way to attach new tools, and it is not a
152
+ > local-vs-MCP switch. Attach tools with `local_tools:` when building the robot,
153
+ > then filter here.
154
+ >
155
+ > **The allowlist entries must match the form the tool was attached in.** Matching
156
+ > is a string comparison against each attached tool's `name`, and `Class#name`
157
+ > differs from `RubyLLM::Tool#name`:
158
+ >
159
+ > | Attached as | Matching allowlist entry | Does not match |
160
+ > |---|---|---|
161
+ > | `local_tools: [RefundTool]` (class) | `[RefundTool]` or `%w[RefundTool]` | `%w[refund]` |
162
+ > | `local_tools: [RefundTool.new]` (instance) | `%w[refund]` | `[RefundTool]` |
163
+ >
164
+ > A task-level `config:` is merged into the network config, so like a
165
+ > network-level config it propagates **only `mcp` and `tools`** — not `model`,
166
+ > `temperature`, or callbacks.
167
+
121
168
  ## SimpleFlow::Result
122
169
 
123
170
  Networks use `SimpleFlow::Result` for data flow between tasks:
124
171
 
125
172
  ```ruby
126
173
  result.value # Current task's output (RobotResult)
127
- result.context # Accumulated context from all tasks
128
- result.halted? # Whether execution stopped early
129
- result.continued? # Whether execution continues
174
+ result.context # Accumulated context, keyed by ROBOT name
175
+ result.continue? # Whether the pipeline is still continuing
176
+ result.errors # Accumulated errors
130
177
  ```
131
178
 
179
+ > [!NOTE]
180
+ > There is no `halted?` and no `continued?` — both raise `NoMethodError`. The
181
+ > predicate is `continue?`. Note also that `result.context` is keyed by each
182
+ > robot's `name:`, not by its task name.
183
+
132
184
  ### Result Methods
133
185
 
186
+ The complete public API is `activate`, `activated_steps`, `context`, `continue`,
187
+ `continue?`, `errors`, `halt`, `value`, `with_context`, and `with_error`.
188
+
134
189
  | Method | Purpose |
135
190
  |--------|---------|
136
191
  | `continue(value)` | Continue to next tasks |
137
192
  | `halt(value)` | Stop pipeline execution |
138
193
  | `with_context(key, val)` | Add data to context |
194
+ | `with_error(key, message)` | Record an error |
139
195
  | `activate(task_name)` | Enable optional task |
196
+ | `activated_steps` | Tasks activated so far |
140
197
 
141
198
  ## Tool
142
199
 
@@ -170,8 +227,29 @@ robot = RobotLab.build(
170
227
  system_prompt: "You can do math.",
171
228
  local_tools: [Calculator]
172
229
  )
230
+
231
+ # tools: :inherit is required -- run() sends no tools by default
232
+ robot.run("What is 17 * 23?", tools: :inherit)
173
233
  ```
174
234
 
235
+ > [!WARNING]
236
+ > `Robot#run` defaults to `mcp: :none, tools: :none`, and an explicit `:none`
237
+ > means "send zero tools this turn". Attaching tools with `local_tools:` does
238
+ > **not** by itself make them available — a plain `robot.run("...")` sends the
239
+ > model no tools at all. Pass `tools: :inherit` on the run (and
240
+ > `mcp: :inherit, tools: :inherit` for MCP servers).
241
+ >
242
+ > For a **standalone** robot, do not pass `tools: :inherit` to `RobotLab.build`.
243
+ > Build-time `:inherit` resolves against the parent level, which for a standalone
244
+ > robot is the global `:none` — that produces an allowlist matching nothing and
245
+ > suppresses the tools even when the run asks for them. Leave `tools:` unset.
246
+ >
247
+ > Inside a **network** it means the opposite: build-time `tools: :inherit` is
248
+ > exactly how a robot opts into the allowlist carried by the network's `config:`.
249
+ > With `RunConfig.new(tools: %w[RefundTool])` on the network and `tools: :inherit`
250
+ > on both the robot and its task, the robot sends only `refund`; leaving the
251
+ > robot's `tools:` unset sends everything it has attached instead.
252
+
175
253
  ### RobotLab::Tool.create Factory
176
254
 
177
255
  ```ruby
@@ -197,16 +275,31 @@ result = robot.run("Hello!")
197
275
 
198
276
  result.last_text_content # => "Hi there!" (String or nil)
199
277
  result.reply # => alias for last_text_content
200
- result.output # => [TextMessage, ...] array of output messages
201
- result.tool_calls # => [] array of tool call results
278
+ result.output # => [TextMessage] built from the final response text
279
+ result.tool_calls # => [] (see note below -- effectively always empty)
202
280
  result.robot_name # => "assistant"
203
- result.stop_reason # => "end_turn" or nil
281
+ result.stop_reason # => nil (always -- see note below)
204
282
  result.has_tool_calls? # => false
205
283
  result.checksum # => "a1b2c3d4..." (for dedup)
206
284
  result.duration # => Float or nil (elapsed seconds, set in pipeline execution)
207
285
  result.raw # => raw LLM response object
208
286
  ```
209
287
 
288
+ > [!NOTE]
289
+ > `result.tool_calls` and `result.has_tool_calls?` read the **final** assistant
290
+ > message. By the time `run` returns, ruby_llm's tool loop has already completed
291
+ > and that message carries no tool calls — so in practice `tool_calls` is always
292
+ > empty and `has_tool_calls?` is always `false`. To observe tool activity, use
293
+ > the `on_tool_call` / `on_tool_result` callbacks instead. Likewise
294
+ > `result.output` is a single-element array built from the final response text,
295
+ > not a transcript of the whole turn.
296
+ >
297
+ > `result.stop_reason` is likewise **always `nil`**: `RubyLLM::Message` does not
298
+ > define `stop_reason`, and `RobotResult` only populates the field when the
299
+ > response responds to it. It is dropped from `result.export` for the same reason.
300
+ > Because `stopped?` is derived from the absence of tool calls, it is
301
+ > correspondingly always `true`. Do not branch on `"end_turn"` / `"tool_use"`.
302
+
210
303
  ## Memory
211
304
 
212
305
  **Memory** is a reactive key-value store that provides persistent storage across robot executions. Standalone robots use their own inherent memory; robots in a network share the network's memory.
@@ -225,10 +318,18 @@ robot.memory.data.category # => "billing" (method-style access)
225
318
  # Runtime memory injection
226
319
  robot.run("Help me", memory: { session_id: "abc123" })
227
320
 
228
- # Reset memory
321
+ # Reset the key-value store (does NOT clear chat history)
229
322
  robot.reset_memory
323
+
324
+ # Clear chat history (does NOT touch the key-value store)
325
+ robot.clear_messages(keep_system: true)
230
326
  ```
231
327
 
328
+ > [!NOTE]
329
+ > `reset_memory` and `clear_messages` are independent. A robot's conversation
330
+ > history and its key-value memory are two separate stores; clearing one leaves
331
+ > the other intact.
332
+
232
333
  ### Reserved Memory Keys
233
334
 
234
335
  | Key | Purpose |
@@ -251,7 +352,7 @@ network.memory.set(:sentiment, { score: 0.8 })
251
352
  result = network.memory.get(:sentiment, wait: true)
252
353
  result = network.memory.get(:sentiment, wait: 30) # timeout in seconds
253
354
 
254
- # Multiple keys
355
+ # Multiple keys -- the timeout applies PER MISSING KEY, not to the call as a whole
255
356
  results = network.memory.get(:sentiment, :entities, :keywords, wait: 60)
256
357
 
257
358
  # Subscribe to changes
@@ -260,6 +361,12 @@ network.memory.subscribe(:status) do |change|
260
361
  end
261
362
  ```
262
363
 
364
+ > [!WARNING]
365
+ > A blocking `get` that times out **raises `RobotLab::AwaitTimeout`** — it does
366
+ > not return `nil`. Wrap it in a `rescue` if a missing key is an acceptable
367
+ > outcome. With several keys, the timeout is applied to each missing key in turn,
368
+ > so `get(:a, :b, :c, wait: 60)` can block for up to 180 seconds.
369
+
263
370
  ## MCP (Model Context Protocol)
264
371
 
265
372
  **MCP** allows robots to connect to external tool servers:
@@ -273,8 +380,30 @@ robot = RobotLab.build(
273
380
  { name: "github", transport: { type: "stdio", command: "mcp-server-github" } }
274
381
  ]
275
382
  )
383
+
384
+ # Connect the servers AND expose their tools for this run
385
+ robot.run("List the Ruby files in ./lib", mcp: :inherit, tools: :inherit)
276
386
  ```
277
387
 
388
+ > [!IMPORTANT]
389
+ > `transport:` must be a **nested hash** — `transport: { type: "stdio", command: ..., args: [...] }`.
390
+ > A flat `transport: stdio` with sibling `command:`/`args:` keys raises
391
+ > `NoMethodError: undefined method 'transform_keys' for an instance of String`,
392
+ > and that exception is **swallowed** rather than raised: the robot builds
393
+ > successfully with zero tools. It is not silent, though — a line is logged at
394
+ > `WARN` through `RobotLab.config.logger` (`Robot 'dev' error connecting to MCP
395
+ > server 'fs': undefined method 'transform_keys' for an instance of String`) and
396
+ > the server name lands in `robot.failed_mcp_server_names`. An *invalid* transport
397
+ > type is swallowed the same way; only a direct `MCP::Server.new` raises
398
+ > `ArgumentError`. Valid transport types are `stdio`, `sse`, `ws`,
399
+ > `websocket`, `streamable-http`, and `http` (`streamable_http` with an
400
+ > underscore is invalid).
401
+ >
402
+ > `mcp:` also defaults to `:none` on `run`. `mcp: :inherit` triggers the
403
+ > connection attempt; `tools: :inherit` is additionally required for the MCP
404
+ > tools to reach the model. MCP connection failures are logged and recorded in
405
+ > `robot.failed_mcp_server_names` — they are not raised.
406
+
278
407
  MCP configuration follows a hierarchical resolution: `runtime > robot > network > global config`. Values can be `:none`, `:inherit`, or explicit arrays.
279
408
 
280
409
  ## Execution Flow
@@ -452,9 +581,24 @@ robot = RobotLab.build(
452
581
 
453
582
  Templates support two categories of front matter keys:
454
583
 
455
- **LLM Config:** `model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop` — applied to the robot's chat configuration.
456
-
457
- **Robot Extras:** `robot_name`, `description`, `tools`, `mcp`, `skills` — applied to the robot's identity and capabilities. These make templates self-contained: reading the `.md` file tells you everything about the robot.
584
+ **LLM Config:** only `model` and `temperature` take effect.
585
+
586
+ **Robot Extras:** `robot_name`, `description`, `tools`, `mcp`, `skills`, `parameters` — applied to the robot's identity and capabilities. These make templates self-contained: reading the `.md` file tells you everything about the robot.
587
+
588
+ > [!WARNING]
589
+ > Front matter is parsed into a `RunConfig` and then applied by dispatching
590
+ > `chat.with_<field>` for each field the chat responds to. The chat object only
591
+ > has `with_model` and `with_temperature`, so **`top_p`, `top_k`, `max_tokens`,
592
+ > `presence_penalty`, `frequency_penalty`, and `stop` are parsed and silently
593
+ > dropped** when declared in front matter. A template declaring all eight yields
594
+ > a chat with empty params.
595
+ >
596
+ > Those six *do* work as constructor keyword arguments or via a `config:`
597
+ > `RunConfig`, because that path goes through `with_params`:
598
+ >
599
+ > ```ruby
600
+ > RobotLab.build(name: "bot", template: :writer, max_tokens: 2000, top_p: 0.3)
601
+ > ```
458
602
 
459
603
  ```markdown
460
604
  ---
@@ -464,20 +608,31 @@ tools:
464
608
  - CodeSearchTool
465
609
  mcp:
466
610
  - name: github
467
- transport: stdio
468
- command: npx
469
- args: ["-y", "@modelcontextprotocol/server-github"]
611
+ transport:
612
+ type: stdio
613
+ command: npx
614
+ args: ["-y", "@modelcontextprotocol/server-github"]
470
615
  model: claude-sonnet-4
471
616
  ---
472
617
  You are a GitHub assistant. Use available tools to help with repository tasks.
473
618
  ```
474
619
 
620
+ > [!IMPORTANT]
621
+ > Note the **nested** `transport:` mapping above. A flat
622
+ > `transport: stdio` with sibling `command:`/`args:` keys raises a `NoMethodError`
623
+ > that is swallowed rather than propagated, leaving you with a robot that has no
624
+ > tools. Look for the `WARN` line on `RobotLab.config.logger` and check
625
+ > `robot.failed_mcp_server_names` — both record the failure.
626
+
475
627
  ```ruby
476
628
  # Template provides everything — minimal constructor
477
629
  robot = RobotLab.build(template: :github_assistant)
630
+
631
+ # ...but the tools and MCP servers it declares still have to be requested per run
632
+ robot.run("Open issues in MadBomber/robot_lab?", mcp: :inherit, tools: :inherit)
478
633
  ```
479
634
 
480
- Constructor-provided values (`local_tools:`, `mcp:`, `name:`, `description:`) always take precedence over front matter values.
635
+ Constructor-provided values (`local_tools:`, `mcp:`, `name:`, `description:`) always take precedence over front matter values. In the cascade, front matter is the **base**: template front matter → `config:` RunConfig → constructor keyword arguments, with constructor arguments always winning.
481
636
 
482
637
  ## Next Steps
483
638
 
@@ -6,11 +6,14 @@ A simple conversational robot example.
6
6
 
7
7
  This example demonstrates the minimal setup for a conversational robot that can respond to user messages using `robot.run("message")`.
8
8
 
9
+ The closest runnable file in this repository is
10
+ [`examples/01_simple_robot.rb`](https://github.com/MadBomber/robot_lab/blob/main/examples/01_simple_robot.rb).
11
+ The snippets below wrap that same API in a small REPL.
12
+
9
13
  ## Complete Example
10
14
 
11
15
  ```ruby
12
16
  #!/usr/bin/env ruby
13
- # examples/basic_chat.rb
14
17
 
15
18
  require "bundler/setup"
16
19
  require "robot_lab"
@@ -49,9 +52,15 @@ puts "\nGoodbye!"
49
52
 
50
53
  ## With Streaming
51
54
 
55
+ Pass a block to `run` to receive each `RubyLLM::Chunk` as it arrives.
56
+
57
+ > [!WARNING]
58
+ > The yielded object is a `RubyLLM::Chunk`. Read its text with `chunk.content`.
59
+ > There is no `chunk.text` method — a guard like `if chunk.respond_to?(:text)`
60
+ > silently prints nothing.
61
+
52
62
  ```ruby
53
63
  #!/usr/bin/env ruby
54
- # examples/streaming_chat.rb
55
64
 
56
65
  require "bundler/setup"
57
66
  require "robot_lab"
@@ -73,30 +82,55 @@ loop do
73
82
  next if input.empty?
74
83
 
75
84
  print "\nAssistant: "
76
- result = assistant.run(input) do |event|
77
- print event.text if event.respond_to?(:text)
78
- end
85
+ assistant.run(input) { |chunk| print chunk.content }
79
86
  puts
80
87
  end
81
88
 
82
89
  puts "\nGoodbye!"
83
90
  ```
84
91
 
92
+ You can also wire streaming once at build time with the `on_content:` callback,
93
+ which fires on every `run`:
94
+
95
+ ```ruby
96
+ assistant = RobotLab.build(
97
+ name: "assistant",
98
+ system_prompt: "You are a helpful assistant.",
99
+ on_content: ->(chunk) { print chunk.content }
100
+ )
101
+
102
+ assistant.run("Tell me a one-sentence fact about Ruby.")
103
+ ```
104
+
105
+ When both are supplied, the stored `on_content` callback fires first, then the
106
+ block. See [`examples/05_streaming.rb`](https://github.com/MadBomber/robot_lab/blob/main/examples/05_streaming.rb)
107
+ for all four variations (stored callback, per-call block, both, and via `RunConfig`).
108
+
85
109
  ## With Template
86
110
 
111
+ Templates are `.md` files with YAML front matter, resolved from the configured
112
+ prompts directory (`ROBOT_LAB_TEMPLATE_PATH`). Parameters declared `null` in the
113
+ front matter are required and are supplied via `context:`.
114
+
87
115
  ```ruby
88
116
  #!/usr/bin/env ruby
89
- # examples/template_chat.rb
90
117
 
91
118
  require "bundler/setup"
92
119
  require "robot_lab"
93
120
 
94
- # Build a robot using a prompt template file
95
- # Template: prompts/assistant.md (Markdown with YAML front matter)
121
+ # Template file: prompts/support.md
122
+ # ---
123
+ # description: Support assistant
124
+ # parameters:
125
+ # company_name: null
126
+ # tone: friendly
127
+ # ---
128
+ # You are a <%= tone %> support assistant for <%= company_name %>.
129
+
96
130
  assistant = RobotLab.build(
97
131
  name: "assistant",
98
- template: :assistant,
99
- context: { tone: "friendly", domain: "general" },
132
+ template: :support,
133
+ context: { company_name: "Acme Corp", tone: "friendly" },
100
134
  model: "claude-sonnet-4"
101
135
  )
102
136
 
@@ -117,11 +151,13 @@ end
117
151
  puts "\nGoodbye!"
118
152
  ```
119
153
 
154
+ A full template-driven network lives in
155
+ [`examples/06_prompt_templates.rb`](https://github.com/MadBomber/robot_lab/blob/main/examples/06_prompt_templates.rb).
156
+
120
157
  ## With Memory
121
158
 
122
159
  ```ruby
123
160
  #!/usr/bin/env ruby
124
- # examples/chat_with_memory.rb
125
161
 
126
162
  require "bundler/setup"
127
163
  require "robot_lab"
@@ -153,11 +189,14 @@ end
153
189
  puts "\nGoodbye!"
154
190
  ```
155
191
 
192
+ The full Memory API — subscriptions, `StateProxy`, blocking reads, clone and
193
+ reset — is demonstrated in
194
+ [`examples/10_memory.rb`](https://github.com/MadBomber/robot_lab/blob/main/examples/10_memory.rb).
195
+
156
196
  ## Bare Robot with Chaining
157
197
 
158
198
  ```ruby
159
199
  #!/usr/bin/env ruby
160
- # examples/bare_robot.rb
161
200
 
162
201
  require "bundler/setup"
163
202
  require "robot_lab"
@@ -175,27 +214,41 @@ result = robot
175
214
  puts result.last_text_content
176
215
  ```
177
216
 
217
+ > [!NOTE]
218
+ > Only the `with_*` methods that `RubyLLM::Chat` exposes are delegated:
219
+ > `with_context`, `with_headers`, `with_instructions`, `with_model`, `with_params`,
220
+ > `with_schema`, `with_temperature`, `with_thinking`, `with_tool`, `with_tools`
221
+ > (plus RobotLab's own `with_template` and `with_bus`).
222
+ > There is no `with_max_tokens` / `with_top_p` / `with_top_k` — use a constructor
223
+ > kwarg (`max_tokens: 2000`) or `with_params(max_tokens: 2000, top_p: 0.3)`.
224
+
225
+ `examples/09_chaining.rb` walks through chaining and reconfiguration without
226
+ making any LLM calls.
227
+
178
228
  ## Running
179
229
 
180
230
  ```bash
181
231
  # Set API key
182
232
  export ANTHROPIC_API_KEY="your-key"
183
233
 
184
- # Run basic chat
185
- ruby examples/basic_chat.rb
234
+ # Simplest runnable robot
235
+ ruby examples/01_simple_robot.rb
236
+
237
+ # Streaming
238
+ ruby examples/05_streaming.rb
186
239
 
187
- # Run with streaming
188
- ruby examples/streaming_chat.rb
240
+ # with_* chaining and reconfiguration (no LLM calls)
241
+ ruby examples/09_chaining.rb
189
242
  ```
190
243
 
191
244
  ## Key Concepts
192
245
 
193
246
  1. **Robot Building**: Use `RobotLab.build(name:, system_prompt:)` or `RobotLab.build(name:, template:)` to create a robot
194
247
  2. **Execution**: Call `robot.run("message")` to send a message and get a response
195
- 3. **Response**: Access the text via `result.last_text_content`
196
- 4. **Streaming**: Pass a block to `robot.run("message") { |event| ... }`
248
+ 3. **Response**: Access the text via `result.last_text_content` (aliased as `result.reply`)
249
+ 4. **Streaming**: Pass a block to `robot.run("message") { |chunk| print chunk.content }`, or set `on_content:` at build time
197
250
  5. **Memory**: Access inherent memory via `robot.memory[:key]`
198
- 6. **Chaining**: Configure with `with_*` methods that return `self`
251
+ 6. **Chaining**: Configure with the delegated `with_*` methods, which return `self`
199
252
  7. **Conversation History**: The persistent `@chat` maintains history across multiple `run` calls
200
253
 
201
254
  ## See Also