robot_lab-to 0.2.6

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 (97) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.github/workflows/deploy-github-pages.yml +52 -0
  4. data/.loki +21 -0
  5. data/.quality/reek_baseline.txt +17 -0
  6. data/.rubocop.yml +7 -0
  7. data/CHANGELOG.md +67 -0
  8. data/CLAUDE.md +74 -0
  9. data/COMMITS.md +27 -0
  10. data/README.md +352 -0
  11. data/Rakefile +132 -0
  12. data/bin/robot-to +14 -0
  13. data/docs/about/changelog.md +82 -0
  14. data/docs/assets/architecture.svg +110 -0
  15. data/docs/assets/images/robot_lab-to.jpg +0 -0
  16. data/docs/assets/iteration-loop.svg +97 -0
  17. data/docs/concepts/evals.md +254 -0
  18. data/docs/concepts/iteration-loop.md +128 -0
  19. data/docs/concepts/notes.md +91 -0
  20. data/docs/concepts/run-state.md +93 -0
  21. data/docs/concepts/stop-conditions.md +111 -0
  22. data/docs/concepts/verification.md +75 -0
  23. data/docs/configuration/cli.md +111 -0
  24. data/docs/configuration/index.md +93 -0
  25. data/docs/configuration/settings.md +270 -0
  26. data/docs/getting-started/anatomy-of-a-run.md +111 -0
  27. data/docs/getting-started/installation.md +78 -0
  28. data/docs/getting-started/quick-start.md +105 -0
  29. data/docs/index.md +96 -0
  30. data/docs/local-models/guardrails.md +117 -0
  31. data/docs/local-models/index.md +88 -0
  32. data/docs/local-models/ollama.md +122 -0
  33. data/docs/local-models/tools.md +92 -0
  34. data/docs/reference/architecture.md +139 -0
  35. data/examples/01_basic_usage/.gitignore +9 -0
  36. data/examples/01_basic_usage/README.md +173 -0
  37. data/examples/01_basic_usage/basic_usage.rb +386 -0
  38. data/examples/02_advanced_usage/.gitignore +8 -0
  39. data/examples/02_advanced_usage/README.md +117 -0
  40. data/examples/02_advanced_usage/advanced_usage.rb +308 -0
  41. data/examples/02_advanced_usage/quality_gate.rb +99 -0
  42. data/examples/03_scored/.gitignore +2 -0
  43. data/examples/03_scored/README.md +117 -0
  44. data/examples/03_scored/scored_run.rb +325 -0
  45. data/examples/04_prose/.gitignore +2 -0
  46. data/examples/04_prose/README.md +43 -0
  47. data/examples/04_prose/prompts_dir/grade_message.md +14 -0
  48. data/examples/04_prose/prompts_dir/judge.md +11 -0
  49. data/examples/04_prose/prompts_dir/outline_criteria.md +9 -0
  50. data/examples/04_prose/prompts_dir/outline_objective.md +11 -0
  51. data/examples/04_prose/prompts_dir/section_criteria.md +7 -0
  52. data/examples/04_prose/prompts_dir/sections_objective.md +14 -0
  53. data/examples/04_prose/prose_run.rb +302 -0
  54. data/lib/robot_lab/to/atomic_file.rb +69 -0
  55. data/lib/robot_lab/to/backoff.rb +46 -0
  56. data/lib/robot_lab/to/cli.rb +176 -0
  57. data/lib/robot_lab/to/commit_manager.rb +135 -0
  58. data/lib/robot_lab/to/config/defaults.yml +27 -0
  59. data/lib/robot_lab/to/config.rb +86 -0
  60. data/lib/robot_lab/to/decision.rb +40 -0
  61. data/lib/robot_lab/to/decision_manager.rb +193 -0
  62. data/lib/robot_lab/to/errors.rb +30 -0
  63. data/lib/robot_lab/to/evals/base.rb +23 -0
  64. data/lib/robot_lab/to/evals/code.rb +77 -0
  65. data/lib/robot_lab/to/evals/context.rb +17 -0
  66. data/lib/robot_lab/to/evals/factory.rb +84 -0
  67. data/lib/robot_lab/to/evals/null.rb +17 -0
  68. data/lib/robot_lab/to/evals/prose.rb +110 -0
  69. data/lib/robot_lab/to/evals/score.rb +22 -0
  70. data/lib/robot_lab/to/exit_summary.rb +75 -0
  71. data/lib/robot_lab/to/guards/checkpoint.rb +84 -0
  72. data/lib/robot_lab/to/guards/grader_lock.rb +62 -0
  73. data/lib/robot_lab/to/guards/path_resolution.rb +61 -0
  74. data/lib/robot_lab/to/guards/quality_monitor.rb +113 -0
  75. data/lib/robot_lab/to/guards/read_before_edit.rb +83 -0
  76. data/lib/robot_lab/to/guards/run_store.rb +31 -0
  77. data/lib/robot_lab/to/guards/write_guard.rb +71 -0
  78. data/lib/robot_lab/to/guards.rb +54 -0
  79. data/lib/robot_lab/to/iteration_result.rb +29 -0
  80. data/lib/robot_lab/to/jsonl_logger.rb +59 -0
  81. data/lib/robot_lab/to/notes_manager.rb +121 -0
  82. data/lib/robot_lab/to/orchestrator.rb +642 -0
  83. data/lib/robot_lab/to/prompt_builder.rb +208 -0
  84. data/lib/robot_lab/to/run.rb +109 -0
  85. data/lib/robot_lab/to/stop_conditions.rb +71 -0
  86. data/lib/robot_lab/to/tools/bash.rb +75 -0
  87. data/lib/robot_lab/to/tools/edit.rb +52 -0
  88. data/lib/robot_lab/to/tools/file_tool.rb +32 -0
  89. data/lib/robot_lab/to/tools/read.rb +57 -0
  90. data/lib/robot_lab/to/tools/request_decision.rb +66 -0
  91. data/lib/robot_lab/to/tools/submit_result.rb +50 -0
  92. data/lib/robot_lab/to/tools/write.rb +32 -0
  93. data/lib/robot_lab/to/verifier.rb +63 -0
  94. data/lib/robot_lab/to/version.rb +7 -0
  95. data/lib/robot_lab/to.rb +81 -0
  96. data/mkdocs.yml +145 -0
  97. metadata +175 -0
@@ -0,0 +1,386 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # Basic Usage would generally be as a CLI tool; however, its a library
5
+ # so that means you can build it inot an application program.
6
+ #
7
+ # ===========================================================================
8
+ # 01_basic_usage — drive robot_lab-to programmatically
9
+ # ===========================================================================
10
+ #
11
+ # This example shows an end-to-end use of the takeover library: it spins up a
12
+ # *throwaway* git repository seeded with a failing test suite, runs an autonomous
13
+ # loop against it with `RobotLab::To.run`, and prints what the robot produced.
14
+ #
15
+ # The objective is a small spec-driven kata: implement a Roman-numeral library
16
+ # (lib/roman_numeral.rb) until a fixed Minitest suite passes. The suite is seeded
17
+ # into the sandbox, so the robot must satisfy real, externally-defined tests it
18
+ # can't game. This exercises the full loop over several iterations: branch,
19
+ # iterate, verify (run the tests), commit on green / roll back on red.
20
+ #
21
+ # It also demonstrates the robot_lab HOOK system (see FeedbackHook below): a
22
+ # globally-registered hook narrates each robot action — thinking, reading,
23
+ # writing, running tests — so a run never sits silent while the model works.
24
+ #
25
+ # Nothing here touches your own repositories. Everything for this example stays
26
+ # under examples/01_basic_usage/:
27
+ # * project/ — a fresh git repo the robot works in
28
+ # * .robot_lab_to/ — the run's event log and cross-iteration notes.md
29
+ # Both are deleted at the start of every run for a clean slate, and are
30
+ # git-ignored, so they never become part of the robot_lab-to repo.
31
+ #
32
+ # ---------------------------------------------------------------------------
33
+ # Run it
34
+ # ---------------------------------------------------------------------------
35
+ # # Local Ollama (default — no API key needed; requires `ollama serve`):
36
+ # ollama pull gpt-oss:20b
37
+ # bundle exec ruby examples/01_basic_usage/basic_usage.rb
38
+ #
39
+ # # A cloud model instead:
40
+ # RLTO_LOCAL=false RLTO_PROVIDER=anthropic RLTO_MODEL=claude-sonnet-4-6 \
41
+ # ANTHROPIC_API_KEY=sk-... \
42
+ # bundle exec ruby examples/01_basic_usage/basic_usage.rb
43
+ #
44
+ # Configuration (all optional, via environment):
45
+ # RLTO_LOCAL true|false use a local Ollama model (default true)
46
+ # RLTO_PROVIDER name LLM provider (default: openai for local)
47
+ # RLTO_MODEL id model id (default: gpt-oss:20b for local)
48
+ # OLLAMA_BASE url Ollama OpenAI endpoint (default localhost:11434/v1)
49
+ # ===========================================================================
50
+
51
+ require "fileutils"
52
+ require "logger"
53
+ require "open3"
54
+ require "net/http"
55
+
56
+ # Make the example runnable straight from the repo during development, with or
57
+ # without `bundle exec`. (When the gem is installed normally, these paths simply
58
+ # don't exist and are skipped.)
59
+ [
60
+ File.expand_path("../../lib", __dir__), # robot_lab-to/lib
61
+ File.expand_path("../../../robot_lab/lib", __dir__) # sibling robot_lab/lib
62
+ ].each { |p| $LOAD_PATH.unshift(p) if Dir.exist?(p) }
63
+
64
+ require "ruby_llm"
65
+ require "robot_lab"
66
+ require "robot_lab/to"
67
+
68
+ # --- configuration ---------------------------------------------------------
69
+
70
+ LOCAL = ENV.fetch("RLTO_LOCAL", "true") == "true"
71
+ PROVIDER = ENV.fetch("RLTO_PROVIDER", LOCAL ? "openai" : "anthropic").to_sym
72
+ MODEL = ENV.fetch("RLTO_MODEL", LOCAL ? "qwen3.6:latest" : "claude-sonnet-4-6")
73
+ OLLAMA = ENV.fetch("OLLAMA_BASE", "http://localhost:11434/v1")
74
+
75
+ # For a local model we route RubyLLM's :openai provider at Ollama's
76
+ # OpenAI-compatible endpoint, refresh the registry so tool attachment works, and
77
+ # run non-streaming (Ollama suppresses tool calls when streaming). See the
78
+ # "Local Models" guide in the docs for why.
79
+ def configure_local!
80
+ RubyLLM.configure do |c|
81
+ c.openai_api_base = OLLAMA
82
+ c.openai_api_key = "ollama" # ignored by Ollama, but RubyLLM wants a value
83
+ c.request_timeout = 600
84
+ end
85
+ RubyLLM.logger.level = Logger::ERROR
86
+ RubyLLM.models.refresh!
87
+ rescue StandardError => e
88
+ warn "warning: could not refresh Ollama models (#{e.class}: #{e.message})"
89
+ end
90
+
91
+ # Fail fast with a friendly message if a local run can't reach Ollama.
92
+ def preflight_local!
93
+ uri = URI.join(OLLAMA, "models")
94
+ Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 2) do |http|
95
+ http.get(uri.request_uri)
96
+ end
97
+ rescue StandardError
98
+ abort <<~MSG
99
+ Cannot reach an Ollama server at #{OLLAMA}.
100
+ Start it and pull a tool-capable model first:
101
+
102
+ ollama serve &
103
+ ollama pull #{MODEL}
104
+
105
+ Or run against a cloud model:
106
+ RLTO_LOCAL=false RLTO_PROVIDER=anthropic RLTO_MODEL=claude-sonnet-4-6 \\
107
+ ANTHROPIC_API_KEY=sk-... ruby #{File.basename(__FILE__)}
108
+ MSG
109
+ end
110
+
111
+ # --- sandbox repository ----------------------------------------------------
112
+
113
+ def sh(*args, chdir:)
114
+ out, err, status = Open3.capture3(*args, chdir: chdir)
115
+ raise "command failed: #{args.join(' ')}\n#{err}" unless status.success?
116
+
117
+ out
118
+ end
119
+
120
+ # The sandbox project, created next to this script and git-ignored.
121
+ SANDBOX_DIR = File.expand_path("project", __dir__)
122
+
123
+ # Run state (event log + cross-iteration notes.md) is kept beside this example —
124
+ # under examples/01_basic_usage/.robot_lab_to — rather than at the repo root or
125
+ # buried inside project, so everything for the example stays in one
126
+ # place. It is git-ignored by the repo's .gitignore (`.robot_lab_to/`).
127
+ RUN_DIR = File.expand_path(".robot_lab_to", __dir__)
128
+
129
+ # Everything this example creates. Removed up front so each run starts clean.
130
+ ARTIFACTS = [SANDBOX_DIR, RUN_DIR].freeze
131
+
132
+ # Delete any leftovers from a previous execution so the app operates on a clean
133
+ # slate. Only the two paths this example creates are ever touched.
134
+ def clean_slate!
135
+ ARTIFACTS.each do |path|
136
+ next unless File.exist?(path)
137
+
138
+ puts "Cleaning leftover: #{path}"
139
+ FileUtils.rm_rf(path)
140
+ end
141
+ end
142
+
143
+ # The fixed specification the robot must satisfy: a comprehensive, *failing*
144
+ # Minitest suite for a Roman-numeral library. Seeded into the sandbox so the
145
+ # robot can't game its own success — it has to make this exact suite pass.
146
+ # (`<<~'RUBY'` is non-interpolating, so the body is copied verbatim.)
147
+ ROMAN_TEST = <<~'RUBY'
148
+ # frozen_string_literal: true
149
+
150
+ require "minitest/autorun"
151
+ require "roman_numeral"
152
+
153
+ class RomanNumeralTest < Minitest::Test
154
+ def test_to_roman_basic_digits
155
+ assert_equal "I", RomanNumeral.to_roman(1)
156
+ assert_equal "III", RomanNumeral.to_roman(3)
157
+ assert_equal "XII", RomanNumeral.to_roman(12)
158
+ end
159
+
160
+ def test_to_roman_subtractive_pairs
161
+ { 4 => "IV", 9 => "IX", 40 => "XL", 90 => "XC", 400 => "CD", 900 => "CM" }.each do |n, roman|
162
+ assert_equal roman, RomanNumeral.to_roman(n)
163
+ end
164
+ end
165
+
166
+ def test_to_roman_composite_values
167
+ assert_equal "MMXXIV", RomanNumeral.to_roman(2024)
168
+ assert_equal "MCMLIV", RomanNumeral.to_roman(1954)
169
+ end
170
+
171
+ def test_to_roman_boundaries
172
+ assert_equal "I", RomanNumeral.to_roman(1)
173
+ assert_equal "MMMCMXCIX", RomanNumeral.to_roman(3999)
174
+ end
175
+
176
+ def test_from_roman_round_trips_to_roman
177
+ (1..3999).step(7) do |n|
178
+ assert_equal n, RomanNumeral.from_roman(RomanNumeral.to_roman(n))
179
+ end
180
+ end
181
+
182
+ def test_from_roman_is_case_insensitive
183
+ assert_equal 14, RomanNumeral.from_roman("xiv")
184
+ end
185
+
186
+ def test_to_roman_rejects_out_of_range
187
+ assert_raises(ArgumentError) { RomanNumeral.to_roman(0) }
188
+ assert_raises(ArgumentError) { RomanNumeral.to_roman(4000) }
189
+ end
190
+
191
+ def test_to_roman_rejects_non_integers
192
+ assert_raises(ArgumentError) { RomanNumeral.to_roman("12") }
193
+ assert_raises(ArgumentError) { RomanNumeral.to_roman(3.5) }
194
+ end
195
+
196
+ def test_from_roman_rejects_non_canonical_or_invalid
197
+ ["IIII", "VV", "ABC", ""].each do |bad|
198
+ assert_raises(ArgumentError) { RomanNumeral.from_roman(bad) }
199
+ end
200
+ end
201
+ end
202
+ RUBY
203
+
204
+ # Create a fresh git repo seeded with the failing test suite and one commit
205
+ # (robot-to needs a HEAD to branch from). Assumes clean_slate! has already run.
206
+ def make_sandbox
207
+ FileUtils.mkdir_p(SANDBOX_DIR)
208
+ sh("git", "init", "-q", chdir: SANDBOX_DIR)
209
+ sh("git", "config", "user.email", "example@example.com", chdir: SANDBOX_DIR)
210
+ sh("git", "config", "user.name", "robot_lab-to example", chdir: SANDBOX_DIR)
211
+
212
+ FileUtils.mkdir_p(File.join(SANDBOX_DIR, "test"))
213
+ File.write(File.join(SANDBOX_DIR, "test", "roman_numeral_test.rb"), ROMAN_TEST)
214
+ File.write(File.join(SANDBOX_DIR, "README.md"),
215
+ "# Roman numeral kata\n\n" \
216
+ "Implement `lib/roman_numeral.rb` so that `test/roman_numeral_test.rb` passes.\n")
217
+
218
+ sh("git", "add", "-A", chdir: SANDBOX_DIR)
219
+ sh("git", "commit", "-qm", "initial: failing roman-numeral test suite", chdir: SANDBOX_DIR)
220
+ SANDBOX_DIR
221
+ end
222
+
223
+ # --- live feedback (robot_lab hook) -----------------------------------------
224
+
225
+ # A RobotLab::Hook that narrates what the robot is doing in real time, so a run
226
+ # never sits silent while the model works. robot_lab fires these class methods
227
+ # around every robot run and tool call; registering the hook GLOBALLY with
228
+ # RobotLab.on (below) makes it apply to each per-iteration robot the orchestrator
229
+ # builds — no need to touch the orchestrator itself.
230
+ #
231
+ # We hand-write it here to show how the hook system works. If you just want live
232
+ # narration without writing your own, robot_lab ships one: `RobotLab::Narrator.enable!`
233
+ # — the 02_advanced_usage example uses it.
234
+ #
235
+ # Hook methods must never raise (an exception would abort the iteration), so each
236
+ # is wrapped defensively.
237
+ class FeedbackHook < RobotLab::Hook
238
+ class << self
239
+ # Fires once when a robot starts generating — the long "thinking" gap.
240
+ def before_llm_generation(_ctx)
241
+ say "🤔 thinking…"
242
+ rescue StandardError
243
+ nil
244
+ end
245
+
246
+ # Fires just before each tool runs — the robot's concrete actions.
247
+ def before_tool_call(ctx)
248
+ icon, detail = describe(ctx.tool_name.to_s, ctx.tool_args || {})
249
+ say "#{icon} #{detail}"
250
+ rescue StandardError
251
+ nil
252
+ end
253
+
254
+ # Fires after each tool — surface errors and shell exit codes.
255
+ def after_tool_call(ctx)
256
+ if ctx.tool_error
257
+ say " ✗ #{snippet(ctx.tool_error.message)}"
258
+ elsif ctx.tool_name.to_s == "bash" && (code = ctx.tool_result.to_s[/\[exit (\d+)\]/, 1])
259
+ say " ↳ exit #{code}"
260
+ end
261
+ rescue StandardError
262
+ nil
263
+ end
264
+
265
+ private
266
+
267
+ # Write to stderr directly (not Kernel#warn — that is a no-op when warnings
268
+ # are disabled, i.e. $VERBOSE is nil, which is common under `bundle exec`).
269
+ # $stderr is unbuffered, so feedback shows up live as the robot works.
270
+ def say(message)
271
+ $stderr.puts " #{message}"
272
+ end
273
+
274
+ # Map a tool call to an icon + one-line description.
275
+ def describe(name, args)
276
+ path = args["path"] || args[:path]
277
+ case name
278
+ when "read" then ["📖", "read #{tidy(path)}"]
279
+ when "write" then ["📝", "write #{tidy(path)}"]
280
+ when "edit" then ["✏️ ", "edit #{tidy(path)}"]
281
+ when "bash" then ["💻", "bash: #{snippet(args['command'] || args[:command])}"]
282
+ when /submit_result/ then ["✅", "submit result: #{snippet(args['summary'] || args[:summary], 160)}"]
283
+ else ["🔧", name]
284
+ end
285
+ end
286
+
287
+ # Shorten paths for display anywhere they appear: drop the project dir and
288
+ # collapse $HOME to ~ (covers bare paths and embedded ones like `cd <path>`).
289
+ def tidy(text)
290
+ text.to_s.gsub("#{Dir.pwd}/", "").gsub(Dir.home, "~")
291
+ end
292
+
293
+ # First line of free text, paths tidied, capped with an ellipsis.
294
+ def snippet(text, limit = 90)
295
+ line = tidy(text.to_s.lines.first.to_s.strip)
296
+ line.length > limit ? "#{line[0, limit]}…" : line
297
+ end
298
+ end
299
+ end
300
+
301
+ # --- report ----------------------------------------------------------------
302
+
303
+ def report(dir)
304
+ puts "\n=== Result ==="
305
+ puts "Branch: #{sh('git', 'branch', '--show-current', chdir: dir).strip}"
306
+ puts "\nCommits:"
307
+ puts sh("git", "log", "--oneline", chdir: dir)
308
+
309
+ impl = File.join(dir, "lib", "roman_numeral.rb")
310
+ if File.exist?(impl)
311
+ puts "\nlib/roman_numeral.rb:"
312
+ puts File.read(impl)
313
+ else
314
+ puts "\n(lib/roman_numeral.rb was not created — check the notes for why)"
315
+ end
316
+
317
+ puts "\nFinal test run:"
318
+ out, = Open3.capture2e("ruby", "-Ilib", "-Itest", "test/roman_numeral_test.rb", chdir: dir)
319
+ summary = out.lines.grep(/runs?, |assertions|failures|Error/i).last(1).join.strip
320
+ puts summary.empty? ? out.strip : summary
321
+
322
+ notes = Dir.glob(File.join(RUN_DIR, "runs", "*", "notes.md")).max # newest run
323
+ puts "\nNotes: #{notes}" if notes
324
+ puts "Project: #{dir}"
325
+ puts "Run logs: #{RUN_DIR}"
326
+ end
327
+
328
+ # --- main ------------------------------------------------------------------
329
+
330
+ OBJECTIVE = <<~OBJ.strip
331
+ Implement a Roman numeral library in lib/roman_numeral.rb so that the existing
332
+ Minitest suite in test/roman_numeral_test.rb passes with no failures or errors.
333
+
334
+ The module RomanNumeral must provide two class methods:
335
+
336
+ 1. RomanNumeral.to_roman(integer) -> String
337
+ - supports the range 1..3999 using standard subtractive notation
338
+ (4=IV, 9=IX, 40=XL, 90=XC, 400=CD, 900=CM); e.g. to_roman(2024) => "MMXXIV"
339
+ - raises ArgumentError for non-Integers and for values outside 1..3999
340
+
341
+ 2. RomanNumeral.from_roman(string) -> Integer
342
+ - the exact inverse of to_roman over 1..3999, accepting upper or lower case
343
+ - raises ArgumentError for any string that is not a CANONICAL Roman numeral
344
+ (e.g. "IIII", "VV", "ABC", and "" are all invalid)
345
+
346
+ Read test/roman_numeral_test.rb for the exact expectations, then implement the
347
+ library. Run the suite with:
348
+
349
+ ruby -Ilib -Itest test/roman_numeral_test.rb
350
+
351
+ Work incrementally, one focused change per iteration, and do NOT edit the test
352
+ file. Call submit_result when the suite passes.
353
+ OBJ
354
+
355
+ if LOCAL
356
+ preflight_local!
357
+ configure_local!
358
+ end
359
+
360
+ clean_slate! # delete any project / .robot_lab_to left by a previous run
361
+ sandbox = make_sandbox
362
+ puts "Project dir: #{sandbox}"
363
+ puts "Provider/model: #{PROVIDER}/#{MODEL} (#{LOCAL ? 'local Ollama' : 'cloud'})"
364
+ puts "Objective: implement lib/roman_numeral.rb to pass the test suite"
365
+ puts
366
+
367
+ # Register the narrator globally so it reports on every per-iteration robot.
368
+ RobotLab.on(FeedbackHook)
369
+
370
+ Dir.chdir(sandbox) do
371
+ RobotLab::To.run(
372
+ OBJECTIVE,
373
+ provider: PROVIDER,
374
+ model: MODEL,
375
+ local_guards: LOCAL, # built-in file tools + small-model guardrails
376
+ stream: !LOCAL, # local Ollama tool calls require non-streaming
377
+ max_iterations: 6, # a richer task needs room to iterate
378
+ run_dir: RUN_DIR, # keep logs + notes under this example directory
379
+ # The change only commits if the seeded test suite passes:
380
+ verify_command: "ruby -Ilib -Itest test/roman_numeral_test.rb",
381
+ # Stop early once the suite is green (the robot judges this after a success):
382
+ stop_when: "test/roman_numeral_test.rb passes with 0 failures and 0 errors"
383
+ )
384
+ end
385
+
386
+ report(sandbox)
@@ -0,0 +1,8 @@
1
+ # Artifacts produced when the example runs — never commit them into the
2
+ # robot_lab-to repository. (gitignore has no inline comments.)
3
+
4
+ # the throwaway git repo the agents work in (recreated each run)
5
+ /project/
6
+
7
+ # the implementation run's event log and cross-iteration notes.md
8
+ /.robot_lab_to/
@@ -0,0 +1,117 @@
1
+ # 02 — Advanced Usage
2
+
3
+ A multi-phase workflow that composes the two robot_lab subsystems: a **network of
4
+ robots** collaborates with you to ideate and plan, then **robot_lab-to**
5
+ autonomously implements the plan behind a real **quality gate**.
6
+
7
+ ```
8
+ ┌─ Phase 1: IDEATE ─┐ ┌─ Phase 2: PLAN ──┐ ┌─ Phase 3: IMPLEMENT ─────────┐
9
+ │ Ideator robot │ │ Planner robot │ │ robot_lab-to autonomous loop │
10
+ │ interviews you │──▶│ writes the │──▶──▶│ writes lib/ until the suite │
11
+ │ (AskUser tool) │ │ acceptance │ spec │ passes AND the quality gate │
12
+ │ │ │ test suite │ │ is clean │
13
+ └───────────────────┘ └──────────────────┘ └───────────────────────────────┘
14
+ gpt-5.5 (cloud) · robot_lab Network qwen3.6:latest (local Ollama)
15
+ ```
16
+
17
+ This is the natural progression from
18
+ [01_basic_usage](../01_basic_usage/): there the acceptance tests were hardcoded;
19
+ here they are **generated collaboratively** — the Planner (a different robot than
20
+ the implementer) writes the spec, so the implementer still can't game it.
21
+
22
+ ## What each phase demonstrates
23
+
24
+ | Phase | robot_lab feature | Model |
25
+ |-------|-------------------|-------|
26
+ | 1 — Ideate | `AskUser` tool, network task, templated robot | OpenAI `gpt-5.5` |
27
+ | 2 — Plan | sequential network `task … depends_on`, data hand-off, file tools | OpenAI `gpt-5.5` |
28
+ | 3 — Implement | `RobotLab::To.run` autonomous loop, verify gate, `stop_when`, guardrails | local Ollama `qwen3.6:latest` |
29
+
30
+ The reasoning phases run on a capable cloud model; the implementation loop runs
31
+ fully local. Because both use RubyLLM's `:openai` provider but different endpoints
32
+ (`api.openai.com` vs Ollama's `/v1`), and `openai_api_base` is global, the example
33
+ toggles it between the sequential phases.
34
+
35
+ ## The quality gate
36
+
37
+ The implementer's `--verify-command` is `ruby quality_gate.rb`, which aggregates
38
+ four checks and commits an iteration only when **all** pass:
39
+
40
+ ```
41
+ quality gate:
42
+ ✓ tests the Planner-authored acceptance suite (test/**/*_test.rb)
43
+ ✓ rubocop no style offenses in lib/
44
+ ✓ flog no method more complex than FLOG_MAX (default 25)
45
+ ✓ flay structural-duplication mass below FLAY_MAX (default 40)
46
+ → PASS
47
+ ```
48
+
49
+ So the robot must earn each commit on **correctness *and* quality** — it can't
50
+ ship working-but-ugly code. This turns `robot_lab_project`'s own bar (rubocop +
51
+ flog + flay) into an autonomous gate. Tune the thresholds with `FLOG_MAX` /
52
+ `FLAY_MAX`.
53
+
54
+ ## Prerequisites
55
+
56
+ ```bash
57
+ export OPENAI_API_KEY="sk-..." # ideation + planning (gpt-5.5)
58
+ ollama serve & # implementation (local)
59
+ ollama pull qwen3.6:latest
60
+ ```
61
+
62
+ ## Run it
63
+
64
+ ```bash
65
+ bundle exec ruby examples/02_advanced_usage/advanced_usage.rb
66
+ ```
67
+
68
+ It is **interactive** — the Ideator will ask you, at the terminal, what to build
69
+ and a few clarifying questions. Answer them, then watch the Planner write the
70
+ acceptance suite and the local model implement it.
71
+
72
+ ## Configuration
73
+
74
+ | Variable | Default | Description |
75
+ |----------|---------|-------------|
76
+ | `RLTO_REASON_MODEL` | `gpt-5.5` | model for ideate + plan |
77
+ | `RLTO_REASON_PROVIDER` | `openai` | provider for ideate + plan |
78
+ | `RLTO_BUILD_MODEL` | `qwen3.6:latest` | model for implementation |
79
+ | `OLLAMA_BASE` | `http://localhost:11434/v1` | Ollama OpenAI-compatible endpoint |
80
+ | `FLOG_MAX` | `25` | per-method complexity ceiling |
81
+ | `FLAY_MAX` | `40` | duplication-mass ceiling |
82
+
83
+ ## Layout
84
+
85
+ Everything stays under `examples/02_advanced_usage/` (both git-ignored, recreated
86
+ each run):
87
+
88
+ ```
89
+ examples/02_advanced_usage/
90
+ ├── advanced_usage.rb # the orchestration
91
+ ├── quality_gate.rb # seeded into the project as the verify command
92
+ ├── project/ # the throwaway git repo the agents work in
93
+ └── .robot_lab_to/ # the implementation run's logs + notes.md
94
+ ```
95
+
96
+ ## Live feedback
97
+
98
+ Narration of every robot's action across all three phases comes from robot_lab
99
+ core — **`RobotLab::Narrator`** — enabled with a single line:
100
+
101
+ ```ruby
102
+ RobotLab::Narrator.enable! # registers globally; covers the network + the -to loop
103
+ ```
104
+
105
+ ```
106
+ · ideator: thinking…
107
+ · → robot_lab--ask_user question="What tiny Ruby library would you like to build?…"
108
+ · planner: thinking…
109
+ · → write path="test/temperature_converter_test.rb"
110
+ · → bash command="ruby quality_gate.rb"
111
+ ```
112
+
113
+ This replaces the hand-written `RobotLab::Hook` that earlier examples used — a
114
+ ~60-line hook became one call. The Narrator writes to `$stderr` (not `Kernel#warn`,
115
+ which is silenced when `$VERBOSE` is `nil` under `bundle exec`). For richer
116
+ per-tool output (icons, shell exit codes) you can subclass `RobotLab::Narrator`
117
+ and override `before_tool_call` / `after_tool_call`.