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.
- checksums.yaml +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.loki +21 -0
- data/.quality/reek_baseline.txt +17 -0
- data/.rubocop.yml +7 -0
- data/CHANGELOG.md +67 -0
- data/CLAUDE.md +74 -0
- data/COMMITS.md +27 -0
- data/README.md +352 -0
- data/Rakefile +132 -0
- data/bin/robot-to +14 -0
- data/docs/about/changelog.md +82 -0
- data/docs/assets/architecture.svg +110 -0
- data/docs/assets/images/robot_lab-to.jpg +0 -0
- data/docs/assets/iteration-loop.svg +97 -0
- data/docs/concepts/evals.md +254 -0
- data/docs/concepts/iteration-loop.md +128 -0
- data/docs/concepts/notes.md +91 -0
- data/docs/concepts/run-state.md +93 -0
- data/docs/concepts/stop-conditions.md +111 -0
- data/docs/concepts/verification.md +75 -0
- data/docs/configuration/cli.md +111 -0
- data/docs/configuration/index.md +93 -0
- data/docs/configuration/settings.md +270 -0
- data/docs/getting-started/anatomy-of-a-run.md +111 -0
- data/docs/getting-started/installation.md +78 -0
- data/docs/getting-started/quick-start.md +105 -0
- data/docs/index.md +96 -0
- data/docs/local-models/guardrails.md +117 -0
- data/docs/local-models/index.md +88 -0
- data/docs/local-models/ollama.md +122 -0
- data/docs/local-models/tools.md +92 -0
- data/docs/reference/architecture.md +139 -0
- data/examples/01_basic_usage/.gitignore +9 -0
- data/examples/01_basic_usage/README.md +173 -0
- data/examples/01_basic_usage/basic_usage.rb +386 -0
- data/examples/02_advanced_usage/.gitignore +8 -0
- data/examples/02_advanced_usage/README.md +117 -0
- data/examples/02_advanced_usage/advanced_usage.rb +308 -0
- data/examples/02_advanced_usage/quality_gate.rb +99 -0
- data/examples/03_scored/.gitignore +2 -0
- data/examples/03_scored/README.md +117 -0
- data/examples/03_scored/scored_run.rb +325 -0
- data/examples/04_prose/.gitignore +2 -0
- data/examples/04_prose/README.md +43 -0
- data/examples/04_prose/prompts_dir/grade_message.md +14 -0
- data/examples/04_prose/prompts_dir/judge.md +11 -0
- data/examples/04_prose/prompts_dir/outline_criteria.md +9 -0
- data/examples/04_prose/prompts_dir/outline_objective.md +11 -0
- data/examples/04_prose/prompts_dir/section_criteria.md +7 -0
- data/examples/04_prose/prompts_dir/sections_objective.md +14 -0
- data/examples/04_prose/prose_run.rb +302 -0
- data/lib/robot_lab/to/atomic_file.rb +69 -0
- data/lib/robot_lab/to/backoff.rb +46 -0
- data/lib/robot_lab/to/cli.rb +176 -0
- data/lib/robot_lab/to/commit_manager.rb +135 -0
- data/lib/robot_lab/to/config/defaults.yml +27 -0
- data/lib/robot_lab/to/config.rb +86 -0
- data/lib/robot_lab/to/decision.rb +40 -0
- data/lib/robot_lab/to/decision_manager.rb +193 -0
- data/lib/robot_lab/to/errors.rb +30 -0
- data/lib/robot_lab/to/evals/base.rb +23 -0
- data/lib/robot_lab/to/evals/code.rb +77 -0
- data/lib/robot_lab/to/evals/context.rb +17 -0
- data/lib/robot_lab/to/evals/factory.rb +84 -0
- data/lib/robot_lab/to/evals/null.rb +17 -0
- data/lib/robot_lab/to/evals/prose.rb +110 -0
- data/lib/robot_lab/to/evals/score.rb +22 -0
- data/lib/robot_lab/to/exit_summary.rb +75 -0
- data/lib/robot_lab/to/guards/checkpoint.rb +84 -0
- data/lib/robot_lab/to/guards/grader_lock.rb +62 -0
- data/lib/robot_lab/to/guards/path_resolution.rb +61 -0
- data/lib/robot_lab/to/guards/quality_monitor.rb +113 -0
- data/lib/robot_lab/to/guards/read_before_edit.rb +83 -0
- data/lib/robot_lab/to/guards/run_store.rb +31 -0
- data/lib/robot_lab/to/guards/write_guard.rb +71 -0
- data/lib/robot_lab/to/guards.rb +54 -0
- data/lib/robot_lab/to/iteration_result.rb +29 -0
- data/lib/robot_lab/to/jsonl_logger.rb +59 -0
- data/lib/robot_lab/to/notes_manager.rb +121 -0
- data/lib/robot_lab/to/orchestrator.rb +642 -0
- data/lib/robot_lab/to/prompt_builder.rb +208 -0
- data/lib/robot_lab/to/run.rb +109 -0
- data/lib/robot_lab/to/stop_conditions.rb +71 -0
- data/lib/robot_lab/to/tools/bash.rb +75 -0
- data/lib/robot_lab/to/tools/edit.rb +52 -0
- data/lib/robot_lab/to/tools/file_tool.rb +32 -0
- data/lib/robot_lab/to/tools/read.rb +57 -0
- data/lib/robot_lab/to/tools/request_decision.rb +66 -0
- data/lib/robot_lab/to/tools/submit_result.rb +50 -0
- data/lib/robot_lab/to/tools/write.rb +32 -0
- data/lib/robot_lab/to/verifier.rb +63 -0
- data/lib/robot_lab/to/version.rb +7 -0
- data/lib/robot_lab/to.rb +81 -0
- data/mkdocs.yml +145 -0
- 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`.
|