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,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