lemans 1.1.0 → 1.2.0
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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +12 -2
- data/lib/lemans/agents/oracle.rb +13 -7
- data/lib/lemans/cli/templates/bench/README.md +7 -2
- data/lib/lemans/cli/templates/bench/tasks/example-task/instruction.md +20 -9
- data/lib/lemans/cli/templates/bench/tasks/example-task/solution.1.patch +17 -0
- data/lib/lemans/cli/templates/bench/tasks/example-task/solution.2.patch +17 -0
- data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.1.rb +31 -0
- data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.rb +1 -1
- data/lib/lemans/config/agent.rb +12 -0
- data/lib/lemans/config/environment.rb +28 -2
- data/lib/lemans/config/network_policy.rb +2 -0
- data/lib/lemans/config/setup.rb +6 -1
- data/lib/lemans/config/verifier.rb +18 -7
- data/lib/lemans/config.rb +44 -9
- data/lib/lemans/environments/docker.rb +1 -1
- data/lib/lemans/ext/deep_merge.rb +13 -0
- data/lib/lemans/result.rb +41 -1
- data/lib/lemans/runner.rb +1 -1
- data/lib/lemans/task_definition.rb +158 -10
- data/lib/lemans/trial/patch.rb +46 -8
- data/lib/lemans/trial/verifier/assets/lemans_minitest_reporter.rb +28 -3
- data/lib/lemans/trial.rb +83 -33
- data/lib/lemans/version.rb +1 -1
- data/lib/miniswen/version.rb +1 -1
- metadata +5 -2
- data/lib/lemans/cli/templates/bench/tasks/example-task/solution.patch +0 -7
data/lib/lemans/result.rb
CHANGED
|
@@ -59,6 +59,18 @@ module Lemans
|
|
|
59
59
|
:cost_usd, :cost_source
|
|
60
60
|
) do
|
|
61
61
|
def as_json(**) = to_h.merge(cost_source: cost_source&.to_h).compact
|
|
62
|
+
|
|
63
|
+
# A multistep trial's totals; an unknown step cost makes the sum unknown.
|
|
64
|
+
def +(other)
|
|
65
|
+
self.class.new(
|
|
66
|
+
input_tokens: input_tokens + other.input_tokens,
|
|
67
|
+
output_tokens: output_tokens + other.output_tokens,
|
|
68
|
+
cached_tokens: cached_tokens + other.cached_tokens,
|
|
69
|
+
steps: steps + other.steps,
|
|
70
|
+
cost_usd: cost_usd && other.cost_usd && cost_usd + other.cost_usd,
|
|
71
|
+
cost_source: other.cost_source || cost_source
|
|
72
|
+
)
|
|
73
|
+
end
|
|
62
74
|
end
|
|
63
75
|
|
|
64
76
|
def Usage.zero
|
|
@@ -95,6 +107,8 @@ module Lemans
|
|
|
95
107
|
@finished_at = time || Time.now.utc
|
|
96
108
|
end
|
|
97
109
|
|
|
110
|
+
def duration = finished_at && (finished_at - started_at).round(1)
|
|
111
|
+
|
|
98
112
|
def as_json(**)
|
|
99
113
|
{
|
|
100
114
|
name:,
|
|
@@ -117,13 +131,23 @@ module Lemans
|
|
|
117
131
|
def as_json(**) = to_h
|
|
118
132
|
end
|
|
119
133
|
|
|
134
|
+
Step = Data.define(:outcome, :usage, :duration) do
|
|
135
|
+
def as_json(**) = { outcome: outcome.as_json, usage: usage&.as_json, duration: }.compact
|
|
136
|
+
|
|
137
|
+
def self.from_json(data)
|
|
138
|
+
new(outcome: Outcome.from_json(data[:outcome]),
|
|
139
|
+
usage: data[:usage] && Usage.from_json(data[:usage]),
|
|
140
|
+
duration: data[:duration])
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
120
144
|
# attributes that must be initialized/specified during construction
|
|
121
145
|
attr_reader :id, :task, :agent, :model, :index,
|
|
122
146
|
:profile_digest, :task_digest, :revision
|
|
123
147
|
|
|
124
148
|
attr_accessor :tags, :metadata
|
|
125
149
|
|
|
126
|
-
attr_reader :phases
|
|
150
|
+
attr_reader :phases, :steps
|
|
127
151
|
|
|
128
152
|
# outcome-related attributes (we use setter-like methods, not accessors)
|
|
129
153
|
attr_reader :reward, :outcome, :usage
|
|
@@ -141,6 +165,7 @@ module Lemans
|
|
|
141
165
|
@tags = []
|
|
142
166
|
@metadata = {}
|
|
143
167
|
@phases = []
|
|
168
|
+
@steps = nil
|
|
144
169
|
|
|
145
170
|
@id = id || "#{task}__#{SecureRandom.alphanumeric(7)}"
|
|
146
171
|
@outcome = Outcome.new(:pending)
|
|
@@ -184,6 +209,14 @@ module Lemans
|
|
|
184
209
|
self
|
|
185
210
|
end
|
|
186
211
|
|
|
212
|
+
def step_completed!(outcome, usage = nil, duration: nil)
|
|
213
|
+
outcome = outcome.is_a?(Outcome) ? outcome : Outcome.new(outcome)
|
|
214
|
+
@steps ||= []
|
|
215
|
+
steps << Step.new(outcome:, usage:, duration:)
|
|
216
|
+
# aggregate right away (so we don't lose data on failure)
|
|
217
|
+
completed!(outcome, aggregate_usage)
|
|
218
|
+
end
|
|
219
|
+
|
|
187
220
|
def graded!(reward)
|
|
188
221
|
@reward = reward
|
|
189
222
|
self
|
|
@@ -199,12 +232,15 @@ module Lemans
|
|
|
199
232
|
self
|
|
200
233
|
end
|
|
201
234
|
|
|
235
|
+
private def aggregate_usage = steps.filter_map(&:usage).reduce(:+)
|
|
236
|
+
|
|
202
237
|
def as_json(**)
|
|
203
238
|
{
|
|
204
239
|
trial: id, task:, agent:, model:, index:,
|
|
205
240
|
profile_digest:, task_digest:, revision: revision&.as_json,
|
|
206
241
|
lemans_version: VERSION,
|
|
207
242
|
tags:, metadata:, phases: phases.map(&:as_json),
|
|
243
|
+
steps: steps&.map(&:as_json),
|
|
208
244
|
reward:, outcome: outcome.as_json, usage: usage&.as_json, duration:,
|
|
209
245
|
started_at: started_at&.iso8601,
|
|
210
246
|
finished_at: finished_at&.iso8601
|
|
@@ -222,6 +258,10 @@ module Lemans
|
|
|
222
258
|
result.tags = data[:tags] || []
|
|
223
259
|
result.metadata = data[:metadata] || {}
|
|
224
260
|
phases_from(data).each { result.phases << it }
|
|
261
|
+
# Steps first: the stored outcome/usage below override the aggregates.
|
|
262
|
+
data[:steps]&.map { Step.from_json(it) }&.each do |step|
|
|
263
|
+
result.step_completed!(step.outcome, step.usage, duration: step.duration)
|
|
264
|
+
end
|
|
225
265
|
if data[:outcome]
|
|
226
266
|
result.completed!(
|
|
227
267
|
Outcome.from_json(data[:outcome]),
|
data/lib/lemans/runner.rb
CHANGED
|
@@ -69,7 +69,7 @@ module Lemans
|
|
|
69
69
|
run.task == task.name &&
|
|
70
70
|
run.model == (model || config.agent.model) &&
|
|
71
71
|
run.agent == config.agent_name &&
|
|
72
|
-
run.profile_digest == config.digest &&
|
|
72
|
+
run.profile_digest == task.config.digest &&
|
|
73
73
|
run.task_digest == task.digest &&
|
|
74
74
|
run.scored?
|
|
75
75
|
end
|
|
@@ -19,17 +19,32 @@ module Lemans
|
|
|
19
19
|
FLAT_SEED = "environment.patch"
|
|
20
20
|
|
|
21
21
|
FRONTMATTER = /\A---\n(.*?)\n---\n/m
|
|
22
|
+
STEP_SEPARATOR = /^---[ \t]*\n/
|
|
23
|
+
|
|
24
|
+
STEP_TESTS = { "tests.%d" => :dir, "verification_test.%d.rb" => FLAT_TEST, "verify.%d" => "verify" }.freeze
|
|
25
|
+
# The same step files, kept inside the shared tests/ directory
|
|
26
|
+
SHARED_STEP_TESTS = { "verification_test.%d.rb" => FLAT_TEST, "verify.%d" => "verify" }.freeze
|
|
27
|
+
# What the final step alone runs (see Config::Verifier::DEFAULT_COMMAND): never shipped to an earlier step
|
|
28
|
+
FINAL_STEP_TESTS = [ FLAT_TEST, "verify", "test.sh" ].freeze
|
|
29
|
+
STEP_SOLUTIONS = { "solution.%d" => :dir, "solution.%d.patch" => FLAT_SOLUTION,
|
|
30
|
+
"solve.%d" => "solve", "solve.%d.sh" => "solve.sh" }.freeze
|
|
31
|
+
|
|
32
|
+
STEP_FILE = /\A(?:tests\.(?<test>\d+)|verification_test\.(?<test>\d+)\.rb|verify\.(?<test>\d+)|
|
|
33
|
+
solution\.(?<solution>\d+)(?:\.patch)?|solve\.(?<solution>\d+)(?:\.sh)?)\z/x
|
|
22
34
|
|
|
23
35
|
class << self
|
|
24
36
|
def load_from_directory(config, dir)
|
|
25
37
|
dir = Pathname(dir)
|
|
26
38
|
data = frontmatter(dir)
|
|
39
|
+
config = Config.load_file(dir, parent: config) if Config::FILENAMES.any? { dir.join(it).file? }
|
|
27
40
|
|
|
28
41
|
task = new(config, data["name"] || dir.basename.to_s, dir:)
|
|
29
42
|
task.description = data["description"].to_s if data["description"]
|
|
30
43
|
task.difficulty = data["difficulty"].to_sym if data["difficulty"]
|
|
31
44
|
task.tags = Array(data["tags"]).map(&:to_s) if data["tags"]
|
|
32
45
|
task.metadata = data["metadata"] if data["metadata"]
|
|
46
|
+
task.environment_profile = data["environment"] if data["environment"]
|
|
47
|
+
task.multistep = data["multistep"] if data.key?("multistep")
|
|
33
48
|
|
|
34
49
|
declared_setup = Config::Setup.from_config(data["setup"], root: dir)
|
|
35
50
|
refuse_config_collisions!(task, declared_setup, config.setup)
|
|
@@ -79,8 +94,21 @@ module Lemans
|
|
|
79
94
|
raise ConfigError, "#{task.dir}: a task may only override verifier.setup, not verifier.#{extras.first}"
|
|
80
95
|
end
|
|
81
96
|
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
if (profile = task.environment_profile)
|
|
98
|
+
unless task.config.environment.profiles.key?(profile)
|
|
99
|
+
declared = task.config.environment.profiles.keys
|
|
100
|
+
listing = declared.any? ? "declares #{declared.join(", ")}" : "declares none"
|
|
101
|
+
raise ConfigError, "#{task.dir}: unknown environment #{profile} — bench.yml #{listing}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
raise ConfigError, "#{task.dir}: environment #{profile} and a local #{ENVIRONMENT_DIR}/Dockerfile are mutually exclusive" if
|
|
105
|
+
task.environment_dockerfile.file?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
raise ConfigError, "#{task.dir}: #{ENVIRONMENT_DIR}/Dockerfile is required when the bench declares no shared image or dockerfile and the task names no environment" unless
|
|
109
|
+
task.environment_profile || task.config.environment.image || task.config.environment.dockerfile || task.environment_dockerfile.file?
|
|
110
|
+
|
|
111
|
+
validate_steps!(task)
|
|
84
112
|
|
|
85
113
|
return unless task.test_files.empty?
|
|
86
114
|
|
|
@@ -88,6 +116,41 @@ module Lemans
|
|
|
88
116
|
"the verifier uploads it at verification time"
|
|
89
117
|
end
|
|
90
118
|
|
|
119
|
+
def validate_steps!(task)
|
|
120
|
+
if task.multistep? && task.steps < 2
|
|
121
|
+
raise ConfigError, "#{task.dir}: multistep: true but #{INSTRUCTION} holds a single section — " \
|
|
122
|
+
"separate step instructions with --- (the first section is the shared preamble)"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
indexed_solutions = false
|
|
126
|
+
entries = task.dir.children
|
|
127
|
+
# Only step tests may carry an index inside tests/; a solution there is just a file.
|
|
128
|
+
entries += task.tests_dir.children.select { |entry| entry.file? && STEP_FILE.match(entry.basename.to_s)&.[](:test) } if task.tests_dir.directory?
|
|
129
|
+
entries.each do |entry|
|
|
130
|
+
match = STEP_FILE.match(entry.basename.to_s) or next
|
|
131
|
+
name = entry.relative_path_from(task.dir)
|
|
132
|
+
|
|
133
|
+
raise ConfigError, "#{task.dir}: #{name} is an indexed step file, but the task is not multistep: true" unless
|
|
134
|
+
task.multistep?
|
|
135
|
+
|
|
136
|
+
step = (match[:test] || match[:solution]).to_i
|
|
137
|
+
raise ConfigError, "#{task.dir}: #{name} names step #{step}, but the task has #{task.steps} steps" unless
|
|
138
|
+
step.between?(1, task.steps)
|
|
139
|
+
|
|
140
|
+
if match[:solution]
|
|
141
|
+
indexed_solutions = true
|
|
142
|
+
elsif step == task.steps
|
|
143
|
+
raise ConfigError, "#{task.dir}: #{name} indexes the final step — the final verification keeps " \
|
|
144
|
+
"the unindexed name"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
return unless indexed_solutions && task.solution_files.any?
|
|
149
|
+
|
|
150
|
+
raise ConfigError, "#{task.dir}: #{FLAT_SOLUTION} is the whole task's solution while indexed step solutions " \
|
|
151
|
+
"chain step by step — ship one or the other, not both"
|
|
152
|
+
end
|
|
153
|
+
|
|
91
154
|
# Collisions would be resolved by upload order, so a task never gets to
|
|
92
155
|
# shadow the bench-wide copy.
|
|
93
156
|
def refuse_config_collisions!(task, declared, base)
|
|
@@ -103,7 +166,9 @@ module Lemans
|
|
|
103
166
|
|
|
104
167
|
attr_reader :config, :name, :dir
|
|
105
168
|
|
|
106
|
-
attr_accessor :difficulty, :tags, :description, :metadata
|
|
169
|
+
attr_accessor :difficulty, :tags, :description, :metadata, :environment_profile, :multistep
|
|
170
|
+
|
|
171
|
+
alias multistep? multistep
|
|
107
172
|
|
|
108
173
|
def initialize(config, name, dir: nil)
|
|
109
174
|
@config = config
|
|
@@ -113,13 +178,26 @@ module Lemans
|
|
|
113
178
|
@tags = []
|
|
114
179
|
@description = ""
|
|
115
180
|
@metadata = {}
|
|
181
|
+
@environment_profile = nil
|
|
182
|
+
@multistep = false
|
|
116
183
|
|
|
184
|
+
@step = nil
|
|
117
185
|
@dir = dir || config.tasks_dir.join(name)
|
|
118
186
|
end
|
|
119
187
|
|
|
120
|
-
#
|
|
188
|
+
# A copy of the task definition for a particular step
|
|
189
|
+
# (so we can generated correct paths and instructions)
|
|
190
|
+
def for_step(index) = dup.tap { it.step = index }
|
|
191
|
+
|
|
192
|
+
def final_step? = !multistep? || step == steps
|
|
193
|
+
|
|
194
|
+
def steps = multistep? ? sections.size - 1 : 1
|
|
195
|
+
|
|
121
196
|
def instruction
|
|
122
|
-
|
|
197
|
+
return body unless multistep?
|
|
198
|
+
raise ArgumentError, "#{name} is multistep — only a step projection (for_step) has an instruction" unless step
|
|
199
|
+
|
|
200
|
+
"#{sections[0].strip}\n\n#{sections.fetch(step).strip}\n"
|
|
123
201
|
end
|
|
124
202
|
|
|
125
203
|
def digest
|
|
@@ -147,14 +225,39 @@ module Lemans
|
|
|
147
225
|
|
|
148
226
|
# [absolute, remote-relative] pairs. Tests stay on the harness side while
|
|
149
227
|
# the agent works; uploaded into the sandbox only at verification.
|
|
228
|
+
#
|
|
229
|
+
# A multistep task keeps one tests/ directory: whatever in it carries no
|
|
230
|
+
# step index (helpers, fixtures) ships with every verified step, the
|
|
231
|
+
# step's own verification_test.N.rb ships as verification_test.rb, and
|
|
232
|
+
# the unindexed verification_test.rb stays with the final step.
|
|
150
233
|
def test_files
|
|
151
|
-
|
|
234
|
+
return final_test_files unless step && !final_step?
|
|
235
|
+
|
|
236
|
+
indexed = step_files(STEP_TESTS) + step_files(SHARED_STEP_TESTS, root: tests_dir)
|
|
237
|
+
return [] if indexed.empty?
|
|
238
|
+
|
|
239
|
+
shared_test_files.reject { |_, remote| FINAL_STEP_TESTS.include?(remote) } + indexed
|
|
152
240
|
end
|
|
153
241
|
|
|
242
|
+
def verifiable? = test_files.any?
|
|
243
|
+
|
|
244
|
+
# A lone whole-task solution is applied before the first step: intermediate
|
|
245
|
+
# gates must see it, and later steps have nothing left to add.
|
|
154
246
|
def solution_files
|
|
247
|
+
if step
|
|
248
|
+
return step_files(STEP_SOLUTIONS) if indexed_solutions?
|
|
249
|
+
return [] if multistep? && step > 1
|
|
250
|
+
end
|
|
251
|
+
|
|
155
252
|
solution_dir.directory? ? expand(solution_dir) : flat(FLAT_SOLUTION)
|
|
156
253
|
end
|
|
157
254
|
|
|
255
|
+
# True for the later steps of a multistep task whose lone solution already
|
|
256
|
+
# shipped with step 1
|
|
257
|
+
def solution_applied_earlier?
|
|
258
|
+
!!(step && step > 1 && !indexed_solutions? && (solution_dir.directory? || dir.join(FLAT_SOLUTION).file?))
|
|
259
|
+
end
|
|
260
|
+
|
|
158
261
|
def solution? = solution_files.any?
|
|
159
262
|
|
|
160
263
|
def tests_dir = dir.join(TESTS_DIR)
|
|
@@ -164,17 +267,62 @@ module Lemans
|
|
|
164
267
|
def environment_dockerfile = dir.join(ENVIRONMENT_DIR, "Dockerfile")
|
|
165
268
|
|
|
166
269
|
def environment_image
|
|
167
|
-
if
|
|
270
|
+
if (profile = environment.profiles[environment_profile])
|
|
271
|
+
if profile.image
|
|
272
|
+
Config::ImageSpec.registry(profile.image)
|
|
273
|
+
else
|
|
274
|
+
Config::ImageSpec.dockerfile(profile.dockerfile, slug: environment_profile)
|
|
275
|
+
end
|
|
276
|
+
elsif environment_dockerfile.file?
|
|
168
277
|
Config::ImageSpec.dockerfile(environment_dockerfile, slug: name)
|
|
169
|
-
elsif environment.
|
|
170
|
-
Config::ImageSpec.registry(environment.image)
|
|
171
|
-
else
|
|
278
|
+
elsif environment.dockerfile
|
|
172
279
|
Config::ImageSpec.dockerfile(environment.dockerfile, slug: "shared")
|
|
280
|
+
else
|
|
281
|
+
Config::ImageSpec.registry(environment.image)
|
|
173
282
|
end
|
|
174
283
|
end
|
|
175
284
|
|
|
285
|
+
protected attr_writer :step
|
|
286
|
+
|
|
176
287
|
private
|
|
177
288
|
|
|
289
|
+
attr_reader :step
|
|
290
|
+
|
|
291
|
+
def body
|
|
292
|
+
@body ||= dir.join(INSTRUCTION).read.sub(FRONTMATTER, "")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def sections
|
|
296
|
+
@sections ||= body.split(STEP_SEPARATOR)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def indexed_solutions? = (1..steps).any? { step_files(STEP_SOLUTIONS, it).any? }
|
|
300
|
+
|
|
301
|
+
# A task may keep its final test flat at the root and use tests/ for shared files
|
|
302
|
+
def final_test_files
|
|
303
|
+
return flat(FLAT_TEST) unless tests_dir.directory?
|
|
304
|
+
|
|
305
|
+
shared_test_files + flat(FLAT_TEST)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Everything under tests/ that no step claims for itself
|
|
309
|
+
def shared_test_files
|
|
310
|
+
return [] unless tests_dir.directory?
|
|
311
|
+
|
|
312
|
+
expand(tests_dir).reject { |_, remote| STEP_FILE.match?(remote) }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def step_files(patterns, index = step, root: dir)
|
|
316
|
+
patterns.flat_map do |pattern, remote|
|
|
317
|
+
path = root.join(format(pattern, index))
|
|
318
|
+
if remote == :dir
|
|
319
|
+
path.directory? ? expand(path) : []
|
|
320
|
+
else
|
|
321
|
+
path.file? ? [ [ path, remote ] ] : []
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
178
326
|
def own_setup_with_seed
|
|
179
327
|
declared = @declared_setup || Config::Setup.new
|
|
180
328
|
return declared unless seed? && declared.files.none? { |_, remote| remote == FLAT_SEED }
|
data/lib/lemans/trial/patch.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Lemans
|
|
|
12
12
|
REMOTE_PATCH = "/tmp/lemans-agent.patch"
|
|
13
13
|
REMOTE_INDEX = "/tmp/lemans-patch.idx"
|
|
14
14
|
|
|
15
|
-
private attr_reader :task, :environment, :path, :workdir, :baseline, :timeout
|
|
15
|
+
private attr_reader :task, :environment, :path, :workdir, :baseline, :savepoint, :timeout
|
|
16
16
|
|
|
17
17
|
def initialize(task, environment, timeout: 300, path: "agent.patch")
|
|
18
18
|
@task = task
|
|
@@ -22,37 +22,75 @@ module Lemans
|
|
|
22
22
|
|
|
23
23
|
@workdir = task.environment.workdir
|
|
24
24
|
@baseline = nil
|
|
25
|
+
@savepoint = nil
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def seal!
|
|
28
29
|
@baseline = write_tree
|
|
30
|
+
@savepoint = @baseline
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
# Must run before the verifier restores the graded surfaces: a patch taken
|
|
32
|
-
# after would not show what the agent did to them
|
|
33
|
-
|
|
34
|
+
# after would not show what the agent did to them. Diffs from the last
|
|
35
|
+
# savepoint (the sealed baseline until a multistep trial moves it), so a
|
|
36
|
+
# step's patch shows that step's work alone.
|
|
37
|
+
def collect!(result, store, path: @path)
|
|
38
|
+
return unless savepoint
|
|
39
|
+
|
|
40
|
+
after = write_tree
|
|
41
|
+
return unless after
|
|
42
|
+
|
|
43
|
+
save_diff(result, store, savepoint, after, path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The compilation of every step: the whole run against the sealed baseline.
|
|
47
|
+
def compile!(result, store)
|
|
34
48
|
return unless baseline
|
|
35
49
|
|
|
36
50
|
after = write_tree
|
|
37
51
|
return unless after
|
|
38
52
|
|
|
39
|
-
|
|
53
|
+
save_diff(result, store, baseline, after, @path)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Marks the tree a finished step left: the next collect! diffs from here,
|
|
57
|
+
# and restore! comes back here. The mark is load-bearing, so failing to
|
|
58
|
+
# write it is an environment error, not a lost artifact.
|
|
59
|
+
def savepoint!
|
|
60
|
+
@savepoint = write_tree
|
|
61
|
+
raise InfrastructureError, "could not savepoint the tree the step left behind" unless savepoint
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Puts the workdir back to the savepoint exactly: the verifier restored
|
|
65
|
+
# the graded surfaces from the baseline and may have littered the tree,
|
|
66
|
+
# and the next step's agent must find neither.
|
|
67
|
+
def restore!
|
|
68
|
+
return unless savepoint
|
|
69
|
+
|
|
70
|
+
environment.exec!(
|
|
71
|
+
"#{git} read-tree #{savepoint} && #{git} checkout-index -f -a && #{git} clean -fd && #{git} reset -q",
|
|
72
|
+
timeout:
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def save_diff(result, store, from, to, destination)
|
|
79
|
+
diffed = environment.exec("#{git} diff --binary #{from} #{to} > #{REMOTE_PATCH}", timeout:)
|
|
40
80
|
return unless diffed.success?
|
|
41
81
|
|
|
42
82
|
Tempfile.create(%w[agent .patch]) do |file|
|
|
43
83
|
environment.download(REMOTE_PATCH, file.path)
|
|
44
|
-
store.save_artifact(result, Pathname(file.path), path:)
|
|
84
|
+
store.save_artifact(result, Pathname(file.path), path: destination)
|
|
45
85
|
end
|
|
46
86
|
|
|
47
87
|
environment.exec("rm -f #{REMOTE_PATCH} #{REMOTE_INDEX}", timeout:)
|
|
48
|
-
|
|
88
|
+
destination
|
|
49
89
|
rescue InfrastructureError => e
|
|
50
90
|
warn "lemans: could not collect the agent patch for #{result.id}: #{e.message}"
|
|
51
91
|
nil
|
|
52
92
|
end
|
|
53
93
|
|
|
54
|
-
private
|
|
55
|
-
|
|
56
94
|
# `safe.directory` because the sandbox may run the tree as a different user
|
|
57
95
|
# than built it, and git refuses to read a repo it thinks is someone else's.
|
|
58
96
|
def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
|
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
module LemansReport
|
|
6
|
+
# A check the task wants recorded but not graded
|
|
7
|
+
# Inherit from Skip to let the tests pass.
|
|
8
|
+
class AllowedFailure < Minitest::Skip; end
|
|
9
|
+
|
|
10
|
+
module Assertions
|
|
11
|
+
# Allow failing minitest assertions inside the block (but halt and record them as allowed failures not affected the grade)
|
|
12
|
+
def allow_failure
|
|
13
|
+
yield
|
|
14
|
+
rescue Minitest::Skip
|
|
15
|
+
raise
|
|
16
|
+
rescue Minitest::Assertion => e
|
|
17
|
+
raise AllowedFailure, e.message
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
6
21
|
# Appends every Minitest result to $LOGS/checks.json. Required by
|
|
7
22
|
# eport-lemans once Minitest is loaded; never load this file directly.
|
|
8
23
|
class Reporter < Minitest::AbstractReporter
|
|
@@ -22,16 +37,21 @@ module LemansReport
|
|
|
22
37
|
return if graded.empty? && prior.empty?
|
|
23
38
|
|
|
24
39
|
checks = prior.merge(graded.to_h { [ name(it), status(it) ] }).sort.to_h
|
|
40
|
+
allowed = existing.fetch("allowed_failures", {}).merge(graded.select { allowed?(it) }.to_h { [ name(it), it.failure.message ] }).sort.to_h
|
|
25
41
|
File.write(
|
|
26
42
|
File.join(@dir, "checks.json"),
|
|
27
|
-
JSON.pretty_generate(
|
|
43
|
+
JSON.pretty_generate(
|
|
44
|
+
checks: checks,
|
|
45
|
+
failures: checks.reject { |_, status| status == "pass" || status == ALLOWED }.keys,
|
|
46
|
+
allowed_failures: allowed
|
|
47
|
+
)
|
|
28
48
|
)
|
|
29
49
|
end
|
|
30
50
|
|
|
31
51
|
# A skip inside the harness-shipped tests is an unverified requirement and
|
|
32
52
|
# fails the run. The app's own suite keeps vanilla skip semantics.
|
|
33
53
|
def passed?
|
|
34
|
-
@results.none? { |result| graded?(result) && result.skipped? }
|
|
54
|
+
@results.none? { |result| graded?(result) && result.skipped? && !allowed?(result) }
|
|
35
55
|
end
|
|
36
56
|
|
|
37
57
|
private
|
|
@@ -50,8 +70,13 @@ module LemansReport
|
|
|
50
70
|
|
|
51
71
|
def name(result) = "#{result.klass}##{result.name}"
|
|
52
72
|
|
|
73
|
+
ALLOWED = "fail (allowed)"
|
|
74
|
+
|
|
75
|
+
def allowed?(result) = result.skipped? && result.failure.is_a?(AllowedFailure)
|
|
76
|
+
|
|
53
77
|
def status(result)
|
|
54
|
-
if result
|
|
78
|
+
if allowed?(result) then ALLOWED
|
|
79
|
+
elsif result.skipped? then "skip"
|
|
55
80
|
elsif result.error? then "error"
|
|
56
81
|
elsif result.passed? then "pass"
|
|
57
82
|
else "fail"
|
data/lib/lemans/trial.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "pathname"
|
|
5
|
+
require "shellwords"
|
|
5
6
|
require "time"
|
|
6
7
|
|
|
7
8
|
module Lemans
|
|
@@ -11,7 +12,7 @@ module Lemans
|
|
|
11
12
|
class Trial
|
|
12
13
|
attr_reader :task, :config, :model, :agent_name, :environment, :result
|
|
13
14
|
|
|
14
|
-
private attr_reader :agent, :store, :snapshot, :patch
|
|
15
|
+
private attr_reader :agent, :store, :snapshot, :patch, :current_step_index
|
|
15
16
|
|
|
16
17
|
def initialize(task, model = nil, result: nil, store: nil, agent: nil, environment: nil)
|
|
17
18
|
@task = task
|
|
@@ -44,6 +45,7 @@ module Lemans
|
|
|
44
45
|
|
|
45
46
|
@snapshot = nil
|
|
46
47
|
@patch = nil
|
|
48
|
+
@current_step_index = nil
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
def run
|
|
@@ -67,42 +69,60 @@ module Lemans
|
|
|
67
69
|
environment.switch_network_policy!(config.agent.environment.network)
|
|
68
70
|
end
|
|
69
71
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
agent
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
72
|
+
each_step do |step_task|
|
|
73
|
+
response =
|
|
74
|
+
phase(:agent) do
|
|
75
|
+
agent.run(step_task, environment)
|
|
76
|
+
rescue InfrastructureError, ::Miniswen::InfrastructureError => e
|
|
77
|
+
# Mark the failure here, where the agent phase is still known
|
|
78
|
+
result.failed!(:agent_error, e.message)
|
|
79
|
+
raise
|
|
80
|
+
end
|
|
78
81
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
# Whatever the agent brought back is evidence, a failed run's included
|
|
83
|
+
save_trajectory!(response.trajectory)
|
|
84
|
+
store&.save_artifact(result, response.raw_result, path: with_step_index("agent.result.json")) if response.raw_result
|
|
82
85
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
if response.error?
|
|
87
|
+
result.failed!(:agent_error, response.error)
|
|
88
|
+
return result
|
|
89
|
+
end
|
|
87
90
|
|
|
88
|
-
|
|
91
|
+
if task.multistep?
|
|
92
|
+
result.step_completed!(response.outcome, response.usage, duration: result.phases.last.duration)
|
|
93
|
+
else
|
|
94
|
+
result.completed!(response.outcome, response.usage)
|
|
95
|
+
end
|
|
89
96
|
|
|
90
|
-
|
|
97
|
+
check_cost_limit!
|
|
91
98
|
|
|
92
|
-
|
|
99
|
+
patch.collect!(result, store, path: with_step_index("agent.patch")) if store
|
|
100
|
+
if step_task.final_step?
|
|
101
|
+
patch.compile!(result, store) if task.multistep? && store
|
|
102
|
+
# Don't index the final verification
|
|
103
|
+
@current_step_index = nil
|
|
104
|
+
else
|
|
105
|
+
patch.savepoint!
|
|
106
|
+
end
|
|
93
107
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
108
|
+
if result.scored? && step_task.verifiable?
|
|
109
|
+
phase(:verifier) do
|
|
110
|
+
# The sandbox is sealed before the tests arrive
|
|
111
|
+
environment.switch_network_policy!(Config::NetworkPolicy.new("none"))
|
|
98
112
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
113
|
+
verification = Verifier.new(step_task, environment, snapshot).verify! do |evidence, path|
|
|
114
|
+
store&.save_artifact(result, evidence, path: with_step_index(path))
|
|
115
|
+
end
|
|
102
116
|
|
|
103
|
-
|
|
117
|
+
store&.save_artifact(result, verification.logs, path: with_step_index("verifier.log"))
|
|
104
118
|
|
|
105
|
-
|
|
119
|
+
if step_task.final_step?
|
|
120
|
+
result.graded!(verification.reward)
|
|
121
|
+
elsif verification.reward.zero?
|
|
122
|
+
result.graded!(0.0)
|
|
123
|
+
throw :halt
|
|
124
|
+
end
|
|
125
|
+
end
|
|
106
126
|
end
|
|
107
127
|
end
|
|
108
128
|
|
|
@@ -125,11 +145,41 @@ module Lemans
|
|
|
125
145
|
|
|
126
146
|
private
|
|
127
147
|
|
|
128
|
-
def save_trajectory(trajectory)
|
|
148
|
+
def save_trajectory!(trajectory)
|
|
129
149
|
return unless trajectory && store
|
|
130
150
|
|
|
131
|
-
|
|
132
|
-
|
|
151
|
+
path = with_step_index("trajectory.json")
|
|
152
|
+
session_id = with_step_index(result.id)
|
|
153
|
+
|
|
154
|
+
trajectory.session_id = session_id
|
|
155
|
+
store.save_artifact(result, JSON.pretty_generate(trajectory.to_atif), path:)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def each_step
|
|
159
|
+
return yield task unless task.multistep?
|
|
160
|
+
|
|
161
|
+
catch(:halt) do
|
|
162
|
+
1.upto(task.steps) do |index|
|
|
163
|
+
resume_agent! if index > 1
|
|
164
|
+
@current_step_index = index
|
|
165
|
+
yield task.for_step(index)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def resume_agent!
|
|
171
|
+
patch.restore!
|
|
172
|
+
environment.exec!("rm -rf #{Verifier::TESTS_DIR} #{Shellwords.escape(task.verifier.logs_dir)}")
|
|
173
|
+
environment.switch_network_policy!(config.agent.environment.network)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def with_step_index(path)
|
|
177
|
+
return path unless current_step_index
|
|
178
|
+
|
|
179
|
+
*pre, last = path.to_s.split(".")
|
|
180
|
+
return "#{last}.#{current_step_index}" if pre.empty?
|
|
181
|
+
|
|
182
|
+
[ *pre, current_step_index, last ].join(".")
|
|
133
183
|
end
|
|
134
184
|
|
|
135
185
|
def check_cost_limit!
|
|
@@ -144,10 +194,10 @@ module Lemans
|
|
|
144
194
|
end
|
|
145
195
|
|
|
146
196
|
def phase(name)
|
|
147
|
-
result.phase_started(name)
|
|
197
|
+
result.phase_started(with_step_index(name).to_sym)
|
|
148
198
|
yield
|
|
149
199
|
ensure
|
|
150
|
-
result.phase_finished(name)
|
|
200
|
+
result.phase_finished(with_step_index(name).to_sym)
|
|
151
201
|
end
|
|
152
202
|
end
|
|
153
203
|
end
|
data/lib/lemans/version.rb
CHANGED