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.
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
- raise ConfigError, "#{task.dir}: #{ENVIRONMENT_DIR}/Dockerfile is required when the bench declares no shared image or dockerfile" unless
83
- task.config.environment.image || task.config.environment.dockerfile || task.environment_dockerfile.file?
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
- # The story alone: frontmatter is for the harness, never for the agent.
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
- @instruction ||= dir.join(INSTRUCTION).read.sub(FRONTMATTER, "")
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
- tests_dir.directory? ? expand(tests_dir) : flat(FLAT_TEST)
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 environment_dockerfile.file?
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.image
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 }
@@ -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
- def collect!(result, store)
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
- diffed = environment.exec("#{git} diff --binary #{baseline} #{after} > #{REMOTE_PATCH}", timeout:)
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
- path
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(checks: checks, failures: checks.reject { |_, status| status == "pass" }.keys)
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.skipped? then "skip"
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
- response =
71
- phase(:agent) do
72
- agent.run(task, environment)
73
- rescue InfrastructureError, ::Miniswen::InfrastructureError => e
74
- # Mark the failure here, where the agent phase is still known
75
- result.failed!(:agent_error, e.message)
76
- raise
77
- end
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
- # Whatever the agent brought back is evidence, a failed run's included
80
- save_trajectory(response.trajectory)
81
- store&.save_artifact(result, response.raw_result, path: "agent.result.json") if response.raw_result
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
- if response.error?
84
- result.failed!(:agent_error, response.error)
85
- return result
86
- end
86
+ if response.error?
87
+ result.failed!(:agent_error, response.error)
88
+ return result
89
+ end
87
90
 
88
- result.completed!(response.outcome, response.usage)
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
- check_cost_limit!
97
+ check_cost_limit!
91
98
 
92
- patch.collect!(result, store) if store
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
- if result.scored?
95
- phase(:verifier) do
96
- # The sandbox is sealed before the tests arrive
97
- environment.switch_network_policy!(Config::NetworkPolicy.new("none"))
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
- verification = Verifier.new(task, environment, snapshot).verify! do |evidence, path|
100
- store&.save_artifact(result, evidence, path:)
101
- end
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
- store&.save_artifact(result, verification.logs, path: "verifier.log")
117
+ store&.save_artifact(result, verification.logs, path: with_step_index("verifier.log"))
104
118
 
105
- result.graded!(verification.reward)
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
- trajectory.session_id = result.id
132
- store.save_artifact(result, JSON.pretty_generate(trajectory.to_atif), path: "trajectory.json")
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lemans
4
- VERSION = "1.1.0"
4
+ VERSION = "1.2.0"
5
5
  end