robot_lab 0.2.1 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.envrc +1 -0
- data/.loki +60 -0
- data/.quality/reek_baseline.txt +43 -0
- data/.rubocop.yml +5 -167
- data/CHANGELOG.md +54 -0
- data/README.md +185 -64
- data/Rakefile +28 -12
- data/docs/api/core/index.md +41 -14
- data/docs/api/core/memory.md +247 -29
- data/docs/api/core/network.md +285 -33
- data/docs/api/core/result.md +120 -32
- data/docs/api/core/robot.md +636 -60
- data/docs/api/core/state.md +87 -197
- data/docs/api/core/tool.md +165 -20
- data/docs/api/errors.md +152 -14
- data/docs/api/hooks.md +469 -0
- data/docs/api/index.md +83 -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 +118 -30
- data/docs/architecture/index.md +86 -11
- data/docs/architecture/message-flow.md +66 -29
- data/docs/architecture/network-orchestration.md +153 -38
- data/docs/architecture/robot-execution.md +173 -91
- 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 +299 -93
- data/docs/getting-started/installation.md +53 -41
- data/docs/getting-started/quick-start.md +51 -6
- data/docs/guides/building-robots.md +296 -50
- data/docs/guides/creating-networks.md +220 -31
- data/docs/guides/hooks.md +1084 -0
- data/docs/guides/index.md +5 -0
- data/docs/guides/knowledge.md +37 -6
- data/docs/guides/mcp-integration.md +211 -44
- data/docs/guides/memory.md +103 -12
- data/docs/guides/observability.md +166 -51
- data/docs/guides/streaming.md +184 -125
- data/docs/guides/using-tools.md +300 -11
- data/docs/index.md +37 -5
- data/examples/01_simple_robot.rb +2 -2
- data/examples/02_tools.rb +14 -4
- data/examples/03_network.rb +23 -9
- 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 +266 -0
- data/examples/README.md +203 -42
- data/examples/common.rb +79 -7
- data/examples/xyzzy.rb +97 -0
- data/lib/robot_lab/agent_skill.rb +5 -4
- data/lib/robot_lab/budget/ledger.rb +98 -0
- data/lib/robot_lab/capabilities.rb +84 -0
- data/lib/robot_lab/config/defaults.yml +10 -0
- data/lib/robot_lab/config.rb +14 -9
- data/lib/robot_lab/error.rb +34 -4
- data/lib/robot_lab/errors.rb +45 -0
- data/lib/robot_lab/hook.rb +79 -0
- data/lib/robot_lab/hook_context.rb +194 -0
- data/lib/robot_lab/hook_registry.rb +55 -0
- data/lib/robot_lab/hooks.rb +87 -0
- data/lib/robot_lab/mcp/connection_poller.rb +2 -2
- data/lib/robot_lab/names.rb +402 -0
- data/lib/robot_lab/narrator.rb +87 -0
- data/lib/robot_lab/network.rb +77 -20
- data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
- data/lib/robot_lab/robot/budget.rb +89 -0
- data/lib/robot_lab/robot/bus_messaging.rb +90 -18
- data/lib/robot_lab/robot/hooking.rb +56 -0
- data/lib/robot_lab/robot/mcp_management.rb +8 -2
- data/lib/robot_lab/robot/template_rendering.rb +18 -5
- data/lib/robot_lab/robot.rb +233 -89
- data/lib/robot_lab/run_config.rb +24 -5
- data/lib/robot_lab/runnable.rb +51 -0
- data/lib/robot_lab/sandbox/null.rb +13 -0
- data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
- data/lib/robot_lab/sandbox.rb +52 -0
- data/lib/robot_lab/script_tool.rb +60 -2
- data/lib/robot_lab/task.rb +26 -20
- data/lib/robot_lab/tool.rb +52 -11
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +70 -15
- data/mkdocs.yml +7 -1
- metadata +41 -99
- data/examples/temp.md +0 -51
- data/site/404.html +0 -2300
- data/site/api/core/index.html +0 -2706
- data/site/api/core/memory/index.html +0 -3793
- data/site/api/core/network/index.html +0 -3500
- data/site/api/core/robot/index.html +0 -4566
- data/site/api/core/state/index.html +0 -3390
- data/site/api/core/tool/index.html +0 -3843
- data/site/api/index.html +0 -2635
- data/site/api/mcp/client/index.html +0 -3435
- data/site/api/mcp/index.html +0 -2783
- data/site/api/mcp/server/index.html +0 -3252
- data/site/api/mcp/transports/index.html +0 -3352
- data/site/api/messages/index.html +0 -2641
- data/site/api/messages/text-message/index.html +0 -3087
- data/site/api/messages/tool-call-message/index.html +0 -3159
- data/site/api/messages/tool-result-message/index.html +0 -3252
- data/site/api/messages/user-message/index.html +0 -3212
- data/site/api/streaming/context/index.html +0 -3282
- data/site/api/streaming/events/index.html +0 -3347
- data/site/api/streaming/index.html +0 -2738
- data/site/architecture/core-concepts/index.html +0 -3757
- data/site/architecture/index.html +0 -2797
- data/site/architecture/message-flow/index.html +0 -3238
- data/site/architecture/network-orchestration/index.html +0 -3433
- data/site/architecture/robot-execution/index.html +0 -3140
- data/site/architecture/state-management/index.html +0 -3498
- data/site/assets/css/custom.css +0 -56
- data/site/assets/images/favicon.png +0 -0
- data/site/assets/images/robot_lab.jpg +0 -0
- data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
- data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
- data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
- data/site/assets/javascripts/lunr/tinyseg.js +0 -206
- data/site/assets/javascripts/lunr/wordcut.js +0 -6708
- data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
- data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
- data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
- data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
- data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
- data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
- data/site/concepts/index.html +0 -3455
- data/site/examples/basic-chat/index.html +0 -2880
- data/site/examples/index.html +0 -2907
- data/site/examples/mcp-server/index.html +0 -3018
- data/site/examples/multi-robot-network/index.html +0 -3131
- data/site/examples/rails-application/index.html +0 -3329
- data/site/examples/tool-usage/index.html +0 -3085
- data/site/getting-started/configuration/index.html +0 -3745
- data/site/getting-started/index.html +0 -2572
- data/site/getting-started/installation/index.html +0 -2981
- data/site/getting-started/quick-start/index.html +0 -2942
- data/site/guides/building-robots/index.html +0 -4290
- data/site/guides/creating-networks/index.html +0 -3858
- data/site/guides/index.html +0 -2586
- data/site/guides/mcp-integration/index.html +0 -3581
- data/site/guides/memory/index.html +0 -3586
- data/site/guides/rails-integration/index.html +0 -4019
- data/site/guides/streaming/index.html +0 -3157
- data/site/guides/using-tools/index.html +0 -3802
- data/site/index.html +0 -2671
- data/site/search/search_index.json +0 -1
- data/site/sitemap.xml +0 -183
- data/site/sitemap.xml.gz +0 -0
- data/site/tags.json +0 -1
data/docs/examples/index.md
CHANGED
|
@@ -13,7 +13,7 @@ These examples show how to use RobotLab for common scenarios, from simple chatbo
|
|
|
13
13
|
| [Basic Chat](basic-chat.md) | Simple conversational robot |
|
|
14
14
|
| [Multi-Robot Network](multi-robot-network.md) | Customer service with routing |
|
|
15
15
|
| [Tool Usage](tool-usage.md) | External API integration |
|
|
16
|
-
| [MCP Server](mcp-server.md) |
|
|
16
|
+
| [MCP Server](mcp-server.md) | Connecting a robot to external MCP servers |
|
|
17
17
|
| [Message Bus](#message-bus) | Bidirectional robot communication with convergence |
|
|
18
18
|
| [Spawning Robots](#spawning-robots) | Dynamic specialist creation at runtime |
|
|
19
19
|
|
|
@@ -43,7 +43,8 @@ require "robot_lab"
|
|
|
43
43
|
# Configuration is handled automatically via MywayConfig.
|
|
44
44
|
# Set API keys via environment variables:
|
|
45
45
|
# ROBOT_LAB_RUBY_LLM__ANTHROPIC_API_KEY=sk-ant-...
|
|
46
|
-
# Or via config files (~/.config/robot_lab/
|
|
46
|
+
# Or via config files (~/.config/robot_lab/robot_lab.yml — the filename
|
|
47
|
+
# repeats the app name; ~/.config/robot_lab/config.yml is never read)
|
|
47
48
|
|
|
48
49
|
robot = RobotLab.build(
|
|
49
50
|
name: "greeter",
|
|
@@ -57,14 +58,25 @@ puts result.last_text_content
|
|
|
57
58
|
|
|
58
59
|
## Robot with Tools
|
|
59
60
|
|
|
61
|
+
Give the LLM a fixed set of operations rather than an expression evaluator —
|
|
62
|
+
never `eval` a string the model produced.
|
|
63
|
+
|
|
60
64
|
```ruby
|
|
61
65
|
class CalculatorTool < RubyLLM::Tool
|
|
62
|
-
description "
|
|
63
|
-
|
|
64
|
-
param :
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
description "Performs basic arithmetic operations"
|
|
67
|
+
|
|
68
|
+
param :operation, type: "string", desc: "add, subtract, multiply, or divide"
|
|
69
|
+
param :a, type: "number", desc: "First operand"
|
|
70
|
+
param :b, type: "number", desc: "Second operand"
|
|
71
|
+
|
|
72
|
+
def execute(operation:, a:, b:)
|
|
73
|
+
case operation
|
|
74
|
+
when "add" then a + b
|
|
75
|
+
when "subtract" then a - b
|
|
76
|
+
when "multiply" then a * b
|
|
77
|
+
when "divide" then a.to_f / b
|
|
78
|
+
else "Unknown operation: #{operation}"
|
|
79
|
+
end
|
|
68
80
|
end
|
|
69
81
|
end
|
|
70
82
|
|
|
@@ -74,41 +86,85 @@ robot = RobotLab.build(
|
|
|
74
86
|
local_tools: [CalculatorTool]
|
|
75
87
|
)
|
|
76
88
|
|
|
77
|
-
|
|
89
|
+
# tools: :inherit is REQUIRED — run() defaults to tools: :none
|
|
90
|
+
result = robot.run("What's 25 * 4?", tools: :inherit)
|
|
78
91
|
puts result.last_text_content
|
|
79
92
|
```
|
|
80
93
|
|
|
94
|
+
> [!WARNING]
|
|
95
|
+
> `Robot#run` defaults to `tools: :none` and `mcp: :none`. A plain
|
|
96
|
+
> `robot.run("...")` sends the LLM **no tools at all**, even when `local_tools:`
|
|
97
|
+
> were attached at build time. Pass `tools: :inherit` on the call that should be
|
|
98
|
+
> able to use them.
|
|
99
|
+
>
|
|
100
|
+
> For a **standalone** robot like this one, do not pass `tools: :inherit` at
|
|
101
|
+
> *build* time — there the parent level is the global default (`:none`), so it
|
|
102
|
+
> produces an allowlist that matches nothing. Leave `tools:` unset in the
|
|
103
|
+
> constructor. (Inside a network the opposite holds: build-time `:inherit` is how a
|
|
104
|
+
> robot opts into the allowlist on the network's `config:`. See
|
|
105
|
+
> [Configuration](../getting-started/configuration.md#hierarchical-mcp-and-tools).)
|
|
106
|
+
|
|
107
|
+
See [`examples/02_tools.rb`](https://github.com/MadBomber/robot_lab/blob/main/examples/02_tools.rb)
|
|
108
|
+
for a second tool definition (`FortuneCookie`) alongside the calculator.
|
|
109
|
+
|
|
81
110
|
## Network with Routing
|
|
82
111
|
|
|
112
|
+
Routing is not configured declaratively — a robot performs it. Subclass
|
|
113
|
+
`RobotLab::Robot`, override `#call`, and activate one of the `depends_on: :optional`
|
|
114
|
+
tasks based on what the LLM returned.
|
|
115
|
+
|
|
83
116
|
```ruby
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
)
|
|
117
|
+
class ClassifierRobot < RobotLab::Robot
|
|
118
|
+
def call(result)
|
|
119
|
+
run_context = extract_run_context(result)
|
|
120
|
+
message = run_context.delete(:message)
|
|
121
|
+
robot_result = run(message, **run_context)
|
|
122
|
+
|
|
123
|
+
new_result = result
|
|
124
|
+
.with_context(@name.to_sym, robot_result)
|
|
125
|
+
.continue(robot_result)
|
|
126
|
+
|
|
127
|
+
case robot_result.reply.to_s.strip.downcase
|
|
128
|
+
when /billing/ then new_result.activate(:billing)
|
|
129
|
+
when /technical/ then new_result.activate(:technical)
|
|
130
|
+
else new_result.activate(:general)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
88
134
|
|
|
89
|
-
|
|
90
|
-
name: "
|
|
91
|
-
system_prompt: "
|
|
135
|
+
classifier = ClassifierRobot.new(
|
|
136
|
+
name: "classifier",
|
|
137
|
+
system_prompt: "Classify the request as BILLING, TECHNICAL, or GENERAL. Respond with only the category."
|
|
92
138
|
)
|
|
93
139
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
)
|
|
140
|
+
billing = RobotLab.build(name: "billing", system_prompt: "You handle billing questions.")
|
|
141
|
+
tech = RobotLab.build(name: "technical", system_prompt: "You handle technical issues.")
|
|
142
|
+
general = RobotLab.build(name: "general", system_prompt: "You handle everything else.")
|
|
98
143
|
|
|
99
144
|
network = RobotLab.create_network(name: "support") do
|
|
100
145
|
task :classifier, classifier, depends_on: :none
|
|
101
|
-
task :billing,
|
|
102
|
-
task :
|
|
146
|
+
task :billing, billing, depends_on: :optional
|
|
147
|
+
task :technical, tech, depends_on: :optional
|
|
148
|
+
task :general, general, depends_on: :optional
|
|
103
149
|
end
|
|
104
150
|
|
|
105
151
|
result = network.run(message: "I was charged twice for my subscription")
|
|
106
152
|
|
|
107
153
|
# Access individual robot results via context
|
|
108
|
-
|
|
109
|
-
puts
|
|
154
|
+
puts result.context[:classifier].last_text_content
|
|
155
|
+
puts result.value.last_text_content # the specialist that was activated
|
|
110
156
|
```
|
|
111
157
|
|
|
158
|
+
> [!WARNING]
|
|
159
|
+
> `result.context` is keyed by the **robot's** name (`with_context(@name.to_sym, ...)`),
|
|
160
|
+
> not the task name, and `activate(:name)` takes a **task** name. Keep the two
|
|
161
|
+
> identical — activating a task name that was never declared **raises
|
|
162
|
+
> `ArgumentError` and aborts the run** (`Step :classifier attempted to activate
|
|
163
|
+
> unknown step :billing`). The same happens for a task that was declared without
|
|
164
|
+
> `depends_on: :optional`.
|
|
165
|
+
|
|
166
|
+
Full version: [`examples/03_network.rb`](https://github.com/MadBomber/robot_lab/blob/main/examples/03_network.rb).
|
|
167
|
+
|
|
112
168
|
## Chaining Configuration
|
|
113
169
|
|
|
114
170
|
Robots support `with_*` methods that return `self` for chaining:
|
|
@@ -123,17 +179,27 @@ result = robot.run("Explain Ruby blocks.")
|
|
|
123
179
|
puts result.last_text_content
|
|
124
180
|
```
|
|
125
181
|
|
|
182
|
+
> [!NOTE]
|
|
183
|
+
> The chainable set is exactly what `RubyLLM::Chat` exposes — `with_context`,
|
|
184
|
+
> `with_headers`, `with_instructions`, `with_model`, `with_params`, `with_schema`,
|
|
185
|
+
> `with_temperature`, `with_thinking`, `with_tool`, `with_tools` — plus RobotLab's
|
|
186
|
+
> `with_template` and `with_bus`. There is no `with_max_tokens`, `with_top_p`, or
|
|
187
|
+
> `with_top_k`; use a constructor kwarg or `with_params(max_tokens: 2000)`.
|
|
188
|
+
|
|
126
189
|
## Using Templates
|
|
127
190
|
|
|
128
|
-
Templates are `.md` files with optional YAML front matter, managed by prompt_manager
|
|
191
|
+
Templates are `.md` files with optional YAML front matter, managed by prompt_manager.
|
|
192
|
+
The body is rendered with **ERB** — interpolate with `<%= var %>`.
|
|
129
193
|
|
|
130
194
|
```ruby
|
|
131
195
|
# Template file: prompts/support.md
|
|
132
196
|
# ---
|
|
133
197
|
# model: claude-sonnet-4
|
|
134
198
|
# temperature: 0.5
|
|
199
|
+
# parameters:
|
|
200
|
+
# company_name: null
|
|
135
201
|
# ---
|
|
136
|
-
# You are a support assistant for
|
|
202
|
+
# You are a support assistant for <%= company_name %>.
|
|
137
203
|
|
|
138
204
|
robot = RobotLab.build(
|
|
139
205
|
name: "support",
|
|
@@ -145,6 +211,13 @@ result = robot.run("How do I reset my password?")
|
|
|
145
211
|
puts result.last_text_content
|
|
146
212
|
```
|
|
147
213
|
|
|
214
|
+
> [!NOTE]
|
|
215
|
+
> `{{ var }}` is **not** interpolated — it passes through to the LLM verbatim.
|
|
216
|
+
> Of the LLM keys accepted in front matter, only `model:` and `temperature:` are
|
|
217
|
+
> actually applied to the chat. `top_p`, `top_k`, `max_tokens`, `presence_penalty`,
|
|
218
|
+
> `frequency_penalty`, and `stop` are parsed and then silently dropped; supply
|
|
219
|
+
> those as constructor kwargs or via a `RunConfig` instead.
|
|
220
|
+
|
|
148
221
|
## Running Examples
|
|
149
222
|
|
|
150
223
|
1. Install dependencies:
|
|
@@ -171,12 +244,16 @@ bundle exec rake examples:run[1] # Run specific example by number
|
|
|
171
244
|
|
|
172
245
|
## Shared Example Setup (`examples/common.rb`)
|
|
173
246
|
|
|
174
|
-
|
|
247
|
+
Most numbered examples (`01_*.rb` through `35_*.rb`) pull in a shared setup file:
|
|
175
248
|
|
|
176
249
|
```ruby
|
|
177
250
|
require_relative "common"
|
|
178
251
|
```
|
|
179
252
|
|
|
253
|
+
The line sits below each example's header comment rather than at the very top of
|
|
254
|
+
the file, and three examples do without it entirely —
|
|
255
|
+
`32_newsletter_reader.rb`, `33_stock_generator.rb`, and `33_stock_predictor.rb`.
|
|
256
|
+
|
|
180
257
|
`common.rb` handles the shared boilerplate so individual examples stay focused:
|
|
181
258
|
|
|
182
259
|
- **`LLM` hash** — frozen lookup of provider/model pairs accessible as `LLM[:default]`, `LLM[:local]`, `LLM[:anthropic]`. Each entry is a `LlmConfig = Data.define(:provider, :model)` value, so you access the model string as `LLM[:default].model`.
|
|
@@ -224,7 +301,7 @@ class Comedian < RobotLab::Robot
|
|
|
224
301
|
@attempts += 1
|
|
225
302
|
temp = [TEMP_START + TEMP_STEP * (@attempts - 1), 1.0].min
|
|
226
303
|
with_temperature(temp)
|
|
227
|
-
joke = run(message.content.to_s).
|
|
304
|
+
joke = run(message.content.to_s).reply.strip
|
|
228
305
|
send_reply(to: message.from.to_sym, content: joke, in_reply_to: message.key)
|
|
229
306
|
end
|
|
230
307
|
end
|
|
@@ -236,10 +313,14 @@ class ComedyCritic < RobotLab::Robot
|
|
|
236
313
|
def initialize(bus:)
|
|
237
314
|
super(name: "alice", template: :comedy_critic, bus: bus)
|
|
238
315
|
@accepted = false
|
|
316
|
+
@rounds = 0
|
|
239
317
|
on_message do |message|
|
|
240
|
-
|
|
318
|
+
@rounds += 1
|
|
319
|
+
verdict = run("Evaluate this joke:\n\n#{message.content}").reply.strip
|
|
241
320
|
@accepted = verdict.start_with?("FUNNY")
|
|
242
|
-
|
|
321
|
+
# The @rounds guard is what terminates the loop — without it the critic
|
|
322
|
+
# keeps sending Bob back forever.
|
|
323
|
+
send_message(to: :bob, content: "Not funny enough. Try again.") unless @accepted || @rounds >= MAX_ATTEMPTS
|
|
243
324
|
end
|
|
244
325
|
end
|
|
245
326
|
|
|
@@ -261,7 +342,12 @@ Key patterns demonstrated:
|
|
|
261
342
|
- **Auto-ack** via 1-arg `on_message` blocks
|
|
262
343
|
- **`send_reply(to:, content:, in_reply_to:)`** for correlated responses
|
|
263
344
|
- **Temperature ramping** (0.2 → 1.0) for increasing creativity
|
|
264
|
-
- **Convergence loop** that terminates when the critic approves
|
|
345
|
+
- **Convergence loop** that terminates when the critic approves *or* `MAX_ATTEMPTS` is reached
|
|
346
|
+
|
|
347
|
+
> [!WARNING]
|
|
348
|
+
> `MAX_ATTEMPTS` only bounds the loop because the critic checks it before sending
|
|
349
|
+
> Bob back. Declaring the constant without testing it leaves the two robots
|
|
350
|
+
> messaging each other indefinitely.
|
|
265
351
|
|
|
266
352
|
Run: `bundle exec ruby examples/12_message_bus.rb`
|
|
267
353
|
|
data/docs/examples/mcp-server.md
CHANGED
|
@@ -6,13 +6,36 @@ Connecting robots to Model Context Protocol servers for external tool access.
|
|
|
6
6
|
|
|
7
7
|
This example demonstrates how to connect robots to external MCP servers. MCP servers expose tools that robots can discover and invoke automatically. RobotLab supports stdio, HTTP, WebSocket, and SSE transports.
|
|
8
8
|
|
|
9
|
+
The runnable version of everything below is
|
|
10
|
+
[`examples/04_mcp.rb`](https://github.com/MadBomber/robot_lab/blob/main/examples/04_mcp.rb)
|
|
11
|
+
(direct `MCP::Client` usage in Part 1, robot integration in Part 2).
|
|
12
|
+
|
|
13
|
+
> [!WARNING]
|
|
14
|
+
> **Two things are required to actually use MCP tools in a run.** `Robot#run`
|
|
15
|
+
> defaults to `mcp: :none, tools: :none`, so build-time `mcp:` servers are never
|
|
16
|
+
> connected by a plain `robot.run("...")`. Pass both:
|
|
17
|
+
>
|
|
18
|
+
> ```ruby
|
|
19
|
+
> robot.run("...", mcp: :inherit, tools: :inherit)
|
|
20
|
+
> ```
|
|
21
|
+
>
|
|
22
|
+
> `mcp: :inherit` triggers the connection attempt; `tools: :inherit` is what sends
|
|
23
|
+
> the discovered tools to the LLM.
|
|
24
|
+
|
|
25
|
+
> [!WARNING]
|
|
26
|
+
> `transport:` must be a **nested hash**. A flat `transport: stdio` with sibling
|
|
27
|
+
> `command:` / `args:` keys raises
|
|
28
|
+
> `NoMethodError: undefined method 'transform_keys' for an instance of String`
|
|
29
|
+
> internally — and that error is swallowed, so the robot silently builds with zero
|
|
30
|
+
> MCP tools. Connection failures are logged and recorded in
|
|
31
|
+
> `robot.failed_mcp_server_names`; they are never raised.
|
|
32
|
+
|
|
9
33
|
## Using MCP with a Robot
|
|
10
34
|
|
|
11
35
|
The primary pattern is to pass MCP server configurations via `mcp:` or `mcp_servers:` when building a robot:
|
|
12
36
|
|
|
13
37
|
```ruby
|
|
14
38
|
#!/usr/bin/env ruby
|
|
15
|
-
# examples/mcp_client.rb
|
|
16
39
|
|
|
17
40
|
require "bundler/setup"
|
|
18
41
|
require "robot_lab"
|
|
@@ -42,27 +65,34 @@ robot = RobotLab.build(
|
|
|
42
65
|
model: "claude-sonnet-4"
|
|
43
66
|
)
|
|
44
67
|
|
|
45
|
-
#
|
|
68
|
+
# MCP clients are created lazily. connect_mcp! forces the connection now so the
|
|
69
|
+
# counts below are meaningful -- without it both lines print empty results.
|
|
70
|
+
robot.connect_mcp!
|
|
71
|
+
|
|
46
72
|
puts "MCP Servers: #{robot.mcp_clients.keys.join(", ")}"
|
|
47
73
|
puts "MCP Tools: #{robot.mcp_tools.size} discovered"
|
|
74
|
+
puts "Failed servers: #{robot.failed_mcp_server_names.join(", ")}" if robot.failed_mcp_server_names.any?
|
|
48
75
|
|
|
49
|
-
# Run the robot -- it
|
|
50
|
-
result = robot.run(
|
|
76
|
+
# Run the robot -- both kwargs are required for it to reach the MCP tools
|
|
77
|
+
result = robot.run(
|
|
78
|
+
"What are the top 3 most starred Ruby web frameworks on GitHub?",
|
|
79
|
+
mcp: :inherit,
|
|
80
|
+
tools: :inherit
|
|
81
|
+
)
|
|
51
82
|
puts result.last_text_content
|
|
52
83
|
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
puts "\nTool calls made:"
|
|
56
|
-
result.tool_calls.each do |tc|
|
|
57
|
-
tool_info = tc.respond_to?(:tool) ? tc.tool : tc
|
|
58
|
-
puts " #{tool_info[:name] || tool_info}"
|
|
59
|
-
end
|
|
60
|
-
end
|
|
84
|
+
# Confirm which tools were sent this turn
|
|
85
|
+
puts "Tools sent: #{robot.chat.tools.size}"
|
|
61
86
|
|
|
62
87
|
# Always disconnect MCP clients when done
|
|
63
88
|
robot.disconnect
|
|
64
89
|
```
|
|
65
90
|
|
|
91
|
+
> [!NOTE]
|
|
92
|
+
> `result.tool_calls` is effectively always empty — it reads the final assistant
|
|
93
|
+
> message, which contains only text once ruby_llm's tool loop has finished. Use the
|
|
94
|
+
> `on_tool_call:` callback or the Hook system to observe MCP tool invocations.
|
|
95
|
+
|
|
66
96
|
## Direct MCP Client Usage
|
|
67
97
|
|
|
68
98
|
You can also use the MCP client directly without a robot:
|
|
@@ -81,6 +111,8 @@ github_server = {
|
|
|
81
111
|
}
|
|
82
112
|
}
|
|
83
113
|
|
|
114
|
+
# NOTE: the config is a single POSITIONAL argument.
|
|
115
|
+
# MCP::Client.new(name: ..., transport: ...) raises ArgumentError.
|
|
84
116
|
client = RobotLab::MCP::Client.new(github_server)
|
|
85
117
|
client.connect
|
|
86
118
|
|
|
@@ -134,7 +166,11 @@ robot = RobotLab.build(
|
|
|
134
166
|
model: "claude-sonnet-4"
|
|
135
167
|
)
|
|
136
168
|
|
|
137
|
-
result = robot.run(
|
|
169
|
+
result = robot.run(
|
|
170
|
+
"Search for Ruby repos with CI configs and list their workflow files",
|
|
171
|
+
mcp: :inherit,
|
|
172
|
+
tools: :inherit
|
|
173
|
+
)
|
|
138
174
|
puts result.last_text_content
|
|
139
175
|
|
|
140
176
|
robot.disconnect
|
|
@@ -142,41 +178,77 @@ robot.disconnect
|
|
|
142
178
|
|
|
143
179
|
## MCP in Networks
|
|
144
180
|
|
|
145
|
-
|
|
181
|
+
`mcp` and `tools` are the only two fields a network passes down to its member
|
|
182
|
+
robots. Declare them **per task** — the `task` DSL is what reliably reaches the
|
|
183
|
+
robot's `run` call.
|
|
146
184
|
|
|
147
185
|
```ruby
|
|
148
|
-
# Create robots
|
|
186
|
+
# Create robots -- leave mcp:/tools: unset in the constructor
|
|
149
187
|
data_analyst = RobotLab.build(
|
|
150
188
|
name: "data_analyst",
|
|
151
|
-
system_prompt: "You analyze data."
|
|
152
|
-
mcp: :inherit # Will use whatever MCP config is resolved at runtime
|
|
189
|
+
system_prompt: "You analyze data."
|
|
153
190
|
)
|
|
154
191
|
|
|
155
192
|
file_manager = RobotLab.build(
|
|
156
193
|
name: "file_manager",
|
|
157
|
-
system_prompt: "You manage files."
|
|
158
|
-
mcp: :inherit,
|
|
159
|
-
tools: :none # Only use inherited MCP tools, no local tools
|
|
194
|
+
system_prompt: "You manage files."
|
|
160
195
|
)
|
|
161
196
|
|
|
162
|
-
#
|
|
197
|
+
# Per-task MCP + tools configuration
|
|
163
198
|
network = RobotLab.create_network(name: "support_with_mcp") do
|
|
164
|
-
task :
|
|
199
|
+
task :data_analyst, data_analyst,
|
|
165
200
|
mcp: [github_server],
|
|
201
|
+
tools: :inherit, # send everything discovered
|
|
166
202
|
depends_on: :none
|
|
167
203
|
|
|
168
|
-
task :
|
|
204
|
+
task :file_manager, file_manager,
|
|
169
205
|
mcp: [filesystem_server],
|
|
170
|
-
tools: %w[read_file list_directory], #
|
|
206
|
+
tools: %w[read_file list_directory], # allowlist only these tool names
|
|
171
207
|
depends_on: :optional
|
|
172
208
|
end
|
|
173
209
|
|
|
174
210
|
result = network.run(message: "Analyze the project structure")
|
|
175
211
|
```
|
|
176
212
|
|
|
213
|
+
> [!WARNING]
|
|
214
|
+
> Build-time `:inherit` cuts both ways, so scope it deliberately.
|
|
215
|
+
>
|
|
216
|
+
> For a **standalone** robot it is a trap: `:inherit` resolves against the global
|
|
217
|
+
> level (`:none`), yielding an allowlist of `["none"]` that matches nothing, so the
|
|
218
|
+
> robot sends no tools no matter what you pass at run time.
|
|
219
|
+
>
|
|
220
|
+
> Inside a **network**, build-time `:inherit` is exactly how a robot opts into the
|
|
221
|
+
> network's `config:` list — the parent is resolved at run time from
|
|
222
|
+
> `network_config`. Verified with a network `config:` of `tools: %w[ReadFile]` on a
|
|
223
|
+
> robot holding `ReadFile` and `ListDir`:
|
|
224
|
+
>
|
|
225
|
+
> | Robot constructor | Task line | Tools sent |
|
|
226
|
+
> |---|---|---|
|
|
227
|
+
> | `tools: :inherit` | `tools: :inherit` | `[:read_file]` — network allowlist applied |
|
|
228
|
+
> | (unset) | `tools: :inherit` | `[:read_file, :list_dir]` — allowlist ignored |
|
|
229
|
+
>
|
|
230
|
+
> A network-level `config:` also propagates *only* `mcp` and `tools`. LLM fields
|
|
231
|
+
> (`model`, `temperature`, `max_tokens`, ...) and callbacks (`on_content`) are read
|
|
232
|
+
> from each robot's own config at construction time and are never inherited from
|
|
233
|
+
> the network.
|
|
234
|
+
|
|
235
|
+
## Transport Types
|
|
236
|
+
|
|
237
|
+
The `type:` field must be one of `stdio`, `sse`, `ws`, `websocket`,
|
|
238
|
+
`streamable-http`, or `http`. Anything else raises `ArgumentError` — note that
|
|
239
|
+
`streamable_http` with an underscore is **not** valid.
|
|
240
|
+
|
|
241
|
+
`timeout:` sits alongside `transport:` on the server config and defaults to 15
|
|
242
|
+
seconds. It is normalized: `nil` becomes 15, any value of 1000 or more is treated
|
|
243
|
+
as milliseconds (5000 → 5.0s), and anything under 1 second is floored to 1.
|
|
244
|
+
|
|
245
|
+
> [!NOTE]
|
|
246
|
+
> Only the stdio transport actually enforces `timeout`. The SSE, WebSocket, and
|
|
247
|
+
> StreamableHTTP transports store the value and never reference it.
|
|
248
|
+
|
|
177
249
|
## HTTP Transport
|
|
178
250
|
|
|
179
|
-
Connect to remote MCP servers over HTTP
|
|
251
|
+
Connect to remote MCP servers over HTTP. Requires the `async-http` gem.
|
|
180
252
|
|
|
181
253
|
```ruby
|
|
182
254
|
robot = RobotLab.build(
|
|
@@ -185,22 +257,36 @@ robot = RobotLab.build(
|
|
|
185
257
|
mcp: [
|
|
186
258
|
{
|
|
187
259
|
name: "remote_api",
|
|
260
|
+
timeout: 30,
|
|
188
261
|
transport: {
|
|
189
262
|
type: "http",
|
|
190
263
|
url: "https://mcp.example.com/mcp",
|
|
191
|
-
|
|
264
|
+
# Auth is supplied by a callable, not a headers hash
|
|
265
|
+
auth_provider: -> { "Bearer #{ENV['MCP_TOKEN']}" }
|
|
192
266
|
}
|
|
193
267
|
}
|
|
194
268
|
]
|
|
195
269
|
)
|
|
196
270
|
|
|
197
|
-
result = robot.run("Use the remote tools to check system status")
|
|
271
|
+
result = robot.run("Use the remote tools to check system status", mcp: :inherit, tools: :inherit)
|
|
198
272
|
robot.disconnect
|
|
199
273
|
```
|
|
200
274
|
|
|
275
|
+
> [!WARNING]
|
|
276
|
+
> A `headers:` key in a streamable-http transport config is **silently discarded**.
|
|
277
|
+
> The transport builds its own header hash on every request and only merges in
|
|
278
|
+
> `Authorization` when `auth_provider` is set. Use `auth_provider:` (a proc
|
|
279
|
+
> returning the full header value) for authentication.
|
|
280
|
+
|
|
281
|
+
> [!WARNING]
|
|
282
|
+
> The HTTP and SSE transports set `@connected = true` *before* the MCP
|
|
283
|
+
> initialization handshake completes, so `connected?` returns `true` even against
|
|
284
|
+
> an unreachable host. Treat it as "a connect was attempted", not "the server
|
|
285
|
+
> answered", and check `robot.failed_mcp_server_names` as well.
|
|
286
|
+
|
|
201
287
|
## WebSocket Transport
|
|
202
288
|
|
|
203
|
-
For real-time bidirectional communication
|
|
289
|
+
For real-time bidirectional communication. Requires the `async-websocket` gem.
|
|
204
290
|
|
|
205
291
|
```ruby
|
|
206
292
|
robot = RobotLab.build(
|
|
@@ -217,13 +303,18 @@ robot = RobotLab.build(
|
|
|
217
303
|
]
|
|
218
304
|
)
|
|
219
305
|
|
|
220
|
-
result = robot.run("Subscribe to the events channel")
|
|
306
|
+
result = robot.run("Subscribe to the events channel", mcp: :inherit, tools: :inherit)
|
|
221
307
|
robot.disconnect
|
|
222
308
|
```
|
|
223
309
|
|
|
310
|
+
> [!CAUTION]
|
|
311
|
+
> The WebSocket transport currently raises `NameError` on
|
|
312
|
+
> `Async::HTTP::Endpoint` inside an un-awaited `Async` block. It is not usable as
|
|
313
|
+
> shipped — prefer stdio or HTTP.
|
|
314
|
+
|
|
224
315
|
## SSE Transport
|
|
225
316
|
|
|
226
|
-
Server-Sent Events transport
|
|
317
|
+
Server-Sent Events transport. Requires the `async-http` gem.
|
|
227
318
|
|
|
228
319
|
```ruby
|
|
229
320
|
robot = RobotLab.build(
|
|
@@ -240,13 +331,13 @@ robot = RobotLab.build(
|
|
|
240
331
|
]
|
|
241
332
|
)
|
|
242
333
|
|
|
243
|
-
result = robot.run("Stream the latest metrics")
|
|
334
|
+
result = robot.run("Stream the latest metrics", mcp: :inherit, tools: :inherit)
|
|
244
335
|
robot.disconnect
|
|
245
336
|
```
|
|
246
337
|
|
|
247
|
-
## Runtime MCP
|
|
338
|
+
## Runtime MCP Selection
|
|
248
339
|
|
|
249
|
-
|
|
340
|
+
`mcp:` and `tools:` on `robot.run` decide, per call, what the LLM sees:
|
|
250
341
|
|
|
251
342
|
```ruby
|
|
252
343
|
robot = RobotLab.build(
|
|
@@ -255,15 +346,28 @@ robot = RobotLab.build(
|
|
|
255
346
|
mcp: [github_server]
|
|
256
347
|
)
|
|
257
348
|
|
|
258
|
-
#
|
|
259
|
-
result = robot.run("Search for Ruby repos")
|
|
349
|
+
# Connect the configured MCP servers and send their tools
|
|
350
|
+
result = robot.run("Search for Ruby repos", mcp: :inherit, tools: :inherit)
|
|
260
351
|
|
|
261
|
-
#
|
|
262
|
-
|
|
352
|
+
# The DEFAULT: no MCP connection, no tools sent. Writing these out is
|
|
353
|
+
# redundant -- omitting both kwargs does exactly the same thing.
|
|
354
|
+
result = robot.run("Just answer from your knowledge")
|
|
355
|
+
|
|
356
|
+
# Connect MCP but send only two named tools
|
|
357
|
+
result = robot.run(
|
|
358
|
+
"List the open issues",
|
|
359
|
+
mcp: :inherit,
|
|
360
|
+
tools: %w[list_issues get_issue]
|
|
361
|
+
)
|
|
263
362
|
|
|
264
363
|
robot.disconnect
|
|
265
364
|
```
|
|
266
365
|
|
|
366
|
+
> [!NOTE]
|
|
367
|
+
> `tools:` is a **name allowlist** across every available tool — local and MCP
|
|
368
|
+
> alike. `tools: :none` means "zero tools of any kind", not "MCP only"; there is no
|
|
369
|
+
> switch that selects local tools versus MCP tools.
|
|
370
|
+
|
|
267
371
|
## Running
|
|
268
372
|
|
|
269
373
|
```bash
|
|
@@ -274,18 +378,23 @@ export GITHUB_PERSONAL_ACCESS_TOKEN="your-token"
|
|
|
274
378
|
# Install MCP server (example: GitHub)
|
|
275
379
|
brew install github-mcp-server
|
|
276
380
|
|
|
277
|
-
#
|
|
278
|
-
ruby examples/
|
|
381
|
+
# Direct MCP::Client usage + robot integration
|
|
382
|
+
ruby examples/04_mcp.rb
|
|
383
|
+
|
|
384
|
+
# Semantic MCP server selection (no LLM calls)
|
|
385
|
+
ruby examples/28_mcp_discovery.rb
|
|
279
386
|
```
|
|
280
387
|
|
|
281
388
|
## Key Concepts
|
|
282
389
|
|
|
283
|
-
1. **MCP Configuration**: Pass server configs via `mcp:`
|
|
284
|
-
2. **
|
|
285
|
-
3. **
|
|
286
|
-
4. **
|
|
287
|
-
5. **
|
|
288
|
-
6. **
|
|
390
|
+
1. **MCP Configuration**: Pass server configs via `mcp:` / `mcp_servers:` on `RobotLab.build` or `Robot.new`. `transport:` must be a nested hash
|
|
391
|
+
2. **Nothing is sent by default**: `run` defaults to `mcp: :none, tools: :none`. Pass `mcp: :inherit, tools: :inherit` to connect and send
|
|
392
|
+
3. **Lazy connection**: MCP clients are created on the first qualifying `run`. Call `robot.connect_mcp!` to connect eagerly — before that, `mcp_clients` and `mcp_tools` are empty
|
|
393
|
+
4. **Failures are silent**: connection errors are logged and recorded in `robot.failed_mcp_server_names`, never raised
|
|
394
|
+
5. **Transport Types**: `stdio`, `sse`, `ws`, `websocket`, `streamable-http`, `http` (`streamable_http` is invalid)
|
|
395
|
+
6. **Tool Filtering**: `tools:` is an allowlist over local *and* MCP tools combined; entries must match how each tool was attached (class-attached → `"ReadFile"`, instance-attached → `"read_file"`)
|
|
396
|
+
7. **Networks**: an explicit list on the `task` line applies directly; to inherit the network's `config:` list instead, build the robot with `tools: :inherit` *and* pass `tools: :inherit` on the task
|
|
397
|
+
8. **Cleanup**: Always call `robot.disconnect` when done to release MCP connections
|
|
289
398
|
|
|
290
399
|
## See Also
|
|
291
400
|
|