robot_lab 0.2.7 → 0.3.0
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 +6 -2
- data/.loki +5 -0
- data/Archspec.rb +44 -0
- data/CHANGELOG.md +70 -1
- data/CLAUDE.md +4 -2
- data/README.md +15 -17
- data/Rakefile +6 -111
- data/_typos.toml +21 -0
- data/docs/api/index.md +2 -2
- data/docs/api/skills.md +53 -149
- data/docs/api/support.md +1 -1
- data/docs/architecture/core-concepts.md +1 -1
- data/docs/architecture/index.md +5 -4
- data/docs/architecture/state-management.md +4 -4
- data/docs/concepts.md +1 -1
- data/docs/getting-started/configuration.md +4 -1
- data/docs/getting-started/installation.md +1 -4
- data/docs/guides/hooks.md +122 -0
- data/docs/guides/memory.md +2 -2
- data/docs/guides/using-tools.md +10 -1
- data/examples/.envrc +2 -0
- data/examples/02_tools.rb +8 -8
- data/examples/03_network.rb +1 -1
- data/examples/04_mcp.rb +7 -5
- data/examples/08_llm_config.rb +5 -5
- data/examples/09_chaining.rb +3 -3
- data/examples/14_rusty_circuit/comic.rb +8 -8
- data/examples/14_rusty_circuit/scout.rb +4 -4
- data/examples/15_memory_network_and_bus/README.md +66 -0
- data/examples/15_memory_network_and_bus/output/combined_article.md +5 -7
- data/examples/15_memory_network_and_bus/output/final_article.md +5 -10
- data/examples/15_memory_network_and_bus/output/linux_draft.md +3 -3
- data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
- data/examples/15_memory_network_and_bus/output/memory.json +6 -6
- data/examples/15_memory_network_and_bus/output/revision_1.md +21 -10
- data/examples/15_memory_network_and_bus/output/revision_2.md +43 -6
- data/examples/15_memory_network_and_bus/output/revision_3.md +8 -0
- data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
- data/examples/16_writers_room/tools.rb +14 -14
- data/examples/19_token_tracking.rb +2 -2
- data/examples/20_circuit_breaker.rb +3 -3
- data/examples/22_context_compression.rb +1 -1
- data/examples/27_incident_response/README.md +65 -0
- data/examples/28_mcp_discovery.rb +2 -2
- data/examples/29_ractor_tools.rb +4 -4
- data/examples/30_ractor_network.rb +2 -2
- data/examples/33_stock_predictor.rb +8 -8
- data/examples/35_hooks.rb +3 -3
- data/examples/README.md +17 -0
- data/examples/common.rb +55 -23
- data/examples/run_all.rb +60 -0
- data/lib/robot_lab/agent_skill_catalog.rb +1 -0
- data/lib/robot_lab/ask_user.rb +5 -3
- data/lib/robot_lab/bus_poller.rb +2 -0
- data/lib/robot_lab/capabilities.rb +4 -0
- data/lib/robot_lab/config/defaults.yml +5 -5
- data/lib/robot_lab/config.rb +10 -2
- data/lib/robot_lab/doom_loop_detector.rb +6 -3
- data/lib/robot_lab/history_compressor.rb +5 -0
- data/lib/robot_lab/hook.rb +1 -0
- data/lib/robot_lab/hook_context.rb +4 -0
- data/lib/robot_lab/hook_registry.rb +1 -0
- data/lib/robot_lab/hooks.rb +6 -3
- data/lib/robot_lab/mcp/client.rb +2 -2
- data/lib/robot_lab/mcp/connection_poller.rb +16 -8
- data/lib/robot_lab/mcp/server_discovery.rb +1 -0
- data/lib/robot_lab/mcp/transports/sse.rb +3 -0
- data/lib/robot_lab/mcp/transports/stdio.rb +5 -0
- data/lib/robot_lab/mcp/transports/streamable_http.rb +5 -3
- data/lib/robot_lab/mcp/transports/websocket.rb +3 -0
- data/lib/robot_lab/memory.rb +39 -13
- data/lib/robot_lab/memory_change.rb +1 -0
- data/lib/robot_lab/message.rb +3 -0
- data/lib/robot_lab/names.rb +2 -4
- data/lib/robot_lab/network.rb +13 -6
- data/lib/robot_lab/robot/agent_skill_matching.rb +2 -0
- data/lib/robot_lab/robot/bus_messaging.rb +3 -0
- data/lib/robot_lab/robot/history_search.rb +2 -0
- data/lib/robot_lab/robot/hooking.rb +29 -0
- data/lib/robot_lab/robot/mcp_management.rb +11 -3
- data/lib/robot_lab/robot/result_building.rb +119 -0
- data/lib/robot_lab/robot/template_rendering.rb +13 -4
- data/lib/robot_lab/robot.rb +88 -130
- data/lib/robot_lab/robot_result.rb +3 -0
- data/lib/robot_lab/run_config.rb +57 -20
- data/lib/robot_lab/script_tool.rb +20 -39
- data/lib/robot_lab/state_proxy.rb +1 -0
- data/lib/robot_lab/streaming/context.rb +1 -0
- data/lib/robot_lab/streaming/events.rb +1 -0
- data/lib/robot_lab/task.rb +2 -0
- data/lib/robot_lab/tool.rb +11 -12
- data/lib/robot_lab/user_message.rb +1 -0
- data/lib/robot_lab/utils.rb +2 -0
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab/waiter.rb +3 -0
- data/lib/robot_lab.rb +9 -6
- metadata +23 -32
- data/lib/robot_lab/sandbox/null.rb +0 -13
- data/lib/robot_lab/sandbox/seatbelt.rb +0 -104
- data/lib/robot_lab/sandbox.rb +0 -52
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# win_writer Draft
|
|
2
2
|
|
|
3
|
-
Windows
|
|
3
|
+
Windows gives a home AI research lab the broadest practical foundation, especially when you need NVIDIA CUDA performance without sacrificing access to Linux-centric tooling. With a native Windows environment, you get first-class NVIDIA driver and CUDA support, making it easy to run PyTorch, Hugging Face Transformers, PEFT/LoRA fine-tuning scripts, TensorBoard, and local inference stacks such as Ollama, llama.cpp, or text-generation-webui. At the same time, WSL2 gives you a near-native Linux userspace with GPU passthrough, so you can work in Ubuntu, Docker, or containerized pipelines while still using Windows-native tools like Visual Studio Code, Windows Terminal, and Docker Desktop. That combination is rare: you get the Linux ecosystem that much of the AI community expects, without being locked out of the desktop experience, hardware compatibility, and driver maturity that Windows still handles best.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
For image generation and local inference, Windows is especially convenient because it supports both CUDA and DirectML paths. If you are using an NVIDIA GPU, you can run Stable Diffusion WebUI, ComfyUI, InvokeAI, or SD.Next with strong CUDA backend support, then move to ONNX Runtime, DirectML, or OpenVINO when you need broader hardware flexibility. For AMD or Intel GPUs, DirectML and ONNX Runtime can still provide a usable local inference stack, while the widest consumer and workstation GPU selection on Windows means you can build a high-VRAM lab without needing a single vendor ecosystem. That matters for a home setup: you can mix high-end NVIDIA cards, consumer accelerators, large local SSDs, and Windows-native management tools while keeping the same machine useful for everyday work.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
The software ecosystem and community support also favor Windows for a hybrid lab workflow. You can develop in Windows-native Python environments, use Miniconda or PyCharm, and run the same CUDA-accelerated PyTorch and Hugging Face workflows that are common in production, while falling back to WSL2 when a package, dataset, or framework expects a Linux environment. The result is a lab that is flexible rather than ideological: CUDA when performance and compatibility are priority one, WSL2 when Linux tooling is expected, DirectML/ONNX when hardware breadth matters, and a large Windows/NVIDIA/Python community to lean on when debugging drivers, VRAM issues, or fine-tuning pipelines. For a home LLM fine-tuning, image generation, and local inference setup, that balance is exactly why Windows is such a strong choice.
|
|
@@ -23,8 +23,8 @@ class BroadcastTool < RobotLab::Tool
|
|
|
23
23
|
"Use for discussion, proposals, questions, or announcements. " \
|
|
24
24
|
"Don't broadcast trivially — only when you have something substantive."
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
parameter :message, type: "string",
|
|
27
|
+
description: "What to say to the room", required: true
|
|
28
28
|
|
|
29
29
|
def execute(message:)
|
|
30
30
|
log&.info("#{robot.name} TOOL broadcast (#{message.length} chars)")
|
|
@@ -42,10 +42,10 @@ class DirectMessageTool < RobotLab::Tool
|
|
|
42
42
|
"Use for feedback on their chapter, coordination on handoffs, " \
|
|
43
43
|
"or questions that don't concern the whole room."
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
parameter :to, type: "string",
|
|
46
|
+
description: "Name of the writer to message", required: true
|
|
47
|
+
parameter :message, type: "string",
|
|
48
|
+
description: "What to say", required: true
|
|
49
49
|
|
|
50
50
|
def execute(to:, message:)
|
|
51
51
|
log&.info("#{robot.name} TOOL direct_message -> #{to} (#{message.length} chars)")
|
|
@@ -63,8 +63,8 @@ class ReadMemoryTool < RobotLab::Tool
|
|
|
63
63
|
"Use to check the story bible, outline, chapter claims, " \
|
|
64
64
|
"or read another writer's chapter draft."
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
parameter :key, type: "string",
|
|
67
|
+
description: "Memory key to read (e.g. story_bible, outline, claims, chapter_3)", required: true
|
|
68
68
|
|
|
69
69
|
def execute(key:)
|
|
70
70
|
value = robot.shared_memory.get(key.to_sym)
|
|
@@ -86,10 +86,10 @@ class WriteMemoryTool < RobotLab::Tool
|
|
|
86
86
|
"Use to store the story bible, outline, claim a chapter, " \
|
|
87
87
|
"or submit a finished chapter draft."
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
parameter :key, type: "string",
|
|
90
|
+
description: "Memory key (e.g. story_bible, outline, claims, chapter_1)", required: true
|
|
91
|
+
parameter :value, type: "string",
|
|
92
|
+
description: "Content to store", required: true
|
|
93
93
|
|
|
94
94
|
def execute(key:, value:)
|
|
95
95
|
log&.info("#{robot.name} TOOL write_memory :#{key} (#{value.length} chars)")
|
|
@@ -124,8 +124,8 @@ class SpawnWriterTool < RobotLab::Tool
|
|
|
124
124
|
description "Bring a new writer into the room to help with the workload. " \
|
|
125
125
|
"Use when there are more unclaimed chapters than active writers."
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
parameter :name, type: "string",
|
|
128
|
+
description: "Name for the new writer (e.g. writer_4)", required: true
|
|
129
129
|
|
|
130
130
|
def execute(name:)
|
|
131
131
|
log&.info("#{robot.name} TOOL spawn_writer '#{name}'")
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
# naturally increase as conversation context accumulates.
|
|
15
15
|
# Use a fresh robot.build when you need a genuinely fresh context.
|
|
16
16
|
#
|
|
17
|
-
#
|
|
17
|
+
# LM Studio reports usage counts like the hosted providers do, so the tracking
|
|
18
18
|
# API behaves identically — only the price per token differs. Token counts are
|
|
19
19
|
# zero for providers that don't report usage data.
|
|
20
20
|
#
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
require_relative "common"
|
|
25
25
|
|
|
26
|
-
# Cost model. A local
|
|
26
|
+
# Cost model. A local LM Studio model bills nothing, so the interesting number is
|
|
27
27
|
# what the same traffic WOULD have cost on a hosted model — set RATE_INPUT_CPM
|
|
28
28
|
# and RATE_OUTPUT_CPM to your provider's $-per-1M-token rates to see it.
|
|
29
29
|
# Defaults are zero: local inference is free.
|
|
@@ -39,9 +39,9 @@ class MultiStepProcessor < RubyLLM::Tool
|
|
|
39
39
|
the status is "complete".
|
|
40
40
|
DESC
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
parameter :step_number,
|
|
43
43
|
type: "integer",
|
|
44
|
-
|
|
44
|
+
description: "Which step to execute. Start at 1, increment by 1 each call."
|
|
45
45
|
|
|
46
46
|
TOTAL_STEPS = 50 # far more than any sensible max_tool_rounds
|
|
47
47
|
|
|
@@ -125,7 +125,7 @@ section "Part 3: No Circuit Breaker — Task Terminates Naturally"
|
|
|
125
125
|
|
|
126
126
|
class SingleStep < RubyLLM::Tool
|
|
127
127
|
description "Doubles a number and returns the result immediately."
|
|
128
|
-
|
|
128
|
+
parameter :value, type: "integer", description: "The number to double"
|
|
129
129
|
|
|
130
130
|
def execute(value:)
|
|
131
131
|
{ result: value * 2, status: "complete" }
|
|
@@ -156,7 +156,7 @@ puts "cheaper robot does the summarizing for the medium-relevance band."
|
|
|
156
156
|
show_code <<~RUBY
|
|
157
157
|
summarizer_bot = RobotLab.build(
|
|
158
158
|
name: "summarizer",
|
|
159
|
-
provider: "
|
|
159
|
+
provider: "lms",
|
|
160
160
|
model: "qwen2.5:7b",
|
|
161
161
|
system_prompt: "Summarize the following text in one sentence."
|
|
162
162
|
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Example 27: Production Incident War Room
|
|
2
|
+
|
|
3
|
+
A simulated payment-service outage, used to demonstrate RobotLab's Phase 5
|
|
4
|
+
infrastructure features: **reactive memory**, **BusPoller serialized
|
|
5
|
+
delivery**, and **poller groups**.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
A network runs four robots against one incident ("elevated HTTP 500s, p99
|
|
10
|
+
latency spiked to 8s"):
|
|
11
|
+
|
|
12
|
+
- **Three SRE scouts** (`db_scout`, `net_scout`, `app_scout`) investigate the
|
|
13
|
+
database, network, and application layers in parallel. Each makes one LLM
|
|
14
|
+
call, writes its two-sentence finding to shared **reactive memory**
|
|
15
|
+
(`:db_finding`, `:net_finding`, `:app_finding`), and broadcasts a status
|
|
16
|
+
line to the war room over TypedBus.
|
|
17
|
+
- **The war room** receives those bus messages through **BusPoller**, which
|
|
18
|
+
serializes delivery — if two scouts finish at the same instant, their
|
|
19
|
+
updates queue and are processed one at a time, in arrival order, with no
|
|
20
|
+
re-entrancy and nothing dropped.
|
|
21
|
+
- **The incident commander** depends on all three scouts and blocks on
|
|
22
|
+
`memory.get(:db_finding, :net_finding, :app_finding, wait: 60)` — an
|
|
23
|
+
IO.pipe-backed waiter woken by `IO.select`, so there is no busy-wait and it
|
|
24
|
+
cooperates with Async. When all findings land it makes one synthesis call
|
|
25
|
+
and writes a 3-5 bullet action plan.
|
|
26
|
+
|
|
27
|
+
Also on display:
|
|
28
|
+
|
|
29
|
+
- **Poller groups** — the scouts run in `poller_group: :investigation`, the
|
|
30
|
+
commander in `:command`, declared per task on the network.
|
|
31
|
+
- **Memory subscriptions** — a `memory.subscribe` callback prints a line as
|
|
32
|
+
each finding is written.
|
|
33
|
+
- **Timeout degradation** — `Memory#get(wait:)` raises
|
|
34
|
+
`RobotLab::AwaitTimeout` rather than returning a sentinel; the commander
|
|
35
|
+
rescues it and re-reads without `wait:`, so a slow scout degrades the
|
|
36
|
+
report instead of killing the run.
|
|
37
|
+
|
|
38
|
+
## How to run
|
|
39
|
+
|
|
40
|
+
From the gem root (an LM Studio server must be running — `lms server start`;
|
|
41
|
+
provider/model come from `examples/common.rb`):
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
bundle exec ruby examples/27_incident_response/incident_response.rb
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Note that `examples/run_all.rb` does **not** include this demo — it only runs
|
|
48
|
+
the top-level `NN_*.rb` files, so run it explicitly as above.
|
|
49
|
+
|
|
50
|
+
The run makes four LLM calls (three scout diagnoses plus the commander's
|
|
51
|
+
synthesis), so expect a few minutes on the default local model;
|
|
52
|
+
`LLM_PROFILE=small` shortens it.
|
|
53
|
+
|
|
54
|
+
## Output
|
|
55
|
+
|
|
56
|
+
- Console: the network graph, each memory write as it happens, the war-room
|
|
57
|
+
updates in BusPoller delivery order, the scouts' findings, and the final
|
|
58
|
+
action plan.
|
|
59
|
+
- `output/incident_report.md` — the commander's incident action plan.
|
|
60
|
+
|
|
61
|
+
## Files
|
|
62
|
+
|
|
63
|
+
- `incident_response.rb` — the whole demo: `SREScout`, `WarRoom`, and
|
|
64
|
+
`IncidentCommander` robot classes plus network wiring and the run itself
|
|
65
|
+
- `output/` — where the incident report is written
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
# == Key config
|
|
13
13
|
#
|
|
14
14
|
# robot = RobotLab.build(
|
|
15
|
-
# provider: "
|
|
15
|
+
# provider: "lms", model: "qwen/qwen3.8-27b",
|
|
16
16
|
# mcp_discovery: true, # ← enables semantic filtering
|
|
17
17
|
# mcp: [ ... ] # ← candidate servers, each with :description
|
|
18
18
|
# )
|
|
@@ -95,7 +95,7 @@ section "mcp_discovery: true on a Robot"
|
|
|
95
95
|
show_code <<~RUBY
|
|
96
96
|
robot = RobotLab.build(
|
|
97
97
|
name: "assistant",
|
|
98
|
-
provider: "
|
|
98
|
+
provider: "lms",
|
|
99
99
|
model: "qwen3.6:latest",
|
|
100
100
|
mcp_discovery: true,
|
|
101
101
|
mcp: [
|
data/examples/29_ractor_tools.rb
CHANGED
|
@@ -46,7 +46,7 @@ end
|
|
|
46
46
|
class WordStatsTool < TextTool
|
|
47
47
|
description "Count words, sentences, and average word length"
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
parameter :text, type: :string, description: "Text to analyze"
|
|
50
50
|
|
|
51
51
|
def execute(text:)
|
|
52
52
|
words = text.scan(/\b\w+\b/)
|
|
@@ -61,7 +61,7 @@ end
|
|
|
61
61
|
class ReadabilityTool < TextTool
|
|
62
62
|
description "Estimate words-per-sentence and long-word density"
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
parameter :text, type: :string, description: "Text to analyze"
|
|
65
65
|
|
|
66
66
|
def execute(text:)
|
|
67
67
|
words = text.scan(/\b\w+\b/)
|
|
@@ -86,7 +86,7 @@ class HeavyDigestTool < TextTool
|
|
|
86
86
|
|
|
87
87
|
ROUNDS = 500_000
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
parameter :text, type: :string, description: "Seed text"
|
|
90
90
|
|
|
91
91
|
def execute(text:)
|
|
92
92
|
digest = text
|
|
@@ -102,7 +102,7 @@ class RequestCounterTool < RobotLab::Tool
|
|
|
102
102
|
|
|
103
103
|
@@hits = 0 # mutable class variable — Ractor workers cannot access this
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
parameter :text, type: :string, description: "Text to count"
|
|
106
106
|
|
|
107
107
|
def execute(text:)
|
|
108
108
|
@@hits += 1
|
|
@@ -190,7 +190,7 @@ puts
|
|
|
190
190
|
|
|
191
191
|
unless ENV["RUN_LIVE"]
|
|
192
192
|
section "Part 3: Live LLM Run"
|
|
193
|
-
puts " Set RUN_LIVE=1 (with
|
|
193
|
+
puts " Set RUN_LIVE=1 (with LM Studio running) to attempt the real pipeline."
|
|
194
194
|
puts " Expected behavior: headline_finder, background_brief, and"
|
|
195
195
|
puts " fact_checker run in parallel; report_writer follows."
|
|
196
196
|
puts
|
|
@@ -204,7 +204,7 @@ unless ENV["RUN_LIVE"]
|
|
|
204
204
|
exit 0
|
|
205
205
|
end
|
|
206
206
|
|
|
207
|
-
|
|
207
|
+
require_lms!
|
|
208
208
|
|
|
209
209
|
section "Part 3: Live LLM Run (RUN_LIVE set)"
|
|
210
210
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
# Prerequisites:
|
|
15
15
|
# gem install redis
|
|
16
16
|
# Redis server running on localhost:6379
|
|
17
|
-
#
|
|
17
|
+
# LM Studio running with the models from common.rb downloaded
|
|
18
18
|
#
|
|
19
19
|
# Usage:
|
|
20
20
|
# ruby examples/33_stock_predictor.rb
|
|
@@ -119,12 +119,12 @@ class AdjustParameters < RobotLab::Tool
|
|
|
119
119
|
description "Adjust one predictor parameter to improve future prediction accuracy. " \
|
|
120
120
|
"Make at most one or two targeted changes per window."
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
122
|
+
parameter :parameter, type: "string",
|
|
123
|
+
description: "Parameter to adjust: sma_window, sma_std_multiplier, ema_alpha, ema_vol_multiplier, sma_weight"
|
|
124
|
+
parameter :value, type: "number",
|
|
125
|
+
description: "New value (sma_window: 3-30 int; std/vol multipliers: 0.5-4.0; ema_alpha: 0.05-0.5; sma_weight: 0.0-1.0)"
|
|
126
|
+
parameter :reasoning, type: "string",
|
|
127
|
+
description: "Why this change should reduce prediction error"
|
|
128
128
|
|
|
129
129
|
LIMITS = {
|
|
130
130
|
"sma_window" => { min: 3, max: 30, integer: true },
|
|
@@ -204,7 +204,7 @@ puts "Warmup : #{PredictorConfig.sma_window} ticks"
|
|
|
204
204
|
puts "Press Ctrl-C to stop."
|
|
205
205
|
puts "-" * 60
|
|
206
206
|
|
|
207
|
-
|
|
207
|
+
require_lms!
|
|
208
208
|
|
|
209
209
|
redis = Redis.new
|
|
210
210
|
prices = []
|
data/examples/35_hooks.rb
CHANGED
|
@@ -129,7 +129,7 @@ end
|
|
|
129
129
|
|
|
130
130
|
class HookDemoTool < RobotLab::Tool
|
|
131
131
|
description "Returns a deterministic hook demo value"
|
|
132
|
-
|
|
132
|
+
parameter :label, type: "string", description: "The label to echo"
|
|
133
133
|
|
|
134
134
|
def execute(label:)
|
|
135
135
|
{ label: label, status: "handled by HookDemoTool" }
|
|
@@ -187,7 +187,7 @@ class HookDemo
|
|
|
187
187
|
|
|
188
188
|
puts " provider=#{LLM[:default].provider} model=#{LLM[:default].model}\n\n"
|
|
189
189
|
|
|
190
|
-
# with_model alone would leave the provider unset, and an
|
|
190
|
+
# with_model alone would leave the provider unset, and an LM Studio model is
|
|
191
191
|
# not in RubyLLM's registry — pass provider and model together.
|
|
192
192
|
robot = RobotLab.build(
|
|
193
193
|
name: "loop_demo_robot",
|
|
@@ -224,7 +224,7 @@ class HookDemo
|
|
|
224
224
|
def run_tool
|
|
225
225
|
section "Tool Call Hooks"
|
|
226
226
|
tool = HookDemoTool.new
|
|
227
|
-
result = tool.call({ "label" => "tool hook payload" })
|
|
227
|
+
result = tool.call(**{ "label" => "tool hook payload" })
|
|
228
228
|
puts "Tool result: #{result.inspect}"
|
|
229
229
|
end
|
|
230
230
|
|
data/examples/README.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Working demonstrations of RobotLab features, from single-robot basics to multi-robot orchestration and message bus communication.
|
|
4
4
|
|
|
5
|
+
> **A note on performance:** how fast (and how well) these demos run depends
|
|
6
|
+
> almost entirely on which provider and model you point them at. A large model
|
|
7
|
+
> served locally on consumer hardware can take a minute or more per LLM call,
|
|
8
|
+
> while a small local model or a hosted API answers in seconds — and smaller
|
|
9
|
+
> models may also give noticeably weaker answers on the multi-robot demos.
|
|
10
|
+
> Wall-clock times you see will differ from anyone else's; tune the
|
|
11
|
+
> provider/model in `common.rb` (or via `LLM_PROFILE`) to trade speed against
|
|
12
|
+
> quality.
|
|
13
|
+
|
|
5
14
|
## Prerequisites
|
|
6
15
|
|
|
7
16
|
- Ruby >= 3.2
|
|
@@ -83,8 +92,16 @@ bundle exec rake examples:all
|
|
|
83
92
|
|
|
84
93
|
# Run directly
|
|
85
94
|
bundle exec ruby examples/01_simple_robot.rb
|
|
95
|
+
|
|
96
|
+
# Run every demo serially with a banner between each (works from any cwd)
|
|
97
|
+
examples/run_all.rb
|
|
86
98
|
```
|
|
87
99
|
|
|
100
|
+
`run_all.rb` executes every executable `NN_*.rb` demo in order, announcing
|
|
101
|
+
each one with a banner so the outputs stay separated. Ctrl-C kills only the
|
|
102
|
+
demo that is currently running — the runner notes the interruption and moves
|
|
103
|
+
on to the next demo.
|
|
104
|
+
|
|
88
105
|
## Tools require `tools: :inherit` at run time
|
|
89
106
|
|
|
90
107
|
This trips up everyone once. `Robot#run` defaults to `tools: :none`, which
|
data/examples/common.rb
CHANGED
|
@@ -1,33 +1,64 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# The examples are runnable directly (./01_simple_robot.rb) as well as via
|
|
4
|
+
# bundle exec. Without bundler/setup, bare requires let RubyGems activate the
|
|
5
|
+
# newest installed json (3.x), which conflicts with ruby_llm's json (< 3) pin;
|
|
6
|
+
# the lockfile pins json 2.x, so honor it in both invocation styles.
|
|
7
|
+
#
|
|
8
|
+
# The workspace .envrc exports BUNDLE_GEMFILE as a RELATIVE path ("Gemfile" or
|
|
9
|
+
# "Gemfile.local"), which bundler resolves against the cwd — so a demo run
|
|
10
|
+
# from examples/ or a subdirectory demo run from its own directory would look
|
|
11
|
+
# for a Gemfile there. Anchor the basename to the gem root before bundler
|
|
12
|
+
# sees it, preserving the prod/dev (Gemfile vs Gemfile.local) choice.
|
|
13
|
+
ENV["BUNDLE_GEMFILE"] = File.expand_path(
|
|
14
|
+
"../#{File.basename(ENV.fetch("BUNDLE_GEMFILE", "Gemfile"))}", __dir__
|
|
15
|
+
)
|
|
16
|
+
require "bundler/setup"
|
|
17
|
+
|
|
3
18
|
require "logger"
|
|
4
19
|
|
|
5
20
|
# Fallback for when direnv has not activated examples/.envrc
|
|
6
21
|
ENV["ROBOT_LAB_TEMPLATE_PATH"] ||= File.join(__dir__, "prompts")
|
|
7
22
|
|
|
8
23
|
require_relative "../lib/robot_lab"
|
|
24
|
+
require "ruby_llm/providers/lms"
|
|
25
|
+
|
|
26
|
+
# robot_lab's dependencies load parts of ActiveSupport, so amazing_print 3.0
|
|
27
|
+
# sees the constant and loads its ActiveSupport extension, which at require
|
|
28
|
+
# time calls ActiveSupport.try and reads ActiveSupport::LogSubscriber —
|
|
29
|
+
# neither of which those parts provide. Load both up front so any demo can
|
|
30
|
+
# `require "amazing_print"` safely.
|
|
31
|
+
require "active_support/core_ext/object/try"
|
|
32
|
+
require "active_support/log_subscriber"
|
|
9
33
|
|
|
10
34
|
# ── Local LLM Configuration ───────────────────────────────────────────────────
|
|
11
35
|
#
|
|
12
|
-
# Every example runs against a LOCAL model served by
|
|
13
|
-
#
|
|
36
|
+
# Every example runs against a LOCAL model served by LM Studio through the
|
|
37
|
+
# ruby_llm-providers-lms gem (provider :lms). No API keys, no network egress,
|
|
38
|
+
# no per-token cost. Start the server and download the models once:
|
|
39
|
+
#
|
|
40
|
+
# lms server start
|
|
41
|
+
# lms get qwen/qwen3.8-27b
|
|
42
|
+
# lms get openai/gpt-oss-20b
|
|
14
43
|
#
|
|
15
|
-
#
|
|
44
|
+
# Model choice: qwen/qwen3.8-27b for complex activities (tools, structured
|
|
45
|
+
# output, multi-robot reasoning — it honors tool_choice and schemas), and
|
|
46
|
+
# openai/gpt-oss-20b for simpler items (plain chat, streaming).
|
|
16
47
|
#
|
|
17
|
-
#
|
|
18
|
-
# supplied alongside `model:` — that is what makes RubyLLM skip the
|
|
19
|
-
# lookup (see Robot#initialize, which sets assume_model_exists when
|
|
20
|
-
# given). Use the `llm_opts` helper below so every robot gets both.
|
|
48
|
+
# LM Studio models are not in RubyLLM's model registry, so a `provider:` must
|
|
49
|
+
# be supplied alongside `model:` — that is what makes RubyLLM skip the
|
|
50
|
+
# registry lookup (see Robot#initialize, which sets assume_model_exists when
|
|
51
|
+
# provider is given). Use the `llm_opts` helper below so every robot gets both.
|
|
21
52
|
|
|
22
53
|
LlmConfig = Data.define(:provider, :model)
|
|
23
54
|
|
|
24
55
|
LLM = {
|
|
25
|
-
default: LlmConfig.new(provider: "
|
|
26
|
-
small: LlmConfig.new(provider: "
|
|
27
|
-
large: LlmConfig.new(provider: "
|
|
56
|
+
default: LlmConfig.new(provider: "lms", model: "qwen/qwen3.8-27b"),
|
|
57
|
+
small: LlmConfig.new(provider: "lms", model: "openai/gpt-oss-20b"),
|
|
58
|
+
large: LlmConfig.new(provider: "lms", model: "qwen/qwen3.8-27b")
|
|
28
59
|
}.freeze
|
|
29
60
|
|
|
30
|
-
|
|
61
|
+
LMS_API_BASE = ENV.fetch("LMS_API_BASE", "http://localhost:1234/v1")
|
|
31
62
|
|
|
32
63
|
# ORDER MATTERS. The first touch of RobotLab.config runs Config#after_load,
|
|
33
64
|
# which calls RubyLLM.configure itself and would clobber anything set before
|
|
@@ -37,9 +68,9 @@ RobotLab.configure do |c|
|
|
|
37
68
|
end
|
|
38
69
|
|
|
39
70
|
RubyLLM.configure do |c|
|
|
40
|
-
c.logger
|
|
41
|
-
c.default_model
|
|
42
|
-
c.
|
|
71
|
+
c.logger = Logger.new(File::NULL)
|
|
72
|
+
c.default_model = LLM[:default].model
|
|
73
|
+
c.lms_api_base = LMS_API_BASE
|
|
43
74
|
|
|
44
75
|
# A large local model on consumer hardware is far slower than a hosted API,
|
|
45
76
|
# and robot_lab's bundled 120s default is comfortably exceeded by a long
|
|
@@ -67,7 +98,7 @@ end
|
|
|
67
98
|
|
|
68
99
|
# Provider + model keyword pair for RobotLab.build / Robot.new.
|
|
69
100
|
#
|
|
70
|
-
# Both are required for a local
|
|
101
|
+
# Both are required for a local LM Studio model. Splat it into any robot
|
|
71
102
|
# constructor:
|
|
72
103
|
#
|
|
73
104
|
# RobotLab.build(name: "helper", **llm_opts) # honors LLM_PROFILE
|
|
@@ -80,19 +111,20 @@ def llm_opts(key = nil)
|
|
|
80
111
|
{ provider: cfg.provider, model: cfg.model }
|
|
81
112
|
end
|
|
82
113
|
|
|
83
|
-
# Fail fast with an actionable message when
|
|
84
|
-
# letting every example die inside an HTTP adapter.
|
|
85
|
-
def
|
|
114
|
+
# Fail fast with an actionable message when LM Studio isn't reachable,
|
|
115
|
+
# instead of letting every example die inside an HTTP adapter.
|
|
116
|
+
def require_lms!
|
|
86
117
|
require "net/http"
|
|
87
|
-
uri = URI(
|
|
118
|
+
uri = URI("#{LMS_API_BASE.sub(%r{/v1/?\z}, '')}/v1/models")
|
|
88
119
|
Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 2) { |h| h.get(uri.request_uri) }
|
|
89
120
|
rescue StandardError => e
|
|
90
121
|
abort <<~ERROR
|
|
91
|
-
Cannot reach
|
|
122
|
+
Cannot reach LM Studio at #{LMS_API_BASE} (#{e.class}).
|
|
92
123
|
|
|
93
|
-
Start
|
|
94
|
-
|
|
95
|
-
|
|
124
|
+
Start the server and download the models used by the examples:
|
|
125
|
+
lms server start
|
|
126
|
+
lms get #{LLM[:default].model}
|
|
127
|
+
lms get #{LLM[:small].model}
|
|
96
128
|
ERROR
|
|
97
129
|
end
|
|
98
130
|
|
data/examples/run_all.rb
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Runs every executable NN_*.rb demo in this directory serially, printing a
|
|
5
|
+
# banner to STDOUT before each one so the outputs are easy to tell apart.
|
|
6
|
+
|
|
7
|
+
module RunAll
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Executable NN_*.rb demos in +dir+, in numeric order.
|
|
11
|
+
def demo_files(dir = __dir__)
|
|
12
|
+
Dir.glob(File.join(dir, "[0-9][0-9]_*.rb"))
|
|
13
|
+
.select { File.executable?(it) }
|
|
14
|
+
.sort
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def banner(path)
|
|
18
|
+
bar = "=" * 70
|
|
19
|
+
<<~BANNER
|
|
20
|
+
|
|
21
|
+
#{bar}
|
|
22
|
+
== #{File.basename(path)}
|
|
23
|
+
#{bar}
|
|
24
|
+
BANNER
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Absolute path to the gem root's Gemfile, honoring the prod/dev choice
|
|
28
|
+
# (Gemfile vs Gemfile.local) carried by BUNDLE_GEMFILE. The inherited value
|
|
29
|
+
# is relative, so left alone it would resolve against the demo's cwd.
|
|
30
|
+
def bundle_gemfile(dir = __dir__)
|
|
31
|
+
name = File.basename(ENV.fetch("BUNDLE_GEMFILE", "Gemfile"))
|
|
32
|
+
File.expand_path("../#{name}", dir)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Runs one demo with this Ruby interpreter; returns true on success.
|
|
36
|
+
def run_demo(path)
|
|
37
|
+
system({ "BUNDLE_GEMFILE" => bundle_gemfile }, RbConfig.ruby, path)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# True when the last demo was killed by Ctrl-C (SIGINT).
|
|
41
|
+
def interrupted?(status = Process.last_status)
|
|
42
|
+
!status.nil? && status.signaled? && status.termsig == Signal.list["INT"]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Ctrl-C reaches the whole foreground process group. A proc trap (unlike
|
|
46
|
+
# "IGNORE") is reset to DEFAULT in the exec'd child, so the demo dies while
|
|
47
|
+
# this runner survives and moves on to the next one.
|
|
48
|
+
def run(files = demo_files)
|
|
49
|
+
previous = trap("INT") { nil }
|
|
50
|
+
files.each do |file|
|
|
51
|
+
puts banner(file)
|
|
52
|
+
run_demo(file)
|
|
53
|
+
puts "-- #{File.basename(file)} interrupted (Ctrl-C); moving on" if interrupted?
|
|
54
|
+
end
|
|
55
|
+
ensure
|
|
56
|
+
trap("INT", previous || "DEFAULT")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
RunAll.run if $PROGRAM_NAME == __FILE__
|
data/lib/robot_lab/ask_user.rb
CHANGED
|
@@ -33,10 +33,12 @@ module RobotLab
|
|
|
33
33
|
#
|
|
34
34
|
class AskUser < Tool
|
|
35
35
|
description "Ask the user a question and wait for their typed response"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
parameter :question, type: "string", description: "The question to ask the user"
|
|
37
|
+
parameter :choices, type: "array", description: "Optional list of choices to present", required: false
|
|
38
|
+
parameter :default, type: "string", description: "Default value if user presses Enter", required: false
|
|
39
39
|
|
|
40
|
+
# :reek:FeatureEnvy -- rendering and resolving the caller-supplied choices list is this tool's whole job.
|
|
41
|
+
# :reek:TooManyStatements -- linear prompt/read/resolve terminal interaction.
|
|
40
42
|
def execute(question:, choices: nil, default: nil)
|
|
41
43
|
out = output_io
|
|
42
44
|
label = robot&.name || "Robot"
|
data/lib/robot_lab/bus_poller.rb
CHANGED
|
@@ -67,6 +67,7 @@ module RobotLab
|
|
|
67
67
|
# @param group [Symbol] poller group label (informational only)
|
|
68
68
|
# @return [void]
|
|
69
69
|
#
|
|
70
|
+
# :reek:TooManyStatements -- the queue-or-run decision must stay inside one mutex critical section.
|
|
70
71
|
def enqueue(robot:, delivery:, group: :default)
|
|
71
72
|
should_process = @mutex.synchronize do
|
|
72
73
|
name = robot.name
|
|
@@ -124,6 +125,7 @@ module RobotLab
|
|
|
124
125
|
RobotLab.config.logger.warn("BusPoller: unexpected error: #{e.message}")
|
|
125
126
|
end
|
|
126
127
|
|
|
128
|
+
# :reek:TooManyStatements -- pop-under-mutex then process-outside-mutex loop; splitting it would separate the lock from its release.
|
|
127
129
|
def drain_queued_deliveries(robot)
|
|
128
130
|
loop do
|
|
129
131
|
next_delivery = @mutex.synchronize do
|
|
@@ -15,6 +15,8 @@ module RobotLab
|
|
|
15
15
|
|
|
16
16
|
attr_reader :fs_read, :fs_write, :network, :timeout, :trust
|
|
17
17
|
|
|
18
|
+
# :reek:BooleanParameter -- `network` is a declared capability value (part of the data model), not a mode switch.
|
|
19
|
+
# :reek:ControlParameter -- `network ? true : false` is boolean coercion of untrusted front-matter input, not behavior selection.
|
|
18
20
|
def initialize(fs_read: [], fs_write: [], network: false, timeout: DEFAULT_TIMEOUT, trust: "external")
|
|
19
21
|
@fs_read = Array(fs_read).map(&:to_s)
|
|
20
22
|
@fs_write = Array(fs_write).map(&:to_s)
|
|
@@ -24,6 +26,7 @@ module RobotLab
|
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
# Build from a SKILL.md front matter hash (string or symbol keys).
|
|
29
|
+
# :reek:ControlParameter -- `front_matter || {}` is a nil-safe default, not control coupling.
|
|
27
30
|
def self.from_front_matter(front_matter)
|
|
28
31
|
fm = front_matter || {}
|
|
29
32
|
new(
|
|
@@ -72,6 +75,7 @@ module RobotLab
|
|
|
72
75
|
|
|
73
76
|
private
|
|
74
77
|
|
|
78
|
+
# :reek:NestedIterators -- 2-deep select/any? over two small path lists is idiomatic Ruby.
|
|
75
79
|
def clamp_paths(requested, allowed)
|
|
76
80
|
roots = expand(allowed)
|
|
77
81
|
expand(requested).select { |p| roots.any? { |r| p == r || p.start_with?("#{r}/") } }
|