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