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,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
module To
|
|
7
|
+
# All git operations needed by the orchestrator.
|
|
8
|
+
#
|
|
9
|
+
# All subprocess calls use explicit argv arrays (no shell interpolation).
|
|
10
|
+
# GIT_TERMINAL_PROMPT=0 prevents credential prompts from hanging the loop.
|
|
11
|
+
class CommitManager
|
|
12
|
+
GIT_ENV = { "GIT_TERMINAL_PROMPT" => "0" }.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(work_dir: Dir.pwd)
|
|
15
|
+
@work_dir = work_dir
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def current_branch
|
|
19
|
+
git("rev-parse", "--abbrev-ref", "HEAD").chomp
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def head_sha
|
|
23
|
+
git("rev-parse", "HEAD").chomp
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def create_branch(name)
|
|
27
|
+
git("checkout", "-b", name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Switch to an existing branch (used when resuming a prior run).
|
|
31
|
+
def checkout_branch(name)
|
|
32
|
+
git("checkout", name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def staged?
|
|
36
|
+
_out, _err, status = Open3.capture3(GIT_ENV, "git", "diff", "--cached", "--quiet",
|
|
37
|
+
chdir: @work_dir)
|
|
38
|
+
!status.success? # exit 1 = changes are staged
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def add_all
|
|
42
|
+
git("add", "-A")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def commit(message)
|
|
46
|
+
out, err, status = Open3.capture3(
|
|
47
|
+
GIT_ENV, "git", "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false",
|
|
48
|
+
"commit", "-m", message,
|
|
49
|
+
chdir: @work_dir
|
|
50
|
+
)
|
|
51
|
+
return if status.success?
|
|
52
|
+
|
|
53
|
+
raise CommitFailedError.new("git commit failed", output: (out + err).strip)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def head_exists?
|
|
57
|
+
_out, _err, status = Open3.capture3(GIT_ENV, "git", "rev-parse", "--verify", "HEAD",
|
|
58
|
+
chdir: @work_dir)
|
|
59
|
+
status.success?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def reset_hard
|
|
63
|
+
return unless head_exists?
|
|
64
|
+
|
|
65
|
+
git("reset", "--hard", "HEAD")
|
|
66
|
+
git("clean", "-fd")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def diff_stat(base_sha)
|
|
70
|
+
out, _err, _status = Open3.capture3(GIT_ENV, "git", "diff", "--stat",
|
|
71
|
+
"#{base_sha}..HEAD", chdir: @work_dir)
|
|
72
|
+
parse_diff_stat(out)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def add_to_local_exclude(entry)
|
|
76
|
+
exclude = Pathname.new(@work_dir).join(".git", "info", "exclude")
|
|
77
|
+
exclude.parent.mkpath
|
|
78
|
+
content = exclude.exist? ? exclude.read : ""
|
|
79
|
+
return if content.include?(entry)
|
|
80
|
+
|
|
81
|
+
File.open(exclude, "a") { |f| f.puts(entry) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def changed_files_since(base_sha)
|
|
85
|
+
out, _err, _status = Open3.capture3(GIT_ENV, "git", "diff", "--name-only",
|
|
86
|
+
"#{base_sha}..HEAD", chdir: @work_dir)
|
|
87
|
+
out.lines.map(&:chomp).reject(&:empty?)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Files differing between a ref and the current working tree (uncommitted
|
|
91
|
+
# changes), INCLUDING new untracked files. Used by pairwise evals to compare
|
|
92
|
+
# a draft against its parent -- and a prose doer typically CREATES the
|
|
93
|
+
# document, so the untracked case is the common one (`git diff` alone misses
|
|
94
|
+
# new files).
|
|
95
|
+
def changed_vs_worktree(ref)
|
|
96
|
+
tracked, = Open3.capture3(GIT_ENV, "git", "diff", "--name-only", ref, chdir: @work_dir)
|
|
97
|
+
untracked, = Open3.capture3(GIT_ENV, "git", "ls-files", "--others", "--exclude-standard",
|
|
98
|
+
chdir: @work_dir)
|
|
99
|
+
(tracked.lines + untracked.lines).map(&:chomp).reject(&:empty?).uniq
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Contents of a path at a given ref, or "" when it did not exist there.
|
|
103
|
+
def show(ref, path)
|
|
104
|
+
out, _err, status = Open3.capture3(GIT_ENV, "git", "show", "#{ref}:#{path}",
|
|
105
|
+
chdir: @work_dir)
|
|
106
|
+
status.success? ? out : ""
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# All tracked files — the project's committed layout. Excludes the run dir
|
|
110
|
+
# (it's in .git/info/exclude), so it's a clean workspace digest.
|
|
111
|
+
def tracked_files
|
|
112
|
+
out, _err, status = Open3.capture3(GIT_ENV, "git", "ls-files", chdir: @work_dir)
|
|
113
|
+
return [] unless status.success?
|
|
114
|
+
|
|
115
|
+
out.lines.map(&:chomp).reject(&:empty?)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def git(*args)
|
|
121
|
+
out, err, status = Open3.capture3(GIT_ENV, "git", *args, chdir: @work_dir)
|
|
122
|
+
raise PermanentError, "git #{args.first} failed: #{err.strip}" unless status.success?
|
|
123
|
+
|
|
124
|
+
out
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def parse_diff_stat(text)
|
|
128
|
+
insertions = text.scan(/(\d+) insertion/).flatten.map(&:to_i).sum
|
|
129
|
+
deletions = text.scan(/(\d+) deletion/).flatten.map(&:to_i).sum
|
|
130
|
+
files = text.scan(/(\d+) file/).flatten.map(&:to_i).first || 0
|
|
131
|
+
{ insertions: insertions, deletions: deletions, files: files }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
defaults:
|
|
2
|
+
provider: openai
|
|
3
|
+
model: gpt-5.5
|
|
4
|
+
max_tool_rounds: 100
|
|
5
|
+
max_consecutive_failures: 3
|
|
6
|
+
max_retries: 2
|
|
7
|
+
max_submit_nudges: 1
|
|
8
|
+
max_verify_repairs: 2
|
|
9
|
+
verify_timeout: 600
|
|
10
|
+
commit_format: default
|
|
11
|
+
run_dir: .robot_lab_to
|
|
12
|
+
local_guards: false
|
|
13
|
+
stream: true
|
|
14
|
+
debug: false
|
|
15
|
+
decisions_enabled: true
|
|
16
|
+
decision_mode: wait # wait = poll until resolved; exit = stop and require --resume
|
|
17
|
+
decision_wait_poll: 30 # seconds between polls while waiting on a blocking decision
|
|
18
|
+
decision_timeout: null # max seconds to wait (null = wait indefinitely)
|
|
19
|
+
|
|
20
|
+
development:
|
|
21
|
+
debug: false
|
|
22
|
+
|
|
23
|
+
test:
|
|
24
|
+
debug: false
|
|
25
|
+
|
|
26
|
+
production:
|
|
27
|
+
debug: false
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "myway_config"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
module To
|
|
7
|
+
# Configuration for a robot-to run.
|
|
8
|
+
#
|
|
9
|
+
# Sources (lowest to highest precedence):
|
|
10
|
+
# 1. Bundled defaults (config/defaults.yml)
|
|
11
|
+
# 2. User config file (~/.config/robot_lab/to.yml)
|
|
12
|
+
# 3. Environment variables (ROBOT_LAB_TO_*)
|
|
13
|
+
# 4. Constructor keyword arguments (CLI overrides)
|
|
14
|
+
class Config < MywayConfig::Base
|
|
15
|
+
config_name :robot_lab_to
|
|
16
|
+
env_prefix :robot_lab_to
|
|
17
|
+
defaults_path File.expand_path("config/defaults.yml", __dir__)
|
|
18
|
+
auto_configure!
|
|
19
|
+
|
|
20
|
+
# Runtime CLI overrides — applied after load.
|
|
21
|
+
attr_writer :provider, :model, :max_iterations, :max_tokens, :stop_when,
|
|
22
|
+
:max_consecutive_failures, :max_submit_nudges, :max_verify_repairs,
|
|
23
|
+
:verify_command, :verify_timeout, :run_dir, :commit_format,
|
|
24
|
+
:local_guards, :stream, :debug,
|
|
25
|
+
:decisions_enabled, :decision_mode, :decision_wait_poll, :decision_timeout,
|
|
26
|
+
:eval, :eval_measure, :eval_target, :require_improvement, :stop_on_plateau
|
|
27
|
+
|
|
28
|
+
# Prose/grader settings (Phase 3). attr_accessor keeps these off the reek
|
|
29
|
+
# method-count budget (Config sits at its TooManyMethods limit); they are
|
|
30
|
+
# assigned in initialize to avoid InstanceVariableAssumption. write_guard
|
|
31
|
+
# (default true) lets local_guards runs opt out of WriteGuard so a robot can
|
|
32
|
+
# freely overwrite files it is iteratively refining (e.g. prose drafts).
|
|
33
|
+
attr_accessor :eval_judge_model, :eval_spec, :eval_floor, :protect_paths, :write_guard
|
|
34
|
+
|
|
35
|
+
def initialize(**overrides)
|
|
36
|
+
super()
|
|
37
|
+
@eval = @eval_measure = @eval_target = nil
|
|
38
|
+
@require_improvement = @stop_on_plateau = nil
|
|
39
|
+
@eval_judge_model = @eval_spec = @eval_floor = nil
|
|
40
|
+
@protect_paths = []
|
|
41
|
+
@write_guard = true
|
|
42
|
+
overrides.each { |k, v| public_send(:"#{k}=", v) if respond_to?(:"#{k}=") }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Keys defined in defaults.yml — super is safe
|
|
46
|
+
def provider = @provider || super
|
|
47
|
+
def model = @model || super
|
|
48
|
+
def max_consecutive_failures = @max_consecutive_failures || super
|
|
49
|
+
def max_submit_nudges = @max_submit_nudges || super
|
|
50
|
+
def max_verify_repairs = @max_verify_repairs || super
|
|
51
|
+
def verify_timeout = @verify_timeout || super
|
|
52
|
+
|
|
53
|
+
def run_dir = @run_dir || super
|
|
54
|
+
def commit_format = @commit_format || super
|
|
55
|
+
def local_guards? = @local_guards.nil? ? super : @local_guards
|
|
56
|
+
def stream? = @stream.nil? ? super : @stream
|
|
57
|
+
def debug? = @debug.nil? ? super : @debug
|
|
58
|
+
|
|
59
|
+
def decisions_enabled? = @decisions_enabled.nil? ? super : @decisions_enabled
|
|
60
|
+
def decision_mode = @decision_mode || super
|
|
61
|
+
def decision_wait_poll = @decision_wait_poll || super
|
|
62
|
+
# nil = wait indefinitely
|
|
63
|
+
def decision_timeout = @decision_timeout || super
|
|
64
|
+
|
|
65
|
+
# CLI-only options with no YAML default (nil means "no limit / not set")
|
|
66
|
+
def max_iterations = @max_iterations
|
|
67
|
+
def max_tokens = @max_tokens
|
|
68
|
+
def stop_when = @stop_when
|
|
69
|
+
def verify_command = @verify_command
|
|
70
|
+
|
|
71
|
+
# Eval strategy selection + tuning (see RobotLab::To::Evals.build). All
|
|
72
|
+
# CLI-only / nil-default. #eval may hold a strategy name ("code"/"null"), a
|
|
73
|
+
# registered symbol, an instance responding to #score, or a proc. The
|
|
74
|
+
# judge/spec/floor readers Prose needs arrive with Phase 3.
|
|
75
|
+
def eval = @eval
|
|
76
|
+
def eval_measure = @eval_measure
|
|
77
|
+
def eval_target = @eval_target
|
|
78
|
+
def stop_on_plateau = @stop_on_plateau
|
|
79
|
+
|
|
80
|
+
# CLI/Ruby-only with a hardcoded default (MywayConfig does not generate a
|
|
81
|
+
# reliable `require_improvement?` predicate). Default true per design: the
|
|
82
|
+
# loop is strictly monotone unless --no-require-improvement is passed.
|
|
83
|
+
def require_improvement? = @require_improvement.nil? || @require_improvement
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module To
|
|
5
|
+
# An immutable snapshot of one decision file on disk.
|
|
6
|
+
#
|
|
7
|
+
# A decision is the artifact the robot writes when it hits a choice it
|
|
8
|
+
# should not make autonomously: the situation, the options, and its own
|
|
9
|
+
# recommended "lean". A human resolves it out-of-band by editing the file
|
|
10
|
+
# (set `status: resolved`, fill `resolution:`); a later iteration reads the
|
|
11
|
+
# resolution back in.
|
|
12
|
+
#
|
|
13
|
+
# Status lifecycle: pending -> resolved (by human) -> closed (resolution
|
|
14
|
+
# delivered to a robot and committed). `dismissed` is a terminal human veto.
|
|
15
|
+
Decision = Data.define(
|
|
16
|
+
:id, # String — d-YYYYMMDD-HHMMSS-<hex>
|
|
17
|
+
:status, # String — pending | resolved | closed | dismissed
|
|
18
|
+
:blocking, # Boolean — does the loop pause until this is resolved?
|
|
19
|
+
:created_at, # String — ISO8601
|
|
20
|
+
:created_iteration, # Integer
|
|
21
|
+
:resolved_at, # String or nil — ISO8601
|
|
22
|
+
:resolution, # String or nil — the human's answer (best available)
|
|
23
|
+
:question, # String
|
|
24
|
+
:situation, # String
|
|
25
|
+
:options, # Array<String>
|
|
26
|
+
:recommendation, # String
|
|
27
|
+
:body, # String — full markdown body (below front matter)
|
|
28
|
+
:path # String — absolute path to the file
|
|
29
|
+
) do
|
|
30
|
+
def pending? = status.to_s == "pending"
|
|
31
|
+
def resolved? = status.to_s == "resolved"
|
|
32
|
+
def closed? = status.to_s == "closed"
|
|
33
|
+
def dismissed? = status.to_s == "dismissed"
|
|
34
|
+
def blocking? = !!blocking
|
|
35
|
+
|
|
36
|
+
# True when a human has answered (resolved with a non-empty resolution).
|
|
37
|
+
def answered? = resolved? && !resolution.to_s.strip.empty?
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module RobotLab
|
|
7
|
+
module To
|
|
8
|
+
# Reads and writes decision files under <run_dir>/decisions/.
|
|
9
|
+
#
|
|
10
|
+
# Parallels NotesManager: the orchestrator records decisions the robot
|
|
11
|
+
# raises and queries their status each iteration; a human resolves them by
|
|
12
|
+
# editing the file between iterations (or between cron ticks).
|
|
13
|
+
#
|
|
14
|
+
# Each decision is a markdown file with a YAML front matter block:
|
|
15
|
+
#
|
|
16
|
+
# ---
|
|
17
|
+
# id: d-20260701-143022-a1b2c3
|
|
18
|
+
# status: pending
|
|
19
|
+
# blocking: true
|
|
20
|
+
# created_at: 2026-07-01T14:30:22Z
|
|
21
|
+
# created_iteration: 7
|
|
22
|
+
# resolved_at:
|
|
23
|
+
# resolution:
|
|
24
|
+
# ---
|
|
25
|
+
# # Decision: <question>
|
|
26
|
+
# ...
|
|
27
|
+
#
|
|
28
|
+
# All writes go through AtomicFile so a crash never leaves a torn file.
|
|
29
|
+
class DecisionManager
|
|
30
|
+
GLOB = "d-*.md"
|
|
31
|
+
|
|
32
|
+
attr_reader :dir
|
|
33
|
+
|
|
34
|
+
def initialize(dir)
|
|
35
|
+
@dir = Pathname.new(dir)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def setup
|
|
39
|
+
@dir.mkpath
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Persist a decision the robot raised. Returns the parsed Decision.
|
|
43
|
+
def record(question:, situation: "", options: [], recommendation: "", blocking: false, iteration: 0)
|
|
44
|
+
id = generate_id
|
|
45
|
+
path = @dir.join("#{id}.md")
|
|
46
|
+
AtomicFile.write(path, render_new(id: id, question: question, situation: situation,
|
|
47
|
+
options: Array(options), recommendation: recommendation.to_s,
|
|
48
|
+
blocking: blocking ? true : false, iteration: iteration.to_i))
|
|
49
|
+
parse(path)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# All decisions on disk, oldest first (ids sort chronologically).
|
|
53
|
+
def all
|
|
54
|
+
Dir.glob(@dir.join(GLOB)).map { |p| parse(p) }.compact
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def pending = all.select(&:pending?)
|
|
58
|
+
# resolved but not yet closed
|
|
59
|
+
def resolved_open = all.select(&:resolved?)
|
|
60
|
+
def blocking_pending = all.select { |d| d.pending? && d.blocking? }
|
|
61
|
+
def blocking_pending? = blocking_pending.any?
|
|
62
|
+
|
|
63
|
+
# Re-read a single decision from disk (a human may have edited it).
|
|
64
|
+
def reload(decision) = parse(decision.path)
|
|
65
|
+
|
|
66
|
+
# Mark a resolved decision closed: its resolution has been delivered to a
|
|
67
|
+
# robot and committed, so it should no longer be re-injected. Flips only
|
|
68
|
+
# the status line, preserving whatever the human wrote in the body.
|
|
69
|
+
def close(decision)
|
|
70
|
+
raw = File.read(decision.path)
|
|
71
|
+
AtomicFile.write(decision.path, flip_status(raw, "closed"))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def generate_id
|
|
77
|
+
"d-#{Time.now.strftime("%Y%m%d-%H%M%S")}-#{SecureRandom.hex(3)}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Replace the status: line inside the leading front matter block only.
|
|
81
|
+
def flip_status(raw, status)
|
|
82
|
+
raw.sub(/^status:.*$/, "status: #{status}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def render_new(id:, question:, situation:, options:, recommendation:, blocking:, iteration:)
|
|
86
|
+
options_md = options.empty? ? "(none provided)\n" : options.each_with_index.map { |o, i| "#{i + 1}. #{o}" }.join("\n") + "\n"
|
|
87
|
+
# Build the front matter with YAML.dump so question/recommendation are
|
|
88
|
+
# escaped correctly (they can contain colons, quotes, etc.).
|
|
89
|
+
front = {
|
|
90
|
+
"id" => id, "status" => "pending", "blocking" => blocking,
|
|
91
|
+
"created_at" => Time.now.utc.iso8601, "created_iteration" => iteration,
|
|
92
|
+
"resolved_at" => nil, "resolution" => nil,
|
|
93
|
+
"question" => question, "recommendation" => recommendation
|
|
94
|
+
}
|
|
95
|
+
fm = YAML.dump(front).sub(/\A---\n/, "").chomp
|
|
96
|
+
<<~MD
|
|
97
|
+
---
|
|
98
|
+
#{fm}
|
|
99
|
+
---
|
|
100
|
+
# Decision: #{question}
|
|
101
|
+
|
|
102
|
+
## Situation
|
|
103
|
+
#{situation.to_s.strip.empty? ? "(none provided)" : situation.strip}
|
|
104
|
+
|
|
105
|
+
## Options
|
|
106
|
+
#{options_md}
|
|
107
|
+
## Recommendation (robot's lean)
|
|
108
|
+
#{recommendation.strip.empty? ? "(none provided)" : recommendation.strip}
|
|
109
|
+
|
|
110
|
+
## Your Decision
|
|
111
|
+
<!-- Human: set `status: resolved` and fill `resolution:` in the front
|
|
112
|
+
matter above (or write your answer below this line), then re-run
|
|
113
|
+
robot-to (or `robot-to --resume <run_id>`). -->
|
|
114
|
+
MD
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Split a file into (front_matter_hash, body). Returns a Decision or nil
|
|
118
|
+
# when the file is unreadable.
|
|
119
|
+
def parse(path)
|
|
120
|
+
text = File.read(path)
|
|
121
|
+
fm, body = split_front_matter(text)
|
|
122
|
+
Decision.new(
|
|
123
|
+
id: fm["id"] || File.basename(path, ".md"),
|
|
124
|
+
status: (fm["status"] || "pending").to_s,
|
|
125
|
+
blocking: truthy?(fm["blocking"]),
|
|
126
|
+
created_at: fm["created_at"].to_s,
|
|
127
|
+
created_iteration: fm["created_iteration"].to_i,
|
|
128
|
+
resolved_at: presence(fm["resolved_at"]),
|
|
129
|
+
resolution: resolution_for(fm, body),
|
|
130
|
+
question: fm["question"].to_s,
|
|
131
|
+
situation: fm["situation"].to_s,
|
|
132
|
+
options: Array(fm["options"]),
|
|
133
|
+
recommendation: fm["recommendation"].to_s,
|
|
134
|
+
body: body,
|
|
135
|
+
path: path.to_s
|
|
136
|
+
)
|
|
137
|
+
rescue SystemCallError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def split_front_matter(text)
|
|
142
|
+
if text.start_with?("---\n") && (close_idx = text.index("\n---", 4))
|
|
143
|
+
raw_fm = text[4...close_idx]
|
|
144
|
+
body = text[(close_idx + 4)..].to_s.sub(/\A\n/, "")
|
|
145
|
+
[safe_yaml(raw_fm), body]
|
|
146
|
+
else
|
|
147
|
+
[{}, text]
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# YAML.safe_load, falling back to a lenient line scanner when a human's
|
|
152
|
+
# hand-edited front matter is not valid YAML.
|
|
153
|
+
def safe_yaml(raw)
|
|
154
|
+
parsed = YAML.safe_load(raw, permitted_classes: [Date, Time])
|
|
155
|
+
parsed.is_a?(Hash) ? stringify(parsed) : lenient_parse(raw)
|
|
156
|
+
rescue Psych::Exception
|
|
157
|
+
lenient_parse(raw)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def lenient_parse(raw)
|
|
161
|
+
raw.lines.each_with_object({}) do |line, h|
|
|
162
|
+
next unless (m = line.match(/^([a-z_]+):\s*(.*)$/))
|
|
163
|
+
|
|
164
|
+
h[m[1]] = m[2].strip
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def stringify(hash)
|
|
169
|
+
hash.transform_keys(&:to_s).transform_values { |v| v.is_a?(Time) ? v.utc.iso8601 : v }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Prefer the front matter resolution; fall back to prose under
|
|
173
|
+
# "## Your Decision" (stripping the HTML instruction comment).
|
|
174
|
+
def resolution_for(fm, body)
|
|
175
|
+
fm_res = presence(fm["resolution"])
|
|
176
|
+
return fm_res if fm_res
|
|
177
|
+
|
|
178
|
+
section = body[/^##\s+Your Decision\s*\n(.*)\z/m, 1].to_s
|
|
179
|
+
section = section.gsub(/<!--.*?-->/m, "").strip
|
|
180
|
+
presence(section)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def presence(value)
|
|
184
|
+
s = value.to_s.strip
|
|
185
|
+
s.empty? ? nil : s
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def truthy?(value)
|
|
189
|
+
%w[true yes 1].include?(value.to_s.strip.downcase)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module To
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Unrecoverable condition (e.g. credit exhaustion). Aborts the run immediately.
|
|
8
|
+
class PermanentError < Error; end
|
|
9
|
+
|
|
10
|
+
# git commit failed. Preserves uncommitted changes so the next iteration can repair them.
|
|
11
|
+
class CommitFailedError < Error
|
|
12
|
+
attr_reader :output
|
|
13
|
+
|
|
14
|
+
def initialize(msg, output: nil)
|
|
15
|
+
super(msg)
|
|
16
|
+
@output = output
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A stop condition was triggered. Results in a clean exit (not an error).
|
|
21
|
+
class AbortError < Error
|
|
22
|
+
attr_reader :reason
|
|
23
|
+
|
|
24
|
+
def initialize(reason)
|
|
25
|
+
super
|
|
26
|
+
@reason = reason
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module To
|
|
5
|
+
module Evals
|
|
6
|
+
# Base class for an Eval strategy. An Eval scores the uncommitted working
|
|
7
|
+
# tree against a spec and returns a Score. It is the deciding authority for
|
|
8
|
+
# commit/rollback (via gate_ok/improved) and stop (via met_target) -- not
|
|
9
|
+
# the robot's self-report.
|
|
10
|
+
#
|
|
11
|
+
# Subclasses override #score. #protected_paths lists grader artifacts the
|
|
12
|
+
# robot must not edit (wired into GraderLock in a later phase); the default
|
|
13
|
+
# is none.
|
|
14
|
+
class Base
|
|
15
|
+
def score(context)
|
|
16
|
+
raise NotImplementedError, "#{self.class}#score must return an Evals::Score"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def protected_paths = []
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module To
|
|
5
|
+
module Evals
|
|
6
|
+
# Measured / deterministic eval for software products.
|
|
7
|
+
#
|
|
8
|
+
# verify - floor gate; a command that must exit 0 (correctness)
|
|
9
|
+
# measure - optional command printing a number to stdout; higher = better
|
|
10
|
+
# target - optional; met_target fires when measured value >= target
|
|
11
|
+
#
|
|
12
|
+
# Composes Verifier for command execution. With no `measure`, it behaves
|
|
13
|
+
# exactly like the original verify-command gate: improved == gate, and
|
|
14
|
+
# met_target never fires (stop falls back to --stop-when).
|
|
15
|
+
class Code < Base
|
|
16
|
+
def initialize(verify: nil, measure: nil, target: nil, timeout: 600, work_dir: Dir.pwd)
|
|
17
|
+
super()
|
|
18
|
+
@verify = verify
|
|
19
|
+
@measure = measure
|
|
20
|
+
@target = target
|
|
21
|
+
@timeout = timeout
|
|
22
|
+
@work_dir = work_dir
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def score(context)
|
|
26
|
+
gate_ok, output = run_gate
|
|
27
|
+
value = measure_value
|
|
28
|
+
Score.new(
|
|
29
|
+
gate_ok: gate_ok,
|
|
30
|
+
improved: improved?(gate_ok, value, context.previous_value),
|
|
31
|
+
met_target: target_met?(value),
|
|
32
|
+
value: value,
|
|
33
|
+
detail: detail_for(gate_ok, value),
|
|
34
|
+
output: output
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Returns [passed?, output]. No verify command means the floor is open.
|
|
41
|
+
def run_gate
|
|
42
|
+
return [true, nil] unless @verify
|
|
43
|
+
|
|
44
|
+
result = Verifier.new(@verify, work_dir: @work_dir, timeout: @timeout).run
|
|
45
|
+
[result.passed?, result.output]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# First number the measure command prints, or nil when not measuring.
|
|
49
|
+
def measure_value
|
|
50
|
+
return nil unless @measure
|
|
51
|
+
|
|
52
|
+
output = Verifier.new(@measure, work_dir: @work_dir, timeout: @timeout).run.output
|
|
53
|
+
match = output[/-?\d+(?:\.\d+)?/]
|
|
54
|
+
match&.to_f
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Without a measured value, any gate-passing change is progress. With one,
|
|
58
|
+
# progress means the value strictly beat the parent (or there is no parent).
|
|
59
|
+
def improved?(gate_ok, value, previous_value)
|
|
60
|
+
return gate_ok if value.nil?
|
|
61
|
+
|
|
62
|
+
previous_value.nil? || value > previous_value
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def target_met?(value)
|
|
66
|
+
!@target.nil? && !value.nil? && value >= @target
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def detail_for(gate_ok, value)
|
|
70
|
+
parts = ["verify=#{gate_ok}"]
|
|
71
|
+
parts << "measure=#{value}" unless value.nil?
|
|
72
|
+
parts.join(" ")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module To
|
|
5
|
+
module Evals
|
|
6
|
+
# What the orchestrator hands an Eval each iteration. One value object so
|
|
7
|
+
# new fields can be added without breaking custom evals.
|
|
8
|
+
#
|
|
9
|
+
# work_dir - the working tree holding the robot's uncommitted changes
|
|
10
|
+
# previous_ref - git ref of the last committed state (HEAD before this iter)
|
|
11
|
+
# previous_value - last committed score value (nil until tracked; Phase 2)
|
|
12
|
+
# objective - the run objective
|
|
13
|
+
# iteration - 1-based iteration number
|
|
14
|
+
Context = Data.define(:work_dir, :previous_ref, :previous_value, :objective, :iteration)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|