robot_lab 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +124 -64
  3. data/docs/api/core/index.md +41 -15
  4. data/docs/api/core/memory.md +247 -29
  5. data/docs/api/core/network.md +255 -33
  6. data/docs/api/core/result.md +120 -32
  7. data/docs/api/core/robot.md +551 -61
  8. data/docs/api/core/state.md +87 -197
  9. data/docs/api/core/tool.md +165 -20
  10. data/docs/api/errors.md +110 -17
  11. data/docs/api/hooks.md +469 -0
  12. data/docs/api/index.md +80 -7
  13. data/docs/api/mcp/client.md +129 -35
  14. data/docs/api/mcp/index.md +164 -23
  15. data/docs/api/mcp/server.md +27 -3
  16. data/docs/api/mcp/transports.md +94 -22
  17. data/docs/api/messages/index.md +26 -3
  18. data/docs/api/messages/text-message.md +33 -11
  19. data/docs/api/messages/tool-call-message.md +27 -4
  20. data/docs/api/messages/tool-result-message.md +23 -4
  21. data/docs/api/messages/user-message.md +45 -8
  22. data/docs/api/skills.md +519 -0
  23. data/docs/api/streaming/context.md +28 -5
  24. data/docs/api/streaming/index.md +57 -11
  25. data/docs/api/support.md +846 -0
  26. data/docs/architecture/core-concepts.md +79 -31
  27. data/docs/architecture/index.md +86 -11
  28. data/docs/architecture/message-flow.md +66 -29
  29. data/docs/architecture/network-orchestration.md +145 -38
  30. data/docs/architecture/robot-execution.md +172 -90
  31. data/docs/architecture/state-management.md +31 -12
  32. data/docs/concepts.md +176 -21
  33. data/docs/examples/basic-chat.md +72 -19
  34. data/docs/examples/index.md +117 -31
  35. data/docs/examples/mcp-server.md +154 -45
  36. data/docs/examples/multi-robot-network.md +91 -21
  37. data/docs/examples/tool-usage.md +104 -37
  38. data/docs/getting-started/configuration.md +284 -97
  39. data/docs/getting-started/installation.md +53 -41
  40. data/docs/getting-started/quick-start.md +51 -6
  41. data/docs/guides/building-robots.md +258 -50
  42. data/docs/guides/creating-networks.md +214 -30
  43. data/docs/guides/hooks.md +141 -54
  44. data/docs/guides/knowledge.md +35 -4
  45. data/docs/guides/mcp-integration.md +211 -44
  46. data/docs/guides/memory.md +103 -12
  47. data/docs/guides/observability.md +95 -47
  48. data/docs/guides/streaming.md +184 -125
  49. data/docs/guides/using-tools.md +237 -17
  50. data/docs/index.md +36 -4
  51. data/examples/01_simple_robot.rb +2 -2
  52. data/examples/02_tools.rb +14 -4
  53. data/examples/03_network.rb +12 -7
  54. data/examples/04_mcp.rb +11 -4
  55. data/examples/05_streaming.rb +8 -5
  56. data/examples/06_prompt_templates.rb +13 -9
  57. data/examples/07_network_memory.rb +5 -5
  58. data/examples/08_llm_config.rb +20 -15
  59. data/examples/09_chaining.rb +4 -4
  60. data/examples/11_network_introspection.rb +4 -4
  61. data/examples/12_message_bus.rb +2 -2
  62. data/examples/13_spawn.rb +2 -2
  63. data/examples/14_rusty_circuit/README.md +1 -0
  64. data/examples/14_rusty_circuit/comic.rb +7 -3
  65. data/examples/14_rusty_circuit/display.rb +14 -0
  66. data/examples/14_rusty_circuit/heckler.rb +8 -6
  67. data/examples/14_rusty_circuit/open_mic.rb +17 -6
  68. data/examples/14_rusty_circuit/scout.rb +17 -10
  69. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
  70. data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
  71. data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
  72. data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
  73. data/examples/16_writers_room/writer.rb +22 -22
  74. data/examples/16_writers_room/writers_room.rb +2 -0
  75. data/examples/17_skills.rb +14 -13
  76. data/examples/18_rails/README.md +20 -1
  77. data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
  78. data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
  79. data/examples/18_rails/app/robots/chat_robot.rb +11 -0
  80. data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
  81. data/examples/19_token_tracking.rb +25 -9
  82. data/examples/20_circuit_breaker.rb +10 -7
  83. data/examples/21_learning_loop.rb +42 -16
  84. data/examples/22_context_compression.rb +23 -23
  85. data/examples/23_convergence.rb +24 -17
  86. data/examples/24_structured_delegation.rb +13 -8
  87. data/examples/25_history_search.rb +12 -8
  88. data/examples/27_incident_response/incident_response.rb +31 -13
  89. data/examples/28_mcp_discovery.rb +17 -13
  90. data/examples/29_ractor_tools.rb +4 -2
  91. data/examples/30_ractor_network.rb +22 -17
  92. data/examples/31_launch_assessment.rb +20 -9
  93. data/examples/32_newsletter_reader.rb +7 -2
  94. data/examples/33_stock_predictor.rb +34 -13
  95. data/examples/34_agentskills.rb +7 -3
  96. data/examples/35_hooks.rb +18 -8
  97. data/examples/README.md +199 -45
  98. data/examples/common.rb +79 -11
  99. data/examples/xyzzy.rb +8 -1
  100. data/lib/robot_lab/config.rb +10 -5
  101. data/lib/robot_lab/names.rb +402 -0
  102. data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
  103. data/lib/robot_lab/robot/bus_messaging.rb +16 -8
  104. data/lib/robot_lab/robot/template_rendering.rb +16 -3
  105. data/lib/robot_lab/robot.rb +23 -2
  106. data/lib/robot_lab/version.rb +1 -1
  107. data/lib/robot_lab.rb +21 -15
  108. data/mkdocs.yml +6 -1
  109. metadata +7 -2
@@ -18,7 +18,7 @@
18
18
  # - Contrast with bus messaging (fire-and-forget) and pipelines (predefined)
19
19
  #
20
20
  # Usage:
21
- # ANTHROPIC_API_KEY=your_key ruby examples/24_structured_delegation.rb
21
+ # ruby examples/24_structured_delegation.rb
22
22
 
23
23
  require_relative "common"
24
24
 
@@ -28,19 +28,19 @@ banner "Structured Delegation"
28
28
  # Build a manager and two specialist robots
29
29
  # ---------------------------------------------------------------------------
30
30
  manager = RobotLab.build(
31
- model: LLM[:default].model,
31
+ **llm_opts,
32
32
  name: "manager",
33
33
  system_prompt: "You are a project manager. Delegate tasks concisely."
34
34
  )
35
35
 
36
36
  summarizer = RobotLab.build(
37
- model: LLM[:default].model,
37
+ **llm_opts,
38
38
  name: "summarizer",
39
39
  system_prompt: "You are a concise summarizer. Produce a 1-2 sentence summary."
40
40
  )
41
41
 
42
42
  analyst = RobotLab.build(
43
- model: LLM[:default].model,
43
+ **llm_opts,
44
44
  name: "analyst",
45
45
  system_prompt: "You are a data analyst. Identify the key metric in one sentence."
46
46
  )
@@ -89,12 +89,12 @@ section "Asynchronous (parallel fan-out)"
89
89
 
90
90
  # Fresh robots — each delegate call should start from a clean slate
91
91
  async_summarizer = RobotLab.build(
92
- model: LLM[:default].model,
92
+ **llm_opts,
93
93
  name: "summarizer",
94
94
  system_prompt: "You are a concise summarizer. Produce a 1-2 sentence summary."
95
95
  )
96
96
  async_analyst = RobotLab.build(
97
- model: LLM[:default].model,
97
+ **llm_opts,
98
98
  name: "analyst",
99
99
  system_prompt: "You are a data analyst. Identify the key metric in one sentence."
100
100
  )
@@ -109,8 +109,13 @@ puts "Both futures launched. Futures resolved? " \
109
109
  "summary=#{f_summary.resolved?} analysis=#{f_analysis.resolved?}"
110
110
  puts "Collecting results..."
111
111
 
112
- summary = f_summary.value(timeout: 60)
113
- analysis = f_analysis.value(timeout: 60)
112
+ # value(timeout:) raises DelegationFuture::DelegationTimeout if the delegatee
113
+ # hasn't finished in time. A local model is much slower than a hosted one, so
114
+ # budget generously — 60s is comfortably too short for a 20B+ model.
115
+ DELEGATION_TIMEOUT = Integer(ENV.fetch("DELEGATION_TIMEOUT", "600"))
116
+
117
+ summary = f_summary.value(timeout: DELEGATION_TIMEOUT)
118
+ analysis = f_analysis.value(timeout: DELEGATION_TIMEOUT)
114
119
 
115
120
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
116
121
 
@@ -23,17 +23,21 @@ CONVERSATION_TURNS = File.readlines(
23
23
  banner "Chat History Search"
24
24
 
25
25
  # ---------------------------------------------------------------------------
26
- # Minimal message stub populates history without LLM calls
26
+ # Build a robot and load the conversation fixture
27
+ #
28
+ # Robot#replace_messages is the supported way to install a saved conversation
29
+ # — checkpoint restore, replaying a transcript, or seeding a fixture like this
30
+ # one. It resets the chat and re-adds each message, so there is no need to
31
+ # reach into @chat and overwrite @messages by hand.
27
32
  # ---------------------------------------------------------------------------
28
- FakeMsg = Struct.new(:role, :content, :tool_calls)
33
+ robot = RobotLab.build(**llm_opts, name: "tech_lead", system_prompt: "You are a senior engineering advisor.")
29
34
 
30
- # ---------------------------------------------------------------------------
31
- # Build a robot and inject the conversation fixture
32
- # ---------------------------------------------------------------------------
33
- robot = RobotLab.build(model: LLM[:default].model, name: "tech_lead", system_prompt: "You are a senior engineering advisor.")
35
+ messages = CONVERSATION_TURNS.map do |turn|
36
+ RubyLLM::Message.new(role: turn[:role].to_sym, content: turn[:content])
37
+ end
38
+ robot.replace_messages(messages)
34
39
 
35
- messages = CONVERSATION_TURNS.map { |t| FakeMsg.new(t[:role], t[:content], nil) }
36
- robot.instance_variable_get(:@chat).instance_variable_set(:@messages, messages)
40
+ messages = robot.messages
37
41
 
38
42
  total_words = messages.sum { |m| m.content.to_s.split.size }
39
43
  puts "Conversation loaded: #{messages.size} messages, ~#{total_words} words"
@@ -60,7 +60,7 @@ class SREScout < RobotLab::Robot
60
60
  def initialize(name:, subsystem:, memory_key:, bus: nil)
61
61
  super(
62
62
  name: name,
63
- model: LLM[:default].model,
63
+ **llm_opts,
64
64
  system_prompt: "You are a senior SRE responding to a production outage. " \
65
65
  "Diagnose one infrastructure layer in 2 sentences: " \
66
66
  "first sentence is root cause, second is customer impact.",
@@ -111,9 +111,8 @@ class WarRoom < RobotLab::Robot
111
111
  attr_reader :updates
112
112
 
113
113
  def initialize(bus:)
114
- super(name: "war_room", model: LLM[:default].model, system_prompt: "SRE war-room coordinator.", bus: bus)
115
- @updates = []
116
- @delivery_mutex = Mutex.new # only for reading @updates outside Async
114
+ super(name: "war_room", **llm_opts, system_prompt: "SRE war-room coordinator.", bus: bus)
115
+ @updates = []
117
116
 
118
117
  on_message do |msg|
119
118
  # BusPoller ensures this block is never re-entered for the same robot.
@@ -137,15 +136,12 @@ end
137
136
  class IncidentCommander < RobotLab::Robot
138
137
  attr_writer :shared_memory
139
138
 
139
+ FINDING_KEYS = %i[db_finding net_finding app_finding].freeze
140
+
140
141
  def call(result)
141
142
  puts " [commander] Blocking on reactive memory — waiting for all scout findings..."
142
143
 
143
- findings = @shared_memory.get(:db_finding, :net_finding, :app_finding, wait: 60)
144
-
145
- if findings.values.include?(:timeout)
146
- timed_out = findings.select { |_k, v| v == :timeout }.keys
147
- puts " [commander] WARNING: scouts timed out: #{timed_out.join(", ")}"
148
- end
144
+ findings = collect_findings
149
145
 
150
146
  prompt = <<~PROMPT
151
147
  Three SRE scouts have reported their findings for a payment-service outage.
@@ -167,6 +163,24 @@ class IncidentCommander < RobotLab::Robot
167
163
 
168
164
  result.with_context(:commander, report).continue(report)
169
165
  end
166
+
167
+ private
168
+
169
+ # Blocking multi-key read with a real degradation path.
170
+ #
171
+ # Memory#get(wait:) signals a timeout by RAISING RobotLab::AwaitTimeout — it
172
+ # never returns a :timeout sentinel value. Rescue it and re-read without
173
+ # wait:, which never blocks and simply omits keys that were never written,
174
+ # so a slow scout degrades the report instead of killing the commander.
175
+ def collect_findings
176
+ @shared_memory.get(*FINDING_KEYS, wait: 60)
177
+ rescue RobotLab::AwaitTimeout => e
178
+ puts " [commander] WARNING: #{e.message}"
179
+ partial = @shared_memory.get(*FINDING_KEYS)
180
+ missing = FINDING_KEYS - partial.keys
181
+ puts " [commander] Proceeding without: #{missing.join(', ')}" if missing.any?
182
+ partial
183
+ end
170
184
  end
171
185
 
172
186
  # ── Wire up ────────────────────────────────────────────────────────────────
@@ -177,7 +191,7 @@ db_scout = SREScout.new(name: "db_scout", subsystem: "database", memory_key
177
191
  net_scout = SREScout.new(name: "net_scout", subsystem: "network", memory_key: :net_finding, bus: bus)
178
192
  app_scout = SREScout.new(name: "app_scout", subsystem: "application", memory_key: :app_finding, bus: bus)
179
193
  war_room = WarRoom.new(bus: bus)
180
- commander = IncidentCommander.new(name: "commander", model: LLM[:default].model, system_prompt: "SRE incident commander.")
194
+ commander = IncidentCommander.new(name: "commander", **llm_opts, system_prompt: "SRE incident commander.")
181
195
 
182
196
  # Build the investigation network
183
197
  # poller_group: labels are registered on the network's shared BusPoller.
@@ -212,8 +226,12 @@ puts
212
226
  puts network.visualize
213
227
  puts
214
228
 
215
- puts "Poller groups registered on network BusPoller:"
216
- puts " #{network.instance_variable_get(:@bus_poller).groups.inspect}"
229
+ puts "Poller groups declared on the network's tasks:"
230
+ puts " :investigation — db_scout, net_scout, app_scout"
231
+ puts " :command — commander"
232
+ puts "(Network#task registers each group on the shared BusPoller; the poller"
233
+ puts " itself is internal, so the groups are documented here rather than"
234
+ puts " introspected.)"
217
235
  puts
218
236
 
219
237
  puts "INCIDENT: Payment service degraded — elevated error rates and timeouts"
@@ -12,7 +12,7 @@
12
12
  # == Key config
13
13
  #
14
14
  # robot = RobotLab.build(
15
- # model: "gpt-5.4",
15
+ # provider: "ollama", model: "qwen3.6:latest",
16
16
  # mcp_discovery: true, # ← enables semantic filtering
17
17
  # mcp: [ ... ] # ← candidate servers, each with :description
18
18
  # )
@@ -55,8 +55,9 @@ def show_query(label, query)
55
55
  selected = RobotLab::MCP::ServerDiscovery.select(query, from: SERVERS)
56
56
  names = selected.map { |s| s[:name] }
57
57
 
58
- puts " Query : #{query.inspect}"
59
- puts " Match : #{names.inspect}"
58
+ puts " #{label}"
59
+ puts " Query : #{query.inspect}"
60
+ puts " Match : #{names.inspect}"
60
61
  puts
61
62
  end
62
63
 
@@ -91,19 +92,22 @@ puts " High threshold : returns all (#{result.size} servers) — no match abov
91
92
 
92
93
  puts
93
94
  section "mcp_discovery: true on a Robot"
94
- puts <<~NOTE
95
- RobotLab.build(
96
- model: "gpt-5.4",
97
- name: "assistant",
95
+ show_code <<~RUBY
96
+ robot = RobotLab.build(
97
+ name: "assistant",
98
+ provider: "ollama",
99
+ model: "qwen3.6:latest",
98
100
  mcp_discovery: true,
99
101
  mcp: [
100
- { name: "filesystem", description: "Read, write...", transport: { ... } },
101
- { name: "github", description: "GitHub repos...", transport: { ... } },
102
- { name: "brew", description: "Install packages...", transport: { ... } }
102
+ { name: "filesystem", description: "Read, write...", transport: { } },
103
+ { name: "github", description: "GitHub repos...", transport: { } },
104
+ { name: "brew", description: "Install packages...", transport: { } }
103
105
  ]
104
106
  )
105
107
 
106
- # Only the :brew server is connected for this message:
107
- robot.run("install imagemagick")
108
- NOTE
108
+ # Only the :brew server is connected for this message.
109
+ # mcp: :inherit and tools: :inherit are both required — run() defaults
110
+ # both to :none, which connects no servers and sends no tools.
111
+ robot.run("install imagemagick", mcp: :inherit, tools: :inherit)
112
+ RUBY
109
113
  hr
@@ -16,7 +16,7 @@
16
16
  # - ToolError — propagated when a tool raises inside a Ractor
17
17
  # - Parallel batch — many threads submitting concurrently vs sequentially
18
18
  #
19
- # Usage (no LLM API key required):
19
+ # Usage (no LLM traffic pure CPU work):
20
20
  # bundle exec ruby examples/29_ractor_tools.rb
21
21
  #
22
22
  # Good to know:
@@ -157,7 +157,9 @@ puts " Inner :tags array frozen? #{frozen[:tags].frozen?}"
157
157
  puts " Inner :meta hash frozen? #{frozen[:meta].frozen?}"
158
158
  puts
159
159
 
160
- # A Proc cannot cross a Ractor boundary — freeze_deep raises immediately.
160
+ # Non-shareable objects (IO handles, Procs, ...) cannot cross a Ractor
161
+ # boundary — freeze_deep rejects them immediately rather than failing later
162
+ # inside a worker.
161
163
  require "stringio"
162
164
  begin
163
165
  RobotLab::RactorBoundary.freeze_deep(StringIO.new("I cannot be frozen"))
@@ -15,20 +15,20 @@
15
15
  # - RactorNetworkScheduler — direct use for testing / custom wiring
16
16
  # - Result Hash — run_pipeline returns { name => result }
17
17
  #
18
- # Part 1 — simulated scheduler, no API key needed.
18
+ # Part 1 — simulated scheduler, no LLM traffic.
19
19
  # Overrides execute_spec with a sleep-based stub so the wave ordering and
20
20
  # timing characteristics are visible without real LLM calls.
21
21
  #
22
22
  # Part 2 — Network.new(parallel_mode: :ractor) API walkthrough.
23
23
  # Shows how to configure the network and inspects the dependency graph.
24
- # No run() call is made, so no API key is required.
24
+ # No run() call is made, so no LLM traffic either.
25
25
  #
26
- # Part 3 — live LLM run (optional).
27
- # Executes automatically when ANTHROPIC_API_KEY is set.
26
+ # Part 3 — live LLM run (opt-in via RUN_LIVE).
27
+ # Expected to fail against ruby_llm today; the rescue clauses explain why.
28
28
  #
29
29
  # Usage:
30
- # bundle exec ruby examples/30_ractor_network.rb # Parts 1 & 2
31
- # ANTHROPIC_API_KEY=key ruby examples/30_ractor_network.rb # Parts 1, 2 & 3
30
+ # bundle exec ruby examples/30_ractor_network.rb # Parts 1 & 2
31
+ # RUN_LIVE=1 bundle exec ruby examples/30_ractor_network.rb # Parts 1, 2 & 3
32
32
 
33
33
  require_relative "common"
34
34
  require "robot_lab/ractor"
@@ -69,7 +69,7 @@ LATENCIES = {
69
69
  #
70
70
  # Speedup ≈ 1.7×
71
71
 
72
- section "Part 1: Simulated Parallel Run (no API key)"
72
+ section "Part 1: Simulated Parallel Run (no LLM traffic)"
73
73
 
74
74
  # SimulatedScheduler overrides execute_spec so that instead of
75
75
  # constructing a real Robot and calling the LLM, it just sleeps for
@@ -141,27 +141,25 @@ section "Part 2: Network.new(parallel_mode: :ractor)"
141
141
  # routes through RactorNetworkScheduler instead of SimpleFlow::Pipeline.
142
142
  # The default mode is :async (unchanged SimpleFlow behavior).
143
143
 
144
- model = LLM[:default].model
145
-
146
144
  network = RobotLab::Network.new(name: "research_pipeline", parallel_mode: :ractor) do
147
145
  task :headline_finder, RobotLab.build(name: "headline_finder",
148
146
  system_prompt: "Find the 3 most relevant news headlines. Be concise.",
149
- model: model),
147
+ **llm_opts),
150
148
  depends_on: :none
151
149
 
152
150
  task :background_brief, RobotLab.build(name: "background_brief",
153
151
  system_prompt: "Provide a 2-sentence background on the topic.",
154
- model: model),
152
+ **llm_opts),
155
153
  depends_on: :none
156
154
 
157
155
  task :fact_checker, RobotLab.build(name: "fact_checker",
158
156
  system_prompt: "List 3 verifiable facts about the topic.",
159
- model: model),
157
+ **llm_opts),
160
158
  depends_on: :none
161
159
 
162
160
  task :report_writer, RobotLab.build(name: "report_writer",
163
161
  system_prompt: "Synthesize the provided context into a 3-sentence report.",
164
- model: model),
162
+ **llm_opts),
165
163
  depends_on: ["headline_finder", "background_brief", "fact_checker"]
166
164
  end
167
165
 
@@ -187,21 +185,28 @@ puts " • Return value is a Hash { robot_name => result_string }"
187
185
  puts
188
186
 
189
187
  # =============================================================================
190
- # Part 3: Live LLM run (optional requires ANTHROPIC_API_KEY)
188
+ # Part 3: Live LLM run (opt-in via RUN_LIVE=1)
191
189
  # =============================================================================
192
190
 
193
- unless ENV["ANTHROPIC_API_KEY"]
191
+ unless ENV["RUN_LIVE"]
194
192
  section "Part 3: Live LLM Run"
195
- puts " Set ANTHROPIC_API_KEY to run the real pipeline."
193
+ puts " Set RUN_LIVE=1 (with Ollama running) to attempt the real pipeline."
196
194
  puts " Expected behavior: headline_finder, background_brief, and"
197
195
  puts " fact_checker run in parallel; report_writer follows."
198
196
  puts
197
+ puts " Heads-up: this is expected to fail today — see the rescue clauses"
198
+ puts " below. ruby_llm keeps Procs in class-level state, which cannot"
199
+ puts " cross a Ractor boundary. Part 1 shows the wave ordering that Part 3"
200
+ puts " will exercise once ruby_llm is Ractor-safe."
201
+ puts
199
202
  hr
200
203
  puts "Example 30 complete."
201
204
  exit 0
202
205
  end
203
206
 
204
- section "Part 3: Live LLM Run (ANTHROPIC_API_KEY detected)"
207
+ require_ollama!
208
+
209
+ section "Part 3: Live LLM Run (RUN_LIVE set)"
205
210
 
206
211
  puts " Running 4-robot research pipeline on:"
207
212
  puts " \"#{topic}\""
@@ -45,7 +45,7 @@
45
45
  # RunConfig.new(max_concurrent_robots: 4)
46
46
  #
47
47
  # Usage:
48
- # ANTHROPIC_API_KEY=your_key ruby examples/31_launch_assessment.rb
48
+ # ruby examples/31_launch_assessment.rb
49
49
 
50
50
  require_relative "common"
51
51
 
@@ -61,7 +61,7 @@ class AnalystRobot < RobotLab::Robot
61
61
  def initialize(name:, memory_key:, role:)
62
62
  super(
63
63
  name: name,
64
- model: LLM[:default].model,
64
+ **llm_opts,
65
65
  system_prompt: "You are a #{role}. " \
66
66
  "Review the product brief in 2-3 crisp sentences from your area of expertise. " \
67
67
  "Close with a one-word verdict: READY or NOT-READY."
@@ -111,12 +111,7 @@ class LaunchDirector < RobotLab::Robot
111
111
 
112
112
  def call(result)
113
113
  puts " [#{name}] reading all findings from shared memory..."
114
- findings = @shared_memory.get(*FINDING_KEYS, wait: 120)
115
-
116
- if findings.values.any? { |v| v == :timeout }
117
- timed_out = findings.select { |_, v| v == :timeout }.keys
118
- puts " [#{name}] WARNING: timed out waiting for: #{timed_out.join(", ")}"
119
- end
114
+ findings = collect_findings
120
115
 
121
116
  prompt = <<~PROMPT
122
117
  Six specialist analysts have reviewed our product launch readiness.
@@ -139,6 +134,22 @@ class LaunchDirector < RobotLab::Robot
139
134
 
140
135
  result.with_context(:recommendation, recommendation).continue(recommendation)
141
136
  end
137
+
138
+ private
139
+
140
+ # Memory#get(wait:) RAISES RobotLab::AwaitTimeout on expiry — it never
141
+ # returns a :timeout sentinel. Rescue it and re-read without wait:, which
142
+ # never blocks and omits keys that were never written, so a stalled analyst
143
+ # degrades the recommendation instead of killing the director.
144
+ def collect_findings
145
+ @shared_memory.get(*FINDING_KEYS, wait: 120)
146
+ rescue RobotLab::AwaitTimeout => e
147
+ puts " [#{name}] WARNING: #{e.message}"
148
+ partial = @shared_memory.get(*FINDING_KEYS)
149
+ missing = FINDING_KEYS - partial.keys
150
+ puts " [#{name}] Proceeding without: #{missing.join(', ')}" if missing.any?
151
+ partial
152
+ end
142
153
  end
143
154
 
144
155
  # ── Product Brief ───────────────────────────────────────────────────────────────
@@ -169,7 +180,7 @@ end
169
180
 
170
181
  director = LaunchDirector.new(
171
182
  name: "launch_director",
172
- model: LLM[:default].model,
183
+ **llm_opts,
173
184
  system_prompt: "You are the VP of Product making the final launch call."
174
185
  )
175
186
 
@@ -3,8 +3,12 @@
3
3
 
4
4
  # Example 32: Newsletter Issue Retriever
5
5
  #
6
+ # NOTE: this is a plain utility script, not a RobotLab demo — it uses no part
7
+ # of the library. It lives here as the content feeder for other experiments.
8
+ # Read examples 01-31 for library capabilities.
9
+ #
6
10
  # Fetches all unprocessed issues from multiple Ruby newsletters via RSS
7
- # and saves each as a Markdown file in the Obsidian Clippings folder.
11
+ # and saves each as a Markdown file in a Markdown clippings folder.
8
12
  #
9
13
  # - Processes oldest unprocessed issue first (across all feeds)
10
14
  # - Filename: <newsletter-name>_YYYYMMDD.md
@@ -13,6 +17,7 @@
13
17
  #
14
18
  # Usage:
15
19
  # ruby examples/32_newsletter_reader.rb
20
+ # CLIPPINGS_DIR=~/Notes/Clippings ruby examples/32_newsletter_reader.rb
16
21
 
17
22
  require "net/http"
18
23
  require "open3"
@@ -28,7 +33,7 @@ NEWSLETTER_RSS_URLS = [
28
33
  "https://cprss.s3.amazonaws.com/rubyweekly.com.xml" # Ruby Weekly
29
34
  ].freeze
30
35
 
31
- CLIPPINGS_DIR = File.expand_path("/Users/dewayne/Documents/obsidian_order_intelligence/PKM/Clippings")
36
+ CLIPPINGS_DIR = File.expand_path(ENV.fetch("CLIPPINGS_DIR", "~/Documents/Clippings"))
32
37
  PROCESSED_STATE_FILE = File.join(Dir.home, ".robot_lab", "newsletter_processed.yaml")
33
38
 
34
39
  # Tracks which newsletter issue URLs have been saved.
@@ -14,12 +14,12 @@
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
18
  #
18
19
  # Usage:
19
20
  # ruby examples/33_stock_predictor.rb
20
21
 
21
- $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
22
- require "robot_lab"
22
+ require_relative "common"
23
23
  require "robot_lab/durable"
24
24
  require "redis"
25
25
  require "json"
@@ -204,12 +204,15 @@ puts "Warmup : #{PredictorConfig.sma_window} ticks"
204
204
  puts "Press Ctrl-C to stop."
205
205
  puts "-" * 60
206
206
 
207
- redis = Redis.new
208
- prices = []
209
- robot = RobotLab.build(
210
- model: "gpt-5.4",
211
- name: "predictor_tuner",
212
- system_prompt: <<~PROMPT,
207
+ require_ollama!
208
+
209
+ redis = Redis.new
210
+ prices = []
211
+
212
+ robot = RobotLab.build(
213
+ **llm_opts,
214
+ name: "predictor_tuner",
215
+ system_prompt: <<~PROMPT,
213
216
  You are a quantitative analyst tuning an ensemble stock price range
214
217
  predictor for ticker XYZZY. Each prediction covers the high and low
215
218
  price over the next #{WINDOW_SIZE} ticks.
@@ -231,10 +234,21 @@ robot = RobotLab.build(
231
234
  4. If you observe a reliable pattern, call RecordKnowledge to preserve it.
232
235
  5. When uncertain, do nothing rather than guess.
233
236
  PROMPT
234
- local_tools: [AdjustParameters],
235
- learn: true,
236
- learn_domain: "xyzzy stock prediction"
237
- )
237
+ local_tools: [AdjustParameters]
238
+ )
239
+
240
+ # Durable learning is enabled AFTER construction. Robot#initialize takes a
241
+ # fixed keyword list with no **rest, so `learn:`/`learn_domain:` constructor
242
+ # params raise ArgumentError; the Durable::Learning mixin exposes
243
+ # setup_durable_learning instead. It seeds @learnings from
244
+ # ~/.robot_lab/durable/<domain>.yml and appends the RecallKnowledge and
245
+ # RecordKnowledge tools to local_tools.
246
+ robot.setup_durable_learning(domain: "xyzzy stock prediction")
247
+
248
+ puts "Durable domain : xyzzy stock prediction"
249
+ puts "Tools : #{robot.local_tools.map(&:name).join(', ')}"
250
+ puts "Seeded learnings: #{robot.learnings.size}"
251
+ puts "-" * 60
238
252
 
239
253
  warmed_up = false
240
254
  pending_pred = nil # { prediction: {high:, low:}, window_prices: [] }
@@ -291,10 +305,17 @@ redis.subscribe(CHANNEL) do |on|
291
305
  puts "#{"─" * 60}"
292
306
 
293
307
  print " [tuner] analyzing window #{window_num}..."
294
- tuner_response = robot.run(tuner_prompt(result))
308
+ # tools: :inherit is required — run() defaults to :none, which sends the
309
+ # provider an empty tool list, so AdjustParameters/RecallKnowledge/
310
+ # RecordKnowledge would never be callable.
311
+ tuner_response = robot.run(tuner_prompt(result), tools: :inherit)
295
312
  tuner_line = tuner_response.reply.lines.first&.chomp || "(no response)"
296
313
  puts "\r [tuner] #{tuner_line}#{" " * 20}"
297
314
  puts " Params: #{PredictorConfig.summary}"
315
+
316
+ # Promote this session's learnings into the durable YAML store. Core
317
+ # Robot#run does not invoke the reflector, so the caller drives it.
318
+ robot.run_reflector
298
319
  puts
299
320
 
300
321
  # ── Start next window ──────────────────────────────────────────
@@ -10,9 +10,10 @@
10
10
  # Usage:
11
11
  # mkdir -p ~/.prompts/skills/code_reviewer
12
12
  # # (create SKILL.md as shown in the example header)
13
- # ANTHROPIC_API_KEY=your_key ruby examples/34_agentskills.rb
13
+ # ruby examples/34_agentskills.rb
14
14
 
15
15
  require_relative "common"
16
+ require "robot_lab/document_store"
16
17
 
17
18
  require "logger"
18
19
  log_file = File.join(__dir__, "34.log")
@@ -36,13 +37,16 @@ end
36
37
  # "Review Ruby code for quality, style, and potential bugs",
37
38
  # the skill's instructions are injected into the system prompt.
38
39
  robot = RobotLab.build(
39
- model: LLM[:default].model,
40
+ **llm_opts,
40
41
  name: "assistant",
41
42
  system_prompt: "You are a helpful Ruby programming assistant.",
42
43
  skills: [:code_reviewer]
43
44
  )
44
45
 
45
- puts "Pending AgentSkills: #{robot.instance_variable_get(:@pending_agent_skills).map(&:name).inspect}"
46
+ puts "Declared skills: #{robot.skills.inspect}"
47
+ puts "(Folder-format skills are matched per-run by embedding similarity, so"
48
+ puts " whether one is injected depends on the message — watch the two queries"
49
+ puts " below.)"
46
50
  puts
47
51
 
48
52
  # Message semantically related to code review — skill should activate
data/examples/35_hooks.rb CHANGED
@@ -11,10 +11,14 @@
11
11
  # bundle exec ruby examples/35_hooks.rb
12
12
 
13
13
  require_relative "common"
14
- require_relative "xyzzy"
15
14
 
15
+ # RobotLab::Hook has no logger accessor — handlers own their own output.
16
+ # xyzzy.rb freezes its log destination into a constant at load time, so the
17
+ # redirect has to happen before the require.
16
18
  XYZZY_LOG = File.join(__dir__, "xyzzy_hooks.log")
17
- RobotLab::Xyzzy.logger = Logger.new(XYZZY_LOG)
19
+ ENV["XYZZY_LOG_PATH"] = XYZZY_LOG
20
+
21
+ require_relative "xyzzy"
18
22
 
19
23
  # ---------------------------------------------------------------------------
20
24
  # Handler classes used in this demo
@@ -183,10 +187,13 @@ class HookDemo
183
187
 
184
188
  puts " provider=#{LLM[:default].provider} model=#{LLM[:default].model}\n\n"
185
189
 
190
+ # with_model alone would leave the provider unset, and an Ollama model is
191
+ # not in RubyLLM's registry — pass provider and model together.
186
192
  robot = RobotLab.build(
187
193
  name: "loop_demo_robot",
188
- system_prompt: "You are a helpful assistant. Answer every question in one concise sentence."
189
- ).with_model(LLM[:default].model)
194
+ system_prompt: "You are a helpful assistant. Answer every question in one concise sentence.",
195
+ **llm_opts
196
+ )
190
197
 
191
198
  robot.on(PerfHook)
192
199
  robot.on(GenerationTracerHook)
@@ -234,8 +241,9 @@ class HookDemo
234
241
 
235
242
  def run_error
236
243
  section "Error Hook"
237
- failing_robot = RobotLab.build(name: "failing_hook_robot", system_prompt: "raise")
238
- failing_robot.instance_variable_get(:@chat).define_singleton_method(:ask) do |_message = nil, **_kwargs|
244
+ failing_robot = RobotLab.build(name: "failing_hook_robot", system_prompt: "raise", **llm_opts)
245
+ # Robot#chat is a public reader — no need to reach for @chat.
246
+ failing_robot.chat.define_singleton_method(:ask) do |_message = nil, **_kwargs|
239
247
  raise "planned hook demo failure"
240
248
  end
241
249
 
@@ -244,9 +252,11 @@ class HookDemo
244
252
  puts "Caught expected error: #{e.message}"
245
253
  end
246
254
 
255
+ # Stubs the chat so the hook pipeline is exercised deterministically with no
256
+ # LLM traffic. Robot#chat is public, so this needs no ivar surgery.
247
257
  def hooked_robot(name)
248
- RobotLab.build(name: name, system_prompt: "deterministic hook demo").tap do |robot|
249
- robot.instance_variable_get(:@chat).define_singleton_method(:ask) do |message = nil, **_kwargs, &_block|
258
+ RobotLab.build(name: name, system_prompt: "deterministic hook demo", **llm_opts).tap do |robot|
259
+ robot.chat.define_singleton_method(:ask) do |message = nil, **_kwargs, &_block|
250
260
  HookDemoResponse.new(content: "deterministic response to #{message.inspect}")
251
261
  end
252
262
  end