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,642 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RobotLab
|
|
7
|
+
module To
|
|
8
|
+
# Main autonomous loop.
|
|
9
|
+
#
|
|
10
|
+
# Creates a new robot per iteration, reads the iteration result from SubmitResultTool,
|
|
11
|
+
# then commits, rolls back, or queues repair depending on the outcome.
|
|
12
|
+
#
|
|
13
|
+
# Human-in-the-loop is asynchronous: when the robot raises a decision it must
|
|
14
|
+
# not make alone, it is written to a decision file (see DecisionManager). A
|
|
15
|
+
# blocking decision either pauses the loop (decision_mode: wait) or stops the
|
|
16
|
+
# run for a later `--resume` (decision_mode: exit).
|
|
17
|
+
class Orchestrator
|
|
18
|
+
SIGNAL_STOP = "graceful_stop"
|
|
19
|
+
|
|
20
|
+
# Sent to a robot that ended its turn without calling submit_result
|
|
21
|
+
# (the "thinks but doesn't act" degenerate case).
|
|
22
|
+
SUBMIT_NUDGE = <<~MSG
|
|
23
|
+
You ended your turn without calling submit_result. You MUST call it now:
|
|
24
|
+
report success: true with a one-sentence summary (and key_changes if you
|
|
25
|
+
modified files), or success: false with what blocked you. Do not do any
|
|
26
|
+
further work first -- just submit the result of what you have done.
|
|
27
|
+
MSG
|
|
28
|
+
|
|
29
|
+
def initialize(objective, config, resume_run_id: nil)
|
|
30
|
+
@objective = objective
|
|
31
|
+
@config = config
|
|
32
|
+
@resume_run_id = resume_run_id
|
|
33
|
+
@stop_requested = false
|
|
34
|
+
@abort_reason = nil
|
|
35
|
+
@pending_commit_failure = nil
|
|
36
|
+
@logger = JsonlLogger.new # buffers pre-open events
|
|
37
|
+
@run = nil
|
|
38
|
+
@git = nil
|
|
39
|
+
@runner_thread = nil
|
|
40
|
+
@evaluator = nil
|
|
41
|
+
@robot = nil
|
|
42
|
+
@submit_tool = nil
|
|
43
|
+
@decisions = nil
|
|
44
|
+
@decision_tool = nil
|
|
45
|
+
@injected_decisions = []
|
|
46
|
+
@accounted_in = 0
|
|
47
|
+
@accounted_out = 0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def run
|
|
51
|
+
@resume_run_id ? setup_resume : setup_run
|
|
52
|
+
install_signal_handlers
|
|
53
|
+
unless @resume_run_id
|
|
54
|
+
@logger.log("orchestrator:start", run_id: @run.run_id, objective: @objective,
|
|
55
|
+
branch: @run.branch, model: @config.model)
|
|
56
|
+
progress "run #{@run.run_id} → branch #{@run.branch}"
|
|
57
|
+
end
|
|
58
|
+
main_loop
|
|
59
|
+
rescue AbortError => e
|
|
60
|
+
@abort_reason = e.reason
|
|
61
|
+
@logger.log("orchestrator:abort", reason: @abort_reason)
|
|
62
|
+
rescue PermanentError => e
|
|
63
|
+
@abort_reason = "permanent error: #{e.message}"
|
|
64
|
+
@git&.reset_hard unless @pending_commit_failure
|
|
65
|
+
@logger.log("orchestrator:abort", reason: @abort_reason, permanent: true)
|
|
66
|
+
rescue => e
|
|
67
|
+
@abort_reason = "fatal: #{e.message}"
|
|
68
|
+
@git&.reset_hard unless @pending_commit_failure
|
|
69
|
+
@logger.log("orchestrator:fatal", error: e.class.to_s, message: e.message)
|
|
70
|
+
ensure
|
|
71
|
+
finalize_run
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def main_loop
|
|
77
|
+
loop do
|
|
78
|
+
break if @stop_requested
|
|
79
|
+
|
|
80
|
+
handle_blocking_decisions
|
|
81
|
+
break if @stop_requested
|
|
82
|
+
|
|
83
|
+
@stop_conditions.before?
|
|
84
|
+
|
|
85
|
+
@run.iteration += 1
|
|
86
|
+
@logger.log("iteration:start", iteration: @run.iteration)
|
|
87
|
+
progress "iteration #{@run.iteration} (#{@config.model})..."
|
|
88
|
+
|
|
89
|
+
result = execute_iteration
|
|
90
|
+
|
|
91
|
+
if result.success?
|
|
92
|
+
handle_success(result)
|
|
93
|
+
else
|
|
94
|
+
handle_failure(result)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
persist_run_state
|
|
98
|
+
@stop_conditions.after?
|
|
99
|
+
break if @stop_requested
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def finalize_run
|
|
104
|
+
if @run
|
|
105
|
+
@logger.log("orchestrator:end",
|
|
106
|
+
iterations: @run.iteration, commits: @run.commits,
|
|
107
|
+
input_tokens: @run.input_tokens, output_tokens: @run.output_tokens,
|
|
108
|
+
abort_reason: @abort_reason, elapsed: @run.elapsed_seconds.round(1))
|
|
109
|
+
@logger.close
|
|
110
|
+
ExitSummary.new(@run, @config, abort_reason: @abort_reason).print
|
|
111
|
+
else
|
|
112
|
+
@logger.close
|
|
113
|
+
progress(@abort_reason) if @abort_reason
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def setup_run
|
|
118
|
+
@git = CommitManager.new
|
|
119
|
+
ensure_head!
|
|
120
|
+
|
|
121
|
+
run_id = Run.generate_id
|
|
122
|
+
branch = Run.branch_name(@objective)
|
|
123
|
+
run_dir = Pathname.new(@config.run_dir).join("runs", run_id)
|
|
124
|
+
|
|
125
|
+
@git.create_branch(branch)
|
|
126
|
+
base_commit = @git.head_sha
|
|
127
|
+
@git.add_to_local_exclude(@config.run_dir)
|
|
128
|
+
|
|
129
|
+
run_dir.mkpath
|
|
130
|
+
|
|
131
|
+
@run = Run.new(run_id: run_id, objective: @objective, branch: branch,
|
|
132
|
+
base_commit: base_commit, notes_path: run_dir.join("notes.md"),
|
|
133
|
+
log_path: run_dir.join("run.log"), run_dir: run_dir)
|
|
134
|
+
|
|
135
|
+
@logger.open(@run.log_path)
|
|
136
|
+
|
|
137
|
+
@notes = NotesManager.new(@run.notes_path)
|
|
138
|
+
@notes.setup(@run)
|
|
139
|
+
|
|
140
|
+
build_collaborators
|
|
141
|
+
persist_run_state
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Resume a prior run from its run.json — enables external scheduling (cron):
|
|
145
|
+
# each tick re-enters the loop on the same branch and advances or re-pauses.
|
|
146
|
+
def setup_resume
|
|
147
|
+
@git = CommitManager.new
|
|
148
|
+
ensure_head!
|
|
149
|
+
|
|
150
|
+
state_path = Pathname.new(@config.run_dir).join("runs", @resume_run_id, "run.json")
|
|
151
|
+
raise AbortError, "no resumable run at #{state_path}" unless state_path.exist?
|
|
152
|
+
|
|
153
|
+
@run = Run.load(state_path)
|
|
154
|
+
@objective = @run.objective
|
|
155
|
+
@git.checkout_branch(@run.branch)
|
|
156
|
+
|
|
157
|
+
@logger.open(@run.log_path)
|
|
158
|
+
@notes = NotesManager.new(@run.notes_path) # append — do NOT re-setup (would clobber history)
|
|
159
|
+
|
|
160
|
+
build_collaborators
|
|
161
|
+
|
|
162
|
+
@logger.log("orchestrator:resume", run_id: @run.run_id, iteration: @run.iteration,
|
|
163
|
+
branch: @run.branch, model: @config.model)
|
|
164
|
+
progress "resumed run #{@run.run_id} at iteration #{@run.iteration} → branch #{@run.branch}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Shared setup used by both a fresh run and a resume.
|
|
168
|
+
def build_collaborators
|
|
169
|
+
@builder = PromptBuilder.new(@config)
|
|
170
|
+
@backoff = Backoff.new
|
|
171
|
+
@stop_conditions = StopConditions.new(@config, @run)
|
|
172
|
+
@evaluator = Evals.build(@config, git: @git)
|
|
173
|
+
@decisions = DecisionManager.new(@run.decisions_path)
|
|
174
|
+
@decisions.setup
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def ensure_head!
|
|
178
|
+
return if @git.head_exists?
|
|
179
|
+
|
|
180
|
+
raise AbortError, "No commits found. robot-to requires at least one commit. " \
|
|
181
|
+
"Run: git commit --allow-empty -m 'initial'"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# If a blocking decision is unresolved, either wait for a human (wait mode)
|
|
185
|
+
# or stop the run so it can be resumed later (exit mode).
|
|
186
|
+
def handle_blocking_decisions
|
|
187
|
+
return unless @config.decisions_enabled?
|
|
188
|
+
return unless @decisions&.blocking_pending?
|
|
189
|
+
|
|
190
|
+
pending = @decisions.blocking_pending
|
|
191
|
+
@logger.log("decision:blocking", count: pending.size, mode: @config.decision_mode)
|
|
192
|
+
|
|
193
|
+
if @config.decision_mode.to_s == "exit"
|
|
194
|
+
persist_run_state
|
|
195
|
+
paths = pending.map(&:path).join("\n ")
|
|
196
|
+
raise AbortError, "awaiting human decision — resolve then resume:\n #{paths}\n " \
|
|
197
|
+
"robot-to --resume #{@run.run_id}"
|
|
198
|
+
else
|
|
199
|
+
wait_for_decisions(pending)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Poll the decision files until every blocking decision is resolved, the run
|
|
204
|
+
# is signaled to stop, or decision_timeout elapses.
|
|
205
|
+
def wait_for_decisions(pending)
|
|
206
|
+
progress "awaiting human decision on #{pending.size} item(s):"
|
|
207
|
+
pending.each { |d| progress " #{d.path}" }
|
|
208
|
+
@logger.log("decision:wait:start", count: pending.size)
|
|
209
|
+
deadline = @config.decision_timeout ? monotonic + @config.decision_timeout.to_i : nil
|
|
210
|
+
|
|
211
|
+
loop do
|
|
212
|
+
return if @stop_requested
|
|
213
|
+
|
|
214
|
+
@backoff.sleep_seconds(@config.decision_wait_poll)
|
|
215
|
+
return if @stop_requested
|
|
216
|
+
break unless @decisions.blocking_pending?
|
|
217
|
+
|
|
218
|
+
if deadline && monotonic >= deadline
|
|
219
|
+
raise AbortError, "awaiting human decision (timed out after #{@config.decision_timeout}s)"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
@logger.log("decision:wait:resolved")
|
|
224
|
+
progress "decision resolved — resuming"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def execute_iteration
|
|
228
|
+
run_agent_iteration
|
|
229
|
+
rescue PermanentError
|
|
230
|
+
raise
|
|
231
|
+
rescue => e
|
|
232
|
+
raise PermanentError, "API authentication failed: #{e.message}" if auth_error?(e)
|
|
233
|
+
|
|
234
|
+
record_iteration_error(e)
|
|
235
|
+
retry if retry_after_backoff?
|
|
236
|
+
raise
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# The happy path: build a fresh robot, run it, and return its submitted
|
|
240
|
+
# result (or a not-submitted marker).
|
|
241
|
+
def run_agent_iteration
|
|
242
|
+
@accounted_in = @accounted_out = 0
|
|
243
|
+
@submit_tool = Tools::SubmitResult.new
|
|
244
|
+
@decision_tool = @config.decisions_enabled? ? Tools::RequestDecision.new : nil
|
|
245
|
+
@injected_decisions = @config.decisions_enabled? ? @decisions.resolved_open : []
|
|
246
|
+
|
|
247
|
+
prompt = @builder.build(@run, @notes.read, workspace: workspace_digest,
|
|
248
|
+
pending_commit_failure: @pending_commit_failure,
|
|
249
|
+
resolved_decisions: @injected_decisions)
|
|
250
|
+
@robot = build_robot(@submit_tool, prompt)
|
|
251
|
+
|
|
252
|
+
@logger.log("agent:run:start", iteration: @run.iteration)
|
|
253
|
+
run_robot_with_interrupt(@robot)
|
|
254
|
+
@logger.log("agent:run:end", iteration: @run.iteration)
|
|
255
|
+
|
|
256
|
+
nudge_for_missing_submit(@robot, @submit_tool) if @submit_tool.captured_result.nil?
|
|
257
|
+
|
|
258
|
+
persist_raised_decisions
|
|
259
|
+
@submit_tool.captured_result || IterationResult.not_submitted
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Write each decision the robot raised this iteration to its own file and
|
|
263
|
+
# record it in notes.md. Blocking ones are caught at the top of the next
|
|
264
|
+
# loop pass by handle_blocking_decisions.
|
|
265
|
+
def persist_raised_decisions
|
|
266
|
+
return unless @decision_tool
|
|
267
|
+
|
|
268
|
+
@decision_tool.captured_requests.each do |req|
|
|
269
|
+
decision = @decisions.record(**req, iteration: @run.iteration)
|
|
270
|
+
@notes.append_decision(decision, @run.iteration)
|
|
271
|
+
@logger.log("decision:raised", id: decision.id, blocking: decision.blocking,
|
|
272
|
+
question: decision.question)
|
|
273
|
+
progress "iteration #{@run.iteration} raised decision: #{decision.question}"
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# R3: tracked files give the robot the project layout up front, so it does
|
|
278
|
+
# not re-explore the workspace every iteration. nil when unavailable.
|
|
279
|
+
def workspace_digest
|
|
280
|
+
files = @git.tracked_files
|
|
281
|
+
files.empty? ? nil : files
|
|
282
|
+
rescue StandardError
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Roll back the working tree, record the failure in notes, and bump the
|
|
287
|
+
# consecutive failure/error counters.
|
|
288
|
+
def record_iteration_error(e)
|
|
289
|
+
@logger.log("agent:run:error", iteration: @run.iteration,
|
|
290
|
+
error: e.class.to_s, message: e.message)
|
|
291
|
+
@git.reset_hard unless @pending_commit_failure
|
|
292
|
+
@notes.append_error(e, @run.iteration)
|
|
293
|
+
@run.consecutive_failures += 1
|
|
294
|
+
@run.consecutive_errors += 1
|
|
295
|
+
@run.iterations_since_improvement += 1
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# True when the iteration should be retried — within the retry budget and
|
|
299
|
+
# not stopping. Sleeps out the backoff as a side effect before returning.
|
|
300
|
+
def retry_after_backoff?
|
|
301
|
+
return false unless @run.consecutive_errors <= @config.max_retries && !@stop_requested
|
|
302
|
+
|
|
303
|
+
@logger.log("backoff:start", consecutive_errors: @run.consecutive_errors)
|
|
304
|
+
@backoff.sleep_for(@run.consecutive_errors)
|
|
305
|
+
@logger.log("backoff:end")
|
|
306
|
+
true
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# A robot that ends its turn without calling submit_result is the
|
|
310
|
+
# "thinks but doesn't act" degenerate case. Rather than burning the
|
|
311
|
+
# iteration as a failure, re-prompt the same robot (preserving its chat
|
|
312
|
+
# context) up to max_submit_nudges times to call submit_result.
|
|
313
|
+
def nudge_for_missing_submit(robot, tool)
|
|
314
|
+
@config.max_submit_nudges.times do |i|
|
|
315
|
+
break if @stop_requested
|
|
316
|
+
|
|
317
|
+
attempt = i + 1
|
|
318
|
+
@logger.log("agent:nudge", iteration: @run.iteration, attempt: attempt)
|
|
319
|
+
progress "iteration #{@run.iteration}: no result submitted — " \
|
|
320
|
+
"nudging (#{attempt}/#{@config.max_submit_nudges})"
|
|
321
|
+
run_robot_with_interrupt(robot, SUBMIT_NUDGE)
|
|
322
|
+
break if tool.captured_result
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def handle_success(result)
|
|
327
|
+
score = evaluate
|
|
328
|
+
result, score = repair_until_gate(result, score) unless score.gate_ok?
|
|
329
|
+
return handle_gate_failure(result, score) unless score.gate_ok?
|
|
330
|
+
return handle_no_improvement(result, score) if reject_no_improvement?(score)
|
|
331
|
+
|
|
332
|
+
commit_improvement(result, score)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# A change that passes the gate but does not beat the parent is discarded so
|
|
336
|
+
# the branch descends monotonically. Disabled by --no-require-improvement.
|
|
337
|
+
def reject_no_improvement?(score)
|
|
338
|
+
@config.require_improvement? && !score.improved?
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# The commit path: record the win, then let the eval (not the robot) decide
|
|
342
|
+
# whether the objective is met.
|
|
343
|
+
def commit_improvement(result, score)
|
|
344
|
+
@notes.append_success(result, @run.iteration)
|
|
345
|
+
attempt_commit(result)
|
|
346
|
+
record_improvement(score)
|
|
347
|
+
close_injected_decisions
|
|
348
|
+
stop_if_target_met(score, result)
|
|
349
|
+
nil
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def record_improvement(score)
|
|
353
|
+
@run.last_score_value = score.value unless score.value.nil?
|
|
354
|
+
@run.iterations_since_improvement = 0
|
|
355
|
+
@run.consecutive_failures = 0
|
|
356
|
+
@run.consecutive_errors = 0
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Orchestrator-owned stop: the eval's measurable target supersedes the
|
|
360
|
+
# robot's self-reported should_fully_stop; --stop-when remains as a fallback.
|
|
361
|
+
def stop_if_target_met(score, result)
|
|
362
|
+
raise AbortError, "target met: #{score.detail}" if score.met_target?
|
|
363
|
+
|
|
364
|
+
@stop_conditions.stop_when_met?(result)
|
|
365
|
+
nil
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def handle_no_improvement(result, score)
|
|
369
|
+
iteration = @run.iteration
|
|
370
|
+
@git.reset_hard unless @pending_commit_failure
|
|
371
|
+
@notes.append_no_improvement(result, score.detail, iteration)
|
|
372
|
+
# A non-improving iteration is valid work that just wasn't better -- NOT a
|
|
373
|
+
# failure. It counts toward the plateau (diminishing-returns) stop only, so
|
|
374
|
+
# it must NOT trip max_consecutive_failures, which is for the robot being
|
|
375
|
+
# genuinely broken (errors, gate failures, no submit). Conflating the two
|
|
376
|
+
# throttles legitimate exploration -- especially for prose, where "same"
|
|
377
|
+
# verdicts are common and normal.
|
|
378
|
+
@run.iterations_since_improvement += 1
|
|
379
|
+
@run.consecutive_errors = 0
|
|
380
|
+
@logger.log("iteration:no_improvement", iteration: iteration, detail: score.detail)
|
|
381
|
+
progress "iteration #{iteration} no improvement (#{score.detail}) — rolled back"
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# A resolved decision that was injected into this successful iteration has
|
|
385
|
+
# now been acted on and committed, so close it — it should not be re-fed to
|
|
386
|
+
# future iterations. Failed iterations leave it open for the next pass.
|
|
387
|
+
def close_injected_decisions
|
|
388
|
+
return if @injected_decisions.nil? || @injected_decisions.empty?
|
|
389
|
+
|
|
390
|
+
@injected_decisions.each { |d| @decisions.close(d) }
|
|
391
|
+
@injected_decisions = []
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Independent gate: the robot claimed success, but the deciding authority is
|
|
395
|
+
# the configured Eval, not the robot. Always returns a Score (never nil) --
|
|
396
|
+
# Evals::Null (the default) reports gate_ok, so behavior matches "no gate".
|
|
397
|
+
def evaluate
|
|
398
|
+
ctx = Evals::Context.new(work_dir: Dir.pwd, previous_ref: @git.head_sha,
|
|
399
|
+
previous_value: @run.last_score_value,
|
|
400
|
+
objective: @objective, iteration: @run.iteration)
|
|
401
|
+
score = @evaluator.score(ctx)
|
|
402
|
+
@logger.log("eval", iteration: @run.iteration, gate: score.gate_ok,
|
|
403
|
+
improved: score.improved, value: score.value,
|
|
404
|
+
met_target: score.met_target, detail: score.detail)
|
|
405
|
+
score
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def handle_gate_failure(result, score)
|
|
409
|
+
@git.reset_hard unless @pending_commit_failure
|
|
410
|
+
@notes.append_verify_failure(result, score.output.to_s, @run.iteration)
|
|
411
|
+
@run.consecutive_failures += 1
|
|
412
|
+
@run.consecutive_errors = 0
|
|
413
|
+
@run.iterations_since_improvement += 1
|
|
414
|
+
@logger.log("iteration:gate_failure", iteration: @run.iteration)
|
|
415
|
+
progress "iteration #{@run.iteration} verification failed — rolled back"
|
|
416
|
+
nil
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# R2: when the gate fails on a claimed success, hand the failure output back
|
|
420
|
+
# to the SAME robot (its chat + the working tree are intact) and let it fix
|
|
421
|
+
# the code, re-verifying after each attempt. Only fall back to a full
|
|
422
|
+
# rollback once the repair budget is spent — a near-miss no longer discards
|
|
423
|
+
# all the work, and nothing commits until the gate is green.
|
|
424
|
+
def repair_until_gate(result, score)
|
|
425
|
+
budget = @config.max_verify_repairs.to_i
|
|
426
|
+
return [result, score] if budget.zero? || @robot.nil?
|
|
427
|
+
|
|
428
|
+
budget.times do |i|
|
|
429
|
+
break if @stop_requested
|
|
430
|
+
|
|
431
|
+
@logger.log("eval:repair", iteration: @run.iteration, attempt: i + 1)
|
|
432
|
+
progress "iteration #{@run.iteration}: verification failed — repairing (#{i + 1}/#{budget})"
|
|
433
|
+
begin
|
|
434
|
+
run_robot_with_interrupt(@robot, repair_prompt(score))
|
|
435
|
+
rescue StandardError => e
|
|
436
|
+
@logger.log("eval:repair_error", iteration: @run.iteration, message: e.message)
|
|
437
|
+
break
|
|
438
|
+
end
|
|
439
|
+
result = @submit_tool.captured_result || result
|
|
440
|
+
score = evaluate
|
|
441
|
+
gate_ok = score.gate_ok?
|
|
442
|
+
break if gate_ok
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
[result, score]
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def repair_prompt(score)
|
|
449
|
+
gate = @config.verify_command.to_s.strip
|
|
450
|
+
target = gate.empty? ? "the verification gate" : "`#{gate}`"
|
|
451
|
+
<<~MSG
|
|
452
|
+
Your changes did NOT pass verification. Running #{target} failed:
|
|
453
|
+
|
|
454
|
+
#{score.output}
|
|
455
|
+
|
|
456
|
+
Fix the code so it passes, then call submit_iteration_result again.
|
|
457
|
+
Build on the work already in the project — do not start over.
|
|
458
|
+
MSG
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def handle_failure(result)
|
|
462
|
+
@git.reset_hard unless @pending_commit_failure
|
|
463
|
+
@notes.append_failure(result, @run.iteration)
|
|
464
|
+
@run.consecutive_failures += 1
|
|
465
|
+
@run.consecutive_errors = 0
|
|
466
|
+
@run.iterations_since_improvement += 1
|
|
467
|
+
@logger.log("iteration:failure", iteration: @run.iteration, summary: result.summary)
|
|
468
|
+
progress "iteration #{@run.iteration} failed: #{result.summary}"
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def attempt_commit(result)
|
|
472
|
+
@git.add_all
|
|
473
|
+
return unless @git.staged?
|
|
474
|
+
|
|
475
|
+
message = commit_message(result)
|
|
476
|
+
@git.commit(message)
|
|
477
|
+
@run.commits += 1
|
|
478
|
+
@pending_commit_failure = nil
|
|
479
|
+
@logger.log("commit:success", iteration: @run.iteration, message: message)
|
|
480
|
+
progress "iteration #{@run.iteration} committed: #{message}"
|
|
481
|
+
rescue CommitFailedError => e
|
|
482
|
+
@pending_commit_failure = e
|
|
483
|
+
@run.consecutive_failures += 1
|
|
484
|
+
@run.consecutive_errors = 0
|
|
485
|
+
@logger.log("commit:failed", iteration: @run.iteration, output: e.output)
|
|
486
|
+
progress "iteration #{@run.iteration} commit failed — queued for repair"
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def commit_message(result)
|
|
490
|
+
if @config.commit_format == "conventional"
|
|
491
|
+
type = result.respond_to?(:type) ? (result.type || "chore") : "chore"
|
|
492
|
+
scope = result.respond_to?(:scope) ? result.scope : nil
|
|
493
|
+
tag = scope ? "#{type}(#{scope})" : type
|
|
494
|
+
"#{tag}: #{result.summary}"
|
|
495
|
+
else
|
|
496
|
+
"robot-to #{@run.iteration}: #{result.summary}"
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Persist run state so an external scheduler can --resume this run.
|
|
501
|
+
def persist_run_state
|
|
502
|
+
return unless @run
|
|
503
|
+
|
|
504
|
+
AtomicFile.write(@run.state_path, JSON.pretty_generate(@run.to_h))
|
|
505
|
+
rescue SystemCallError => e
|
|
506
|
+
@logger.log("run_state:write_failed", message: e.message)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def build_robot(submit_tool, system_prompt)
|
|
510
|
+
robot = RobotLab.build(
|
|
511
|
+
name: "robot-to-#{@run.run_id}-#{@run.iteration}",
|
|
512
|
+
system_prompt: system_prompt,
|
|
513
|
+
local_tools: iteration_tools(submit_tool),
|
|
514
|
+
provider: @config.provider&.to_sym,
|
|
515
|
+
model: @config.model,
|
|
516
|
+
max_tool_rounds: @config.max_tool_rounds,
|
|
517
|
+
on_content: (@config.stream? ? token_tracker : nil)
|
|
518
|
+
)
|
|
519
|
+
Guards.install(robot, run: @run, except: guard_exclusions) if @config.local_guards?
|
|
520
|
+
install_grader_lock(robot)
|
|
521
|
+
robot
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Always-on (independent of --local-guards): lock the eval's grader
|
|
525
|
+
# artifacts so the robot cannot edit the criteria it is scored against.
|
|
526
|
+
def install_grader_lock(robot)
|
|
527
|
+
paths = grader_lock_paths
|
|
528
|
+
robot.on(Guards::GraderLock, context: { paths: paths }) unless paths.empty?
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def grader_lock_paths
|
|
532
|
+
(Array(@evaluator&.protected_paths) + Array(@config.protect_paths))
|
|
533
|
+
.flat_map { |glob| Dir.glob(glob) }
|
|
534
|
+
.map { |path| File.expand_path(path) }
|
|
535
|
+
.uniq
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Guards to skip when installing the local-guards set. Dropping WriteGuard
|
|
539
|
+
# lets the robot overwrite files it is iteratively refining.
|
|
540
|
+
def guard_exclusions
|
|
541
|
+
@config.write_guard ? [] : [Guards::WriteGuard]
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Submit tool is always first (callers read tools.first). The decision tool
|
|
545
|
+
# follows when decisions are enabled. When local_guards is on, the robot
|
|
546
|
+
# also gets the built-in workspace tools the guards protect.
|
|
547
|
+
def iteration_tools(submit_tool)
|
|
548
|
+
tools = [submit_tool]
|
|
549
|
+
tools << @decision_tool if @decision_tool
|
|
550
|
+
return tools unless @config.local_guards?
|
|
551
|
+
|
|
552
|
+
tools + [Tools::Read.new, Tools::Write.new, Tools::Edit.new, Tools::Bash.new]
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def token_tracker
|
|
556
|
+
lambda do |chunk|
|
|
557
|
+
@run.input_tokens += chunk.input_tokens.to_i
|
|
558
|
+
@run.output_tokens += chunk.output_tokens.to_i
|
|
559
|
+
|
|
560
|
+
return unless @stop_conditions.token_limit_exceeded?
|
|
561
|
+
|
|
562
|
+
@stop_requested = true
|
|
563
|
+
@backoff.interrupt!
|
|
564
|
+
@runner_thread&.raise(Interrupt)
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def run_robot_with_interrupt(robot, task = @objective)
|
|
569
|
+
thread_error = nil
|
|
570
|
+
result = nil
|
|
571
|
+
@runner_thread = Thread.new do
|
|
572
|
+
# tools: :inherit is REQUIRED — RobotLab::Robot#run defaults to
|
|
573
|
+
# tools: :none (its prompt-driven tool selector sends nothing unless
|
|
574
|
+
# told). Without this the robot receives zero tools and can never call
|
|
575
|
+
# submit_iteration_result / read / write, so every iteration fails.
|
|
576
|
+
result = robot.run(task, tools: :inherit)
|
|
577
|
+
rescue Interrupt
|
|
578
|
+
# killed by signal handler — main loop checks @stop_requested
|
|
579
|
+
rescue => e
|
|
580
|
+
thread_error = e
|
|
581
|
+
end
|
|
582
|
+
@runner_thread.join
|
|
583
|
+
@runner_thread = nil
|
|
584
|
+
raise thread_error if thread_error
|
|
585
|
+
|
|
586
|
+
# Streaming runs account tokens per-chunk via token_tracker; non-streaming
|
|
587
|
+
# runs (required for local models whose tool calls don't survive the
|
|
588
|
+
# OpenAI-compatible stream) account them from the robot instead.
|
|
589
|
+
account_tokens(robot) unless @config.stream?
|
|
590
|
+
result
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# R4: account a non-streaming run's tokens from the robot's CUMULATIVE
|
|
594
|
+
# totals (which include every tool round), not just the final response's
|
|
595
|
+
# usage. @accounted_* is reset per iteration (fresh robot starts at zero),
|
|
596
|
+
# so adding the delta accumulates correctly across the initial run, nudges,
|
|
597
|
+
# and repairs without double-counting. Trips the stop flag on budget.
|
|
598
|
+
def account_tokens(robot)
|
|
599
|
+
return unless robot.respond_to?(:total_input_tokens)
|
|
600
|
+
|
|
601
|
+
in_total = robot.total_input_tokens.to_i
|
|
602
|
+
out_total = robot.total_output_tokens.to_i
|
|
603
|
+
@run.input_tokens += in_total - @accounted_in
|
|
604
|
+
@run.output_tokens += out_total - @accounted_out
|
|
605
|
+
@accounted_in = in_total
|
|
606
|
+
@accounted_out = out_total
|
|
607
|
+
@stop_requested = true if @stop_conditions.token_limit_exceeded?
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def install_signal_handlers
|
|
611
|
+
trap("INT") do
|
|
612
|
+
@stop_requested = true
|
|
613
|
+
@backoff.interrupt!
|
|
614
|
+
@runner_thread&.raise(Interrupt)
|
|
615
|
+
end
|
|
616
|
+
trap("TERM") do
|
|
617
|
+
@stop_requested = true
|
|
618
|
+
@backoff.interrupt!
|
|
619
|
+
@runner_thread&.raise(Interrupt)
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# Live progress to stderr. Uses $stderr.puts rather than Kernel#warn,
|
|
624
|
+
# which is silenced when Ruby warnings are disabled ($VERBOSE is nil —
|
|
625
|
+
# common under `bundle exec`), hiding progress from the user.
|
|
626
|
+
def progress(msg)
|
|
627
|
+
$stderr.puts "robot-to: #{msg}"
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def monotonic
|
|
631
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def auth_error?(e)
|
|
635
|
+
klass = e.class.name.to_s
|
|
636
|
+
klass.include?("Unauthorized") ||
|
|
637
|
+
klass.include?("Authentication") ||
|
|
638
|
+
e.message.to_s.match?(/invalid api key|unauthorized|authentication failed/i)
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
end
|