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,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module To
|
|
5
|
+
module Evals
|
|
6
|
+
# Wraps a bare callable (proc/lambda) as an Eval.
|
|
7
|
+
class ProcEval < Base
|
|
8
|
+
def initialize(callable)
|
|
9
|
+
super()
|
|
10
|
+
@callable = callable
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def score(context)
|
|
14
|
+
@callable.call(context)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Resolves the configured eval strategy into an object responding to
|
|
19
|
+
# #score(context). Precedence: an explicit instance (responds to #score) or
|
|
20
|
+
# proc, a registered symbol, then the named built-ins, then a default that
|
|
21
|
+
# matches legacy behavior (Code when a verify command is set, else Null).
|
|
22
|
+
#
|
|
23
|
+
# Phase 1 ships "code" and "null"; "prose", file paths, and "auto" arrive in
|
|
24
|
+
# later phases (they raise a clear error until then).
|
|
25
|
+
def self.build(config, git: CommitManager.new)
|
|
26
|
+
chosen = config.respond_to?(:eval) ? config.eval : nil
|
|
27
|
+
return chosen if chosen.respond_to?(:score)
|
|
28
|
+
return ProcEval.new(chosen) if chosen.is_a?(Proc)
|
|
29
|
+
|
|
30
|
+
from_name(chosen, config, git)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.from_name(chosen, config, git)
|
|
34
|
+
case chosen
|
|
35
|
+
when Symbol then from_registry(chosen, config)
|
|
36
|
+
when "code" then code(config)
|
|
37
|
+
when "null" then Null.new
|
|
38
|
+
when "prose" then prose(config, git)
|
|
39
|
+
when nil then use_code?(config) ? code(config) : Null.new
|
|
40
|
+
else raise ArgumentError, unknown_strategy(chosen)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.prose(config, git)
|
|
45
|
+
Prose.new(objective_model: config.model, git: git,
|
|
46
|
+
spec: config.eval_spec, floor: config.eval_floor,
|
|
47
|
+
judge_model: config.eval_judge_model || config.model)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Default to Code when any of its inputs are configured (a verify command,
|
|
51
|
+
# a measure command, or a target); otherwise Null preserves legacy behavior.
|
|
52
|
+
def self.use_code?(config)
|
|
53
|
+
!blank?(config.verify_command) || !config.eval_measure.nil? || !config.eval_target.nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.code(config)
|
|
57
|
+
Code.new(verify: presence(config.verify_command),
|
|
58
|
+
measure: presence(config.eval_measure),
|
|
59
|
+
target: config.eval_target,
|
|
60
|
+
timeout: config.verify_timeout)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.from_registry(name, config)
|
|
64
|
+
block = RobotLab::To.evals[name]
|
|
65
|
+
raise ArgumentError, "no eval registered as #{name.inspect}" unless block
|
|
66
|
+
|
|
67
|
+
block.call(config)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.presence(value)
|
|
71
|
+
text = value.to_s.strip
|
|
72
|
+
text.empty? ? nil : text
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.blank?(value) = presence(value).nil?
|
|
76
|
+
|
|
77
|
+
def self.unknown_strategy(chosen)
|
|
78
|
+
"unknown eval strategy: #{chosen.inspect} (available: code, null, " \
|
|
79
|
+
"a symbol registered via RobotLab::To.register_eval, " \
|
|
80
|
+
"an object responding to #score, or a proc)"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module To
|
|
5
|
+
module Evals
|
|
6
|
+
# The default Eval when nothing is configured: no gate, every reported
|
|
7
|
+
# success counts as progress, no measurable target. Preserves robot_lab-to's
|
|
8
|
+
# original "commit any claimed success; stop via --stop-when" behavior.
|
|
9
|
+
class Null < Base
|
|
10
|
+
def score(_context)
|
|
11
|
+
Score.new(gate_ok: true, improved: true, met_target: false,
|
|
12
|
+
value: nil, detail: "no eval configured", output: nil)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module To
|
|
5
|
+
module Evals
|
|
6
|
+
# Judged / pairwise eval for prose products (docs, opinion, a book).
|
|
7
|
+
#
|
|
8
|
+
# spec - optional spec/outline artifact the judge measures against
|
|
9
|
+
# judge_model - model for the pairwise judge (defaults to the doer's model)
|
|
10
|
+
# floor - optional command for mechanizable checks (links, coverage)
|
|
11
|
+
#
|
|
12
|
+
# The descent signal is a pairwise verdict: the judge is asked whether the
|
|
13
|
+
# working tree is BETTER than the parent commit -- absolute scores from an
|
|
14
|
+
# LLM are too noisy to descend. There is no measurable target, so met_target
|
|
15
|
+
# is always false; runs end on --stop-on-plateau or a human.
|
|
16
|
+
class Prose < Base
|
|
17
|
+
JUDGE_SYSTEM = <<~PROMPT
|
|
18
|
+
You are a meticulous editor. You will see two versions of a document
|
|
19
|
+
(A = the previously accepted version, B = a new draft) plus the objective
|
|
20
|
+
and, if given, the spec they serve. Decide whether B is BETTER, WORSE, or
|
|
21
|
+
the SAME as A at satisfying them. Judge substance -- accuracy,
|
|
22
|
+
completeness, clarity, structure -- not length. Reply with exactly one
|
|
23
|
+
word: better, worse, or same.
|
|
24
|
+
PROMPT
|
|
25
|
+
|
|
26
|
+
def initialize(objective_model:, work_dir: Dir.pwd,
|
|
27
|
+
git: CommitManager.new(work_dir: work_dir),
|
|
28
|
+
judge_model: objective_model, spec: nil, floor: nil)
|
|
29
|
+
super()
|
|
30
|
+
@spec = spec
|
|
31
|
+
@judge_model = judge_model
|
|
32
|
+
@floor = floor
|
|
33
|
+
@work_dir = work_dir
|
|
34
|
+
@git = git
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The spec is the eval's own criteria and must be locked from the robot.
|
|
38
|
+
def protected_paths = Array(@spec)
|
|
39
|
+
|
|
40
|
+
def score(context)
|
|
41
|
+
gate, output = floor_result
|
|
42
|
+
verdict = gate ? pairwise(context) : :worse
|
|
43
|
+
Score.new(gate_ok: gate, improved: verdict == :better, met_target: false,
|
|
44
|
+
value: nil, detail: "floor=#{gate} judge=#{verdict}", output: output)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Map a judge reply onto a verdict; defaults to :same when unclear so an
|
|
48
|
+
# ambiguous judgment does not count as improvement.
|
|
49
|
+
def parse_verdict(reply)
|
|
50
|
+
text = reply.to_s.downcase
|
|
51
|
+
return :better if text.match?(/\bbetter\b/)
|
|
52
|
+
return :worse if text.match?(/\bworse\b/)
|
|
53
|
+
|
|
54
|
+
:same
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def floor_result
|
|
60
|
+
return [true, nil] unless @floor
|
|
61
|
+
|
|
62
|
+
result = Verifier.new(@floor, work_dir: @work_dir).run
|
|
63
|
+
[result.passed?, result.output]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def pairwise(context)
|
|
67
|
+
files = @git.changed_vs_worktree(context.previous_ref)
|
|
68
|
+
return :same if files.empty?
|
|
69
|
+
|
|
70
|
+
old = files.map { |f| @git.show(context.previous_ref, f) }.join("\n\n")
|
|
71
|
+
new = files.map { |f| read_working(f) }.join("\n\n")
|
|
72
|
+
# A brand-new document (no prior version) is an improvement over nothing.
|
|
73
|
+
# Skip the judge: a pairwise "empty vs content" comparison is degenerate
|
|
74
|
+
# and models frequently (wrongly) call it "same".
|
|
75
|
+
return :better if old.strip.empty? && !new.strip.empty?
|
|
76
|
+
|
|
77
|
+
parse_verdict(ask_judge(context.objective, old, new))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def ask_judge(objective, old, new)
|
|
81
|
+
judge = RobotLab.build(name: "prose-judge", model: @judge_model, system_prompt: JUDGE_SYSTEM)
|
|
82
|
+
# robot.run returns a RobotResult; its text is #last_text_content, NOT
|
|
83
|
+
# #to_s (which is the object's inspection string).
|
|
84
|
+
judge.run(comparison(objective, old, new)).last_text_content.to_s
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def comparison(objective, old_text, new_text)
|
|
88
|
+
spec_block = @spec && File.exist?(@spec) ? "SPEC:\n#{File.read(@spec)}\n\n" : ""
|
|
89
|
+
<<~MSG
|
|
90
|
+
OBJECTIVE: #{objective}
|
|
91
|
+
|
|
92
|
+
#{spec_block}VERSION A (accepted):
|
|
93
|
+
#{old_text}
|
|
94
|
+
|
|
95
|
+
VERSION B (new draft):
|
|
96
|
+
#{new_text}
|
|
97
|
+
|
|
98
|
+
Is B better, worse, or the same as A? Reply with one word.
|
|
99
|
+
MSG
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def read_working(file)
|
|
103
|
+
File.read(File.join(@work_dir, file))
|
|
104
|
+
rescue SystemCallError
|
|
105
|
+
""
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module To
|
|
5
|
+
module Evals
|
|
6
|
+
# The verdict an Eval returns for one iteration.
|
|
7
|
+
#
|
|
8
|
+
# gate_ok - floor holds? (correctness) -- must be true to commit
|
|
9
|
+
# improved - better than the parent commit? (the descent signal)
|
|
10
|
+
# met_target - objective reached? (orchestrator-owned stop)
|
|
11
|
+
# value - the score (higher = better); nil for pure-pairwise evals
|
|
12
|
+
# detail - one-line human summary for logs / progress
|
|
13
|
+
# output - diagnostic text (gate command output, judge rationale) used
|
|
14
|
+
# for repair prompts and notes; nil when there is none
|
|
15
|
+
Score = Data.define(:gate_ok, :improved, :met_target, :value, :detail, :output) do
|
|
16
|
+
def gate_ok? = gate_ok
|
|
17
|
+
def improved? = improved
|
|
18
|
+
def met_target? = met_target
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module To
|
|
5
|
+
# Prints the post-run summary to stdout.
|
|
6
|
+
class ExitSummary
|
|
7
|
+
def initialize(run, config, abort_reason: nil)
|
|
8
|
+
@run = run
|
|
9
|
+
@config = config
|
|
10
|
+
@abort_reason = abort_reason
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def print
|
|
14
|
+
stat = @run.base_commit ? diff_stat : { insertions: 0, deletions: 0, files: 0 }
|
|
15
|
+
aborted = !@abort_reason.nil?
|
|
16
|
+
good_iters = @run.commits
|
|
17
|
+
fail_iters = @run.iteration - good_iters
|
|
18
|
+
|
|
19
|
+
puts ""
|
|
20
|
+
print_header(aborted)
|
|
21
|
+
print_counters(good_iters, fail_iters, stat)
|
|
22
|
+
print_paths
|
|
23
|
+
print_next_steps
|
|
24
|
+
puts ""
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def print_header(aborted)
|
|
30
|
+
if aborted
|
|
31
|
+
puts "robot-to stopped — #{@config.model} — #{@run.elapsed_human}"
|
|
32
|
+
puts "Reason: #{@abort_reason}"
|
|
33
|
+
else
|
|
34
|
+
puts "robot-to complete — #{@config.model} — #{@run.elapsed_human}"
|
|
35
|
+
puts "Branch: #{@run.branch}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def print_counters(good_iters, fail_iters, stat)
|
|
40
|
+
puts ""
|
|
41
|
+
puts "Iterations: #{@run.iteration} total (#{good_iters} good / #{fail_iters} failed)"
|
|
42
|
+
puts "Tokens: #{comma(@run.total_tokens)} (#{comma(@run.input_tokens)} in / #{comma(@run.output_tokens)} out)"
|
|
43
|
+
puts format("%-16s %s", "Commits:", @run.commits.to_s)
|
|
44
|
+
puts "Changes: +#{stat[:insertions]} / -#{stat[:deletions]} lines across #{stat[:files]} files"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def print_paths
|
|
48
|
+
puts ""
|
|
49
|
+
puts format("%-8s %s", "Notes:", @run.notes_path)
|
|
50
|
+
puts format("%-8s %s", "Log:", @run.log_path)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def print_next_steps
|
|
54
|
+
return unless @run.commits.positive?
|
|
55
|
+
|
|
56
|
+
puts ""
|
|
57
|
+
puts "Next steps:"
|
|
58
|
+
puts " git log --oneline #{@run.base_commit}..HEAD"
|
|
59
|
+
puts " git diff --stat #{@run.base_commit}..HEAD"
|
|
60
|
+
puts " gh pr create"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def diff_stat
|
|
64
|
+
git = CommitManager.new
|
|
65
|
+
git.diff_stat(@run.base_commit)
|
|
66
|
+
rescue StandardError
|
|
67
|
+
{ insertions: 0, deletions: 0, files: 0 }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def comma(n)
|
|
71
|
+
n.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require_relative "path_resolution"
|
|
6
|
+
require_relative "run_store"
|
|
7
|
+
|
|
8
|
+
module RobotLab
|
|
9
|
+
module To
|
|
10
|
+
module Guards
|
|
11
|
+
# First-write-wins file snapshot before Write/Edit, ported from
|
|
12
|
+
# little-coder checkpoint. Finer-grained than robot_lab-to's git rollback:
|
|
13
|
+
# it survives WITHIN an iteration, so the model (or a future repair step)
|
|
14
|
+
# can revert a single botched edit without discarding the iteration's good
|
|
15
|
+
# work. Best-effort — never raises into the tool path.
|
|
16
|
+
#
|
|
17
|
+
# Backups land under <run_dir>/checkpoints/, falling back to a temp dir
|
|
18
|
+
# when no Run is supplied. Files that did not exist get a `.absent`
|
|
19
|
+
# sentinel. The snapshotted-set spans calls, so it lives in RunStore.
|
|
20
|
+
class Checkpoint < RobotLab::Hook
|
|
21
|
+
self.namespace = :checkpoint
|
|
22
|
+
|
|
23
|
+
KEY = :robot_lab_to_checkpoint
|
|
24
|
+
MUTATING_TOOLS = %w[write edit].freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def before_run(_ctx)
|
|
28
|
+
RunStore.reset(KEY, [])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def before_tool_call(ctx)
|
|
32
|
+
return unless MUTATING_TOOLS.include?(ctx.tool_name.to_s.downcase)
|
|
33
|
+
|
|
34
|
+
path = PathResolution.resolve(ctx.tool_args)
|
|
35
|
+
return unless path
|
|
36
|
+
|
|
37
|
+
snapshot(path, checkpoint_dir(ctx))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Copy `path`'s current bytes into `dir`, once per run (first-write
|
|
41
|
+
# wins). Swallows all I/O errors — checkpointing must never break a
|
|
42
|
+
# tool call.
|
|
43
|
+
#
|
|
44
|
+
# @return [String, nil] the backup path written, or nil if skipped
|
|
45
|
+
def snapshot(path, dir)
|
|
46
|
+
tracked = RunStore.fetch(KEY, [])
|
|
47
|
+
return if tracked.include?(path)
|
|
48
|
+
|
|
49
|
+
tracked << path
|
|
50
|
+
FileUtils.mkdir_p(dir)
|
|
51
|
+
dest = File.join(dir, safe_name(path))
|
|
52
|
+
if File.exist?(path)
|
|
53
|
+
FileUtils.cp(path, dest)
|
|
54
|
+
dest
|
|
55
|
+
else
|
|
56
|
+
File.write("#{dest}.absent", "")
|
|
57
|
+
"#{dest}.absent"
|
|
58
|
+
end
|
|
59
|
+
rescue SystemCallError, IOError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [String] directory for this run's checkpoints (created)
|
|
64
|
+
def checkpoint_dir(ctx)
|
|
65
|
+
run = ctx.local.run
|
|
66
|
+
base = run.respond_to?(:run_dir) ? run.run_dir : Dir.tmpdir
|
|
67
|
+
dir = File.join(base.to_s, "checkpoints")
|
|
68
|
+
FileUtils.mkdir_p(dir)
|
|
69
|
+
dir
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Flatten an absolute path into a single safe filename.
|
|
73
|
+
#
|
|
74
|
+
# @param path [String]
|
|
75
|
+
# @return [String]
|
|
76
|
+
def safe_name(path)
|
|
77
|
+
flat = path.gsub(/[^A-Za-z0-9._-]/, "_")
|
|
78
|
+
flat.length > 200 ? flat[-200..] : flat
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "path_resolution"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
module To
|
|
7
|
+
module Guards
|
|
8
|
+
# Refuses any attempt by the robot to modify its own grader artifacts (the
|
|
9
|
+
# eval's spec / floor script / --protect-path files), so it cannot "win" by
|
|
10
|
+
# rewriting the criteria it is scored against.
|
|
11
|
+
#
|
|
12
|
+
# Unlike the other guards, this one is installed unconditionally whenever
|
|
13
|
+
# protected paths exist -- not gated behind --local-guards -- because a
|
|
14
|
+
# frontier model can reward-hack too. The protected absolute paths are
|
|
15
|
+
# passed in via context: { paths: [...] }.
|
|
16
|
+
class GraderLock < RobotLab::Hook
|
|
17
|
+
self.namespace = :grader_lock
|
|
18
|
+
|
|
19
|
+
MUTATING_TOOLS = %w[write edit].freeze
|
|
20
|
+
BASH_TOOLS = %w[bash].freeze
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def around_tool_call(ctx)
|
|
24
|
+
paths = Array(ctx.local.paths)
|
|
25
|
+
return yield if paths.empty?
|
|
26
|
+
|
|
27
|
+
target = offending_path(ctx, paths)
|
|
28
|
+
return yield unless target
|
|
29
|
+
|
|
30
|
+
ctx.tool_result = refusal(target)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The locked path a tool call targets, or nil if it touches none.
|
|
34
|
+
# Write/Edit are matched by resolved path; Bash by the command string
|
|
35
|
+
# referencing an absolute path or a locked basename (conservative).
|
|
36
|
+
def offending_path(ctx, paths)
|
|
37
|
+
name = ctx.tool_name.to_s.downcase
|
|
38
|
+
if MUTATING_TOOLS.include?(name)
|
|
39
|
+
resolved = PathResolution.resolve(ctx.tool_args)
|
|
40
|
+
resolved if resolved && paths.include?(resolved)
|
|
41
|
+
elsif BASH_TOOLS.include?(name)
|
|
42
|
+
command_hits(ctx.tool_args, paths)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def command_hits(args, paths)
|
|
47
|
+
command = (args["command"] || args[:command]).to_s
|
|
48
|
+
paths.find { |p| command.include?(p) || command.include?(File.basename(p)) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def refusal(path)
|
|
52
|
+
<<~MSG
|
|
53
|
+
Refused -- #{path} is a locked grader artifact (the criteria you are
|
|
54
|
+
scored against). You must not create, edit, or delete it. Improve the
|
|
55
|
+
work being scored instead.
|
|
56
|
+
MSG
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module To
|
|
5
|
+
module Guards
|
|
6
|
+
# Shared, pure path helpers used by the file guards. No hook state here so
|
|
7
|
+
# each function is trivially testable in isolation.
|
|
8
|
+
module PathResolution
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Keys a tool might use to carry the destination path.
|
|
12
|
+
PATH_KEYS = %w[path file_path].freeze
|
|
13
|
+
|
|
14
|
+
# Extract the raw path string from tool args, accepting either `path`
|
|
15
|
+
# (RubyLLM/MCP style) or `file_path`.
|
|
16
|
+
#
|
|
17
|
+
# @param args [Hash]
|
|
18
|
+
# @return [String, nil]
|
|
19
|
+
def raw_path(args)
|
|
20
|
+
key = PATH_KEYS.find { |k| args[k].is_a?(String) || args[k.to_sym].is_a?(String) }
|
|
21
|
+
return nil unless key
|
|
22
|
+
|
|
23
|
+
(args[key] || args[key.to_sym]).to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Normalize a model-supplied path against cwd.
|
|
27
|
+
#
|
|
28
|
+
# Two deterministic rewrites (ported from little-coder write-guard):
|
|
29
|
+
# 1. "/<single-segment>" (e.g. "/foo.md") -> "<cwd>/foo.md" — small
|
|
30
|
+
# models anchor at filesystem root when handed an "absolute path"
|
|
31
|
+
# schema; a real system path always has an intermediate dir.
|
|
32
|
+
# 2. bare/relative path -> resolved against cwd.
|
|
33
|
+
# Any absolute path with an intermediate directory is left untouched.
|
|
34
|
+
#
|
|
35
|
+
# @param file_path [String]
|
|
36
|
+
# @param cwd [String]
|
|
37
|
+
# @return [String] the resolved absolute path
|
|
38
|
+
def normalize(file_path, cwd: Dir.pwd)
|
|
39
|
+
if file_path.match?(%r{\A/[^/]+\z})
|
|
40
|
+
File.join(cwd, file_path.delete_prefix("/"))
|
|
41
|
+
elsif !file_path.start_with?("/")
|
|
42
|
+
File.join(cwd, file_path)
|
|
43
|
+
else
|
|
44
|
+
file_path
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Resolve a tool's path argument to a concrete absolute path, or nil if
|
|
49
|
+
# the args carry no resolvable path.
|
|
50
|
+
#
|
|
51
|
+
# @param args [Hash]
|
|
52
|
+
# @param cwd [String]
|
|
53
|
+
# @return [String, nil]
|
|
54
|
+
def resolve(args, cwd: Dir.pwd)
|
|
55
|
+
raw = raw_path(args)
|
|
56
|
+
raw && normalize(raw, cwd: cwd)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "run_store"
|
|
5
|
+
|
|
6
|
+
module RobotLab
|
|
7
|
+
module To
|
|
8
|
+
module Guards
|
|
9
|
+
# Response-quality monitor, ported from little-coder quality-monitor.
|
|
10
|
+
# Detects the degenerate output modes that waste a small local model's
|
|
11
|
+
# token budget on an unattended overnight run:
|
|
12
|
+
#
|
|
13
|
+
# repeated_tool_call — exact name+args match with the previous call
|
|
14
|
+
# empty_response — final turn produced no text and no tool calls
|
|
15
|
+
#
|
|
16
|
+
# Loop detection (repeated_tool_call) is the highest-value guard: it is
|
|
17
|
+
# what stops a 9-35B model spinning on the same call until max_tokens. It
|
|
18
|
+
# runs in after_tool_call (RobotLab fires the llm_generation hook only once
|
|
19
|
+
# per run, so per-round comparison must key off tool calls), with the
|
|
20
|
+
# previous call held in the per-run RunStore.
|
|
21
|
+
#
|
|
22
|
+
# RobotLab gives no in-flight "steer" primitive, so after MAX_CONSECUTIVE
|
|
23
|
+
# repeats we raise QualityError. Orchestrator#execute_iteration already
|
|
24
|
+
# rescues iteration errors — it rolls back and records the reason in
|
|
25
|
+
# notes.md, so the next iteration's prompt carries the correction forward.
|
|
26
|
+
class QualityMonitor < RobotLab::Hook
|
|
27
|
+
self.namespace = :quality_monitor
|
|
28
|
+
|
|
29
|
+
KEY = :robot_lab_to_quality
|
|
30
|
+
MAX_CONSECUTIVE = 2
|
|
31
|
+
|
|
32
|
+
# Raised when the model exceeds the consecutive-repeat budget.
|
|
33
|
+
class QualityError < StandardError
|
|
34
|
+
attr_reader :reason
|
|
35
|
+
|
|
36
|
+
def initialize(reason)
|
|
37
|
+
@reason = reason
|
|
38
|
+
super("response quality failure: #{reason}")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
def before_run(_ctx)
|
|
44
|
+
RunStore.reset(KEY, { previous: nil, repeats: 0 })
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Per-call loop detection. This is the load-bearing guard: it stops a
|
|
48
|
+
# small model spinning on the same tool call until the token budget is
|
|
49
|
+
# gone. Empty-response / no-progress handling is intentionally left to
|
|
50
|
+
# the orchestrator's submit-nudge → not_submitted path, which recovers
|
|
51
|
+
# gracefully instead of hard-erroring the iteration.
|
|
52
|
+
def after_tool_call(ctx)
|
|
53
|
+
call = { name: ctx.tool_name.to_s, input: ctx.tool_args }
|
|
54
|
+
state = RunStore.fetch(KEY, { previous: nil, repeats: 0 })
|
|
55
|
+
|
|
56
|
+
if repeated?([call], [state[:previous]].compact)
|
|
57
|
+
state[:repeats] += 1
|
|
58
|
+
raise QualityError, :repeated_tool_call if state[:repeats] > MAX_CONSECUTIVE
|
|
59
|
+
else
|
|
60
|
+
state[:repeats] = 0
|
|
61
|
+
end
|
|
62
|
+
state[:previous] = call
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# ---- pure logic (testable in isolation) -----------------------------
|
|
66
|
+
|
|
67
|
+
# @param text [String]
|
|
68
|
+
# @param calls [Array<Hash>] each {name:, input:}
|
|
69
|
+
# @param previous [Array<Hash>] previous round's calls
|
|
70
|
+
# @param known [Array<String>] registered tool names (optional)
|
|
71
|
+
# @return [Symbol, String] :ok or a failure reason
|
|
72
|
+
def assess(text, calls, previous, known)
|
|
73
|
+
return :empty_response if text.to_s.strip.empty? && calls.empty?
|
|
74
|
+
|
|
75
|
+
calls.each do |c|
|
|
76
|
+
return :empty_tool_name if c[:name].to_s.empty?
|
|
77
|
+
return "unknown_tool:#{c[:name]}" if known.any? && !known.include?(c[:name])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
return :repeated_tool_call if repeated?(calls, previous)
|
|
81
|
+
|
|
82
|
+
:ok
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# True when any current call exactly matches any previous call.
|
|
86
|
+
def repeated?(calls, previous)
|
|
87
|
+
return false if calls.empty? || previous.empty?
|
|
88
|
+
|
|
89
|
+
calls.any? do |c|
|
|
90
|
+
previous.any? do |p|
|
|
91
|
+
c[:name] == p[:name] && JSON.generate(c[:input]) == JSON.generate(p[:input])
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def correction_message(reason)
|
|
97
|
+
case reason.to_s
|
|
98
|
+
when "repeated_tool_call"
|
|
99
|
+
"You repeated the previous tool call verbatim — you appear to be " \
|
|
100
|
+
"stuck. Try a different approach or explain what is blocking you."
|
|
101
|
+
when "empty_response"
|
|
102
|
+
"Your response was empty. Respond with text or a tool call to make progress."
|
|
103
|
+
when /\Aunknown_tool:/
|
|
104
|
+
"Tool '#{reason.to_s.split(':', 2).last}' does not exist. Use only your registered tools."
|
|
105
|
+
else
|
|
106
|
+
"Response quality issue (#{reason}). Please correct and continue."
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|