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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +6 -2
  3. data/.loki +5 -0
  4. data/Archspec.rb +44 -0
  5. data/CHANGELOG.md +70 -1
  6. data/CLAUDE.md +4 -2
  7. data/README.md +15 -17
  8. data/Rakefile +6 -111
  9. data/_typos.toml +21 -0
  10. data/docs/api/index.md +2 -2
  11. data/docs/api/skills.md +53 -149
  12. data/docs/api/support.md +1 -1
  13. data/docs/architecture/core-concepts.md +1 -1
  14. data/docs/architecture/index.md +5 -4
  15. data/docs/architecture/state-management.md +4 -4
  16. data/docs/concepts.md +1 -1
  17. data/docs/getting-started/configuration.md +4 -1
  18. data/docs/getting-started/installation.md +1 -4
  19. data/docs/guides/hooks.md +122 -0
  20. data/docs/guides/memory.md +2 -2
  21. data/docs/guides/using-tools.md +10 -1
  22. data/examples/.envrc +2 -0
  23. data/examples/02_tools.rb +8 -8
  24. data/examples/03_network.rb +1 -1
  25. data/examples/04_mcp.rb +7 -5
  26. data/examples/08_llm_config.rb +5 -5
  27. data/examples/09_chaining.rb +3 -3
  28. data/examples/14_rusty_circuit/comic.rb +8 -8
  29. data/examples/14_rusty_circuit/scout.rb +4 -4
  30. data/examples/15_memory_network_and_bus/README.md +66 -0
  31. data/examples/15_memory_network_and_bus/output/combined_article.md +5 -7
  32. data/examples/15_memory_network_and_bus/output/final_article.md +5 -10
  33. data/examples/15_memory_network_and_bus/output/linux_draft.md +3 -3
  34. data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
  35. data/examples/15_memory_network_and_bus/output/memory.json +6 -6
  36. data/examples/15_memory_network_and_bus/output/revision_1.md +21 -10
  37. data/examples/15_memory_network_and_bus/output/revision_2.md +43 -6
  38. data/examples/15_memory_network_and_bus/output/revision_3.md +8 -0
  39. data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
  40. data/examples/16_writers_room/tools.rb +14 -14
  41. data/examples/19_token_tracking.rb +2 -2
  42. data/examples/20_circuit_breaker.rb +3 -3
  43. data/examples/22_context_compression.rb +1 -1
  44. data/examples/27_incident_response/README.md +65 -0
  45. data/examples/28_mcp_discovery.rb +2 -2
  46. data/examples/29_ractor_tools.rb +4 -4
  47. data/examples/30_ractor_network.rb +2 -2
  48. data/examples/33_stock_predictor.rb +8 -8
  49. data/examples/35_hooks.rb +3 -3
  50. data/examples/README.md +17 -0
  51. data/examples/common.rb +55 -23
  52. data/examples/run_all.rb +60 -0
  53. data/lib/robot_lab/agent_skill_catalog.rb +1 -0
  54. data/lib/robot_lab/ask_user.rb +5 -3
  55. data/lib/robot_lab/bus_poller.rb +2 -0
  56. data/lib/robot_lab/capabilities.rb +4 -0
  57. data/lib/robot_lab/config/defaults.yml +5 -5
  58. data/lib/robot_lab/config.rb +10 -2
  59. data/lib/robot_lab/doom_loop_detector.rb +6 -3
  60. data/lib/robot_lab/history_compressor.rb +5 -0
  61. data/lib/robot_lab/hook.rb +1 -0
  62. data/lib/robot_lab/hook_context.rb +4 -0
  63. data/lib/robot_lab/hook_registry.rb +1 -0
  64. data/lib/robot_lab/hooks.rb +6 -3
  65. data/lib/robot_lab/mcp/client.rb +2 -2
  66. data/lib/robot_lab/mcp/connection_poller.rb +16 -8
  67. data/lib/robot_lab/mcp/server_discovery.rb +1 -0
  68. data/lib/robot_lab/mcp/transports/sse.rb +3 -0
  69. data/lib/robot_lab/mcp/transports/stdio.rb +5 -0
  70. data/lib/robot_lab/mcp/transports/streamable_http.rb +5 -3
  71. data/lib/robot_lab/mcp/transports/websocket.rb +3 -0
  72. data/lib/robot_lab/memory.rb +39 -13
  73. data/lib/robot_lab/memory_change.rb +1 -0
  74. data/lib/robot_lab/message.rb +3 -0
  75. data/lib/robot_lab/names.rb +2 -4
  76. data/lib/robot_lab/network.rb +13 -6
  77. data/lib/robot_lab/robot/agent_skill_matching.rb +2 -0
  78. data/lib/robot_lab/robot/bus_messaging.rb +3 -0
  79. data/lib/robot_lab/robot/history_search.rb +2 -0
  80. data/lib/robot_lab/robot/hooking.rb +29 -0
  81. data/lib/robot_lab/robot/mcp_management.rb +11 -3
  82. data/lib/robot_lab/robot/result_building.rb +119 -0
  83. data/lib/robot_lab/robot/template_rendering.rb +13 -4
  84. data/lib/robot_lab/robot.rb +88 -130
  85. data/lib/robot_lab/robot_result.rb +3 -0
  86. data/lib/robot_lab/run_config.rb +57 -20
  87. data/lib/robot_lab/script_tool.rb +20 -39
  88. data/lib/robot_lab/state_proxy.rb +1 -0
  89. data/lib/robot_lab/streaming/context.rb +1 -0
  90. data/lib/robot_lab/streaming/events.rb +1 -0
  91. data/lib/robot_lab/task.rb +2 -0
  92. data/lib/robot_lab/tool.rb +11 -12
  93. data/lib/robot_lab/user_message.rb +1 -0
  94. data/lib/robot_lab/utils.rb +2 -0
  95. data/lib/robot_lab/version.rb +1 -1
  96. data/lib/robot_lab/waiter.rb +3 -0
  97. data/lib/robot_lab.rb +9 -6
  98. metadata +23 -32
  99. data/lib/robot_lab/sandbox/null.rb +0 -13
  100. data/lib/robot_lab/sandbox/seatbelt.rb +0 -104
  101. data/lib/robot_lab/sandbox.rb +0 -52
@@ -1,7 +1,7 @@
1
1
  # win_writer Draft
2
2
 
3
- Windows is the strongest all-around choice for a home AI research lab because it gives you the best combination of raw GPU support, hardware flexibility, and practical developer workflow. For LLM fine-tuning, image generation, and local inference, NVIDIA CUDA support on Windows is first-class: PyTorch, TensorFlow, xFormers, bitsandbytes, TensorRT, ONNX Runtime, and popular tools like Ollama, LM Studio, ComfyUI, AUTOMATIC1111, and Stable Diffusion WebUI all have strong Windows paths. If you want the highest-performance consumer GPUs for AI work, Windows pairs especially well with NVIDIA RTX hardware, from a single 4070 Ti Super up to multi-GPU prosumer setups. And if you need Linux-native scripts or package workflows, WSL2 gives you a near-seamless Ubuntu environment on the same machine, so you can run Hugging Face Transformers, PEFT, DeepSpeed, vLLM, llama.cpp, JupyterLab, and CUDA-enabled Linux tooling without giving up native Windows apps.
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
- Windows also wins on hardware compatibility and ecosystem breadth. A home lab often mixes gaming-class GPUs, high-core-count CPUs, fast NVMe storage, USB devices, capture cards, and sometimes unusual motherboard or networking gear; Windows consistently offers the widest driver support and easiest setup across that mix. Beyond CUDA, Windows also supports DirectML, which broadens acceleration options on AMD and Intel hardware for local inference and experimentation. On the tooling side, it integrates cleanly with Visual Studio Code, Visual Studio, Docker Desktop, PowerShell, Windows Terminal, Git for Windows, and enterprise-friendly tools for remote access, file sharing, backup, and virtualization. In practice, that means you can fine-tune a LoRA in WSL2, monitor GPUs with native Windows utilities, manage datasets on NTFS storage, launch ComfyUI or LM Studio from the desktop, and still have access to familiar scripting and automation.
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
- Just as important, Windows has massive community support for home AI builders. Most consumer AI guides now assume either native Windows or Windows plus WSL2, especially for Stable Diffusion workflows, local LLM front ends, and single-node fine-tuning recipes. Troubleshooting is easier when there are abundant guides for CUDA driver versions, PyTorch wheels, Flash Attention builds, and model-serving stacks on common RTX cards. For someone building a serious home AI lab, Windows offers the most practical balance: top-tier NVIDIA performance, Linux compatibility when you need it, broad hardware support, and a mature tool ecosystem that scales from casual experimentation to disciplined research workflows.
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
- param :message, type: "string",
27
- desc: "What to say to the room", required: true
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
- param :to, type: "string",
46
- desc: "Name of the writer to message", required: true
47
- param :message, type: "string",
48
- desc: "What to say", required: true
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
- param :key, type: "string",
67
- desc: "Memory key to read (e.g. story_bible, outline, claims, chapter_3)", required: true
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
- param :key, type: "string",
90
- desc: "Memory key (e.g. story_bible, outline, claims, chapter_1)", required: true
91
- param :value, type: "string",
92
- desc: "Content to store", required: true
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
- param :name, type: "string",
128
- desc: "Name for the new writer (e.g. writer_4)", required: true
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
- # Ollama reports usage counts like the hosted providers do, so the tracking
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 Ollama model bills nothing, so the interesting number is
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
- param :step_number,
42
+ parameter :step_number,
43
43
  type: "integer",
44
- desc: "Which step to execute. Start at 1, increment by 1 each call."
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
- param :value, type: "integer", desc: "The number to double"
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: "ollama",
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: "ollama", model: "qwen3.6:latest",
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: "ollama",
98
+ provider: "lms",
99
99
  model: "qwen3.6:latest",
100
100
  mcp_discovery: true,
101
101
  mcp: [
@@ -46,7 +46,7 @@ end
46
46
  class WordStatsTool < TextTool
47
47
  description "Count words, sentences, and average word length"
48
48
 
49
- param :text, type: :string, desc: "Text to analyze"
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
- param :text, type: :string, desc: "Text to analyze"
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
- param :text, type: :string, desc: "Seed text"
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
- param :text, type: :string, desc: "Text to count"
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 Ollama running) to attempt the real pipeline."
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
- require_ollama!
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
- # Ollama running with the model from common.rb pulled
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
- param :parameter, type: "string",
123
- desc: "Parameter to adjust: sma_window, sma_std_multiplier, ema_alpha, ema_vol_multiplier, sma_weight"
124
- param :value, type: "number",
125
- desc: "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
- param :reasoning, type: "string",
127
- desc: "Why this change should reduce prediction error"
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
- require_ollama!
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
- param :label, type: "string", desc: "The label to echo"
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 Ollama model is
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 Ollama. No API keys, no
13
- # network egress, no per-token cost. Pull the model once before running:
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
- # ollama pull qwen3.6
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
- # Ollama models are not in RubyLLM's model registry, so a `provider:` must be
18
- # supplied alongside `model:` — that is what makes RubyLLM skip the registry
19
- # lookup (see Robot#initialize, which sets assume_model_exists when provider is
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: "ollama", model: "qwen3.6:latest"),
26
- small: LlmConfig.new(provider: "ollama", model: "qwen2.5:7b"),
27
- large: LlmConfig.new(provider: "ollama", model: "llama3.3:latest")
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
- OLLAMA_API_BASE = ENV.fetch("OLLAMA_API_BASE", "http://localhost:11434/v1")
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 = Logger.new(File::NULL)
41
- c.default_model = LLM[:default].model
42
- c.ollama_api_base = OLLAMA_API_BASE
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 Ollama model. Splat it into any robot
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 Ollama isn't reachable, instead of
84
- # letting every example die inside an HTTP adapter.
85
- def require_ollama!
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(OLLAMA_API_BASE.sub(%r{/v1/?$}, "") + "/api/tags")
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 Ollama at #{OLLAMA_API_BASE} (#{e.class}).
122
+ Cannot reach LM Studio at #{LMS_API_BASE} (#{e.class}).
92
123
 
93
- Start it and pull the model used by the examples:
94
- ollama serve
95
- ollama pull #{LLM[:default].model.sub(/:latest\z/, "")}
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
 
@@ -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__
@@ -52,6 +52,7 @@ module RobotLab
52
52
  @mutex.synchronize { load_skills! unless @loaded }
53
53
  end
54
54
 
55
+ # :reek:TooManyStatements -- linear directory scan; each guard clause skips a non-skill entry.
55
56
  def load_skills!
56
57
  @loaded = true
57
58
  return unless @skills_root.directory?
@@ -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
- param :question, type: "string", desc: "The question to ask the user"
37
- param :choices, type: "array", desc: "Optional list of choices to present", required: false
38
- param :default, type: "string", desc: "Default value if user presses Enter", required: false
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"
@@ -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}/") } }