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,302 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
#
|
|
4
|
+
# ===========================================================================
|
|
5
|
+
# 04_prose — two-phase authoring: outline (graded), then sections (graded)
|
|
6
|
+
# ===========================================================================
|
|
7
|
+
#
|
|
8
|
+
# A single pairwise loop can't reliably build a structured document with a small
|
|
9
|
+
# local model. This demo splits the work into two graded phases, each driven by a
|
|
10
|
+
# CUSTOM eval that asks a *separate* judge model for an ABSOLUTE verdict:
|
|
11
|
+
#
|
|
12
|
+
# Phase 1 — OUTLINE: the doer writes outline.md; the judge grades it READY or
|
|
13
|
+
# NEEDS_WORK (with feedback). The run ends when the outline is READY.
|
|
14
|
+
# Phase 2 — SECTIONS: for the approved outline, the doer writes one section per
|
|
15
|
+
# iteration as its own file (sections/NN_*.md); the judge grades each
|
|
16
|
+
# section. The run ends when every outline section has a READY file.
|
|
17
|
+
# Finally the approved sections are assembled into guide.md.
|
|
18
|
+
#
|
|
19
|
+
# Two models, two roles:
|
|
20
|
+
# DOER (default qwen3.6:latest) — writes the outline and the sections
|
|
21
|
+
# VERIFIER (default gpt-oss:latest) — the judge; grades each artifact absolutely
|
|
22
|
+
#
|
|
23
|
+
# The judge writes its feedback to REVIEW.md (git-ignored in the sandbox); the
|
|
24
|
+
# doer is told to read REVIEW.md each iteration. write_guard is disabled for the
|
|
25
|
+
# doer so it can freely rewrite the file it is refining.
|
|
26
|
+
#
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Run it
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# ollama pull qwen3.6:latest # doer
|
|
31
|
+
# ollama pull gpt-oss:latest # verifier / judge
|
|
32
|
+
# bundle exec ruby examples/04_prose/prose_run.rb
|
|
33
|
+
#
|
|
34
|
+
# Env: RLTO_MODEL (doer), RLTO_JUDGE_MODEL (verifier), RLTO_TOPIC, RLTO_LOCAL,
|
|
35
|
+
# RLTO_PROVIDER, OLLAMA_BASE.
|
|
36
|
+
# ===========================================================================
|
|
37
|
+
|
|
38
|
+
require "fileutils"
|
|
39
|
+
require "logger"
|
|
40
|
+
require "open3"
|
|
41
|
+
require "net/http"
|
|
42
|
+
|
|
43
|
+
[
|
|
44
|
+
File.expand_path("../../lib", __dir__),
|
|
45
|
+
File.expand_path("../../../robot_lab/lib", __dir__)
|
|
46
|
+
].each { |p| $LOAD_PATH.unshift(p) if Dir.exist?(p) }
|
|
47
|
+
|
|
48
|
+
# Load robot_lab prompt templates (the judge's system prompt) from this demo's
|
|
49
|
+
# prompts_dir via robot_lab's OWN config env var — no reaching into PromptManager.
|
|
50
|
+
# Set before robot_lab loads its config so RobotLab.build(template:) resolves here.
|
|
51
|
+
PROMPTS_DIR = File.expand_path("prompts_dir", __dir__)
|
|
52
|
+
ENV["ROBOT_LAB_TEMPLATE_PATH"] = PROMPTS_DIR
|
|
53
|
+
|
|
54
|
+
require "ruby_llm"
|
|
55
|
+
require "robot_lab"
|
|
56
|
+
require "robot_lab/to"
|
|
57
|
+
RobotLab.reload_config! if RobotLab.respond_to?(:reload_config!)
|
|
58
|
+
|
|
59
|
+
# --- configuration ---------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
LOCAL = ENV.fetch("RLTO_LOCAL", "true") == "true"
|
|
62
|
+
PROVIDER = ENV.fetch("RLTO_PROVIDER", LOCAL ? "openai" : "anthropic").to_sym
|
|
63
|
+
DOER_MODEL = ENV.fetch("RLTO_MODEL", LOCAL ? "qwen3.6:latest" : "claude-sonnet-4-6")
|
|
64
|
+
JUDGE_MODEL = ENV.fetch("RLTO_JUDGE_MODEL", LOCAL ? "gpt-oss:latest" : "claude-sonnet-4-6")
|
|
65
|
+
OLLAMA = ENV.fetch("OLLAMA_BASE", "http://localhost:11434/v1")
|
|
66
|
+
TOPIC = ENV.fetch("RLTO_TOPIC", "writing good Git commit messages")
|
|
67
|
+
|
|
68
|
+
def configure_local!
|
|
69
|
+
RubyLLM.configure do |c|
|
|
70
|
+
c.openai_api_base = OLLAMA
|
|
71
|
+
c.openai_api_key = "ollama"
|
|
72
|
+
c.request_timeout = 600
|
|
73
|
+
end
|
|
74
|
+
RubyLLM.logger.level = Logger::ERROR
|
|
75
|
+
RubyLLM.models.refresh!
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
warn "warning: could not refresh Ollama models (#{e.class}: #{e.message})"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def preflight_local!
|
|
81
|
+
uri = URI.join(OLLAMA, "models")
|
|
82
|
+
Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 2) { |h| h.get(uri.request_uri) }
|
|
83
|
+
rescue StandardError
|
|
84
|
+
abort "Cannot reach Ollama at #{OLLAMA}. Start it and pull #{DOER_MODEL} + #{JUDGE_MODEL}."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# --- the judge (absolute grader) -------------------------------------------
|
|
88
|
+
|
|
89
|
+
# Ask the judge model to grade `text` against `criteria`. The judge's system
|
|
90
|
+
# prompt is a robot_lab template (prompts_dir/judge.md).
|
|
91
|
+
# @return [Array(Boolean, String)] [ready?, feedback]
|
|
92
|
+
def grade(criteria, text)
|
|
93
|
+
judge = RobotLab.build(name: "judge", model: JUDGE_MODEL, provider: PROVIDER, template: :judge)
|
|
94
|
+
message = RobotLab.render_template(:grade_message, criteria: criteria, artifact: text)
|
|
95
|
+
reply = judge.run(message).last_text_content.to_s
|
|
96
|
+
ready = reply.match?(/\bREADY\b/i) && !reply.match?(/NEEDS_WORK/i)
|
|
97
|
+
feedback = reply[/NEEDS_WORK:?\s*(.+)/im, 1].to_s.strip.slice(0, 400)
|
|
98
|
+
[ready, feedback.empty? ? reply.strip.slice(0, 400) : feedback]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The judge's feedback for the doer's next iteration. Written by the eval
|
|
102
|
+
# (orchestrator-side, so no WriteGuard); read by the doer. Git-ignored.
|
|
103
|
+
def write_review(dir, text)
|
|
104
|
+
File.write(File.join(dir, "REVIEW.md"), "# Reviewer feedback (read me before revising)\n\n#{text}\n")
|
|
105
|
+
rescue StandardError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def pending_score(detail)
|
|
110
|
+
RobotLab::To::Evals::Score.new(gate_ok: true, improved: false, met_target: false,
|
|
111
|
+
value: nil, detail: detail, output: nil)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# --- Phase 1 eval: grade the outline ---------------------------------------
|
|
115
|
+
|
|
116
|
+
class OutlineGrader < RobotLab::To::Evals::Base
|
|
117
|
+
def initialize(work_dir:)
|
|
118
|
+
super()
|
|
119
|
+
@work_dir = work_dir
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def score(_context)
|
|
123
|
+
outline = File.read(File.join(@work_dir, "outline.md")) rescue ""
|
|
124
|
+
return pending_score("no outline yet") if outline.strip.empty?
|
|
125
|
+
|
|
126
|
+
ready, feedback = grade(RobotLab.render_template(:outline_criteria, topic: TOPIC), outline)
|
|
127
|
+
write_review(@work_dir, ready ? "The outline is approved. (Phase 2 will use it.)" : feedback)
|
|
128
|
+
RobotLab::To::Evals::Score.new(
|
|
129
|
+
gate_ok: true, improved: true, met_target: ready, value: nil,
|
|
130
|
+
detail: ready ? "outline READY" : "NEEDS_WORK: #{feedback}", output: feedback
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# --- Phase 2 eval: grade each section, finish when all are written ----------
|
|
136
|
+
|
|
137
|
+
class SectionGrader < RobotLab::To::Evals::Base
|
|
138
|
+
def initialize(work_dir:, total:)
|
|
139
|
+
super()
|
|
140
|
+
@work_dir = work_dir
|
|
141
|
+
@total = total
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def score(_context)
|
|
145
|
+
files = section_files
|
|
146
|
+
return pending_score("no sections yet") if files.empty?
|
|
147
|
+
|
|
148
|
+
latest = files.last
|
|
149
|
+
body = File.read(latest) rescue ""
|
|
150
|
+
ready, feedback = grade(RobotLab.render_template(:section_criteria, topic: TOPIC), body)
|
|
151
|
+
done = files.size >= @total && ready
|
|
152
|
+
write_review(@work_dir, section_note(files.size, ready, feedback))
|
|
153
|
+
RobotLab::To::Evals::Score.new(
|
|
154
|
+
gate_ok: true, improved: true, met_target: done, value: nil,
|
|
155
|
+
detail: "sections #{files.size}/#{@total} latest=#{ready ? 'READY' : 'NEEDS_WORK'}",
|
|
156
|
+
output: feedback
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def section_files
|
|
163
|
+
Dir.glob(File.join(@work_dir, "sections", "*.md")).sort
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def section_note(count, ready, feedback)
|
|
167
|
+
return "Section #{count}/#{@total} needs work: #{feedback}" unless ready
|
|
168
|
+
return "Section #{count} looks good. Write the NEXT section (#{count + 1}/#{@total})." if count < @total
|
|
169
|
+
|
|
170
|
+
"All #{@total} sections are written and approved."
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# --- live feedback (robot_lab hook) ----------------------------------------
|
|
175
|
+
|
|
176
|
+
class FeedbackHook < RobotLab::Hook
|
|
177
|
+
class << self
|
|
178
|
+
def before_llm_generation(_ctx)
|
|
179
|
+
$stderr.puts " 🤔 thinking…"
|
|
180
|
+
rescue StandardError
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def before_tool_call(ctx)
|
|
185
|
+
args = ctx.tool_args || {}
|
|
186
|
+
target = tidy(args["path"] || args[:path] || args["command"] || args[:command])
|
|
187
|
+
$stderr.puts " 🔧 #{ctx.tool_name}: #{target}"
|
|
188
|
+
rescue StandardError
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def tidy(text)
|
|
193
|
+
text.to_s.lines.first.to_s.strip.gsub("#{Dir.pwd}/", "").slice(0, 90)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# --- sandbox ---------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def sh(*args, chdir:)
|
|
201
|
+
out, err, status = Open3.capture3(*args, chdir: chdir)
|
|
202
|
+
raise "command failed: #{args.join(' ')}\n#{err}" unless status.success?
|
|
203
|
+
|
|
204
|
+
out
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
SANDBOX_DIR = File.expand_path("project", __dir__)
|
|
208
|
+
RUN_DIR = File.expand_path(".robot_lab_to", __dir__)
|
|
209
|
+
|
|
210
|
+
def clean_slate!
|
|
211
|
+
[SANDBOX_DIR, RUN_DIR].each do |path|
|
|
212
|
+
next unless File.exist?(path)
|
|
213
|
+
|
|
214
|
+
puts "Cleaning leftover: #{path}"
|
|
215
|
+
FileUtils.rm_rf(path)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def make_sandbox
|
|
220
|
+
FileUtils.mkdir_p(SANDBOX_DIR)
|
|
221
|
+
sh("git", "init", "-q", chdir: SANDBOX_DIR)
|
|
222
|
+
sh("git", "config", "user.email", "example@example.com", chdir: SANDBOX_DIR)
|
|
223
|
+
sh("git", "config", "user.name", "robot_lab-to example", chdir: SANDBOX_DIR)
|
|
224
|
+
File.write(File.join(SANDBOX_DIR, ".gitignore"), "REVIEW.md\n") # judge feedback, not part of the work
|
|
225
|
+
File.write(File.join(SANDBOX_DIR, "README.md"), "# Prose demo\n\nBuilt in two graded phases; see the guide.\n")
|
|
226
|
+
sh("git", "add", "-A", chdir: SANDBOX_DIR)
|
|
227
|
+
sh("git", "commit", "-qm", "initial: empty sandbox", chdir: SANDBOX_DIR)
|
|
228
|
+
SANDBOX_DIR
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Count the numbered items in the approved outline — the number of sections.
|
|
232
|
+
def outline_section_count(dir)
|
|
233
|
+
File.read(File.join(dir, "outline.md")).scan(/^\s*\d+\.\s+\S/).size
|
|
234
|
+
rescue StandardError
|
|
235
|
+
0
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def assemble_guide(dir)
|
|
239
|
+
sections = Dir.glob(File.join(dir, "sections", "*.md")).sort
|
|
240
|
+
return if sections.empty?
|
|
241
|
+
|
|
242
|
+
body = sections.map { |f| File.read(f).strip }.join("\n\n")
|
|
243
|
+
File.write(File.join(dir, "guide.md"), "# An Opinionated Guide to #{TOPIC.capitalize}\n\n#{body}\n")
|
|
244
|
+
sh("git", "add", "-A", chdir: dir)
|
|
245
|
+
sh("git", "commit", "-qm", "assemble: guide.md from approved sections", chdir: dir)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# --- objectives ------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
OUTLINE_OBJECTIVE = RobotLab.render_template(:outline_objective, topic: TOPIC).strip
|
|
251
|
+
|
|
252
|
+
def sections_objective(total)
|
|
253
|
+
RobotLab.render_template(:sections_objective, total: total).strip
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# --- main ------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
if LOCAL
|
|
259
|
+
preflight_local!
|
|
260
|
+
configure_local!
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
clean_slate!
|
|
264
|
+
sandbox = make_sandbox
|
|
265
|
+
puts "Project dir: #{sandbox}"
|
|
266
|
+
puts "Doer model: #{DOER_MODEL}"
|
|
267
|
+
puts "Verifier model: #{JUDGE_MODEL}"
|
|
268
|
+
puts "Topic: #{TOPIC}"
|
|
269
|
+
puts
|
|
270
|
+
|
|
271
|
+
RobotLab.on(FeedbackHook)
|
|
272
|
+
|
|
273
|
+
common = {
|
|
274
|
+
provider: PROVIDER, model: DOER_MODEL, local_guards: LOCAL, stream: !LOCAL,
|
|
275
|
+
run_dir: RUN_DIR, write_guard: false, require_improvement: false
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
Dir.chdir(sandbox) do
|
|
279
|
+
puts "=== Phase 1: outline (graded until READY) ==="
|
|
280
|
+
RobotLab::To.run(OUTLINE_OBJECTIVE, **common, max_iterations: 6,
|
|
281
|
+
eval: OutlineGrader.new(work_dir: sandbox))
|
|
282
|
+
|
|
283
|
+
total = outline_section_count(sandbox)
|
|
284
|
+
total = 5 if total.zero? # fall back if the outline wasn't a clean numbered list
|
|
285
|
+
puts "\n=== Phase 2: #{total} sections (each graded) ==="
|
|
286
|
+
RobotLab::To.run(sections_objective(total), **common, max_iterations: total * 3 + 2,
|
|
287
|
+
eval: SectionGrader.new(work_dir: sandbox, total: total))
|
|
288
|
+
|
|
289
|
+
assemble_guide(sandbox)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# --- report ----------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
puts "\n=== Result ==="
|
|
295
|
+
puts "Commits:"; puts sh("git", "log", "--oneline", chdir: sandbox)
|
|
296
|
+
guide = File.join(sandbox, "guide.md")
|
|
297
|
+
if File.exist?(guide)
|
|
298
|
+
puts "\nguide.md (#{File.read(guide).lines.size} lines):"
|
|
299
|
+
puts File.read(guide)
|
|
300
|
+
else
|
|
301
|
+
puts "\n(guide.md not assembled — check outline.md and sections/ in #{sandbox})"
|
|
302
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
module To
|
|
7
|
+
# Crash-safe writes for run state (notes.md and friends).
|
|
8
|
+
#
|
|
9
|
+
# An overnight run rewrites its state every iteration; a crash or kill
|
|
10
|
+
# mid-write must not leave a truncated file (notes.md is re-injected into
|
|
11
|
+
# the next robot's prompt). The pattern: serialize writers with an flock,
|
|
12
|
+
# stage the full content in a sibling temp file, fsync it, atomically
|
|
13
|
+
# rename it over the target, then fsync the directory. A crash at any point
|
|
14
|
+
# leaves either the previous complete file or the new complete file --
|
|
15
|
+
# never a torn write.
|
|
16
|
+
module AtomicFile
|
|
17
|
+
class << self
|
|
18
|
+
# Atomically replace +path+ with +content+.
|
|
19
|
+
def write(path, content)
|
|
20
|
+
path = Pathname.new(path)
|
|
21
|
+
path.parent.mkpath
|
|
22
|
+
with_lock(path) { replace(path, content.to_s) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Atomically append +text+ to +path+ (read-modify-write under lock).
|
|
26
|
+
def append(path, text)
|
|
27
|
+
path = Pathname.new(path)
|
|
28
|
+
path.parent.mkpath
|
|
29
|
+
with_lock(path) do
|
|
30
|
+
existing = path.exist? ? path.read : +""
|
|
31
|
+
replace(path, existing + text.to_s)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def replace(path, content)
|
|
38
|
+
tmp = path.parent.join(".#{path.basename}.#{Process.pid}.tmp")
|
|
39
|
+
File.open(tmp, "w") do |f|
|
|
40
|
+
f.write(content)
|
|
41
|
+
f.flush
|
|
42
|
+
f.fsync
|
|
43
|
+
end
|
|
44
|
+
File.rename(tmp, path)
|
|
45
|
+
fsync_dir(path.parent)
|
|
46
|
+
ensure
|
|
47
|
+
# tmp is assigned on the first line, so it is always set here; on the
|
|
48
|
+
# success path the rename already moved it, so rm_f is a no-op.
|
|
49
|
+
FileUtils.rm_f(tmp)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def with_lock(path)
|
|
53
|
+
lock = path.parent.join(".#{path.basename}.lock")
|
|
54
|
+
File.open(lock, File::RDWR | File::CREAT, 0o600) do |f|
|
|
55
|
+
f.flock(File::LOCK_EX)
|
|
56
|
+
yield
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def fsync_dir(dir)
|
|
61
|
+
File.open(dir, &:fsync)
|
|
62
|
+
rescue SystemCallError
|
|
63
|
+
# Some filesystems/platforms reject directory fsync; best-effort.
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module To
|
|
5
|
+
# Exponential backoff with interruptible sleep.
|
|
6
|
+
#
|
|
7
|
+
# Formula: 60 * 2^(consecutive_errors - 1) seconds
|
|
8
|
+
# 1 error → 60s
|
|
9
|
+
# 2 errors → 120s
|
|
10
|
+
# 3 errors → 240s
|
|
11
|
+
class Backoff
|
|
12
|
+
BASE_SECONDS = 60
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@interrupted = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def sleep_for(consecutive_errors)
|
|
19
|
+
@interrupted = false
|
|
20
|
+
return if consecutive_errors <= 0
|
|
21
|
+
|
|
22
|
+
duration = (BASE_SECONDS * (2**(consecutive_errors - 1))).to_i
|
|
23
|
+
duration.times do
|
|
24
|
+
return if @interrupted
|
|
25
|
+
|
|
26
|
+
sleep(1)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Sleep a fixed number of seconds, interruptibly (1s ticks). Used for
|
|
31
|
+
# polling a blocking decision file. Does NOT reset @interrupted: once a
|
|
32
|
+
# signal fires, subsequent waits return immediately so shutdown is prompt.
|
|
33
|
+
def sleep_seconds(seconds)
|
|
34
|
+
seconds.to_i.times do
|
|
35
|
+
return if @interrupted
|
|
36
|
+
|
|
37
|
+
sleep(1)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def interrupt!
|
|
42
|
+
@interrupted = true
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
module To
|
|
7
|
+
# OptionParser-based CLI for robot-to.
|
|
8
|
+
class CLI
|
|
9
|
+
# Value options: [flag, type-or-choices, description, opts-key]. Each sets
|
|
10
|
+
# opts[key] = parsed value. Kept as data so build_parser stays simple.
|
|
11
|
+
VALUE_OPTIONS = [
|
|
12
|
+
["--provider NAME", String, "LLM provider to use (default: openai)", :provider],
|
|
13
|
+
["--model MODEL", String, "LLM model to use (default: gpt-5.5)", :model],
|
|
14
|
+
["--max-iterations N", Integer, "Stop after N iterations (default: unlimited)", :max_iterations],
|
|
15
|
+
["--max-tokens N", Integer, "Stop after N total tokens (default: unlimited)", :max_tokens],
|
|
16
|
+
["--stop-when CONDITION", String,
|
|
17
|
+
"Natural-language condition; robot sets should_fully_stop when met", :stop_when],
|
|
18
|
+
["--max-consecutive-failures N", Integer,
|
|
19
|
+
"Abort after N consecutive failures (default: 3)", :max_consecutive_failures],
|
|
20
|
+
["--max-verify-repairs N", Integer,
|
|
21
|
+
"Repair-in-place attempts when --verify-command fails (default: 2)", :max_verify_repairs],
|
|
22
|
+
["--verify-command CMD", String,
|
|
23
|
+
"Command that must pass before a successful iteration is committed", :verify_command],
|
|
24
|
+
["--verify-timeout SECONDS", Integer, "Timeout for --verify-command (default: 600)", :verify_timeout],
|
|
25
|
+
["--eval NAME", String, "Eval strategy: code | null (default: code when --verify-command set)", :eval],
|
|
26
|
+
["--measure CMD", String,
|
|
27
|
+
"Command printing a numeric score; higher is better (Evals::Code)", :eval_measure],
|
|
28
|
+
["--target FLOAT", Float, "Stop once the measured score reaches TARGET", :eval_target],
|
|
29
|
+
["--spec PATH", String, "Spec/outline artifact the eval measures against (Evals::Prose)", :eval_spec],
|
|
30
|
+
["--floor CMD", String,
|
|
31
|
+
"Mechanizable floor-check command for prose (links, coverage, AI-tells)", :eval_floor],
|
|
32
|
+
["--judge-model MODEL", String,
|
|
33
|
+
"Model for the pairwise prose judge (default: --model)", :eval_judge_model],
|
|
34
|
+
["--stop-on-plateau N", Integer,
|
|
35
|
+
"Stop after N iterations with no committed improvement", :stop_on_plateau],
|
|
36
|
+
["--run-dir PATH", String, "Directory for run state (default: .robot_lab_to)", :run_dir],
|
|
37
|
+
["--commit-format FORMAT", %w[default conventional],
|
|
38
|
+
"Commit message format: default or conventional", :commit_format],
|
|
39
|
+
["--resume RUN_ID", String,
|
|
40
|
+
"Resume a paused run (loads objective + state from its run.json)", :resume],
|
|
41
|
+
["--decision-mode MODE", %w[wait exit],
|
|
42
|
+
"On a blocking decision: wait (poll) or exit (stop for --resume). Default: wait", :decision_mode],
|
|
43
|
+
["--decision-timeout SECONDS", Integer,
|
|
44
|
+
"Max seconds to wait on a blocking decision (default: no limit)", :decision_timeout],
|
|
45
|
+
["--decision-poll SECONDS", Integer,
|
|
46
|
+
"Seconds between polls while waiting on a decision (default: 30)", :decision_wait_poll]
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
def self.run(argv = ARGV)
|
|
50
|
+
new.run(argv)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def run(argv)
|
|
54
|
+
return run_decisions(argv[1..] || []) if argv.first == "decisions"
|
|
55
|
+
|
|
56
|
+
opts = {}
|
|
57
|
+
parser = build_parser(opts)
|
|
58
|
+
args = parser.parse!(argv.dup)
|
|
59
|
+
|
|
60
|
+
if opts[:version]
|
|
61
|
+
puts "robot-to #{VERSION}"
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if opts[:resume]
|
|
66
|
+
return RobotLab::To.resume(opts[:resume], **opts.except(:version, :resume))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
objective = args.first || read_stdin_objective
|
|
70
|
+
if objective.nil? || objective.strip.empty?
|
|
71
|
+
# $stderr.puts, not warn: warn is silenced when $VERBOSE is nil.
|
|
72
|
+
$stderr.puts "Error: objective required (pass as argument or via stdin)"
|
|
73
|
+
$stderr.puts parser
|
|
74
|
+
exit 1
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
RobotLab::To.run(objective.strip, **opts.except(:version, :resume))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# `robot-to decisions [run_id]` — list pending decisions and their file
|
|
83
|
+
# paths so a human knows what needs resolving before resuming.
|
|
84
|
+
def run_decisions(args)
|
|
85
|
+
run_dir = Config.new.run_dir
|
|
86
|
+
run_id = args.first || latest_run_id(run_dir)
|
|
87
|
+
if run_id.nil?
|
|
88
|
+
$stderr.puts "No runs found under #{run_dir}/runs"
|
|
89
|
+
exit 1
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
manager = DecisionManager.new(Pathname.new(run_dir).join("runs", run_id, "decisions"))
|
|
93
|
+
print_decisions(run_id, manager)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def print_decisions(run_id, manager)
|
|
97
|
+
pending = manager.pending
|
|
98
|
+
resolved = manager.resolved_open
|
|
99
|
+
puts "Run #{run_id}"
|
|
100
|
+
puts ""
|
|
101
|
+
if pending.empty? && resolved.empty?
|
|
102
|
+
puts "No open decisions."
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
list_group("Pending (awaiting your answer)", pending)
|
|
106
|
+
list_group("Resolved (not yet consumed)", resolved)
|
|
107
|
+
puts ""
|
|
108
|
+
puts "Resolve a pending decision by editing its file: set `status: resolved`"
|
|
109
|
+
puts "and fill `resolution:`, then run `robot-to --resume #{run_id}`."
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def list_group(title, decisions)
|
|
113
|
+
return if decisions.empty?
|
|
114
|
+
|
|
115
|
+
puts "#{title}:"
|
|
116
|
+
decisions.each do |d|
|
|
117
|
+
flag = d.blocking? ? " [BLOCKING]" : ""
|
|
118
|
+
puts " - #{d.question}#{flag}"
|
|
119
|
+
puts " #{d.path}"
|
|
120
|
+
end
|
|
121
|
+
puts ""
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def latest_run_id(run_dir)
|
|
125
|
+
Dir.glob(Pathname.new(run_dir).join("runs", "*")).select { |p| File.directory?(p) }
|
|
126
|
+
.sort.map { |p| File.basename(p) }.last
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def build_parser(opts)
|
|
130
|
+
OptionParser.new do |p|
|
|
131
|
+
p.banner = "Usage: robot-to [objective] [options]\n " \
|
|
132
|
+
"robot-to --resume RUN_ID [options]\n " \
|
|
133
|
+
"robot-to decisions [RUN_ID]"
|
|
134
|
+
p.separator ""
|
|
135
|
+
p.separator "Runs a robot_lab Robot in an autonomous loop toward the given objective."
|
|
136
|
+
p.separator ""
|
|
137
|
+
p.separator "Options:"
|
|
138
|
+
|
|
139
|
+
VALUE_OPTIONS.each do |flag, type, desc, key|
|
|
140
|
+
p.on(flag, type, desc) { |v| opts[key] = v }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
add_flag_options(p, opts)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Boolean and terminal flags (each has bespoke behavior).
|
|
148
|
+
def add_flag_options(parser, opts)
|
|
149
|
+
parser.on("--[no-]require-improvement",
|
|
150
|
+
"Roll back gate-passing iterations that don't improve (default: on)") do |v|
|
|
151
|
+
opts[:require_improvement] = v
|
|
152
|
+
end
|
|
153
|
+
parser.on("--protect-path GLOB", "Lock a grader file from robot edits (repeatable)") do |v|
|
|
154
|
+
(opts[:protect_paths] ||= []) << v
|
|
155
|
+
end
|
|
156
|
+
parser.on("--no-decisions", "Disable the request_decision tool for this run") { opts[:decisions_enabled] = false }
|
|
157
|
+
parser.on("--local-guards", "Add built-in file tools + small-model guardrails (for local models)") do
|
|
158
|
+
opts[:local_guards] = true
|
|
159
|
+
end
|
|
160
|
+
parser.on("--no-stream", "Disable response streaming (required for local Ollama tool calls)") { opts[:stream] = false }
|
|
161
|
+
parser.on("--debug", "Enable verbose JSONL logging to stderr") { opts[:debug] = true }
|
|
162
|
+
parser.on("--version", "Print version and exit") { opts[:version] = true }
|
|
163
|
+
parser.on("-h", "--help", "Show this help") do
|
|
164
|
+
puts parser
|
|
165
|
+
exit
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def read_stdin_objective
|
|
170
|
+
return nil if $stdin.tty?
|
|
171
|
+
|
|
172
|
+
$stdin.read.strip.then { |s| s.empty? ? nil : s }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|