robot_lab-to 0.2.7 → 0.3.0
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 +4 -4
- data/.envrc +6 -0
- data/Archspec.rb +34 -0
- data/CHANGELOG.md +4 -0
- data/CLAUDE.md +3 -2
- data/README.md +9 -7
- data/Rakefile +6 -108
- data/docs/concepts/stop-conditions.md +3 -3
- data/docs/configuration/cli.md +4 -5
- data/docs/configuration/index.md +2 -3
- data/docs/configuration/settings.md +7 -7
- data/docs/getting-started/installation.md +5 -5
- data/docs/index.md +3 -3
- data/docs/local-models/index.md +14 -17
- data/docs/local-models/lm-studio.md +92 -0
- data/docs/reference/architecture.md +4 -4
- data/examples/.envrc +8 -0
- data/examples/01_basic_usage/README.md +14 -15
- data/examples/01_basic_usage/basic_usage.rb +20 -57
- data/examples/02_advanced_usage/README.md +10 -7
- data/examples/02_advanced_usage/advanced_usage.rb +23 -31
- data/examples/03_scored/scored_run.rb +19 -51
- data/examples/04_prose/README.md +15 -14
- data/examples/04_prose/prose_run.rb +22 -38
- data/examples/common.rb +111 -0
- data/lib/robot_lab/to/cli.rb +61 -34
- data/lib/robot_lab/to/commit_manager.rb +4 -0
- data/lib/robot_lab/to/config.rb +12 -0
- data/lib/robot_lab/to/decision_manager.rb +12 -1
- data/lib/robot_lab/to/exit_summary.rb +17 -16
- data/lib/robot_lab/to/guards/checkpoint.rb +6 -3
- data/lib/robot_lab/to/guards/quality_monitor.rb +4 -2
- data/lib/robot_lab/to/guards/run_store.rb +4 -2
- data/lib/robot_lab/to/notes_manager.rb +3 -0
- data/lib/robot_lab/to/orchestrator.rb +85 -34
- data/lib/robot_lab/to/prompt_builder.rb +2 -0
- data/lib/robot_lab/to/run.rb +8 -8
- data/lib/robot_lab/to/stop_conditions.rb +10 -6
- data/lib/robot_lab/to/tools/bash.rb +7 -2
- data/lib/robot_lab/to/tools/edit.rb +9 -4
- data/lib/robot_lab/to/tools/read.rb +3 -3
- data/lib/robot_lab/to/tools/request_decision.rb +14 -10
- data/lib/robot_lab/to/tools/submit_result.rb +12 -11
- data/lib/robot_lab/to/tools/write.rb +2 -2
- data/lib/robot_lab/to/version.rb +1 -1
- data/lib/robot_lab/to.rb +22 -0
- data/mkdocs.yml +1 -1
- metadata +10 -7
- data/docs/local-models/ollama.md +0 -122
|
@@ -14,6 +14,17 @@ module RobotLab
|
|
|
14
14
|
# not make alone, it is written to a decision file (see DecisionManager). A
|
|
15
15
|
# blocking decision either pauses the loop (decision_mode: wait) or stops the
|
|
16
16
|
# run for a later `--resume` (decision_mode: exit).
|
|
17
|
+
#
|
|
18
|
+
# :reek:TooManyMethods :reek:TooManyInstanceVariables -- this is the
|
|
19
|
+
# single object that owns the whole iteration lifecycle (setup, agent
|
|
20
|
+
# turn, eval gate, commit/rollback, decisions, tokens, signals). Splitting
|
|
21
|
+
# it apart would scatter state (@run, @git, @pending_commit_failure,
|
|
22
|
+
# @stop_requested, ...) across collaborators that would just pass it back
|
|
23
|
+
# and forth -- see the "Architecture" section of CLAUDE.md.
|
|
24
|
+
# :reek:RepeatedConditional -- @stop_requested, @pending_commit_failure,
|
|
25
|
+
# and decisions_enabled? are real orchestrator-wide state, legitimately
|
|
26
|
+
# checked at each point that must react to it; that is what a run loop's
|
|
27
|
+
# flags are for, not a sign the checks belong on another object.
|
|
17
28
|
class Orchestrator
|
|
18
29
|
SIGNAL_STOP = "graceful_stop"
|
|
19
30
|
|
|
@@ -43,10 +54,17 @@ module RobotLab
|
|
|
43
54
|
@decisions = nil
|
|
44
55
|
@decision_tool = nil
|
|
45
56
|
@injected_decisions = []
|
|
57
|
+
@backoff = nil
|
|
58
|
+
@builder = nil
|
|
59
|
+
@notes = nil
|
|
60
|
+
@stop_conditions = nil
|
|
46
61
|
@accounted_in = 0
|
|
47
62
|
@accounted_out = 0
|
|
48
63
|
end
|
|
49
64
|
|
|
65
|
+
# :reek:TooManyStatements -- top-level run/rescue/ensure sequence for the
|
|
66
|
+
# whole process; the rescue branches are already split into their own
|
|
67
|
+
# error-shaped clauses.
|
|
50
68
|
def run
|
|
51
69
|
@resume_run_id ? setup_resume : setup_run
|
|
52
70
|
install_signal_handlers
|
|
@@ -64,15 +82,18 @@ module RobotLab
|
|
|
64
82
|
@git&.reset_hard unless @pending_commit_failure
|
|
65
83
|
@logger.log("orchestrator:abort", reason: @abort_reason, permanent: true)
|
|
66
84
|
rescue => e
|
|
67
|
-
|
|
85
|
+
message = e.message
|
|
86
|
+
@abort_reason = "fatal: #{message}"
|
|
68
87
|
@git&.reset_hard unless @pending_commit_failure
|
|
69
|
-
@logger.log("orchestrator:fatal", error: e.class.to_s, message:
|
|
88
|
+
@logger.log("orchestrator:fatal", error: e.class.to_s, message: message)
|
|
70
89
|
ensure
|
|
71
90
|
finalize_run
|
|
72
91
|
end
|
|
73
92
|
|
|
74
93
|
private
|
|
75
94
|
|
|
95
|
+
# :reek:TooManyStatements -- the loop body is the iteration lifecycle in
|
|
96
|
+
# its literal execution order; splitting it further would hide that order.
|
|
76
97
|
def main_loop
|
|
77
98
|
loop do
|
|
78
99
|
break if @stop_requested
|
|
@@ -83,8 +104,9 @@ module RobotLab
|
|
|
83
104
|
@stop_conditions.before?
|
|
84
105
|
|
|
85
106
|
@run.iteration += 1
|
|
86
|
-
|
|
87
|
-
|
|
107
|
+
iteration = @run.iteration
|
|
108
|
+
@logger.log("iteration:start", iteration: iteration)
|
|
109
|
+
progress "iteration #{iteration} (#{@config.model})..."
|
|
88
110
|
|
|
89
111
|
result = execute_iteration
|
|
90
112
|
|
|
@@ -114,6 +136,8 @@ module RobotLab
|
|
|
114
136
|
end
|
|
115
137
|
end
|
|
116
138
|
|
|
139
|
+
# :reek:TooManyStatements -- one-time run bootstrap, each step required
|
|
140
|
+
# in this order (branch, run dir, Run record, logger, notes, collaborators).
|
|
117
141
|
def setup_run
|
|
118
142
|
@git = CommitManager.new
|
|
119
143
|
ensure_head!
|
|
@@ -143,6 +167,8 @@ module RobotLab
|
|
|
143
167
|
|
|
144
168
|
# Resume a prior run from its run.json — enables external scheduling (cron):
|
|
145
169
|
# each tick re-enters the loop on the same branch and advances or re-pauses.
|
|
170
|
+
# :reek:TooManyStatements -- mirrors #setup_run's bootstrap order for the
|
|
171
|
+
# resume path.
|
|
146
172
|
def setup_resume
|
|
147
173
|
@git = CommitManager.new
|
|
148
174
|
ensure_head!
|
|
@@ -152,7 +178,8 @@ module RobotLab
|
|
|
152
178
|
|
|
153
179
|
@run = Run.load(state_path)
|
|
154
180
|
@objective = @run.objective
|
|
155
|
-
@
|
|
181
|
+
branch = @run.branch
|
|
182
|
+
@git.checkout_branch(branch)
|
|
156
183
|
|
|
157
184
|
@logger.open(@run.log_path)
|
|
158
185
|
@notes = NotesManager.new(@run.notes_path) # append — do NOT re-setup (would clobber history)
|
|
@@ -160,8 +187,8 @@ module RobotLab
|
|
|
160
187
|
build_collaborators
|
|
161
188
|
|
|
162
189
|
@logger.log("orchestrator:resume", run_id: @run.run_id, iteration: @run.iteration,
|
|
163
|
-
branch:
|
|
164
|
-
progress "resumed run #{@run.run_id} at iteration #{@run.iteration} → branch #{
|
|
190
|
+
branch: branch, model: @config.model)
|
|
191
|
+
progress "resumed run #{@run.run_id} at iteration #{@run.iteration} → branch #{branch}"
|
|
165
192
|
end
|
|
166
193
|
|
|
167
194
|
# Shared setup used by both a fresh run and a resume.
|
|
@@ -202,11 +229,14 @@ module RobotLab
|
|
|
202
229
|
|
|
203
230
|
# Poll the decision files until every blocking decision is resolved, the run
|
|
204
231
|
# is signaled to stop, or decision_timeout elapses.
|
|
232
|
+
# :reek:TooManyStatements -- the three exit conditions (stopped, resolved,
|
|
233
|
+
# timed out) must all be checked inside one poll loop.
|
|
205
234
|
def wait_for_decisions(pending)
|
|
206
235
|
progress "awaiting human decision on #{pending.size} item(s):"
|
|
207
236
|
pending.each { |d| progress " #{d.path}" }
|
|
208
237
|
@logger.log("decision:wait:start", count: pending.size)
|
|
209
|
-
|
|
238
|
+
timeout = @config.decision_timeout
|
|
239
|
+
deadline = timeout ? monotonic + timeout.to_i : nil
|
|
210
240
|
|
|
211
241
|
loop do
|
|
212
242
|
return if @stop_requested
|
|
@@ -216,7 +246,7 @@ module RobotLab
|
|
|
216
246
|
break unless @decisions.blocking_pending?
|
|
217
247
|
|
|
218
248
|
if deadline && monotonic >= deadline
|
|
219
|
-
raise AbortError, "awaiting human decision (timed out after #{
|
|
249
|
+
raise AbortError, "awaiting human decision (timed out after #{timeout}s)"
|
|
220
250
|
end
|
|
221
251
|
end
|
|
222
252
|
|
|
@@ -238,6 +268,8 @@ module RobotLab
|
|
|
238
268
|
|
|
239
269
|
# The happy path: build a fresh robot, run it, and return its submitted
|
|
240
270
|
# result (or a not-submitted marker).
|
|
271
|
+
# :reek:TooManyStatements -- one call per lifecycle step (reset counters,
|
|
272
|
+
# build prompt/robot, run, nudge, persist decisions, return result).
|
|
241
273
|
def run_agent_iteration
|
|
242
274
|
@accounted_in = @accounted_out = 0
|
|
243
275
|
@submit_tool = Tools::SubmitResult.new
|
|
@@ -265,12 +297,13 @@ module RobotLab
|
|
|
265
297
|
def persist_raised_decisions
|
|
266
298
|
return unless @decision_tool
|
|
267
299
|
|
|
300
|
+
iteration = @run.iteration
|
|
268
301
|
@decision_tool.captured_requests.each do |req|
|
|
269
|
-
decision = @decisions.record(**req, iteration:
|
|
270
|
-
@notes.append_decision(decision,
|
|
302
|
+
decision = @decisions.record(**req, iteration: iteration)
|
|
303
|
+
@notes.append_decision(decision, iteration)
|
|
271
304
|
@logger.log("decision:raised", id: decision.id, blocking: decision.blocking,
|
|
272
305
|
question: decision.question)
|
|
273
|
-
progress "iteration #{
|
|
306
|
+
progress "iteration #{iteration} raised decision: #{decision.question}"
|
|
274
307
|
end
|
|
275
308
|
end
|
|
276
309
|
|
|
@@ -298,10 +331,11 @@ module RobotLab
|
|
|
298
331
|
# True when the iteration should be retried — within the retry budget and
|
|
299
332
|
# not stopping. Sleeps out the backoff as a side effect before returning.
|
|
300
333
|
def retry_after_backoff?
|
|
301
|
-
|
|
334
|
+
errors = @run.consecutive_errors
|
|
335
|
+
return false unless errors <= @config.max_retries && !@stop_requested
|
|
302
336
|
|
|
303
|
-
@logger.log("backoff:start", consecutive_errors:
|
|
304
|
-
@backoff.sleep_for(
|
|
337
|
+
@logger.log("backoff:start", consecutive_errors: errors)
|
|
338
|
+
@backoff.sleep_for(errors)
|
|
305
339
|
@logger.log("backoff:end")
|
|
306
340
|
true
|
|
307
341
|
end
|
|
@@ -367,8 +401,9 @@ module RobotLab
|
|
|
367
401
|
|
|
368
402
|
def handle_no_improvement(result, score)
|
|
369
403
|
iteration = @run.iteration
|
|
404
|
+
detail = score.detail
|
|
370
405
|
@git.reset_hard unless @pending_commit_failure
|
|
371
|
-
@notes.append_no_improvement(result,
|
|
406
|
+
@notes.append_no_improvement(result, detail, iteration)
|
|
372
407
|
# A non-improving iteration is valid work that just wasn't better -- NOT a
|
|
373
408
|
# failure. It counts toward the plateau (diminishing-returns) stop only, so
|
|
374
409
|
# it must NOT trip max_consecutive_failures, which is for the robot being
|
|
@@ -377,8 +412,8 @@ module RobotLab
|
|
|
377
412
|
# verdicts are common and normal.
|
|
378
413
|
@run.iterations_since_improvement += 1
|
|
379
414
|
@run.consecutive_errors = 0
|
|
380
|
-
@logger.log("iteration:no_improvement", iteration: iteration, detail:
|
|
381
|
-
progress "iteration #{iteration} no improvement (#{
|
|
415
|
+
@logger.log("iteration:no_improvement", iteration: iteration, detail: detail)
|
|
416
|
+
progress "iteration #{iteration} no improvement (#{detail}) — rolled back"
|
|
382
417
|
end
|
|
383
418
|
|
|
384
419
|
# A resolved decision that was injected into this successful iteration has
|
|
@@ -405,14 +440,16 @@ module RobotLab
|
|
|
405
440
|
score
|
|
406
441
|
end
|
|
407
442
|
|
|
443
|
+
# :reek:TooManyStatements -- rollback + record + report, one statement each.
|
|
408
444
|
def handle_gate_failure(result, score)
|
|
445
|
+
iteration = @run.iteration
|
|
409
446
|
@git.reset_hard unless @pending_commit_failure
|
|
410
|
-
@notes.append_verify_failure(result, score.output.to_s,
|
|
447
|
+
@notes.append_verify_failure(result, score.output.to_s, iteration)
|
|
411
448
|
@run.consecutive_failures += 1
|
|
412
449
|
@run.consecutive_errors = 0
|
|
413
450
|
@run.iterations_since_improvement += 1
|
|
414
|
-
@logger.log("iteration:gate_failure", iteration:
|
|
415
|
-
progress "iteration #{
|
|
451
|
+
@logger.log("iteration:gate_failure", iteration: iteration)
|
|
452
|
+
progress "iteration #{iteration} verification failed — rolled back"
|
|
416
453
|
nil
|
|
417
454
|
end
|
|
418
455
|
|
|
@@ -421,19 +458,22 @@ module RobotLab
|
|
|
421
458
|
# the code, re-verifying after each attempt. Only fall back to a full
|
|
422
459
|
# rollback once the repair budget is spent — a near-miss no longer discards
|
|
423
460
|
# all the work, and nothing commits until the gate is green.
|
|
461
|
+
# :reek:TooManyStatements -- repair/re-verify loop with its own rescue;
|
|
462
|
+
# each statement is a distinct step of one repair attempt.
|
|
424
463
|
def repair_until_gate(result, score)
|
|
425
|
-
budget
|
|
464
|
+
budget = @config.max_verify_repairs.to_i
|
|
465
|
+
iteration = @run.iteration
|
|
426
466
|
return [result, score] if budget.zero? || @robot.nil?
|
|
427
467
|
|
|
428
468
|
budget.times do |i|
|
|
429
469
|
break if @stop_requested
|
|
430
470
|
|
|
431
|
-
@logger.log("eval:repair", iteration:
|
|
432
|
-
progress "iteration #{
|
|
471
|
+
@logger.log("eval:repair", iteration: iteration, attempt: i + 1)
|
|
472
|
+
progress "iteration #{iteration}: verification failed — repairing (#{i + 1}/#{budget})"
|
|
433
473
|
begin
|
|
434
474
|
run_robot_with_interrupt(@robot, repair_prompt(score))
|
|
435
475
|
rescue StandardError => e
|
|
436
|
-
@logger.log("eval:repair_error", iteration:
|
|
476
|
+
@logger.log("eval:repair_error", iteration: iteration, message: e.message)
|
|
437
477
|
break
|
|
438
478
|
end
|
|
439
479
|
result = @submit_tool.captured_result || result
|
|
@@ -459,16 +499,20 @@ module RobotLab
|
|
|
459
499
|
end
|
|
460
500
|
|
|
461
501
|
def handle_failure(result)
|
|
502
|
+
iteration = @run.iteration
|
|
462
503
|
@git.reset_hard unless @pending_commit_failure
|
|
463
|
-
@notes.append_failure(result,
|
|
504
|
+
@notes.append_failure(result, iteration)
|
|
464
505
|
@run.consecutive_failures += 1
|
|
465
506
|
@run.consecutive_errors = 0
|
|
466
507
|
@run.iterations_since_improvement += 1
|
|
467
|
-
@logger.log("iteration:failure", iteration:
|
|
468
|
-
progress "iteration #{
|
|
508
|
+
@logger.log("iteration:failure", iteration: iteration, summary: result.summary)
|
|
509
|
+
progress "iteration #{iteration} failed: #{result.summary}"
|
|
469
510
|
end
|
|
470
511
|
|
|
512
|
+
# :reek:TooManyStatements -- commit path plus its own CommitFailedError
|
|
513
|
+
# rescue; the two branches (success, queue-for-repair) are each tight.
|
|
471
514
|
def attempt_commit(result)
|
|
515
|
+
iteration = @run.iteration
|
|
472
516
|
@git.add_all
|
|
473
517
|
return unless @git.staged?
|
|
474
518
|
|
|
@@ -476,16 +520,17 @@ module RobotLab
|
|
|
476
520
|
@git.commit(message)
|
|
477
521
|
@run.commits += 1
|
|
478
522
|
@pending_commit_failure = nil
|
|
479
|
-
@logger.log("commit:success", iteration:
|
|
480
|
-
progress "iteration #{
|
|
523
|
+
@logger.log("commit:success", iteration: iteration, message: message)
|
|
524
|
+
progress "iteration #{iteration} committed: #{message}"
|
|
481
525
|
rescue CommitFailedError => e
|
|
482
526
|
@pending_commit_failure = e
|
|
483
527
|
@run.consecutive_failures += 1
|
|
484
528
|
@run.consecutive_errors = 0
|
|
485
|
-
@logger.log("commit:failed", iteration:
|
|
486
|
-
progress "iteration #{
|
|
529
|
+
@logger.log("commit:failed", iteration: iteration, output: e.output)
|
|
530
|
+
progress "iteration #{iteration} commit failed — queued for repair"
|
|
487
531
|
end
|
|
488
532
|
|
|
533
|
+
# :reek:FeatureEnvy -- formatting the commit message from result's own fields.
|
|
489
534
|
def commit_message(result)
|
|
490
535
|
if @config.commit_format == "conventional"
|
|
491
536
|
type = result.respond_to?(:type) ? (result.type || "chore") : "chore"
|
|
@@ -552,10 +597,14 @@ module RobotLab
|
|
|
552
597
|
tools + [Tools::Read.new, Tools::Write.new, Tools::Edit.new, Tools::Bash.new]
|
|
553
598
|
end
|
|
554
599
|
|
|
600
|
+
# :reek:TooManyStatements -- the streaming callback accumulates usage and enforces the stop in one place.
|
|
555
601
|
def token_tracker
|
|
556
602
|
lambda do |chunk|
|
|
557
|
-
|
|
558
|
-
|
|
603
|
+
# ruby_llm 2.0 nests counts under chunk.tokens (usually only on the
|
|
604
|
+
# final chunk of a stream).
|
|
605
|
+
tokens = chunk.respond_to?(:tokens) ? chunk.tokens : nil
|
|
606
|
+
@run.input_tokens += tokens&.input.to_i
|
|
607
|
+
@run.output_tokens += tokens&.output.to_i
|
|
559
608
|
|
|
560
609
|
return unless @stop_conditions.token_limit_exceeded?
|
|
561
610
|
|
|
@@ -565,6 +614,8 @@ module RobotLab
|
|
|
565
614
|
end
|
|
566
615
|
end
|
|
567
616
|
|
|
617
|
+
# :reek:TooManyStatements -- runs the robot on its own Thread so a signal
|
|
618
|
+
# can interrupt it; each statement manages that thread's lifecycle.
|
|
568
619
|
def run_robot_with_interrupt(robot, task = @objective)
|
|
569
620
|
thread_error = nil
|
|
570
621
|
result = nil
|
|
@@ -8,6 +8,8 @@ module RobotLab
|
|
|
8
8
|
@config = config
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
+
# :reek:TooManyStatements -- each line is one optional prompt section, in
|
|
12
|
+
# the exact order they appear; a data-table would hide that ordering.
|
|
11
13
|
def build(run, notes_content, workspace: nil, pending_commit_failure: nil, resolved_decisions: [])
|
|
12
14
|
sections = [role_section(run), notes_section(run, notes_content)]
|
|
13
15
|
sections << workspace_section(workspace) if workspace && !workspace.empty?
|
data/lib/robot_lab/to/run.rb
CHANGED
|
@@ -13,6 +13,8 @@ module RobotLab
|
|
|
13
13
|
:commits, :input_tokens, :output_tokens,
|
|
14
14
|
:last_score_value, :iterations_since_improvement
|
|
15
15
|
|
|
16
|
+
# :reek:ControlParameter -- run_dir/decisions_path fall back to a sibling
|
|
17
|
+
# path when omitted; that's a constructor default, not a behavior branch.
|
|
16
18
|
def initialize(run_id:, objective:, branch:, base_commit:, notes_path:, log_path:,
|
|
17
19
|
run_dir: nil, decisions_path: nil)
|
|
18
20
|
@run_id = run_id
|
|
@@ -38,6 +40,7 @@ module RobotLab
|
|
|
38
40
|
def elapsed_seconds = Time.now - started_at
|
|
39
41
|
def state_path = @run_dir.join("run.json")
|
|
40
42
|
|
|
43
|
+
# :reek:FeatureEnvy -- decomposing our own elapsed_seconds into h/m/s.
|
|
41
44
|
def elapsed_human
|
|
42
45
|
secs = elapsed_seconds.to_i
|
|
43
46
|
h = secs / 3600
|
|
@@ -71,14 +74,11 @@ module RobotLab
|
|
|
71
74
|
base_commit: data[:base_commit], notes_path: data[:notes_path],
|
|
72
75
|
log_path: data[:log_path], run_dir: data[:run_dir],
|
|
73
76
|
decisions_path: data[:decisions_path])
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
run.
|
|
79
|
-
run.output_tokens = data[:output_tokens].to_i
|
|
80
|
-
run.last_score_value = data[:last_score_value]
|
|
81
|
-
run.iterations_since_improvement = data[:iterations_since_improvement].to_i
|
|
77
|
+
%i[iteration consecutive_failures consecutive_errors commits input_tokens
|
|
78
|
+
output_tokens iterations_since_improvement].each do |key|
|
|
79
|
+
run.public_send(:"#{key}=", data[key].to_i)
|
|
80
|
+
end
|
|
81
|
+
run.last_score_value = data[:last_score_value]
|
|
82
82
|
run.instance_variable_set(:@started_at, Time.parse(data[:started_at])) if data[:started_at]
|
|
83
83
|
run
|
|
84
84
|
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module RobotLab
|
|
4
4
|
module To
|
|
5
5
|
# Evaluates all stop conditions against the current run state.
|
|
6
|
+
# :reek:RepeatedConditional -- each `limit` is an unrelated config value
|
|
7
|
+
# (max_iterations/max_tokens/stop_on_plateau) nil-guarded in its own check.
|
|
6
8
|
class StopConditions
|
|
7
9
|
def initialize(config, run)
|
|
8
10
|
@config = config
|
|
@@ -38,17 +40,19 @@ module RobotLab
|
|
|
38
40
|
private
|
|
39
41
|
|
|
40
42
|
def check_max_iterations
|
|
41
|
-
|
|
42
|
-
return unless
|
|
43
|
+
limit = @config.max_iterations
|
|
44
|
+
return unless limit
|
|
45
|
+
return unless @run.iteration >= limit
|
|
43
46
|
|
|
44
|
-
raise AbortError, "max iterations reached (#{
|
|
47
|
+
raise AbortError, "max iterations reached (#{limit})"
|
|
45
48
|
end
|
|
46
49
|
|
|
47
50
|
def check_max_tokens
|
|
48
|
-
|
|
49
|
-
return unless
|
|
51
|
+
limit = @config.max_tokens
|
|
52
|
+
return unless limit
|
|
53
|
+
return unless @run.total_tokens >= limit
|
|
50
54
|
|
|
51
|
-
raise AbortError, "max tokens reached (#{
|
|
55
|
+
raise AbortError, "max tokens reached (#{limit})"
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
def check_consecutive_failures
|
|
@@ -17,14 +17,16 @@ module RobotLab
|
|
|
17
17
|
output and exit status. Use for building, testing, listing, and git.
|
|
18
18
|
DESC
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
parameter :command, type: "string", description: "The shell command to run"
|
|
21
|
+
parameter :timeout, type: "integer", description: "Seconds before the command is killed", required: false
|
|
22
22
|
|
|
23
23
|
def initialize(robot: nil, timeout: DEFAULT_TIMEOUT)
|
|
24
24
|
super(robot: robot)
|
|
25
25
|
@default_timeout = timeout
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# :reek:ControlParameter -- falling back to the instance default when
|
|
29
|
+
# the LLM omits the optional `timeout` argument.
|
|
28
30
|
def execute(command:, timeout: nil, **)
|
|
29
31
|
out, status = run(command, (timeout || @default_timeout).to_i)
|
|
30
32
|
format_result(out, status)
|
|
@@ -34,6 +36,9 @@ module RobotLab
|
|
|
34
36
|
# status_string is "0".."n" for an exit code, or "timeout"/"error: ...".
|
|
35
37
|
#
|
|
36
38
|
# @return [Array(String, String)]
|
|
39
|
+
# :reek:TooManyStatements :reek:NestedIterators -- spawn, read
|
|
40
|
+
# concurrently, wait-or-kill, join: each step is one statement and the
|
|
41
|
+
# streaming reader inherently nests a loop inside its own Thread.
|
|
37
42
|
def run(command, timeout)
|
|
38
43
|
out_str = +""
|
|
39
44
|
status = nil
|
|
@@ -14,11 +14,16 @@ module RobotLab
|
|
|
14
14
|
replace_all is true. Read the file first to get the exact text.
|
|
15
15
|
DESC
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
parameter :path, type: "string", description: "Path of the file to edit"
|
|
18
|
+
parameter :old_text, type: "string", description: "Exact text to replace"
|
|
19
|
+
parameter :new_text, type: "string", description: "Replacement text"
|
|
20
|
+
parameter :replace_all, type: "boolean", description: "Replace every occurrence", required: false
|
|
21
21
|
|
|
22
|
+
# :reek:BooleanParameter -- replace_all is a tool param the LLM sets;
|
|
23
|
+
# the `param` declarations above fix this signature.
|
|
24
|
+
# :reek:FeatureEnvy -- inspecting #apply's own return value.
|
|
25
|
+
# :reek:TooManyStatements -- validate, transform, write, report; each
|
|
26
|
+
# step is one statement in a tool #execute contract method.
|
|
22
27
|
def execute(path:, old_text:, new_text:, replace_all: false, **)
|
|
23
28
|
resolved = File.expand_path(path, Dir.pwd)
|
|
24
29
|
return "Error: file not found: #{path}" unless File.file?(resolved)
|
|
@@ -14,9 +14,9 @@ module RobotLab
|
|
|
14
14
|
start line) and limit (max lines). Always read a file before editing it.
|
|
15
15
|
DESC
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
parameter :path, type: "string", description: "Path to the file to read"
|
|
18
|
+
parameter :offset, type: "integer", description: "1-based first line to return", required: false
|
|
19
|
+
parameter :limit, type: "integer", description: "Maximum number of lines to return", required: false
|
|
20
20
|
|
|
21
21
|
def execute(path:, offset: nil, limit: nil, **)
|
|
22
22
|
resolved = File.expand_path(path, Dir.pwd)
|
|
@@ -25,25 +25,29 @@ module RobotLab
|
|
|
25
25
|
for routine engineering choices you are equipped to make yourself.
|
|
26
26
|
DESC
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
parameter :question, type: "string",
|
|
29
|
+
description: "The decision that needs a human answer, phrased as a question"
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
parameter :situation, type: "string",
|
|
32
|
+
description: "Why this needs a human and what is at stake", required: false
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
parameter :options, type: "array",
|
|
35
|
+
description: "The distinct options you see (strings)", required: false
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
parameter :recommendation, type: "string",
|
|
38
|
+
description: "Your recommended option and the reasoning behind it", required: false
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
parameter :blocking, type: "boolean",
|
|
41
|
+
description: "true if work cannot correctly proceed until this is answered", required: false
|
|
42
42
|
|
|
43
43
|
def captured_requests
|
|
44
44
|
@captured_requests ||= []
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# :reek:BooleanParameter :reek:ControlParameter -- blocking is both
|
|
48
|
+
# stored data and legitimately changes the guidance message returned
|
|
49
|
+
# to the robot; the `param` declarations above fix this signature.
|
|
50
|
+
# :reek:LongParameterList -- one field per tool param declared above.
|
|
47
51
|
def execute(question:, situation: "", options: [], recommendation: "", blocking: false, **)
|
|
48
52
|
(@captured_requests ||= []) << {
|
|
49
53
|
question: question,
|
|
@@ -13,27 +13,28 @@ module RobotLab
|
|
|
13
13
|
FINAL action before finishing. Do not call it until your work is complete.
|
|
14
14
|
DESC
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
parameter :success, type: "boolean",
|
|
17
|
+
description: "true if you made meaningful progress toward the objective; " \
|
|
18
|
+
"false if you made no meaningful changes AND have no new learnings"
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
parameter :summary, type: "string",
|
|
21
|
+
description: "Brief one-sentence description of what you accomplished or why you stopped"
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
parameter :key_changes, type: "array",
|
|
24
|
+
description: "List of files or changes made this iteration (empty if none)",
|
|
25
25
|
required: false
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
parameter :key_learnings, type: "array",
|
|
28
|
+
description: "Insights worth remembering for future iterations (empty if none)",
|
|
29
29
|
required: false
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
parameter :should_fully_stop, type: "boolean",
|
|
32
|
+
description: "Set to true only when instructed by a stop condition",
|
|
33
33
|
required: false
|
|
34
34
|
|
|
35
35
|
attr_reader :captured_result
|
|
36
36
|
|
|
37
|
+
# :reek:LongParameterList -- one field per tool param declared above.
|
|
37
38
|
def execute(success:, summary:, key_changes: [], key_learnings: [], should_fully_stop: nil, **)
|
|
38
39
|
@captured_result = IterationResult.new(
|
|
39
40
|
success: success,
|
|
@@ -15,8 +15,8 @@ module RobotLab
|
|
|
15
15
|
do not exist yet; to change an existing file, use Edit.
|
|
16
16
|
DESC
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
parameter :path, type: "string", description: "Path of the file to create"
|
|
19
|
+
parameter :content, type: "string", description: "Full text content to write"
|
|
20
20
|
|
|
21
21
|
def execute(path:, content:, **)
|
|
22
22
|
resolved = File.expand_path(path, Dir.pwd)
|
data/lib/robot_lab/to/version.rb
CHANGED
data/lib/robot_lab/to.rb
CHANGED
|
@@ -44,6 +44,7 @@ module RobotLab
|
|
|
44
44
|
# @return [void]
|
|
45
45
|
def run(objective, **)
|
|
46
46
|
config = Config.new(**)
|
|
47
|
+
ensure_provider_loaded(config.provider)
|
|
47
48
|
suppress_llm_logging unless config.debug?
|
|
48
49
|
Orchestrator.new(objective, config).run
|
|
49
50
|
end
|
|
@@ -52,6 +53,7 @@ module RobotLab
|
|
|
52
53
|
# objective and prior state are loaded from the run's run.json.
|
|
53
54
|
def resume(run_id, **)
|
|
54
55
|
config = Config.new(**)
|
|
56
|
+
ensure_provider_loaded(config.provider)
|
|
55
57
|
suppress_llm_logging unless config.debug?
|
|
56
58
|
Orchestrator.new(nil, config, resume_run_id: run_id).run
|
|
57
59
|
end
|
|
@@ -66,6 +68,26 @@ module RobotLab
|
|
|
66
68
|
|
|
67
69
|
private
|
|
68
70
|
|
|
71
|
+
# Provider gems register themselves on require by convention
|
|
72
|
+
# (ruby_llm-providers-<name> defines ruby_llm/providers/<name>). When the
|
|
73
|
+
# configured provider isn't registered yet — e.g. `robot-to --provider
|
|
74
|
+
# lms` from the CLI — try that conventional require so the user doesn't
|
|
75
|
+
# need a wrapper script. A miss falls through to RubyLLM's own "Unknown
|
|
76
|
+
# provider" error at request time.
|
|
77
|
+
def ensure_provider_loaded(provider)
|
|
78
|
+
return unless provider
|
|
79
|
+
return unless defined?(RubyLLM::Provider)
|
|
80
|
+
|
|
81
|
+
name = provider.to_s
|
|
82
|
+
return if RubyLLM::Provider.resolve(name)
|
|
83
|
+
|
|
84
|
+
begin
|
|
85
|
+
require "ruby_llm/providers/#{name}"
|
|
86
|
+
rescue LoadError
|
|
87
|
+
# Not a provider-gem provider; RubyLLM will report it if truly unknown.
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
69
91
|
def suppress_llm_logging
|
|
70
92
|
require "logger"
|
|
71
93
|
null = Logger.new(File::NULL)
|
data/mkdocs.yml
CHANGED
|
@@ -136,7 +136,7 @@ nav:
|
|
|
136
136
|
- CLI Reference: configuration/cli.md
|
|
137
137
|
- Local Models:
|
|
138
138
|
- Overview: local-models/index.md
|
|
139
|
-
-
|
|
139
|
+
- LM Studio Setup: local-models/lm-studio.md
|
|
140
140
|
- Built-in Tools: local-models/tools.md
|
|
141
141
|
- Guardrails: local-models/guardrails.md
|
|
142
142
|
- Reference:
|