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,325 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
#
|
|
4
|
+
# ===========================================================================
|
|
5
|
+
# 03_scored — drive robot_lab-to with an EVAL (measured descent)
|
|
6
|
+
# ===========================================================================
|
|
7
|
+
#
|
|
8
|
+
# 01_basic_usage uses a verify command: a change commits if the test suite exits
|
|
9
|
+
# 0 — pure pass/fail. This example instead scores each iteration with an **eval**
|
|
10
|
+
# and makes the loop *descend* toward a target.
|
|
11
|
+
#
|
|
12
|
+
# The metric is the number of PASSING test methods. A small grader script
|
|
13
|
+
# (score.rb) runs the seeded Roman-numeral suite and prints that count; higher is
|
|
14
|
+
# better. The orchestrator commits an iteration ONLY if it raises the count, rolls
|
|
15
|
+
# back anything that doesn't, and stops once every test passes (the target). Each
|
|
16
|
+
# iteration's prompt gets a "Score Feedback" section — "Best score so far: 6/9" —
|
|
17
|
+
# so the robot's next attempt is aimed, not blind.
|
|
18
|
+
#
|
|
19
|
+
# The grader (score.rb) and the test file are LOCKED with --protect-path: the
|
|
20
|
+
# GraderLock guard refuses any attempt by the robot to edit the criteria it is
|
|
21
|
+
# scored against, so it can't "win" by deleting a failing test.
|
|
22
|
+
#
|
|
23
|
+
# Everything stays under examples/03_scored/ (project/ and .robot_lab_to/), both
|
|
24
|
+
# git-ignored and wiped at the start of every run.
|
|
25
|
+
#
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Run it
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# # Local Ollama (default — no API key; requires `ollama serve`):
|
|
30
|
+
# ollama pull gpt-oss:20b
|
|
31
|
+
# bundle exec ruby examples/03_scored/scored_run.rb
|
|
32
|
+
#
|
|
33
|
+
# # A cloud model instead:
|
|
34
|
+
# RLTO_LOCAL=false RLTO_PROVIDER=anthropic RLTO_MODEL=claude-sonnet-4-6 \
|
|
35
|
+
# ANTHROPIC_API_KEY=sk-... \
|
|
36
|
+
# bundle exec ruby examples/03_scored/scored_run.rb
|
|
37
|
+
#
|
|
38
|
+
# Configuration (all optional, via environment):
|
|
39
|
+
# RLTO_LOCAL true|false use a local Ollama model (default true)
|
|
40
|
+
# RLTO_PROVIDER name LLM provider (default: openai for local)
|
|
41
|
+
# RLTO_MODEL id model id (default: qwen3.6:latest for local)
|
|
42
|
+
# OLLAMA_BASE url Ollama OpenAI endpoint (default localhost:11434/v1)
|
|
43
|
+
# ===========================================================================
|
|
44
|
+
|
|
45
|
+
require "fileutils"
|
|
46
|
+
require "logger"
|
|
47
|
+
require "open3"
|
|
48
|
+
require "net/http"
|
|
49
|
+
|
|
50
|
+
# Make the example runnable straight from the repo during development, with or
|
|
51
|
+
# without `bundle exec`. (When the gem is installed normally these paths don't
|
|
52
|
+
# exist and are skipped.)
|
|
53
|
+
[
|
|
54
|
+
File.expand_path("../../lib", __dir__), # robot_lab-to/lib
|
|
55
|
+
File.expand_path("../../../robot_lab/lib", __dir__) # sibling robot_lab/lib
|
|
56
|
+
].each { |p| $LOAD_PATH.unshift(p) if Dir.exist?(p) }
|
|
57
|
+
|
|
58
|
+
require "ruby_llm"
|
|
59
|
+
require "robot_lab"
|
|
60
|
+
require "robot_lab/to"
|
|
61
|
+
|
|
62
|
+
# --- configuration ---------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
LOCAL = ENV.fetch("RLTO_LOCAL", "true") == "true"
|
|
65
|
+
PROVIDER = ENV.fetch("RLTO_PROVIDER", LOCAL ? "openai" : "anthropic").to_sym
|
|
66
|
+
MODEL = ENV.fetch("RLTO_MODEL", LOCAL ? "qwen3.6:latest" : "claude-sonnet-4-6")
|
|
67
|
+
OLLAMA = ENV.fetch("OLLAMA_BASE", "http://localhost:11434/v1")
|
|
68
|
+
|
|
69
|
+
TOTAL_TESTS = 9 # the seeded suite has 9 test methods — the target
|
|
70
|
+
|
|
71
|
+
# Route RubyLLM's :openai provider at Ollama's OpenAI-compatible endpoint for a
|
|
72
|
+
# local run (non-streaming, since Ollama suppresses tool calls when streaming).
|
|
73
|
+
def configure_local!
|
|
74
|
+
RubyLLM.configure do |c|
|
|
75
|
+
c.openai_api_base = OLLAMA
|
|
76
|
+
c.openai_api_key = "ollama"
|
|
77
|
+
c.request_timeout = 600
|
|
78
|
+
end
|
|
79
|
+
RubyLLM.logger.level = Logger::ERROR
|
|
80
|
+
RubyLLM.models.refresh!
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
warn "warning: could not refresh Ollama models (#{e.class}: #{e.message})"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def preflight_local!
|
|
86
|
+
uri = URI.join(OLLAMA, "models")
|
|
87
|
+
Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 2) { |h| h.get(uri.request_uri) }
|
|
88
|
+
rescue StandardError
|
|
89
|
+
abort <<~MSG
|
|
90
|
+
Cannot reach an Ollama server at #{OLLAMA}.
|
|
91
|
+
Start it and pull a tool-capable model first:
|
|
92
|
+
|
|
93
|
+
ollama serve &
|
|
94
|
+
ollama pull #{MODEL}
|
|
95
|
+
|
|
96
|
+
Or run against a cloud model:
|
|
97
|
+
RLTO_LOCAL=false RLTO_PROVIDER=anthropic RLTO_MODEL=claude-sonnet-4-6 \\
|
|
98
|
+
ANTHROPIC_API_KEY=sk-... ruby #{File.basename(__FILE__)}
|
|
99
|
+
MSG
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# --- sandbox repository ----------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def sh(*args, chdir:)
|
|
105
|
+
out, err, status = Open3.capture3(*args, chdir: chdir)
|
|
106
|
+
raise "command failed: #{args.join(' ')}\n#{err}" unless status.success?
|
|
107
|
+
|
|
108
|
+
out
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
SANDBOX_DIR = File.expand_path("project", __dir__)
|
|
112
|
+
RUN_DIR = File.expand_path(".robot_lab_to", __dir__)
|
|
113
|
+
ARTIFACTS = [SANDBOX_DIR, RUN_DIR].freeze
|
|
114
|
+
|
|
115
|
+
def clean_slate!
|
|
116
|
+
ARTIFACTS.each do |path|
|
|
117
|
+
next unless File.exist?(path)
|
|
118
|
+
|
|
119
|
+
puts "Cleaning leftover: #{path}"
|
|
120
|
+
FileUtils.rm_rf(path)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The fixed, *failing* Minitest suite the robot must satisfy (9 test methods).
|
|
125
|
+
# Seeded into the sandbox and locked, so the robot can't game its own success.
|
|
126
|
+
ROMAN_TEST = <<~'RUBY'
|
|
127
|
+
# frozen_string_literal: true
|
|
128
|
+
|
|
129
|
+
require "minitest/autorun"
|
|
130
|
+
require "roman_numeral"
|
|
131
|
+
|
|
132
|
+
class RomanNumeralTest < Minitest::Test
|
|
133
|
+
def test_to_roman_basic_digits
|
|
134
|
+
assert_equal "I", RomanNumeral.to_roman(1)
|
|
135
|
+
assert_equal "III", RomanNumeral.to_roman(3)
|
|
136
|
+
assert_equal "XII", RomanNumeral.to_roman(12)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def test_to_roman_subtractive_pairs
|
|
140
|
+
{ 4 => "IV", 9 => "IX", 40 => "XL", 90 => "XC", 400 => "CD", 900 => "CM" }.each do |n, roman|
|
|
141
|
+
assert_equal roman, RomanNumeral.to_roman(n)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def test_to_roman_composite_values
|
|
146
|
+
assert_equal "MMXXIV", RomanNumeral.to_roman(2024)
|
|
147
|
+
assert_equal "MCMLIV", RomanNumeral.to_roman(1954)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def test_to_roman_boundaries
|
|
151
|
+
assert_equal "I", RomanNumeral.to_roman(1)
|
|
152
|
+
assert_equal "MMMCMXCIX", RomanNumeral.to_roman(3999)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def test_from_roman_round_trips_to_roman
|
|
156
|
+
(1..3999).step(7) { |n| assert_equal n, RomanNumeral.from_roman(RomanNumeral.to_roman(n)) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def test_from_roman_is_case_insensitive
|
|
160
|
+
assert_equal 14, RomanNumeral.from_roman("xiv")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def test_to_roman_rejects_out_of_range
|
|
164
|
+
assert_raises(ArgumentError) { RomanNumeral.to_roman(0) }
|
|
165
|
+
assert_raises(ArgumentError) { RomanNumeral.to_roman(4000) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def test_to_roman_rejects_non_integers
|
|
169
|
+
assert_raises(ArgumentError) { RomanNumeral.to_roman("12") }
|
|
170
|
+
assert_raises(ArgumentError) { RomanNumeral.to_roman(3.5) }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def test_from_roman_rejects_non_canonical_or_invalid
|
|
174
|
+
["IIII", "VV", "ABC", ""].each { |bad| assert_raises(ArgumentError) { RomanNumeral.from_roman(bad) } }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
RUBY
|
|
178
|
+
|
|
179
|
+
# The GRADER: prints the number of passing test methods (higher = better). Wired
|
|
180
|
+
# as --measure and locked with --protect-path so the robot can't edit the metric.
|
|
181
|
+
# When the library is missing, the suite fails to load and this prints 0.
|
|
182
|
+
SCORE_SCRIPT = <<~'RUBY'
|
|
183
|
+
#!/usr/bin/env ruby
|
|
184
|
+
# frozen_string_literal: true
|
|
185
|
+
out = `ruby -Ilib -Itest test/roman_numeral_test.rb 2>&1`
|
|
186
|
+
m = out.match(/(\d+)\s+runs?,.*?(\d+)\s+failures?,\s*(\d+)\s+errors?/m)
|
|
187
|
+
puts(m ? (m[1].to_i - m[2].to_i - m[3].to_i) : 0)
|
|
188
|
+
RUBY
|
|
189
|
+
|
|
190
|
+
def make_sandbox
|
|
191
|
+
FileUtils.mkdir_p(File.join(SANDBOX_DIR, "test"))
|
|
192
|
+
sh("git", "init", "-q", chdir: SANDBOX_DIR)
|
|
193
|
+
sh("git", "config", "user.email", "example@example.com", chdir: SANDBOX_DIR)
|
|
194
|
+
sh("git", "config", "user.name", "robot_lab-to example", chdir: SANDBOX_DIR)
|
|
195
|
+
|
|
196
|
+
File.write(File.join(SANDBOX_DIR, "test", "roman_numeral_test.rb"), ROMAN_TEST)
|
|
197
|
+
File.write(File.join(SANDBOX_DIR, "score.rb"), SCORE_SCRIPT)
|
|
198
|
+
File.write(File.join(SANDBOX_DIR, "README.md"),
|
|
199
|
+
"# Roman numeral kata (scored)\n\n" \
|
|
200
|
+
"Implement `lib/roman_numeral.rb` to make more of the suite pass.\n" \
|
|
201
|
+
"Score = passing test methods (grader: `ruby score.rb`). Target #{TOTAL_TESTS}.\n")
|
|
202
|
+
|
|
203
|
+
sh("git", "add", "-A", chdir: SANDBOX_DIR)
|
|
204
|
+
sh("git", "commit", "-qm", "initial: failing suite + grader", chdir: SANDBOX_DIR)
|
|
205
|
+
SANDBOX_DIR
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# --- live feedback (robot_lab hook) ----------------------------------------
|
|
209
|
+
|
|
210
|
+
# Narrates each robot action so a run never sits silent. See 01_basic_usage for a
|
|
211
|
+
# fuller writeup of the hook system.
|
|
212
|
+
class FeedbackHook < RobotLab::Hook
|
|
213
|
+
class << self
|
|
214
|
+
def before_llm_generation(_ctx)
|
|
215
|
+
say "🤔 thinking…"
|
|
216
|
+
rescue StandardError
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def before_tool_call(ctx)
|
|
221
|
+
icon, detail = describe(ctx.tool_name.to_s, ctx.tool_args || {})
|
|
222
|
+
say "#{icon} #{detail}"
|
|
223
|
+
rescue StandardError
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
def say(message)
|
|
230
|
+
$stderr.puts " #{message}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def describe(name, args)
|
|
234
|
+
path = args["path"] || args[:path]
|
|
235
|
+
case name
|
|
236
|
+
when "read" then ["📖", "read #{tidy(path)}"]
|
|
237
|
+
when "write" then ["📝", "write #{tidy(path)}"]
|
|
238
|
+
when "edit" then ["✏️ ", "edit #{tidy(path)}"]
|
|
239
|
+
when "bash" then ["💻", "bash: #{snippet(args['command'] || args[:command])}"]
|
|
240
|
+
when /submit_result/ then ["✅", "submit: #{snippet(args['summary'] || args[:summary], 160)}"]
|
|
241
|
+
else ["🔧", name]
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def tidy(text)
|
|
246
|
+
text.to_s.gsub("#{Dir.pwd}/", "").gsub(Dir.home, "~")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def snippet(text, limit = 90)
|
|
250
|
+
line = tidy(text.to_s.lines.first.to_s.strip)
|
|
251
|
+
line.length > limit ? "#{line[0, limit]}…" : line
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# --- report ----------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
def final_score(dir)
|
|
259
|
+
out, = Open3.capture2e("ruby", "score.rb", chdir: dir)
|
|
260
|
+
out.strip
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def report(dir)
|
|
264
|
+
puts "\n=== Result ==="
|
|
265
|
+
puts "Branch: #{sh('git', 'branch', '--show-current', chdir: dir).strip}"
|
|
266
|
+
puts "Score: #{final_score(dir)}/#{TOTAL_TESTS} tests passing"
|
|
267
|
+
puts "\nCommits (one per improvement):"
|
|
268
|
+
puts sh("git", "log", "--oneline", chdir: dir)
|
|
269
|
+
|
|
270
|
+
notes = Dir.glob(File.join(RUN_DIR, "runs", "*", "notes.md")).max
|
|
271
|
+
puts "\nNotes: #{notes}" if notes
|
|
272
|
+
puts "Project: #{dir}"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# --- main ------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
OBJECTIVE = <<~OBJ.strip
|
|
278
|
+
Implement a Roman numeral library in lib/roman_numeral.rb so that MORE of the
|
|
279
|
+
Minitest suite in test/roman_numeral_test.rb passes each iteration.
|
|
280
|
+
|
|
281
|
+
The module RomanNumeral must provide:
|
|
282
|
+
1. RomanNumeral.to_roman(integer) -> String (range 1..3999, subtractive
|
|
283
|
+
notation; raises ArgumentError for non-Integers and out-of-range values)
|
|
284
|
+
2. RomanNumeral.from_roman(string) -> Integer (inverse of to_roman, case
|
|
285
|
+
insensitive; raises ArgumentError for non-canonical strings)
|
|
286
|
+
|
|
287
|
+
Your score is the number of passing test methods (run `ruby score.rb`). Make
|
|
288
|
+
incremental progress — one focused change per iteration that raises the score.
|
|
289
|
+
You may NOT edit test/roman_numeral_test.rb or score.rb (they are locked).
|
|
290
|
+
Call submit_result each iteration describing what you improved.
|
|
291
|
+
OBJ
|
|
292
|
+
|
|
293
|
+
if LOCAL
|
|
294
|
+
preflight_local!
|
|
295
|
+
configure_local!
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
clean_slate!
|
|
299
|
+
sandbox = make_sandbox
|
|
300
|
+
puts "Project dir: #{sandbox}"
|
|
301
|
+
puts "Provider/model: #{PROVIDER}/#{MODEL} (#{LOCAL ? 'local Ollama' : 'cloud'})"
|
|
302
|
+
puts "Eval: measured descent — score = passing tests, target #{TOTAL_TESTS}"
|
|
303
|
+
puts "Locked grader: score.rb, test/roman_numeral_test.rb"
|
|
304
|
+
puts
|
|
305
|
+
|
|
306
|
+
RobotLab.on(FeedbackHook)
|
|
307
|
+
|
|
308
|
+
Dir.chdir(sandbox) do
|
|
309
|
+
RobotLab::To.run(
|
|
310
|
+
OBJECTIVE,
|
|
311
|
+
provider: PROVIDER,
|
|
312
|
+
model: MODEL,
|
|
313
|
+
local_guards: LOCAL,
|
|
314
|
+
stream: !LOCAL,
|
|
315
|
+
max_iterations: 12,
|
|
316
|
+
run_dir: RUN_DIR,
|
|
317
|
+
# The eval: commit only when the passing-test count RISES; stop at the target.
|
|
318
|
+
eval_measure: "ruby score.rb",
|
|
319
|
+
eval_target: TOTAL_TESTS.to_f,
|
|
320
|
+
# Lock the grader + the test file so the robot can't game the metric.
|
|
321
|
+
protect_paths: ["score.rb", "test/roman_numeral_test.rb"]
|
|
322
|
+
)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
report(sandbox)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# 04 — Prose: split the doer from the verifier
|
|
2
|
+
|
|
3
|
+
Demo 03 scores **code** with a deterministic command (passing tests). Prose has no
|
|
4
|
+
`rake coverage`, so this demo uses the **`prose` eval** — a pairwise LLM judge — and
|
|
5
|
+
puts two *different* models in two roles:
|
|
6
|
+
|
|
7
|
+
| Role | Model (default) | Job |
|
|
8
|
+
|------|-----------------|-----|
|
|
9
|
+
| **Doer** | `qwen3.6:latest` | writes and improves `guide.md` |
|
|
10
|
+
| **Verifier** (judge) | `gpt-oss:latest` | compares each draft to the last committed one and rules it **better / worse / same** |
|
|
11
|
+
|
|
12
|
+
Only a draft the judge rules **better** is committed, so every commit is a genuine
|
|
13
|
+
improvement. There's no absolute target (LLM scores are too noisy to descend), so
|
|
14
|
+
the run ends on `--stop-on-plateau` (N drafts with no improvement) or max
|
|
15
|
+
iterations. The spec (`outline.md`) is **locked** with `--protect-path` — the doer
|
|
16
|
+
cannot edit the criteria it's judged against.
|
|
17
|
+
|
|
18
|
+
## Run it
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
ollama pull qwen3.6:latest # doer
|
|
22
|
+
ollama pull gpt-oss:latest # verifier / judge
|
|
23
|
+
bundle exec ruby examples/04_prose/prose_run.rb
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Override the models via env: `RLTO_MODEL` (doer), `RLTO_JUDGE_MODEL` (verifier).
|
|
27
|
+
|
|
28
|
+
## Why gpt-oss works as the judge here (but not as a doer)
|
|
29
|
+
|
|
30
|
+
gpt-oss is a reasoning model that, when *offered tools*, tends to call its own
|
|
31
|
+
built-ins (`container.exec`) instead of the provided ones — which makes it a poor
|
|
32
|
+
**doer**. But the **judge** is given **no tools**; it just reads two versions and
|
|
33
|
+
replies `better`/`worse`/`same`. As a pure text responder it's reliable, which is
|
|
34
|
+
exactly the verifier's job. This is the separation-of-duties payoff: the model that
|
|
35
|
+
*decides* is independent of the model that *acts*.
|
|
36
|
+
|
|
37
|
+
## The mechanism
|
|
38
|
+
|
|
39
|
+
`RobotLab::To.run(..., eval: "prose", eval_judge_model: "gpt-oss:latest")` builds an
|
|
40
|
+
`Evals::Prose`. Each iteration, after the doer edits `guide.md`, the eval diffs the
|
|
41
|
+
working tree against the parent commit, shows both versions plus the spec to the
|
|
42
|
+
judge model, and maps its verdict onto `Score#improved`. Commit on *better*, roll
|
|
43
|
+
back otherwise.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: The message handed to the judge — the criteria plus the artifact to grade.
|
|
3
|
+
parameters:
|
|
4
|
+
criteria: null
|
|
5
|
+
artifact: null
|
|
6
|
+
---
|
|
7
|
+
CRITERIA:
|
|
8
|
+
<%= criteria %>
|
|
9
|
+
|
|
10
|
+
ARTIFACT TO GRADE:
|
|
11
|
+
-----
|
|
12
|
+
<%= artifact %>
|
|
13
|
+
-----
|
|
14
|
+
Answer with READY or "NEEDS_WORK: <one improvement>".
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: >-
|
|
3
|
+
A demanding editor that grades one artifact against explicit criteria and
|
|
4
|
+
answers only READY or NEEDS_WORK. Used as the verifier/judge robot's system
|
|
5
|
+
prompt (RobotLab.build(template: :judge)).
|
|
6
|
+
---
|
|
7
|
+
You are a demanding editor. You grade a single artifact against explicit
|
|
8
|
+
criteria and answer in ONE of exactly two forms, nothing else:
|
|
9
|
+
READY
|
|
10
|
+
NEEDS_WORK: <one specific, actionable improvement>
|
|
11
|
+
Judge substance, not length. Be strict but fair.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Grading rubric the judge applies to the outline (Phase 1).
|
|
3
|
+
parameters:
|
|
4
|
+
topic: null
|
|
5
|
+
---
|
|
6
|
+
This is an OUTLINE for an opinionated guide on "<%= topic %>". It is READY only
|
|
7
|
+
when it lists comprehensive, well-ordered sections as a numbered markdown
|
|
8
|
+
list — each item a short section title plus a one-line description — enough
|
|
9
|
+
to write a strong, non-repetitive guide from.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Phase-1 doer objective — create a comprehensive, graded outline.
|
|
3
|
+
parameters:
|
|
4
|
+
topic: null
|
|
5
|
+
---
|
|
6
|
+
Create outline.md — a comprehensive outline for an opinionated guide on
|
|
7
|
+
<%= topic %>. Write outline.md in your CURRENT directory (bare filename, no
|
|
8
|
+
".robot_lab_to" path). List the sections as a NUMBERED markdown list, one per
|
|
9
|
+
line: "N. <short section title> — <one-line description>". A separate reviewer
|
|
10
|
+
grades the outline; read REVIEW.md for its feedback and revise outline.md until
|
|
11
|
+
the reviewer marks it READY. Call submit_result each iteration.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Phase-2 doer objective — write each section, one per iteration, as its own file.
|
|
3
|
+
parameters:
|
|
4
|
+
total: null
|
|
5
|
+
---
|
|
6
|
+
Following the approved outline.md, write the guide ONE section at a time. There
|
|
7
|
+
are <%= total %> sections. Each section goes in its OWN new file under sections/,
|
|
8
|
+
named sections/NN_slug.md (NN = 01, 02, …, matching the outline order).
|
|
9
|
+
|
|
10
|
+
Each iteration: read outline.md and list the sections/ directory, then write the
|
|
11
|
+
NEXT outline section that has no file yet. Write real, opinionated, concrete
|
|
12
|
+
prose — no placeholders. Read REVIEW.md for the reviewer's feedback on the last
|
|
13
|
+
section and fix it if asked. Do not touch outline.md. Use bare relative paths
|
|
14
|
+
(no ".robot_lab_to"). Call submit_result each iteration naming the section.
|