lemans 1.1.0 → 1.3.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +14 -4
  4. data/exe/lemans-remote +22 -10
  5. data/lib/lemans/agents/miniswen.rb +2 -2
  6. data/lib/lemans/agents/oracle.rb +13 -7
  7. data/lib/lemans/cli/progress_reporter.rb +6 -1
  8. data/lib/lemans/cli/regrade.rb +193 -0
  9. data/lib/lemans/cli/report/aggregate.rb +18 -11
  10. data/lib/lemans/cli/report.rb +25 -8
  11. data/lib/lemans/cli/templates/bench/README.md +7 -2
  12. data/lib/lemans/cli/templates/bench/tasks/example-task/instruction.md +20 -9
  13. data/lib/lemans/cli/templates/bench/tasks/example-task/solution.1.patch +17 -0
  14. data/lib/lemans/cli/templates/bench/tasks/example-task/solution.2.patch +17 -0
  15. data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.1.rb +31 -0
  16. data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.rb +1 -1
  17. data/lib/lemans/cli.rb +38 -1
  18. data/lib/lemans/config/agent.rb +12 -0
  19. data/lib/lemans/config/environment.rb +31 -2
  20. data/lib/lemans/config/network_policy.rb +2 -0
  21. data/lib/lemans/config/setup.rb +6 -1
  22. data/lib/lemans/config/verifier.rb +18 -7
  23. data/lib/lemans/config.rb +45 -10
  24. data/lib/lemans/environment.rb +6 -3
  25. data/lib/lemans/environments/daytona/retries.rb +5 -3
  26. data/lib/lemans/environments/daytona/shell.rb +11 -1
  27. data/lib/lemans/environments/daytona.rb +16 -6
  28. data/lib/lemans/environments/docker.rb +3 -3
  29. data/lib/lemans/ext/deep_merge.rb +13 -0
  30. data/lib/lemans/result.rb +57 -7
  31. data/lib/lemans/runner.rb +1 -1
  32. data/lib/lemans/store.rb +7 -1
  33. data/lib/lemans/stores/fs.rb +9 -3
  34. data/lib/lemans/task_definition.rb +158 -10
  35. data/lib/lemans/trial/patch.rb +46 -8
  36. data/lib/lemans/trial/verifier/assets/eport-lemans.rb +6 -0
  37. data/lib/lemans/trial/verifier/assets/lemans_minitest_reporter.rb +44 -3
  38. data/lib/lemans/trial/verifier.rb +30 -3
  39. data/lib/lemans/trial.rb +89 -33
  40. data/lib/lemans/version.rb +1 -1
  41. data/lib/miniswen/agent.rb +21 -2
  42. data/lib/miniswen/version.rb +1 -1
  43. metadata +6 -2
  44. data/lib/lemans/cli/templates/bench/tasks/example-task/solution.patch +0 -7
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require "pathname"
4
5
  require "shellwords"
5
6
  require "tmpdir"
@@ -9,7 +10,7 @@ module Lemans
9
10
  # Verifies a trial in the sandbox the agent worked in, after Trial has closed
10
11
  # its network. The tests are uploaded fresh at verification time, never before.
11
12
  class Verifier
12
- Verification = Data.define(:reward, :logs)
13
+ Verification = Data.define(:reward, :credit, :logs)
13
14
 
14
15
  REWARD_RANGE = (0.0..1.0)
15
16
 
@@ -40,7 +41,7 @@ module Lemans
40
41
  prepare_env!
41
42
 
42
43
  # A baseline the agent made unrestorable is a verdict, not an error.
43
- return Verification.new(reward: 0.0, logs: TAMPERED) unless snapshot.restore!
44
+ return Verification.new(reward: 0.0, credit: 0.0, logs: TAMPERED) unless snapshot.restore!
44
45
 
45
46
  verification = run_tests!
46
47
 
@@ -98,7 +99,8 @@ module Lemans
98
99
 
99
100
  result = environment.exec(command, timeout:, env:)
100
101
 
101
- Verification.new(reward: read_reward(result), logs: result.output.to_s)
102
+ reward = read_reward(result)
103
+ Verification.new(reward:, credit: read_credit(reward), logs: result.output.to_s)
102
104
  end
103
105
 
104
106
  def verifier_script
@@ -124,6 +126,31 @@ module Lemans
124
126
  value
125
127
  end
126
128
 
129
+ def read_credit(reward)
130
+ path = File.join(task.verifier.logs_dir, "checks.json")
131
+ return reward unless environment.exec("test -e #{Shellwords.escape(path)}").success?
132
+
133
+ result = environment.exec("cat #{Shellwords.escape(path)}")
134
+ raise VerifierError, "could not read #{path}: #{result.output.to_s[0, 500]}" unless result.success?
135
+
136
+ checks = begin
137
+ JSON.parse(result.output.to_s)
138
+ rescue JSON::ParserError => e
139
+ raise VerifierError, "#{path} is not JSON: #{e.message[0, 500]}"
140
+ end
141
+
142
+ grading = checks["grading"]
143
+ return reward unless grading && (base_credit = grading["base_credit"])
144
+ return 0.0 if reward.zero?
145
+
146
+ points = grading.fetch("points", {})
147
+ total = points.values.sum
148
+ return reward if total.zero?
149
+
150
+ passed = points.sum { |check, value| checks.dig("checks", check) == "pass" ? value : 0 }
151
+ (base_credit + (1 - base_credit) * (passed.to_f / total)).round(2)
152
+ end
153
+
127
154
  def reward_from_exit(command_result)
128
155
  case command_result.exit_code
129
156
  when 0 then 1.0
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
@@ -34,6 +35,7 @@ module Lemans
34
35
  resources: task.environment.resources,
35
36
  network: task.environment.network,
36
37
  build_timeout: task.environment.build_timeout,
38
+ ttl: sandbox_ttl,
37
39
  labels: {
38
40
  "lemans.task" => task.name,
39
41
  "lemans.trial" => self.result.id,
@@ -44,6 +46,7 @@ module Lemans
44
46
 
45
47
  @snapshot = nil
46
48
  @patch = nil
49
+ @current_step_index = nil
47
50
  end
48
51
 
49
52
  def run
@@ -67,42 +70,60 @@ module Lemans
67
70
  environment.switch_network_policy!(config.agent.environment.network)
68
71
  end
69
72
 
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
73
+ each_step do |step_task|
74
+ response =
75
+ phase(:agent) do
76
+ agent.run(step_task, environment)
77
+ rescue InfrastructureError, ::Miniswen::InfrastructureError => e
78
+ # Mark the failure here, where the agent phase is still known
79
+ result.failed!(:agent_error, e.message)
80
+ raise
81
+ end
78
82
 
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
83
+ # Whatever the agent brought back is evidence, a failed run's included
84
+ save_trajectory!(response.trajectory)
85
+ store&.save_artifact(result, response.raw_result, path: with_step_index("agent.result.json")) if response.raw_result
82
86
 
83
- if response.error?
84
- result.failed!(:agent_error, response.error)
85
- return result
86
- end
87
+ if response.error?
88
+ result.failed!(:agent_error, response.error)
89
+ return result
90
+ end
87
91
 
88
- result.completed!(response.outcome, response.usage)
92
+ if task.multistep?
93
+ result.step_completed!(response.outcome, response.usage, duration: result.phases.last.duration)
94
+ else
95
+ result.completed!(response.outcome, response.usage)
96
+ end
89
97
 
90
- check_cost_limit!
98
+ check_cost_limit!
91
99
 
92
- patch.collect!(result, store) if store
100
+ patch.collect!(result, store, path: with_step_index("agent.patch")) if store
101
+ if step_task.final_step?
102
+ patch.compile!(result, store) if task.multistep? && store
103
+ # Don't index the final verification
104
+ @current_step_index = nil
105
+ else
106
+ patch.savepoint!
107
+ end
93
108
 
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"))
109
+ if result.scored? && step_task.verifiable?
110
+ phase(:verifier) do
111
+ # The sandbox is sealed before the tests arrive
112
+ environment.switch_network_policy!(Config::NetworkPolicy.new("none"))
98
113
 
99
- verification = Verifier.new(task, environment, snapshot).verify! do |evidence, path|
100
- store&.save_artifact(result, evidence, path:)
101
- end
114
+ verification = Verifier.new(step_task, environment, snapshot).verify! do |evidence, path|
115
+ store&.save_artifact(result, evidence, path: with_step_index(path))
116
+ end
102
117
 
103
- store&.save_artifact(result, verification.logs, path: "verifier.log")
118
+ store&.save_artifact(result, verification.logs, path: with_step_index("verifier.log"))
104
119
 
105
- result.graded!(verification.reward)
120
+ if step_task.final_step?
121
+ result.graded!(verification.reward, credit: verification.credit)
122
+ elsif verification.reward.zero?
123
+ result.graded!(0.0)
124
+ throw :halt
125
+ end
126
+ end
106
127
  end
107
128
  end
108
129
 
@@ -125,11 +146,46 @@ module Lemans
125
146
 
126
147
  private
127
148
 
128
- def save_trajectory(trajectory)
149
+ def save_trajectory!(trajectory)
129
150
  return unless trajectory && store
130
151
 
131
- trajectory.session_id = result.id
132
- store.save_artifact(result, JSON.pretty_generate(trajectory.to_atif), path: "trajectory.json")
152
+ path = with_step_index("trajectory.json")
153
+ session_id = with_step_index(result.id)
154
+
155
+ trajectory.session_id = session_id
156
+ store.save_artifact(result, JSON.pretty_generate(trajectory.to_atif), path:)
157
+ end
158
+
159
+ def each_step
160
+ return yield task unless task.multistep?
161
+
162
+ catch(:halt) do
163
+ 1.upto(task.steps) do |index|
164
+ resume_agent! if index > 1
165
+ @current_step_index = index
166
+ yield task.for_step(index)
167
+ end
168
+ end
169
+ end
170
+
171
+ def resume_agent!
172
+ patch.restore!
173
+ environment.exec!("rm -rf #{Verifier::TESTS_DIR} #{Shellwords.escape(task.verifier.logs_dir)}")
174
+ environment.switch_network_policy!(config.agent.environment.network)
175
+ end
176
+
177
+ def with_step_index(path)
178
+ return path unless current_step_index
179
+
180
+ *pre, last = path.to_s.split(".")
181
+ return "#{last}.#{current_step_index}" if pre.empty?
182
+
183
+ [ *pre, current_step_index, last ].join(".")
184
+ end
185
+
186
+ def sandbox_ttl
187
+ task.environment.sandbox_ttl ||
188
+ [ 3600, task.environment.build_timeout + task.steps * (config.agent.timeout + task.verifier.timeout) + 600 ].max
133
189
  end
134
190
 
135
191
  def check_cost_limit!
@@ -144,10 +200,10 @@ module Lemans
144
200
  end
145
201
 
146
202
  def phase(name)
147
- result.phase_started(name)
203
+ result.phase_started(with_step_index(name).to_sym)
148
204
  yield
149
205
  ensure
150
- result.phase_finished(name)
206
+ result.phase_finished(with_step_index(name).to_sym)
151
207
  end
152
208
  end
153
209
  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.3.0"
5
5
  end
@@ -27,6 +27,11 @@ module Miniswen
27
27
  # The breakpoint marker Anthropic reads, shaped the way OpenRouter forwards it.
28
28
  CACHE_CONTROL = { type: "ephemeral" }.freeze
29
29
 
30
+ # Left unset, the provider reserves the model's advertised maximum output
31
+ # ahead of the prompt (qwen3.8-27b: 128K of a 256K window), halving the
32
+ # history an agent turn of a few hundred tokens can build on.
33
+ MAX_OUTPUT_TOKENS = 32_768
34
+
30
35
  EXEC_ENV = {
31
36
  "PAGER" => "cat",
32
37
  "MANPAGER" => "cat",
@@ -224,7 +229,7 @@ module Miniswen
224
229
  end
225
230
 
226
231
  CostSource = Data.define(:name, :model, :priced_as, :registry) do
227
- def to_h = { name: name, model: model, priced_as: priced_as, registry: registry }.compact
232
+ def to_h = { name:, model:, priced_as:, registry: }.compact
228
233
  end
229
234
 
230
235
  attr_reader :messages, :environment
@@ -503,7 +508,7 @@ module Miniswen
503
508
  tools: { bash: @bash_tool },
504
509
  temperature: nil,
505
510
  model: model_info,
506
- params: routing_params,
511
+ params: routing_params.merge(output_cap_params(model_info)),
507
512
  thinking: (RubyLLM::Thinking::Config.new(effort: @effort) if @effort)
508
513
  )
509
514
  payload(response)
@@ -543,6 +548,20 @@ module Miniswen
543
548
 
544
549
  def provider_order = ENV["LEMANS_PROVIDER_ORDER"] || ENV["OPENROUTER_PROVIDER_ORDER"]
545
550
 
551
+ # OpenAI itself retired `max_tokens` for its reasoning models; the
552
+ # OpenAI-compatible providers and Anthropic still read it.
553
+ def output_cap_params(model_info)
554
+ cap = [ info&.max_tokens, MAX_OUTPUT_TOKENS ].compact.min
555
+ provider_class = RubyLLM::Provider.providers[model_info.provider.to_sym]
556
+ if [ RubyLLM::Providers::OpenAI, RubyLLM::Providers::Azure ].include?(provider_class)
557
+ { max_completion_tokens: cap }
558
+ elsif provider_class <= RubyLLM::Providers::OpenAI || provider_class <= RubyLLM::Providers::Anthropic
559
+ { max_tokens: cap }
560
+ else
561
+ {}
562
+ end
563
+ end
564
+
546
565
  def cost_source
547
566
  if local?
548
567
  return CostSource.new(name: :local_provider, model: @model,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Miniswen
4
- VERSION = "1.1.0"
4
+ VERSION = "1.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lemans
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov
@@ -163,6 +163,7 @@ files:
163
163
  - lib/lemans/cli.rb
164
164
  - lib/lemans/cli/board_reporter.rb
165
165
  - lib/lemans/cli/progress_reporter.rb
166
+ - lib/lemans/cli/regrade.rb
166
167
  - lib/lemans/cli/report.rb
167
168
  - lib/lemans/cli/report/aggregate.rb
168
169
  - lib/lemans/cli/templates/bench/README.md
@@ -170,7 +171,9 @@ files:
170
171
  - lib/lemans/cli/templates/bench/environment/Dockerfile
171
172
  - lib/lemans/cli/templates/bench/tasks/example-task/environment.patch
172
173
  - lib/lemans/cli/templates/bench/tasks/example-task/instruction.md
173
- - lib/lemans/cli/templates/bench/tasks/example-task/solution.patch
174
+ - lib/lemans/cli/templates/bench/tasks/example-task/solution.1.patch
175
+ - lib/lemans/cli/templates/bench/tasks/example-task/solution.2.patch
176
+ - lib/lemans/cli/templates/bench/tasks/example-task/verification_test.1.rb
174
177
  - lib/lemans/cli/templates/bench/tasks/example-task/verification_test.rb
175
178
  - lib/lemans/cli/templates/bench/tasks/hello-world/environment.patch
176
179
  - lib/lemans/cli/templates/bench/tasks/hello-world/instruction.md
@@ -196,6 +199,7 @@ files:
196
199
  - lib/lemans/environments/daytona/shell.rb
197
200
  - lib/lemans/environments/daytona/snapshot_store.rb
198
201
  - lib/lemans/environments/docker.rb
202
+ - lib/lemans/ext/deep_merge.rb
199
203
  - lib/lemans/result.rb
200
204
  - lib/lemans/runner.rb
201
205
  - lib/lemans/runner/executor.rb
@@ -1,7 +0,0 @@
1
- diff --git a/fizzbuzz.rb b/fizzbuzz.rb
2
- index 345b032..2c15f7d 100644
3
- --- a/fizzbuzz.rb
4
- +++ b/fizzbuzz.rb
5
- @@ -1 +1 @@
6
- -1.upto(100) { |i| puts i }
7
- +1.upto(100) { |i| s = "#{"Fizz" if i % 3 == 0}#{"Buzz" if i % 5 == 0}"; puts s.empty? ? i : s }