robot_lab-to 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.github/workflows/deploy-github-pages.yml +52 -0
  4. data/.loki +21 -0
  5. data/.quality/reek_baseline.txt +17 -0
  6. data/.rubocop.yml +7 -0
  7. data/CHANGELOG.md +67 -0
  8. data/CLAUDE.md +74 -0
  9. data/COMMITS.md +27 -0
  10. data/README.md +352 -0
  11. data/Rakefile +132 -0
  12. data/bin/robot-to +14 -0
  13. data/docs/about/changelog.md +82 -0
  14. data/docs/assets/architecture.svg +110 -0
  15. data/docs/assets/images/robot_lab-to.jpg +0 -0
  16. data/docs/assets/iteration-loop.svg +97 -0
  17. data/docs/concepts/evals.md +254 -0
  18. data/docs/concepts/iteration-loop.md +128 -0
  19. data/docs/concepts/notes.md +91 -0
  20. data/docs/concepts/run-state.md +93 -0
  21. data/docs/concepts/stop-conditions.md +111 -0
  22. data/docs/concepts/verification.md +75 -0
  23. data/docs/configuration/cli.md +111 -0
  24. data/docs/configuration/index.md +93 -0
  25. data/docs/configuration/settings.md +270 -0
  26. data/docs/getting-started/anatomy-of-a-run.md +111 -0
  27. data/docs/getting-started/installation.md +78 -0
  28. data/docs/getting-started/quick-start.md +105 -0
  29. data/docs/index.md +96 -0
  30. data/docs/local-models/guardrails.md +117 -0
  31. data/docs/local-models/index.md +88 -0
  32. data/docs/local-models/ollama.md +122 -0
  33. data/docs/local-models/tools.md +92 -0
  34. data/docs/reference/architecture.md +139 -0
  35. data/examples/01_basic_usage/.gitignore +9 -0
  36. data/examples/01_basic_usage/README.md +173 -0
  37. data/examples/01_basic_usage/basic_usage.rb +386 -0
  38. data/examples/02_advanced_usage/.gitignore +8 -0
  39. data/examples/02_advanced_usage/README.md +117 -0
  40. data/examples/02_advanced_usage/advanced_usage.rb +308 -0
  41. data/examples/02_advanced_usage/quality_gate.rb +99 -0
  42. data/examples/03_scored/.gitignore +2 -0
  43. data/examples/03_scored/README.md +117 -0
  44. data/examples/03_scored/scored_run.rb +325 -0
  45. data/examples/04_prose/.gitignore +2 -0
  46. data/examples/04_prose/README.md +43 -0
  47. data/examples/04_prose/prompts_dir/grade_message.md +14 -0
  48. data/examples/04_prose/prompts_dir/judge.md +11 -0
  49. data/examples/04_prose/prompts_dir/outline_criteria.md +9 -0
  50. data/examples/04_prose/prompts_dir/outline_objective.md +11 -0
  51. data/examples/04_prose/prompts_dir/section_criteria.md +7 -0
  52. data/examples/04_prose/prompts_dir/sections_objective.md +14 -0
  53. data/examples/04_prose/prose_run.rb +302 -0
  54. data/lib/robot_lab/to/atomic_file.rb +69 -0
  55. data/lib/robot_lab/to/backoff.rb +46 -0
  56. data/lib/robot_lab/to/cli.rb +176 -0
  57. data/lib/robot_lab/to/commit_manager.rb +135 -0
  58. data/lib/robot_lab/to/config/defaults.yml +27 -0
  59. data/lib/robot_lab/to/config.rb +86 -0
  60. data/lib/robot_lab/to/decision.rb +40 -0
  61. data/lib/robot_lab/to/decision_manager.rb +193 -0
  62. data/lib/robot_lab/to/errors.rb +30 -0
  63. data/lib/robot_lab/to/evals/base.rb +23 -0
  64. data/lib/robot_lab/to/evals/code.rb +77 -0
  65. data/lib/robot_lab/to/evals/context.rb +17 -0
  66. data/lib/robot_lab/to/evals/factory.rb +84 -0
  67. data/lib/robot_lab/to/evals/null.rb +17 -0
  68. data/lib/robot_lab/to/evals/prose.rb +110 -0
  69. data/lib/robot_lab/to/evals/score.rb +22 -0
  70. data/lib/robot_lab/to/exit_summary.rb +75 -0
  71. data/lib/robot_lab/to/guards/checkpoint.rb +84 -0
  72. data/lib/robot_lab/to/guards/grader_lock.rb +62 -0
  73. data/lib/robot_lab/to/guards/path_resolution.rb +61 -0
  74. data/lib/robot_lab/to/guards/quality_monitor.rb +113 -0
  75. data/lib/robot_lab/to/guards/read_before_edit.rb +83 -0
  76. data/lib/robot_lab/to/guards/run_store.rb +31 -0
  77. data/lib/robot_lab/to/guards/write_guard.rb +71 -0
  78. data/lib/robot_lab/to/guards.rb +54 -0
  79. data/lib/robot_lab/to/iteration_result.rb +29 -0
  80. data/lib/robot_lab/to/jsonl_logger.rb +59 -0
  81. data/lib/robot_lab/to/notes_manager.rb +121 -0
  82. data/lib/robot_lab/to/orchestrator.rb +642 -0
  83. data/lib/robot_lab/to/prompt_builder.rb +208 -0
  84. data/lib/robot_lab/to/run.rb +109 -0
  85. data/lib/robot_lab/to/stop_conditions.rb +71 -0
  86. data/lib/robot_lab/to/tools/bash.rb +75 -0
  87. data/lib/robot_lab/to/tools/edit.rb +52 -0
  88. data/lib/robot_lab/to/tools/file_tool.rb +32 -0
  89. data/lib/robot_lab/to/tools/read.rb +57 -0
  90. data/lib/robot_lab/to/tools/request_decision.rb +66 -0
  91. data/lib/robot_lab/to/tools/submit_result.rb +50 -0
  92. data/lib/robot_lab/to/tools/write.rb +32 -0
  93. data/lib/robot_lab/to/verifier.rb +63 -0
  94. data/lib/robot_lab/to/version.rb +7 -0
  95. data/lib/robot_lab/to.rb +81 -0
  96. data/mkdocs.yml +145 -0
  97. metadata +175 -0
@@ -0,0 +1,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