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,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module To
5
+ # Builds the per-iteration system prompt injected into each robot.
6
+ class PromptBuilder
7
+ def initialize(config)
8
+ @config = config
9
+ end
10
+
11
+ def build(run, notes_content, workspace: nil, pending_commit_failure: nil, resolved_decisions: [])
12
+ sections = [role_section(run), notes_section(run, notes_content)]
13
+ sections << workspace_section(workspace) if workspace && !workspace.empty?
14
+ sections << decisions_answered_section(resolved_decisions) if resolved_decisions && !resolved_decisions.empty?
15
+ sections << task_section
16
+ sections << score_feedback_section(run)
17
+ sections << verify_section if @config.verify_command
18
+ sections << decision_guidance_section if @config.decisions_enabled?
19
+ sections << submit_section
20
+ sections << repair_section(pending_commit_failure) if pending_commit_failure
21
+ sections << stop_when_section(@config.stop_when) if @config.stop_when
22
+ sections.compact.join("\n\n")
23
+ end
24
+
25
+ private
26
+
27
+ # Close the loop: tell the robot how its recent iterations scored so each
28
+ # attempt is a testable hypothesis against the target, not a blind guess.
29
+ # Returns nil for unscored runs (no measure/target/prose) so legacy runs are
30
+ # unchanged.
31
+ def score_feedback_section(run)
32
+ return unless scored_run?
33
+
34
+ body = [best_line(run), progress_line(run)].compact.join("\n")
35
+ return if body.empty?
36
+
37
+ <<~MD.chomp
38
+ ## Score Feedback
39
+
40
+ #{body}
41
+ MD
42
+ end
43
+
44
+ def scored_run?
45
+ !@config.eval_measure.nil? || !@config.eval_target.nil? ||
46
+ @config.eval == "prose" || !@config.eval_spec.nil?
47
+ end
48
+
49
+ def best_line(run)
50
+ return unless run.last_score_value
51
+
52
+ target = @config.eval_target ? " (target: #{@config.eval_target})" : ""
53
+ "Best score committed so far: #{run.last_score_value}#{target}."
54
+ end
55
+
56
+ def progress_line(run)
57
+ stalled = run.iterations_since_improvement
58
+ if stalled.positive?
59
+ "The last #{stalled} iteration(s) did not improve and were rolled back. " \
60
+ "A similar change will be rolled back again — try a DIFFERENT approach now."
61
+ elsif run.iteration > 1
62
+ "Your last change improved the result and was committed. Build on it."
63
+ end
64
+ end
65
+
66
+ # Feed back the human's answers to questions the robot raised earlier so it
67
+ # acts on them this iteration instead of re-raising the same decision.
68
+ def decisions_answered_section(decisions)
69
+ items = decisions.map do |d|
70
+ "- **#{d.question}**\n → Human decision: #{d.resolution || "(resolved — see #{d.path})"}"
71
+ end.join("\n")
72
+
73
+ <<~MD.chomp
74
+ ## Answered Decisions
75
+
76
+ A human has answered questions you raised in earlier iterations. Act on
77
+ these decisions now — do NOT raise them again:
78
+
79
+ #{items}
80
+ MD
81
+ end
82
+
83
+ # Calibration guidance: the article's hardest problem is teaching the agent
84
+ # WHEN to escalate. Name the bar explicitly.
85
+ def decision_guidance_section
86
+ <<~MD.chomp
87
+ ## When to Ask for a Human Decision
88
+
89
+ You have a `request_decision` tool. Use it ONLY for a choice that is
90
+ irreversible or hard to reverse, that sets a public contract, that is
91
+ genuinely ambiguous about the intent behind the objective, or that
92
+ falls outside the objective's scope. Always include your recommended
93
+ option and reasoning (your "lean"). Set blocking=true only when work
94
+ cannot correctly continue without the answer.
95
+
96
+ Do NOT use it for routine engineering choices you are equipped to make
97
+ yourself — naming, structure, library selection within scope, and the
98
+ like. Prefer making progress over asking.
99
+ MD
100
+ end
101
+
102
+ # R3: front-load the project layout so the robot doesn't burn its first
103
+ # several tool calls re-discovering the workspace (ls/find/read) every run.
104
+ def workspace_section(files)
105
+ listing = files.first(200).map { |f| "- #{f}" }.join("\n")
106
+ <<~MD.chomp
107
+ ## Project Files
108
+
109
+ The project already contains the files below — read them directly instead
110
+ of re-discovering the layout. (Files you create appear here next iteration.)
111
+
112
+ #{listing}
113
+ MD
114
+ end
115
+
116
+ # R1: name the exact command the orchestrator will grade this iteration by,
117
+ # so the robot can satisfy it instead of guessing.
118
+ def verify_section
119
+ <<~MD.chomp
120
+ ## Verification — how this iteration is judged
121
+
122
+ After your change, the orchestrator runs this EXACT command and only commits
123
+ if it exits 0 — otherwise everything is rolled back:
124
+
125
+ #{@config.verify_command}
126
+
127
+ Run it yourself and make it pass BEFORE calling submit_iteration_result.
128
+ MD
129
+ end
130
+
131
+ def role_section(run)
132
+ <<~MD.chomp
133
+ You are an autonomous software engineer working on the following objective:
134
+
135
+ **#{run.objective}**
136
+
137
+ You are on iteration #{run.iteration}. Work incrementally — make ONE focused improvement per iteration.
138
+ MD
139
+ end
140
+
141
+ def notes_section(run, content)
142
+ <<~MD.chomp
143
+ ## Prior Work (notes.md)
144
+
145
+ Read the notes below to understand what has been done in previous iterations.
146
+ Do NOT modify notes.md — it is maintained automatically by the orchestrator.
147
+
148
+ File path: #{run.notes_path}
149
+
150
+ ```
151
+ #{content.strip.empty? ? "(no iterations yet)" : content.strip}
152
+ ```
153
+ MD
154
+ end
155
+
156
+ def task_section
157
+ <<~MD.chomp
158
+ ## Your Task
159
+
160
+ 1. Make ONE focused, meaningful change toward the objective.
161
+ 2. Before finishing: stop any background processes (dev servers, watchers, browsers).
162
+ 3. Before finishing: run tests, build, or linters if they exist in the project.
163
+ 4. A complete no-op (no file changes AND no new learnings) is NOT success — set success=false.
164
+ MD
165
+ end
166
+
167
+ def submit_section
168
+ <<~MD.chomp
169
+ ## Required: Submit Your Result
170
+
171
+ You MUST call `submit_iteration_result` as your FINAL action. Include:
172
+ - success: true/false
173
+ - summary: one sentence describing what you did or why you stopped
174
+ - key_changes: list of files or changes made
175
+ - key_learnings: insights worth recording for future iterations
176
+ MD
177
+ end
178
+
179
+ def repair_section(error)
180
+ <<~MD.chomp
181
+ ## ⚠ Previous Commit Failure — Repair Required
182
+
183
+ The previous iteration completed successfully but git commit failed with:
184
+
185
+ ```
186
+ #{error.output}
187
+ ```
188
+
189
+ **Fix the uncommitted changes first** before doing anything else. Once the
190
+ commit issue is resolved, call submit_iteration_result with success=true.
191
+ MD
192
+ end
193
+
194
+ def stop_when_section(condition)
195
+ <<~MD.chomp
196
+ ## Stop Condition
197
+
198
+ After completing your work, evaluate whether the following condition is met:
199
+
200
+ > #{condition}
201
+
202
+ If the condition is met AND this iteration was successful, set should_fully_stop=true
203
+ in your submit_iteration_result call.
204
+ MD
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module RobotLab
7
+ module To
8
+ # Holds mutable state for a single autonomous run.
9
+ class Run
10
+ attr_reader :run_id, :objective, :branch, :base_commit, :notes_path, :log_path,
11
+ :run_dir, :decisions_path, :started_at
12
+ attr_accessor :iteration, :consecutive_failures, :consecutive_errors,
13
+ :commits, :input_tokens, :output_tokens,
14
+ :last_score_value, :iterations_since_improvement
15
+
16
+ def initialize(run_id:, objective:, branch:, base_commit:, notes_path:, log_path:,
17
+ run_dir: nil, decisions_path: nil)
18
+ @run_id = run_id
19
+ @objective = objective
20
+ @branch = branch
21
+ @base_commit = base_commit
22
+ @notes_path = Pathname.new(notes_path)
23
+ @log_path = Pathname.new(log_path)
24
+ @run_dir = Pathname.new(run_dir || @notes_path.parent)
25
+ @decisions_path = Pathname.new(decisions_path || @run_dir.join("decisions"))
26
+ @started_at = Time.now
27
+ @iteration = 0
28
+ @consecutive_failures = 0
29
+ @consecutive_errors = 0
30
+ @commits = 0
31
+ @input_tokens = 0
32
+ @output_tokens = 0
33
+ @last_score_value = nil
34
+ @iterations_since_improvement = 0
35
+ end
36
+
37
+ def total_tokens = input_tokens + output_tokens
38
+ def elapsed_seconds = Time.now - started_at
39
+ def state_path = @run_dir.join("run.json")
40
+
41
+ def elapsed_human
42
+ secs = elapsed_seconds.to_i
43
+ h = secs / 3600
44
+ m = (secs % 3600) / 60
45
+ s = secs % 60
46
+ if h.positive?
47
+ "#{h}h #{m}m #{s}s"
48
+ else
49
+ m.positive? ? "#{m}m #{s}s" : "#{s}s"
50
+ end
51
+ end
52
+
53
+ # Serializable snapshot for run.json (enables --resume across processes).
54
+ def to_h
55
+ {
56
+ run_id: run_id, objective: objective, branch: branch, base_commit: base_commit,
57
+ notes_path: notes_path.to_s, log_path: log_path.to_s, run_dir: run_dir.to_s,
58
+ decisions_path: decisions_path.to_s, started_at: started_at.iso8601,
59
+ iteration: iteration, consecutive_failures: consecutive_failures,
60
+ consecutive_errors: consecutive_errors, commits: commits,
61
+ input_tokens: input_tokens, output_tokens: output_tokens,
62
+ last_score_value: last_score_value,
63
+ iterations_since_improvement: iterations_since_improvement
64
+ }
65
+ end
66
+
67
+ # Rebuild a Run from a run.json written by #to_h.
68
+ def self.load(path)
69
+ data = JSON.parse(File.read(path), symbolize_names: true)
70
+ run = new(run_id: data[:run_id], objective: data[:objective], branch: data[:branch],
71
+ base_commit: data[:base_commit], notes_path: data[:notes_path],
72
+ log_path: data[:log_path], run_dir: data[:run_dir],
73
+ decisions_path: data[:decisions_path])
74
+ run.iteration = data[:iteration].to_i
75
+ run.consecutive_failures = data[:consecutive_failures].to_i
76
+ run.consecutive_errors = data[:consecutive_errors].to_i
77
+ run.commits = data[:commits].to_i
78
+ run.input_tokens = data[:input_tokens].to_i
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
82
+ run.instance_variable_set(:@started_at, Time.parse(data[:started_at])) if data[:started_at]
83
+ run
84
+ end
85
+
86
+ # Derive run_id from current timestamp + random hex suffix.
87
+ def self.generate_id
88
+ t = Time.now
89
+ hex = SecureRandom.hex(3)
90
+ "#{t.strftime("%Y%m%d-%H%M%S")}-#{hex}"
91
+ end
92
+
93
+ # Slugify objective into a branch-name fragment (max 40 chars).
94
+ def self.slugify(text)
95
+ text.downcase
96
+ .gsub(/[^a-z0-9]+/, "-")
97
+ .gsub(/^-+|-+$/, "")
98
+ .slice(0, 40)
99
+ .then { |s| s.empty? ? "takeover" : s }
100
+ end
101
+
102
+ def self.branch_name(objective)
103
+ slug = slugify(objective)
104
+ ts = Time.now.strftime("%Y%m%d-%H%M%S")
105
+ "robot-to/#{slug}-#{ts}"
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module To
5
+ # Evaluates all stop conditions against the current run state.
6
+ class StopConditions
7
+ def initialize(config, run)
8
+ @config = config
9
+ @run = run
10
+ end
11
+
12
+ # Checked before starting a new iteration.
13
+ def before?
14
+ check_max_iterations || check_max_tokens
15
+ end
16
+
17
+ # Checked after an iteration completes.
18
+ def after?
19
+ check_max_iterations || check_max_tokens || check_consecutive_failures || check_plateau
20
+ end
21
+
22
+ # Checked after a successful iteration with a stop_when condition.
23
+ def stop_when_met?(result)
24
+ return false unless @config.stop_when
25
+ return false unless result.stop?
26
+
27
+ raise AbortError, "stop condition met: #{@config.stop_when}"
28
+ end
29
+
30
+ # Called by the orchestrator's token-tracking callback mid-iteration.
31
+ # Returns the abort reason string if exceeded, nil otherwise.
32
+ def token_limit_exceeded?
33
+ return false unless @config.max_tokens
34
+
35
+ @run.total_tokens >= @config.max_tokens
36
+ end
37
+
38
+ private
39
+
40
+ def check_max_iterations
41
+ return unless @config.max_iterations
42
+ return unless @run.iteration >= @config.max_iterations
43
+
44
+ raise AbortError, "max iterations reached (#{@config.max_iterations})"
45
+ end
46
+
47
+ def check_max_tokens
48
+ return unless @config.max_tokens
49
+ return unless @run.total_tokens >= @config.max_tokens
50
+
51
+ raise AbortError, "max tokens reached (#{@config.max_tokens})"
52
+ end
53
+
54
+ def check_consecutive_failures
55
+ return unless @run.consecutive_failures >= @config.max_consecutive_failures
56
+
57
+ raise AbortError, "#{@run.consecutive_failures} consecutive failures"
58
+ end
59
+
60
+ # Diminishing returns: stop after N iterations with no committed improvement.
61
+ # The primary terminator for pairwise/judged evals, which never set a target.
62
+ def check_plateau
63
+ limit = @config.stop_on_plateau
64
+ return unless limit
65
+ return unless @run.iterations_since_improvement >= limit
66
+
67
+ raise AbortError, "plateau: #{limit} iterations without improvement"
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RobotLab
6
+ module To
7
+ module Tools
8
+ # Run a shell command in the working directory and return combined
9
+ # stdout+stderr plus the exit status. Output is capped and the command is
10
+ # killed past a timeout so a runaway process can't stall an overnight loop.
11
+ class Bash < FileTool
12
+ DEFAULT_TIMEOUT = 120
13
+ MAX_OUTPUT = 30_000
14
+
15
+ description <<~DESC
16
+ Run a shell command in the project directory and return its combined
17
+ output and exit status. Use for building, testing, listing, and git.
18
+ DESC
19
+
20
+ param :command, type: "string", desc: "The shell command to run"
21
+ param :timeout, type: "integer", desc: "Seconds before the command is killed", required: false
22
+
23
+ def initialize(robot: nil, timeout: DEFAULT_TIMEOUT)
24
+ super(robot: robot)
25
+ @default_timeout = timeout
26
+ end
27
+
28
+ def execute(command:, timeout: nil, **)
29
+ out, status = run(command, (timeout || @default_timeout).to_i)
30
+ format_result(out, status)
31
+ end
32
+
33
+ # Run `command`, returning [combined_output, status_string].
34
+ # status_string is "0".."n" for an exit code, or "timeout"/"error: ...".
35
+ #
36
+ # @return [Array(String, String)]
37
+ def run(command, timeout)
38
+ out_str = +""
39
+ status = nil
40
+ Open3.popen2e(command, chdir: Dir.pwd) do |_stdin, out, wait|
41
+ reader = Thread.new { out.each_char { |c| out_str << c } }
42
+ status = finished_within?(wait, timeout) ? wait.value.exitstatus.to_s : kill(wait)
43
+ reader.join(1)
44
+ end
45
+ [out_str, status]
46
+ rescue SystemCallError, IOError => e
47
+ ["", "error: #{e.message}"]
48
+ end
49
+
50
+ private
51
+
52
+ # @return [Boolean] true if the process finished within `timeout`
53
+ def finished_within?(wait, timeout)
54
+ deadline = clock + timeout
55
+ sleep(0.01) while wait.alive? && clock < deadline
56
+ !wait.alive?
57
+ end
58
+
59
+ def kill(wait)
60
+ Process.kill("TERM", wait.pid)
61
+ "timeout"
62
+ rescue Errno::ESRCH
63
+ "timeout"
64
+ end
65
+
66
+ def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+
68
+ def format_result(out, status)
69
+ body = out.length > MAX_OUTPUT ? "#{out[0, MAX_OUTPUT]}\n[output truncated]" : out
70
+ "[exit #{status}]\n#{body}"
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module To
5
+ module Tools
6
+ # Exact-string replacement in an existing file. old_text must match the
7
+ # file's current contents exactly (whitespace included) and be unique
8
+ # unless replace_all is set. The ReadBeforeEdit guard requires the file to
9
+ # have been Read this session first, so old_text reflects real contents.
10
+ class Edit < FileTool
11
+ description <<~DESC
12
+ Replace an exact substring in an existing file. old_text must match the
13
+ current file contents exactly (including whitespace) and be unique unless
14
+ replace_all is true. Read the file first to get the exact text.
15
+ DESC
16
+
17
+ param :path, type: "string", desc: "Path of the file to edit"
18
+ param :old_text, type: "string", desc: "Exact text to replace"
19
+ param :new_text, type: "string", desc: "Replacement text"
20
+ param :replace_all, type: "boolean", desc: "Replace every occurrence", required: false
21
+
22
+ def execute(path:, old_text:, new_text:, replace_all: false, **)
23
+ resolved = File.expand_path(path, Dir.pwd)
24
+ return "Error: file not found: #{path}" unless File.file?(resolved)
25
+
26
+ original = File.read(resolved)
27
+ result = apply(original, old_text.to_s, new_text.to_s, replace_all)
28
+ return result if result.is_a?(String) && result.start_with?("Error:")
29
+
30
+ File.write(resolved, result)
31
+ "Edited #{path}"
32
+ rescue SystemCallError => e
33
+ "Error editing #{path}: #{e.message}"
34
+ end
35
+
36
+ # Pure replacement logic (testable in isolation). Returns the new content
37
+ # string, or an "Error: ..." string on a precondition failure.
38
+ #
39
+ # @return [String]
40
+ def apply(content, old_text, new_text, replace_all)
41
+ count = content.scan(old_text).size
42
+ return "Error: old_text not found in file" if count.zero?
43
+ if count > 1 && !replace_all
44
+ return "Error: old_text is not unique (#{count} matches); add context or set replace_all"
45
+ end
46
+
47
+ replace_all ? content.gsub(old_text, new_text) : content.sub(old_text, new_text)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module To
5
+ module Tools
6
+ # Base for robot_lab-to's built-in workspace tools. RubyLLM derives a
7
+ # fully-namespaced tool name (e.g. "robot_lab--to--tools--read"); small
8
+ # models do far better with the short, conventional names the guards and
9
+ # skill docs reference, so we expose the bare last segment ("read",
10
+ # "write", "edit", "bash", ...).
11
+ class FileTool < RobotLab::Tool
12
+ # @return [String] the short, lowercase tool name
13
+ def name
14
+ self.class.short_name
15
+ end
16
+
17
+ # @return [String] last class path segment, snake_cased
18
+ def self.short_name
19
+ name_segment ||
20
+ to_s.split("::").last
21
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
22
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
23
+ .downcase
24
+ end
25
+
26
+ # Subclasses may override the derived short name.
27
+ # @return [String, nil]
28
+ def self.name_segment = nil
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module To
5
+ module Tools
6
+ # Read a UTF-8 text file, optionally a line range. Output is capped so a
7
+ # small local model's context can't be blown by one oversized read
8
+ # (mirrors little-coder's read-guard intent).
9
+ class Read < FileTool
10
+ MAX_LINES = 2000
11
+
12
+ description <<~DESC
13
+ Read a text file and return its contents. Optionally pass offset (1-based
14
+ start line) and limit (max lines). Always read a file before editing it.
15
+ DESC
16
+
17
+ param :path, type: "string", desc: "Path to the file to read"
18
+ param :offset, type: "integer", desc: "1-based first line to return", required: false
19
+ param :limit, type: "integer", desc: "Maximum number of lines to return", required: false
20
+
21
+ def execute(path:, offset: nil, limit: nil, **)
22
+ resolved = File.expand_path(path, Dir.pwd)
23
+ return "Error: file not found: #{path}" unless File.file?(resolved)
24
+
25
+ lines = File.readlines(resolved)
26
+ slice = select_lines(lines, offset, limit)
27
+ render(slice, lines.size, offset)
28
+ rescue SystemCallError => e
29
+ "Error reading #{path}: #{e.message}"
30
+ end
31
+
32
+ # @return [Array<String>] the chosen lines, capped at MAX_LINES
33
+ def select_lines(lines, offset, limit)
34
+ start = offset ? [offset.to_i - 1, 0].max : 0
35
+ count = limit ? limit.to_i : MAX_LINES
36
+ lines[start, [count, MAX_LINES].min] || []
37
+ end
38
+
39
+ private
40
+
41
+ def render(slice, total, offset)
42
+ body = slice.join
43
+ start = offset ? offset.to_i : 1
44
+ shown = slice.size
45
+ return "(empty file)" if total.zero?
46
+
47
+ if start + shown - 1 < total
48
+ "#{body}\n[truncated: showed #{shown} of #{total} lines starting at #{start}; " \
49
+ "use offset/limit to read more]"
50
+ else
51
+ body
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module To
5
+ module Tools
6
+ # Tool the robot calls to escalate a choice it should NOT make on its own.
7
+ #
8
+ # Instead of blocking on a human (impossible in an unattended loop), the
9
+ # robot records the decision as an artifact — situation, options, and its
10
+ # recommended lean — and keeps going or stops. The orchestrator persists
11
+ # each captured request as a decision file; a human resolves it out-of-band.
12
+ #
13
+ # Create a fresh instance per iteration. After robot.run() returns, read
14
+ # captured_requests (empty if the robot raised none).
15
+ class RequestDecision < RobotLab::Tool
16
+ description <<~DESC
17
+ Record a decision that requires HUMAN judgment and must not be made
18
+ autonomously — an irreversible or hard-to-reverse choice, a public
19
+ contract, an ambiguity about intent, or something outside the stated
20
+ objective. Always include your recommended option (your "lean").
21
+
22
+ This does NOT block for an answer. If blocking is true, the run pauses
23
+ after this iteration until a human resolves the decision; if false,
24
+ keep making progress on other work you can safely do. Do NOT use this
25
+ for routine engineering choices you are equipped to make yourself.
26
+ DESC
27
+
28
+ param :question, type: "string",
29
+ desc: "The decision that needs a human answer, phrased as a question"
30
+
31
+ param :situation, type: "string",
32
+ desc: "Why this needs a human and what is at stake", required: false
33
+
34
+ param :options, type: "array",
35
+ desc: "The distinct options you see (strings)", required: false
36
+
37
+ param :recommendation, type: "string",
38
+ desc: "Your recommended option and the reasoning behind it", required: false
39
+
40
+ param :blocking, type: "boolean",
41
+ desc: "true if work cannot correctly proceed until this is answered", required: false
42
+
43
+ def captured_requests
44
+ @captured_requests ||= []
45
+ end
46
+
47
+ def execute(question:, situation: "", options: [], recommendation: "", blocking: false, **)
48
+ (@captured_requests ||= []) << {
49
+ question: question,
50
+ situation: situation.to_s,
51
+ options: Array(options),
52
+ recommendation: recommendation.to_s,
53
+ blocking: blocking ? true : false
54
+ }
55
+
56
+ if blocking
57
+ "Decision recorded (BLOCKING). Stop work now and call submit_iteration_result; " \
58
+ "the run will pause until a human resolves it."
59
+ else
60
+ "Decision recorded. Continue with other work you can safely do, then submit your result."
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end