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.
- checksums.yaml +4 -4
- data/README.md +124 -64
- data/docs/api/core/index.md +41 -15
- data/docs/api/core/memory.md +247 -29
- data/docs/api/core/network.md +255 -33
- data/docs/api/core/result.md +120 -32
- data/docs/api/core/robot.md +551 -61
- data/docs/api/core/state.md +87 -197
- data/docs/api/core/tool.md +165 -20
- data/docs/api/errors.md +110 -17
- data/docs/api/hooks.md +469 -0
- data/docs/api/index.md +80 -7
- data/docs/api/mcp/client.md +129 -35
- data/docs/api/mcp/index.md +164 -23
- data/docs/api/mcp/server.md +27 -3
- data/docs/api/mcp/transports.md +94 -22
- data/docs/api/messages/index.md +26 -3
- data/docs/api/messages/text-message.md +33 -11
- data/docs/api/messages/tool-call-message.md +27 -4
- data/docs/api/messages/tool-result-message.md +23 -4
- data/docs/api/messages/user-message.md +45 -8
- data/docs/api/skills.md +519 -0
- data/docs/api/streaming/context.md +28 -5
- data/docs/api/streaming/index.md +57 -11
- data/docs/api/support.md +846 -0
- data/docs/architecture/core-concepts.md +79 -31
- data/docs/architecture/index.md +86 -11
- data/docs/architecture/message-flow.md +66 -29
- data/docs/architecture/network-orchestration.md +145 -38
- data/docs/architecture/robot-execution.md +172 -90
- data/docs/architecture/state-management.md +31 -12
- data/docs/concepts.md +176 -21
- data/docs/examples/basic-chat.md +72 -19
- data/docs/examples/index.md +117 -31
- data/docs/examples/mcp-server.md +154 -45
- data/docs/examples/multi-robot-network.md +91 -21
- data/docs/examples/tool-usage.md +104 -37
- data/docs/getting-started/configuration.md +284 -97
- data/docs/getting-started/installation.md +53 -41
- data/docs/getting-started/quick-start.md +51 -6
- data/docs/guides/building-robots.md +258 -50
- data/docs/guides/creating-networks.md +214 -30
- data/docs/guides/hooks.md +141 -54
- data/docs/guides/knowledge.md +35 -4
- data/docs/guides/mcp-integration.md +211 -44
- data/docs/guides/memory.md +103 -12
- data/docs/guides/observability.md +95 -47
- data/docs/guides/streaming.md +184 -125
- data/docs/guides/using-tools.md +237 -17
- data/docs/index.md +36 -4
- data/examples/01_simple_robot.rb +2 -2
- data/examples/02_tools.rb +14 -4
- data/examples/03_network.rb +12 -7
- data/examples/04_mcp.rb +11 -4
- data/examples/05_streaming.rb +8 -5
- data/examples/06_prompt_templates.rb +13 -9
- data/examples/07_network_memory.rb +5 -5
- data/examples/08_llm_config.rb +20 -15
- data/examples/09_chaining.rb +4 -4
- data/examples/11_network_introspection.rb +4 -4
- data/examples/12_message_bus.rb +2 -2
- data/examples/13_spawn.rb +2 -2
- data/examples/14_rusty_circuit/README.md +1 -0
- data/examples/14_rusty_circuit/comic.rb +7 -3
- data/examples/14_rusty_circuit/display.rb +14 -0
- data/examples/14_rusty_circuit/heckler.rb +8 -6
- data/examples/14_rusty_circuit/open_mic.rb +17 -6
- data/examples/14_rusty_circuit/scout.rb +17 -10
- data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
- data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
- data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
- data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
- data/examples/16_writers_room/writer.rb +22 -22
- data/examples/16_writers_room/writers_room.rb +2 -0
- data/examples/17_skills.rb +14 -13
- data/examples/18_rails/README.md +20 -1
- data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
- data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
- data/examples/18_rails/app/robots/chat_robot.rb +11 -0
- data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
- data/examples/19_token_tracking.rb +25 -9
- data/examples/20_circuit_breaker.rb +10 -7
- data/examples/21_learning_loop.rb +42 -16
- data/examples/22_context_compression.rb +23 -23
- data/examples/23_convergence.rb +24 -17
- data/examples/24_structured_delegation.rb +13 -8
- data/examples/25_history_search.rb +12 -8
- data/examples/27_incident_response/incident_response.rb +31 -13
- data/examples/28_mcp_discovery.rb +17 -13
- data/examples/29_ractor_tools.rb +4 -2
- data/examples/30_ractor_network.rb +22 -17
- data/examples/31_launch_assessment.rb +20 -9
- data/examples/32_newsletter_reader.rb +7 -2
- data/examples/33_stock_predictor.rb +34 -13
- data/examples/34_agentskills.rb +7 -3
- data/examples/35_hooks.rb +18 -8
- data/examples/README.md +199 -45
- data/examples/common.rb +79 -11
- data/examples/xyzzy.rb +8 -1
- data/lib/robot_lab/config.rb +10 -5
- data/lib/robot_lab/names.rb +402 -0
- data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
- data/lib/robot_lab/robot/bus_messaging.rb +16 -8
- data/lib/robot_lab/robot/template_rendering.rb +16 -3
- data/lib/robot_lab/robot.rb +23 -2
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +21 -15
- data/mkdocs.yml +6 -1
- metadata +7 -2
data/docs/guides/using-tools.md
CHANGED
|
@@ -85,16 +85,29 @@ weather_tool = RobotLab::Tool.create(
|
|
|
85
85
|
|
|
86
86
|
`RobotLab::AskUser` lets a robot ask the user a question via the terminal. The LLM decides when it needs human input and calls the tool with a question, optional choices, and an optional default.
|
|
87
87
|
|
|
88
|
+
`AskUser` reads its IO from `robot.input` / `robot.output`, and it finds the
|
|
89
|
+
robot through its own `robot` accessor — which is only populated when you attach
|
|
90
|
+
an **instance constructed with `robot:`**. Build the robot first, then attach:
|
|
91
|
+
|
|
88
92
|
```ruby
|
|
89
93
|
robot = RobotLab.build(
|
|
90
94
|
name: "onboarding",
|
|
91
|
-
system_prompt: "Walk the user through project setup. Ask questions to understand their needs."
|
|
92
|
-
local_tools: [RobotLab::AskUser]
|
|
95
|
+
system_prompt: "Walk the user through project setup. Ask questions to understand their needs."
|
|
93
96
|
)
|
|
94
|
-
robot.
|
|
97
|
+
robot.local_tools << RobotLab::AskUser.new(robot: robot)
|
|
98
|
+
|
|
99
|
+
robot.run("Help the user set up a new project", tools: :inherit)
|
|
95
100
|
```
|
|
96
101
|
|
|
97
|
-
|
|
102
|
+
> [!WARNING]
|
|
103
|
+
> Passing the bare class — `local_tools: [RobotLab::AskUser]` — leaves
|
|
104
|
+
> `tool.robot` as `nil`. The tool then ignores `robot.input`/`robot.output`,
|
|
105
|
+
> reads from the real `$stdin` (blocking your process, and hanging any test
|
|
106
|
+
> suite), and labels its prompt `[Robot]` instead of the robot's name.
|
|
107
|
+
> Note also the `tools: :inherit` above: a plain `run()` sends **zero** tools,
|
|
108
|
+
> so the LLM would never see `AskUser` at all.
|
|
109
|
+
|
|
110
|
+
The tool displays the robot's name and question, then waits for input:
|
|
98
111
|
|
|
99
112
|
```
|
|
100
113
|
[onboarding] What programming language will you use?
|
|
@@ -110,13 +123,27 @@ Features:
|
|
|
110
123
|
- **Multiple choice**: numbered options, user types the number or text
|
|
111
124
|
- **Default value**: shown in the prompt, used when user presses Enter
|
|
112
125
|
|
|
113
|
-
IO is sourced from
|
|
126
|
+
Because the IO is sourced from the robot, a `StringIO` pair makes the tool
|
|
127
|
+
testable without a terminal:
|
|
114
128
|
|
|
115
129
|
```ruby
|
|
116
130
|
robot.input = StringIO.new("2\n")
|
|
117
131
|
robot.output = StringIO.new
|
|
132
|
+
|
|
133
|
+
# ... the LLM calls ask_user(question: "Which language?", choices: %w[Ruby Python Go])
|
|
134
|
+
robot.output.string
|
|
135
|
+
# => "\n[onboarding] Which language?\n 1. Ruby\n 2. Python\n 3. Go\n> "
|
|
118
136
|
```
|
|
119
137
|
|
|
138
|
+
> [!NOTE]
|
|
139
|
+
> The name the LLM sees is **`robot_lab--ask_user`**, not `ask_user`. RubyLLM
|
|
140
|
+
> derives tool names from the full class name including its namespace, so
|
|
141
|
+
> `RobotLab::AskUser` becomes `robot_lab--ask_user`. (A trailing `Tool` is
|
|
142
|
+
> stripped: a top-level `WeatherTool` is exposed as `weather`.) These derived
|
|
143
|
+
> names are what an allowlist must match **for tools attached as instances** —
|
|
144
|
+
> a tool attached as a class matches its class name instead. See
|
|
145
|
+
> [Runtime Tool Filtering](#runtime-tool-filtering).
|
|
146
|
+
|
|
120
147
|
See the [AskUser API reference](../api/core/tool.md#built-in-askuser) for full details.
|
|
121
148
|
|
|
122
149
|
## Attaching Tools to Robots
|
|
@@ -165,13 +192,29 @@ Tool classes must be defined and loaded before building the robot. Unresolvable
|
|
|
165
192
|
|
|
166
193
|
### Via Chaining
|
|
167
194
|
|
|
168
|
-
|
|
195
|
+
`with_tools` is delegated straight to the underlying `RubyLLM::Chat`, so it
|
|
196
|
+
writes tools onto the chat rather than onto the robot's `local_tools`:
|
|
169
197
|
|
|
170
198
|
```ruby
|
|
171
199
|
robot = RobotLab.build(name: "assistant", system_prompt: "...")
|
|
172
200
|
robot.with_tools(GetWeather, CalculatorTool)
|
|
173
201
|
```
|
|
174
202
|
|
|
203
|
+
> [!WARNING]
|
|
204
|
+
> Chained tools do not survive a `run`. Because `run` defaults to
|
|
205
|
+
> `tools: :none`, it replaces the chat's tool list with an empty one before
|
|
206
|
+
> asking the LLM — wiping whatever `with_tools` put there:
|
|
207
|
+
>
|
|
208
|
+
> ```ruby
|
|
209
|
+
> robot.with_tools(GetWeather, CalculatorTool)
|
|
210
|
+
> robot.chat.tools.keys # => [:get_weather, :calculator]
|
|
211
|
+
> robot.run("Hello")
|
|
212
|
+
> robot.chat.tools.keys # => [] <- cleared by the run
|
|
213
|
+
> ```
|
|
214
|
+
>
|
|
215
|
+
> For tools that should persist across runs, attach them with `local_tools:`
|
|
216
|
+
> and pass `tools: :inherit` on each `run`.
|
|
217
|
+
|
|
175
218
|
## Runtime Tool Filtering
|
|
176
219
|
|
|
177
220
|
`tools:` (and `mcp:`) also work as a **per-run** override, passed to `run()` itself, on top of the build-time/network/global hierarchy described in [Hierarchical MCP and Tools](../getting-started/configuration.md#hierarchical-mcp-and-tools):
|
|
@@ -179,18 +222,72 @@ robot.with_tools(GetWeather, CalculatorTool)
|
|
|
179
222
|
```ruby
|
|
180
223
|
robot.run("What's the weather?", tools: :inherit) # every attached tool
|
|
181
224
|
robot.run("Just chat, no tools needed.", tools: :none) # zero tools this turn
|
|
182
|
-
|
|
225
|
+
# Allowlist. This robot attached its tools as CLASSES, so match the class names:
|
|
226
|
+
robot.run("Only use the calculator.", tools: %w[CalculatorTool])
|
|
183
227
|
```
|
|
184
228
|
|
|
185
229
|
| Value | Meaning |
|
|
186
230
|
|-------|---------|
|
|
187
|
-
| `:inherit` |
|
|
188
|
-
| `:none`, `[]
|
|
189
|
-
| `
|
|
231
|
+
| `:inherit` | Propagate the parent level's allowlist (build-time / network / global) |
|
|
232
|
+
| `:none`, `[]` | Send **zero** tools this turn |
|
|
233
|
+
| `nil` | No filter at all — every attached tool, **discarding** any parent allowlist |
|
|
234
|
+
| `["name", ...]` | Only these tools, matched against how each was attached |
|
|
235
|
+
|
|
236
|
+
> [!CAUTION]
|
|
237
|
+
> `tools: nil` does **not** send zero tools, and it is **not** the same as
|
|
238
|
+
> `:inherit`. `:inherit` carries the parent's allowlist down; `nil` throws the
|
|
239
|
+
> filter away entirely. With a robot built `tools: %w[GetWeather]` holding
|
|
240
|
+
> `GetWeather` and `CalculatorTool`:
|
|
241
|
+
>
|
|
242
|
+
> ```
|
|
243
|
+
> run(tools: :inherit) -> [:get_weather] # parent allowlist honored
|
|
244
|
+
> run(tools: nil) -> [:get_weather, :calculator] # allowlist discarded
|
|
245
|
+
> ```
|
|
246
|
+
>
|
|
247
|
+
> If you mean zero tools, write `:none`.
|
|
248
|
+
|
|
249
|
+
> [!IMPORTANT]
|
|
250
|
+
> An allowlist entry must match **how the tool was attached**, because the
|
|
251
|
+
> comparison uses `tool.name` and `Class#name` differs from `RubyLLM::Tool#name`:
|
|
252
|
+
>
|
|
253
|
+
> | Attached as | Allowlist that matches |
|
|
254
|
+
> |---|---|
|
|
255
|
+
> | `local_tools: [CalculatorTool]` (class) | `[CalculatorTool]` or `%w[CalculatorTool]` |
|
|
256
|
+
> | `local_tools: [CalculatorTool.new]` (instance) | `%w[calculator]` |
|
|
257
|
+
>
|
|
258
|
+
> Note the constructor's `tools:` is validated and **rejects** classes and
|
|
259
|
+
> instances outright (`ArgumentError`); only task-level and `run()` values accept
|
|
260
|
+
> them.
|
|
190
261
|
|
|
191
262
|
An explicit `:none`/`[]` is useful for a relevance filter that decided no tool is useful for the current message — it now genuinely sends zero tools for that turn (previously an empty allowlist was silently treated as "all tools," which could overflow small-context local models with the full tool set). Each turn's resolved tool set fully **replaces** the chat's tools rather than accumulating, so a later `:none` turn correctly clears whatever a prior turn attached.
|
|
192
263
|
|
|
193
|
-
>
|
|
264
|
+
> [!CAUTION]
|
|
265
|
+
> **Watch the default.** Both `Robot.new`'s and `run()`'s `tools:`/`mcp:`
|
|
266
|
+
> parameters default to `:none`, not `:inherit`. If you build a robot with
|
|
267
|
+
> `local_tools:` and then call `robot.run(message)` with no `tools:` override at
|
|
268
|
+
> all, the runtime default takes the explicit-`:none` path above — sending no
|
|
269
|
+
> tools for that turn. **Pass `tools: :inherit` on the `run()` call** (or on the
|
|
270
|
+
> network `task`, which is forwarded to `run()`) anywhere you need the robot's
|
|
271
|
+
> attached tools available.
|
|
272
|
+
|
|
273
|
+
> [!WARNING]
|
|
274
|
+
> For a **standalone** robot, do not pass `tools: :inherit` at *build* time as a
|
|
275
|
+
> way to turn tools on. Build-time `:inherit` resolves against the level above
|
|
276
|
+
> it, which for a standalone robot is the global `:none` — producing an
|
|
277
|
+
> allowlist of `["none"]` that matches nothing, and the empty result then
|
|
278
|
+
> carries into the runtime pass. Leave build-time `tools:` unset:
|
|
279
|
+
>
|
|
280
|
+
> ```ruby
|
|
281
|
+
> # build tools: unset + run(tools: :inherit) -> [:t1] correct
|
|
282
|
+
> # build tools: :inherit + run(tools: :inherit) -> [] the standalone trap
|
|
283
|
+
> # build tools: :none + run(tools: :inherit) -> [:t1] also fine
|
|
284
|
+
> ```
|
|
285
|
+
>
|
|
286
|
+
> This applies to a robot run on its own. Inside a **network** whose `config:`
|
|
287
|
+
> supplies `tools:`/`mcp:`, the parent level is that network list rather than
|
|
288
|
+
> `:none`, and build-time `:inherit` is *required* — it is the robot's opt-in to
|
|
289
|
+
> the network value. See
|
|
290
|
+
> [Network-Wide Tool and MCP Defaults](creating-networks.md#network-wide-tool-and-mcp-defaults).
|
|
194
291
|
|
|
195
292
|
### Tool Capping and Per-Turn Filtering
|
|
196
293
|
|
|
@@ -206,9 +303,22 @@ robot = RobotLab.build(
|
|
|
206
303
|
```
|
|
207
304
|
|
|
208
305
|
- Default ceiling: **128** tools per turn (`RobotLab::Robot::DEFAULT_MAX_TOOLS`)
|
|
209
|
-
- Override with `max_tools:` on `RunConfig` (or the `max_tools:` cascade field — see [Available Fields](../getting-started/configuration.md#available-fields))
|
|
306
|
+
- Override with a **positive** `max_tools:` on `RunConfig` (or the `max_tools:` cascade field — see [Available Fields](../getting-started/configuration.md#available-fields))
|
|
210
307
|
- When a turn's resolved tools exceed the cap, RobotLab logs a warning naming how many were dropped and sends the first `max_tools` entries
|
|
211
308
|
|
|
309
|
+
> [!WARNING]
|
|
310
|
+
> The cap **cannot be disabled.** `max_tools: nil`, `0`, or a negative number
|
|
311
|
+
> all fall back to the 128 default — only a positive integer changes the
|
|
312
|
+
> ceiling. If you attach more than 128 tools and set `max_tools: 0` expecting
|
|
313
|
+
> "unlimited", you get the first 128.
|
|
314
|
+
>
|
|
315
|
+
> The truncation is not silent, though — `cap_tools` logs it at `WARN` through
|
|
316
|
+
> `RobotLab.config.logger` every time it fires:
|
|
317
|
+
>
|
|
318
|
+
> ```
|
|
319
|
+
> [power_user] tool list (150) exceeds max_tools (128); sending 128, dropping 22
|
|
320
|
+
> ```
|
|
321
|
+
|
|
212
322
|
## Skill Scripts and Sandboxing
|
|
213
323
|
|
|
214
324
|
A skill bundle (a directory with a `SKILL.md` plus `scripts/`, discovered via `AgentSkill`) can expose its scripts as tools (`ScriptTool`). Because those scripts run as real OS processes, each `SKILL.md` can declare the capabilities its scripts need directly in front matter, alongside `name`/`description`:
|
|
@@ -266,10 +376,22 @@ param :active, type: :boolean, desc: "Whether the user is active"
|
|
|
266
376
|
param :tags, type: :array, desc: "List of tags"
|
|
267
377
|
```
|
|
268
378
|
|
|
269
|
-
###
|
|
379
|
+
### Enumerated Values
|
|
380
|
+
|
|
381
|
+
`param` has **no `enum:` option**. Its full signature is
|
|
382
|
+
`param(name, type: "string", desc: nil, description: nil, required: true)` —
|
|
383
|
+
anything else raises `ArgumentError: unknown keyword: :enum`. Express the
|
|
384
|
+
allowed values in the description instead, and validate in `execute`:
|
|
270
385
|
|
|
271
386
|
```ruby
|
|
272
|
-
|
|
387
|
+
STATUSES = %w[pending active completed].freeze
|
|
388
|
+
|
|
389
|
+
param :status, type: :string, desc: "Order status — one of: pending, active, completed"
|
|
390
|
+
|
|
391
|
+
def execute(status:)
|
|
392
|
+
return { error: "status must be one of #{STATUSES.join(', ')}" } unless STATUSES.include?(status)
|
|
393
|
+
# ...
|
|
394
|
+
end
|
|
273
395
|
```
|
|
274
396
|
|
|
275
397
|
### Required vs Optional
|
|
@@ -416,7 +538,39 @@ result = tool.call({ "id" => "missing" })
|
|
|
416
538
|
# => "Error (fetch_resource): connection refused"
|
|
417
539
|
```
|
|
418
540
|
|
|
419
|
-
This applies to all `RobotLab::Tool` variants — subclasses, `Tool.create` factory tools, and MCP tools.
|
|
541
|
+
This applies to all `RobotLab::Tool` variants — subclasses, `Tool.create` factory tools, and MCP tools.
|
|
542
|
+
|
|
543
|
+
### RobotLab::ToolError and Retryability
|
|
544
|
+
|
|
545
|
+
`RobotLab::ToolError` is handled on a separate path from ordinary
|
|
546
|
+
`StandardError`s. Raising one with `retryable: true` appends `" (retryable)"`
|
|
547
|
+
to the text the LLM sees, hinting that another attempt may succeed:
|
|
548
|
+
|
|
549
|
+
```ruby
|
|
550
|
+
class FetchResource < RobotLab::Tool
|
|
551
|
+
description "Fetch a resource from an external API"
|
|
552
|
+
param :id, type: :string, desc: "Resource ID"
|
|
553
|
+
|
|
554
|
+
def execute(id:)
|
|
555
|
+
raise RobotLab::ToolError.new("upstream 503", retryable: true)
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
FetchResource.new.call({ "id" => "1" })
|
|
560
|
+
# => "Error (fetch_resource): upstream 503 (retryable)"
|
|
561
|
+
|
|
562
|
+
# Without retryable:, no suffix is appended:
|
|
563
|
+
# raise RobotLab::ToolError, "bad input"
|
|
564
|
+
# => "Error (fetch_resource): bad input"
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
> [!WARNING]
|
|
568
|
+
> Only the ordinary `StandardError` path writes to the log
|
|
569
|
+
> (`RobotLab.config.logger.warn("Tool 'name' error: RuntimeError: …")`).
|
|
570
|
+
> A `RobotLab::ToolError` is turned into text for the LLM and logged
|
|
571
|
+
> **nowhere** — if you rely on `ToolError` for expected failures, add your own
|
|
572
|
+
> logging inside `execute`, or those failures leave no trace outside the
|
|
573
|
+
> transcript.
|
|
420
574
|
|
|
421
575
|
### Critical Tools (Opt-Out)
|
|
422
576
|
|
|
@@ -437,6 +591,24 @@ end
|
|
|
437
591
|
|
|
438
592
|
`raise_on_error` is per-class and defaults to `false`. Setting it on one class does not affect others.
|
|
439
593
|
|
|
594
|
+
> [!WARNING]
|
|
595
|
+
> `raise_on_error` does **not** walk the inheritance chain. A subclass of a
|
|
596
|
+
> class that set it silently reverts to `false`:
|
|
597
|
+
>
|
|
598
|
+
> ```ruby
|
|
599
|
+
> class CriticalBase < RobotLab::Tool
|
|
600
|
+
> self.raise_on_error = true
|
|
601
|
+
> end
|
|
602
|
+
>
|
|
603
|
+
> class ChargeCard < CriticalBase; end
|
|
604
|
+
>
|
|
605
|
+
> CriticalBase.raise_on_error? # => true
|
|
606
|
+
> ChargeCard.raise_on_error? # => false <- NOT inherited
|
|
607
|
+
> ```
|
|
608
|
+
>
|
|
609
|
+
> Set `self.raise_on_error = true` on every class that needs it. (This differs
|
|
610
|
+
> from `ractor_safe`, below, which *does* consult the superclass.)
|
|
611
|
+
|
|
440
612
|
### Manual Error Handling
|
|
441
613
|
|
|
442
614
|
You can still handle specific errors inside `execute` for domain-specific responses:
|
|
@@ -458,20 +630,68 @@ class FetchResource < RobotLab::Tool
|
|
|
458
630
|
end
|
|
459
631
|
```
|
|
460
632
|
|
|
633
|
+
## Ractor-Safe Tools
|
|
634
|
+
|
|
635
|
+
A tool class can declare itself safe to run inside a Ractor. When it is, and the
|
|
636
|
+
[`robot_lab-ractor`](https://github.com/MadBomber/robot_lab-ractor) extension
|
|
637
|
+
gem is loaded, `call` dispatches the work to `RobotLab.ractor_pool` instead of
|
|
638
|
+
running it inline on the calling thread — giving real CPU parallelism for
|
|
639
|
+
compute-bound tools.
|
|
640
|
+
|
|
641
|
+
```ruby
|
|
642
|
+
class Fibonacci < RobotLab::Tool
|
|
643
|
+
ractor_safe true
|
|
644
|
+
|
|
645
|
+
description "Compute the nth Fibonacci number"
|
|
646
|
+
param :n, type: :integer, desc: "Which Fibonacci number to compute"
|
|
647
|
+
|
|
648
|
+
def execute(n:)
|
|
649
|
+
a, b = 0, 1
|
|
650
|
+
n.times { a, b = b, a + b }
|
|
651
|
+
a
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
Fibonacci.ractor_safe? # => true
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
Dispatch to the pool happens only when **all three** hold:
|
|
659
|
+
|
|
660
|
+
1. `ractor_safe?` is true
|
|
661
|
+
2. the class has a resolvable name — anonymous classes (including every tool
|
|
662
|
+
built by `Tool.create`) fall back to the inline path
|
|
663
|
+
3. the `robot_lab-ractor` extension is loaded
|
|
664
|
+
|
|
665
|
+
Otherwise `execute` runs inline, exactly as an ordinary tool would. Pool size
|
|
666
|
+
comes from the `ractor_pool_size` `RunConfig` field (`:auto` when unset).
|
|
667
|
+
|
|
668
|
+
> [!WARNING]
|
|
669
|
+
> A Ractor-safe tool must be genuinely stateless: it is instantiated **fresh
|
|
670
|
+
> inside the Ractor worker** for every call, so `robot`, captured closures, and
|
|
671
|
+
> mutable class-level state are not available to it, and the result comes back
|
|
672
|
+
> frozen. Do not set `ractor_safe true` on a tool that touches its robot.
|
|
673
|
+
|
|
674
|
+
Unlike `raise_on_error`, `ractor_safe` **does** walk the inheritance chain — a
|
|
675
|
+
subclass of a Ractor-safe tool is Ractor-safe unless it says otherwise.
|
|
676
|
+
|
|
461
677
|
## Tool Callbacks
|
|
462
678
|
|
|
463
|
-
Robots support `on_tool_call` and `on_tool_result` callbacks for monitoring tool usage:
|
|
679
|
+
Robots support `on_tool_call` and `on_tool_result` callbacks for monitoring tool usage. Each receives exactly **one** argument — `on_tool_result` gets the result only, not the originating call:
|
|
464
680
|
|
|
465
681
|
```ruby
|
|
466
682
|
robot = RobotLab.build(
|
|
467
683
|
name: "assistant",
|
|
468
684
|
system_prompt: "...",
|
|
469
685
|
local_tools: [GetWeather],
|
|
470
|
-
on_tool_call: ->(
|
|
686
|
+
on_tool_call: ->(tool_call) { puts "Calling: #{tool_call.name}" },
|
|
471
687
|
on_tool_result: ->(result) { puts "Result: #{result}" }
|
|
472
688
|
)
|
|
689
|
+
|
|
690
|
+
robot.run("What's the weather in Tokyo?", tools: :inherit)
|
|
473
691
|
```
|
|
474
692
|
|
|
693
|
+
See [Streaming](streaming.md#tool-callbacks) for details, including the RubyLLM deprecation warning these emit.
|
|
694
|
+
|
|
475
695
|
## RobotLab::Tool.create with Schema
|
|
476
696
|
|
|
477
697
|
For dynamic tools via `Tool.create`, pass parameters as a JSON Schema hash:
|
data/docs/index.md
CHANGED
|
@@ -142,16 +142,35 @@ Each robot is backed by a persistent LLM chat, configured with keyword arguments
|
|
|
142
142
|
|
|
143
143
|
</div>
|
|
144
144
|
|
|
145
|
+
> [!NOTE]
|
|
146
|
+
> A few of the features above need a gem the core `robot_lab` gem does not install.
|
|
147
|
+
> **Knowledge & Retrieval** (`memory.store_document` / `memory.search_documents`)
|
|
148
|
+
> and **Runtime Skill Matching** (`AgentSkill` catalogs) both require
|
|
149
|
+
> `robot_lab-document_store`; the Redis backing for **Reactive Memory** requires
|
|
150
|
+
> `redis`; Ractor parallelism requires `robot_lab-ractor`; and
|
|
151
|
+
> `robot.compress_history` requires `classifier`. See
|
|
152
|
+
> [Optional Dependencies](getting-started/installation.md#optional-dependencies).
|
|
153
|
+
|
|
145
154
|
## Extension Gems
|
|
146
155
|
|
|
147
156
|
These optional gems extend RobotLab with additional capabilities:
|
|
148
157
|
|
|
149
158
|
| Gem | What it adds |
|
|
150
159
|
|-----|-------------|
|
|
151
|
-
| [robot_lab-
|
|
152
|
-
| [robot_lab-
|
|
153
|
-
| [robot_lab-
|
|
160
|
+
| [robot_lab-a2a](https://github.com/MadBomber/robot_lab-a2a) | Agent2Agent (A2A) protocol adapter over HTTP + SSE |
|
|
161
|
+
| [robot_lab-audit](https://github.com/MadBomber/robot_lab-audit) | SQLite-backed execution audit log, wired through the Hook system |
|
|
162
|
+
| [robot_lab-discovery](https://github.com/MadBomber/robot_lab-discovery) | Zero-configuration mDNS/DNS-SD robot discovery on the local network |
|
|
154
163
|
| [robot_lab-document_store](https://github.com/MadBomber/robot_lab-document_store) | Embedding-based semantic document search powered by fastembed |
|
|
164
|
+
| [robot_lab-durable](https://github.com/MadBomber/robot_lab-durable) | HTM-backed long-term memory — robots accumulate and recall learned facts across sessions |
|
|
165
|
+
| [robot_lab-ractor](https://github.com/MadBomber/robot_lab-ractor) | CPU parallelism for `ractor_safe` tools and robot networks via Ruby Ractors |
|
|
166
|
+
| [robot_lab-rails](https://github.com/MadBomber/robot_lab-rails) | Rails Engine, generators, `RobotLab::Job` base class, Turbo Stream broadcasting |
|
|
167
|
+
| [robot_lab-to](https://github.com/MadBomber/robot_lab-to) | Autonomous overnight agent loop — iterate a robot toward an objective, committing each step |
|
|
168
|
+
| [robot_lab-web](https://github.com/MadBomber/robot_lab-web) | Rails-free Sinatra + HTMX browser console that streams a robot's run over SSE |
|
|
169
|
+
|
|
170
|
+
> [!NOTE]
|
|
171
|
+
> The Rails Engine, generators, `RobotLab::Job`, and Turbo broadcasting are
|
|
172
|
+
> **only** in `robot_lab-rails`. The core gem ships no Railtie and no generators.
|
|
173
|
+
> The former `robot_lab-acp` gem is retired and superseded by `robot_lab-a2a`.
|
|
155
174
|
|
|
156
175
|
## Quick Example
|
|
157
176
|
|
|
@@ -162,7 +181,10 @@ require "robot_lab"
|
|
|
162
181
|
# Set API keys via env vars:
|
|
163
182
|
# ROBOT_LAB_RUBY_LLM__ANTHROPIC_API_KEY=sk-ant-...
|
|
164
183
|
#
|
|
165
|
-
# Or place a config file at ~/.config/robot_lab/
|
|
184
|
+
# Or place a config file at ~/.config/robot_lab/robot_lab.yml
|
|
185
|
+
# (the filename repeats the app name; config.yml is never read). Keys go at the
|
|
186
|
+
# top level, or under a section named for the current environment
|
|
187
|
+
# (development:/test:/production:). Only a `defaults:` wrapper is ignored.
|
|
166
188
|
# Access config values: RobotLab.config.ruby_llm.model #=> "claude-sonnet-4"
|
|
167
189
|
|
|
168
190
|
# Create a robot with keyword arguments
|
|
@@ -188,6 +210,16 @@ puts result.last_text_content
|
|
|
188
210
|
robot.with_instructions("Be extra concise.").with_temperature(0.3).run("Explain Ruby in one sentence.")
|
|
189
211
|
```
|
|
190
212
|
|
|
213
|
+
> [!IMPORTANT]
|
|
214
|
+
> `Robot#run` defaults to `tools: :none` and `mcp: :none`, so a plain
|
|
215
|
+
> `robot.run("...")` sends the model **no tools** even when you attached them
|
|
216
|
+
> with `local_tools:` or `mcp:` at build time. Ask for them on the run:
|
|
217
|
+
>
|
|
218
|
+
> ```ruby
|
|
219
|
+
> robot.run("...", tools: :inherit) # attached local tools
|
|
220
|
+
> robot.run("...", mcp: :inherit, tools: :inherit) # MCP servers and their tools
|
|
221
|
+
> ```
|
|
222
|
+
|
|
191
223
|
## Supported LLM Providers
|
|
192
224
|
|
|
193
225
|
RobotLab supports multiple LLM providers through the [ruby_llm](https://github.com/crmne/ruby_llm) library:
|
data/examples/01_simple_robot.rb
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
# Demonstrates creating and running a basic robot with a template.
|
|
7
7
|
#
|
|
8
8
|
# Usage:
|
|
9
|
-
#
|
|
9
|
+
# ruby examples/01_simple_robot.rb
|
|
10
10
|
|
|
11
11
|
require_relative "common"
|
|
12
12
|
|
|
13
13
|
# Create a simple robot using a template
|
|
14
14
|
robot = RobotLab.build(
|
|
15
|
-
|
|
15
|
+
**llm_opts,
|
|
16
16
|
name: "helper",
|
|
17
17
|
template: :helper
|
|
18
18
|
)
|
data/examples/02_tools.rb
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
# Demonstrates creating a robot with custom tools using RubyLLM::Tool.
|
|
7
7
|
#
|
|
8
8
|
# Usage:
|
|
9
|
-
#
|
|
9
|
+
# ruby examples/02_tools.rb
|
|
10
10
|
|
|
11
11
|
require_relative "common"
|
|
12
12
|
|
|
@@ -78,7 +78,7 @@ end
|
|
|
78
78
|
|
|
79
79
|
# Create robot with tools
|
|
80
80
|
robot = RobotLab.build(
|
|
81
|
-
|
|
81
|
+
**llm_opts,
|
|
82
82
|
name: "assistant",
|
|
83
83
|
template: :assistant,
|
|
84
84
|
local_tools: [Calculator, FortuneCookie]
|
|
@@ -86,11 +86,21 @@ robot = RobotLab.build(
|
|
|
86
86
|
|
|
87
87
|
banner "Robot with Tools"
|
|
88
88
|
|
|
89
|
-
# Run the robot
|
|
90
|
-
|
|
89
|
+
# Run the robot.
|
|
90
|
+
#
|
|
91
|
+
# tools: :inherit is REQUIRED to actually offer the tools to the model.
|
|
92
|
+
# Robot#run defaults to tools: :none, which means "send zero tools this turn"
|
|
93
|
+
# — the robot still holds them in local_tools, but the provider never sees
|
|
94
|
+
# them and the LLM answers from memory instead of calling Calculator.
|
|
95
|
+
# :inherit means "no per-turn filter, use every attached tool".
|
|
96
|
+
result = robot.run(
|
|
97
|
+
"What is 15 multiplied by 7? Also, give me a fortune about my career.",
|
|
98
|
+
tools: :inherit
|
|
99
|
+
)
|
|
91
100
|
|
|
92
101
|
# Display results
|
|
93
102
|
puts "Robot: #{robot.name}"
|
|
103
|
+
puts "Tools offered: #{robot.local_tools.map(&:name).join(', ')}"
|
|
94
104
|
puts "\nOutput:"
|
|
95
105
|
result.output.each do |message|
|
|
96
106
|
puts " #{message.content}" if message.respond_to?(:content)
|
data/examples/03_network.rb
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# using SimpleFlow's optional step activation.
|
|
8
8
|
#
|
|
9
9
|
# Usage:
|
|
10
|
-
#
|
|
10
|
+
# ruby examples/03_network.rb
|
|
11
11
|
|
|
12
12
|
require_relative "common"
|
|
13
13
|
|
|
@@ -38,32 +38,37 @@ class ClassifierRobot < RobotLab::Robot
|
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
# Shared RunConfig —
|
|
42
|
-
|
|
41
|
+
# Shared RunConfig — operational defaults every robot in this network inherits.
|
|
42
|
+
#
|
|
43
|
+
# RunConfig has no `provider` field (see RunConfig::FIELDS), and an Ollama
|
|
44
|
+
# model is absent from RubyLLM's registry, so provider and model still travel
|
|
45
|
+
# together on each robot via **llm_opts. The RunConfig carries the settings
|
|
46
|
+
# that genuinely are shared.
|
|
47
|
+
shared_config = RobotLab::RunConfig.new(temperature: 0.3, max_tool_rounds: 5)
|
|
43
48
|
|
|
44
|
-
# Create specialized robots (no model: needed — inherited from RunConfig)
|
|
45
49
|
classifier = ClassifierRobot.new(
|
|
50
|
+
**llm_opts,
|
|
46
51
|
name: "classifier",
|
|
47
52
|
template: :classifier,
|
|
48
53
|
config: shared_config
|
|
49
54
|
)
|
|
50
55
|
|
|
51
56
|
billing_robot = RobotLab.build(
|
|
52
|
-
|
|
57
|
+
**llm_opts,
|
|
53
58
|
name: "billing",
|
|
54
59
|
template: :billing,
|
|
55
60
|
config: shared_config
|
|
56
61
|
)
|
|
57
62
|
|
|
58
63
|
technical_robot = RobotLab.build(
|
|
59
|
-
|
|
64
|
+
**llm_opts,
|
|
60
65
|
name: "technical",
|
|
61
66
|
template: :technical,
|
|
62
67
|
config: shared_config
|
|
63
68
|
)
|
|
64
69
|
|
|
65
70
|
general_robot = RobotLab.build(
|
|
66
|
-
|
|
71
|
+
**llm_opts,
|
|
67
72
|
name: "general",
|
|
68
73
|
template: :general,
|
|
69
74
|
config: shared_config
|
data/examples/04_mcp.rb
CHANGED
|
@@ -9,11 +9,10 @@
|
|
|
9
9
|
# Prerequisites:
|
|
10
10
|
# 1. Install the GitHub MCP server: brew install github-mcp-server
|
|
11
11
|
# 2. Set environment variables:
|
|
12
|
-
# - ANTHROPIC_API_KEY: Your Anthropic API key
|
|
13
12
|
# - GITHUB_PERSONAL_ACCESS_TOKEN: Your GitHub personal access token
|
|
14
13
|
#
|
|
15
14
|
# Usage:
|
|
16
|
-
#
|
|
15
|
+
# GITHUB_PERSONAL_ACCESS_TOKEN=your_token ruby examples/04_mcp.rb
|
|
17
16
|
#
|
|
18
17
|
# The GitHub MCP server provides tools for:
|
|
19
18
|
# - Searching repositories, code, issues, and users
|
|
@@ -136,7 +135,7 @@ begin
|
|
|
136
135
|
# connect_mcp! forces eager connection; without it MCP clients initialize
|
|
137
136
|
# lazily on the first run() call, leaving mcp_clients empty until then.
|
|
138
137
|
robot = RobotLab.build(
|
|
139
|
-
|
|
138
|
+
**llm_opts,
|
|
140
139
|
name: "github_assistant",
|
|
141
140
|
template: :github_assistant,
|
|
142
141
|
mcp_servers: [github_server]
|
|
@@ -164,7 +163,15 @@ begin
|
|
|
164
163
|
puts "Query: 'What are the top 3 most starred Ruby web frameworks on GitHub?'"
|
|
165
164
|
hr
|
|
166
165
|
|
|
167
|
-
|
|
166
|
+
# mcp: :inherit and tools: :inherit are both required. Both keywords default
|
|
167
|
+
# to :none on run(), which resolves to "no MCP servers this turn" and "send
|
|
168
|
+
# zero tools this turn" — the discovered MCP tools would never reach the
|
|
169
|
+
# provider and the model would answer from its training data.
|
|
170
|
+
result = robot.run(
|
|
171
|
+
"What are the top 3 most starred Ruby web frameworks on GitHub? Just list their names and star counts.",
|
|
172
|
+
mcp: :inherit,
|
|
173
|
+
tools: :inherit
|
|
174
|
+
)
|
|
168
175
|
|
|
169
176
|
puts
|
|
170
177
|
puts "Robot Response:"
|
data/examples/05_streaming.rb
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
# 3. Both together — stored fires first, then block
|
|
10
10
|
#
|
|
11
11
|
# Usage:
|
|
12
|
-
#
|
|
12
|
+
# ruby examples/05_streaming.rb
|
|
13
13
|
|
|
14
14
|
require_relative "common"
|
|
15
15
|
|
|
@@ -29,7 +29,7 @@ section "1. Stored Callback (on_content:)"
|
|
|
29
29
|
|
|
30
30
|
chunks_received = 0
|
|
31
31
|
robot = RobotLab.build(
|
|
32
|
-
|
|
32
|
+
**llm_opts,
|
|
33
33
|
name: "storyteller",
|
|
34
34
|
system_prompt: "You are a concise storyteller. Keep responses under 3 sentences.",
|
|
35
35
|
on_content: ->(chunk) {
|
|
@@ -52,7 +52,7 @@ section "2. Per-call Block"
|
|
|
52
52
|
|
|
53
53
|
block_chunks = 0
|
|
54
54
|
bare_robot = RobotLab.build(
|
|
55
|
-
|
|
55
|
+
**llm_opts,
|
|
56
56
|
name: "factbot",
|
|
57
57
|
system_prompt: "You are concise. Answer in one sentence."
|
|
58
58
|
)
|
|
@@ -76,7 +76,7 @@ stored_log = []
|
|
|
76
76
|
block_log = []
|
|
77
77
|
|
|
78
78
|
combo_robot = RobotLab.build(
|
|
79
|
-
|
|
79
|
+
**llm_opts,
|
|
80
80
|
name: "combo",
|
|
81
81
|
system_prompt: "You are concise. Answer in one sentence.",
|
|
82
82
|
on_content: ->(chunk) { stored_log << chunk.content }
|
|
@@ -99,6 +99,9 @@ puts ""
|
|
|
99
99
|
section "4. Via RunConfig (config cascade)"
|
|
100
100
|
|
|
101
101
|
config_chunks = 0
|
|
102
|
+
# RunConfig accepts only the fields in RunConfig::FIELDS — `provider` is not
|
|
103
|
+
# one of them, so it stays on the robot. on_content is a first-class config
|
|
104
|
+
# field and cascades normally.
|
|
102
105
|
config = RobotLab::RunConfig.new(
|
|
103
106
|
model: LLM[:default].model,
|
|
104
107
|
on_content: ->(chunk) {
|
|
@@ -108,7 +111,7 @@ config = RobotLab::RunConfig.new(
|
|
|
108
111
|
)
|
|
109
112
|
|
|
110
113
|
config_robot = RobotLab.build(
|
|
111
|
-
|
|
114
|
+
**llm_opts,
|
|
112
115
|
name: "config_bot",
|
|
113
116
|
system_prompt: "You are concise. Answer in one sentence.",
|
|
114
117
|
config: config
|