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