lemans 1.0.0.rc.2 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3d6d6b65f450eb332d1c032b762e512a22a1bd246c5e395584bca5b6f0d8c2f
4
- data.tar.gz: 0730367d18c2d98f8d585ac45ed16c37c1318d5a6bc65edf2acaa54d05cc5e32
3
+ metadata.gz: 5fb516d2427e7ccef8507dc31a9bc774f9b7c9df726419a4cae1f94e8cb2ac4f
4
+ data.tar.gz: 242a5682996f39841cb2c1530b8b0418acb46e523a1074e60d4751960e69daaf
5
5
  SHA512:
6
- metadata.gz: b4c70f4a803a443dc8c7c11642fdae76ca5c932a81a2afab3c78f1a09af1c384b1033d78a28acb84cb4172bfe3481cb8192adfeb4d82f5b58808f929546ef0d4
7
- data.tar.gz: 33edaf57dd9d1d7ed92893c4c1550c28a67ffd8ab29366a384cd5263c9ca3fc53d2130a39ef7467f96b1289cac4a861a269c9e5ab56ce80a68bd8c918b53ccf5
6
+ metadata.gz: 842df498109f5ac2fb3dd427f429f84938f66c853242b8e376a101a752b2670913f239310e0a55b5b3adb833dedb3324fc66eaaff532214c64444d3dffe002c3
7
+ data.tar.gz: ee056ec66e59ed51c8998be640997295b3fa4877f21823f64b7cabf66b66d58110f3681f6e859a9a80f6ec48748dc2eb5bb00d506a871cbced609a394a520c9a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,5 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [1.0.0] - 2026-08-21
3
+ ## [1.0.0] - 2026-08-24
4
4
 
5
5
  - Initial release
data/README.md CHANGED
@@ -1,11 +1,11 @@
1
- # Lemans
1
+ # lemans
2
2
 
3
- Lemans is a harness for benchmarking coding agents, the Ruby way:
3
+ lemans is a harness for benchmarking coding agents, the Ruby way:
4
4
 
5
5
  - **CLI-first**: the `lemans` command is all you (or your agent) need for running tasks and generating reports
6
6
  - **Conventional**, aka _boilerplate-free_: an instruction, a Docker environment, and a test script — that's all you need to describe an eval
7
7
  - **Trustworthy**: a grade can't be gamed by the agent, an infrastructure failure can't masquerade as a model failure, and every result carries enough digests to prove what it actually measured
8
- - Powered by **Miniswen**, a Ruby version of mini-swe-agent (powered by [RubyLLM](https://rubyllm.com/), so it works with any LLM) and [Daytona](https://www.daytona.io) sandboxes.
8
+ - Powered by **miniswen**, a Ruby version of mini-swe-agent (powered by [RubyLLM](https://rubyllm.com/), so it works with any LLM) and [Daytona](https://www.daytona.io) sandboxes.
9
9
 
10
10
  > [!TIP]
11
11
  > Check [Rails AI Evals](https://github.com/rails/ai-evals) for a full-featured example.
@@ -13,14 +13,14 @@ Lemans is a harness for benchmarking coding agents, the Ruby way:
13
13
  ## Prerequisites
14
14
 
15
15
  - Ruby 3.4+ is required to run `lemans`
16
- - Daytona account (API token)
16
+ - Daytona account (API token) or Docker (for local sandboxes)
17
17
  - Some LLM provider/proxy credentials (e.g., OpenRouter)
18
18
 
19
19
  ## Getting started
20
20
 
21
21
  ### 1. Install lemans
22
22
 
23
- Install Lemans CLI:
23
+ Install lemans CLI:
24
24
 
25
25
  ```bash
26
26
  gem install lemans
@@ -65,6 +65,7 @@ version: 1
65
65
  # A bare list is a commands shorthand: `setup: [bin/sandbox-setup]`
66
66
 
67
67
  environment:
68
+ # backend: "daytona" # or "docker"
68
69
  # dockerfile: environment/Dockerfile # the default — or pin a published image instead:
69
70
  # image: ghcr.io/acme/my-bench@sha256:...
70
71
  resources: { cpus: 2, memory: 2GB, storage: 5GB }
@@ -163,7 +164,7 @@ An `environment.patch` next to `instruction.md` is always applied, declared or n
163
164
  ### 3. Set credentials
164
165
 
165
166
  ```bash
166
- export DAYTONA_API_KEY=... # or DAYTONA_TOKEN
167
+ export DAYTONA_API_KEY=... # or DAYTONA_TOKEN (if using Daytona)
167
168
  export OPENROUTER_API_KEY=... # or ANTHROPIC_API_KEY, OPENAI_API_KEY, ... — matching your model
168
169
 
169
170
  export LEMANS_PROVIDER_ORDER="Chutes" # [optional] Pin an OpenRouter model to named backends
@@ -244,9 +245,9 @@ gpt-5.6-luna ar-archive-book-access 2/2 2m 23s $0.0132 12.5 156905
244
245
  | `lemans report` | Summarize `runs/` as a table or CSV (`--tag`, `-A [task-agent-model]` to aggregate, `-S <column>` to sort); repeated attempts add pass@k per model × task |
245
246
  | `lemans clobber` | Delete run results (`--task`, `--ttl 10m\|2h\|1d`, `--invalid`, `-f` to skip the confirmation) |
246
247
 
247
- ## Miniswen
248
+ ## miniswen
248
249
 
249
- Miniswen can be used independently of Lemans as a basic coding agent:
250
+ miniswen can be used independently of lemans as a basic coding agent:
250
251
 
251
252
  ```sh
252
253
  $ gem install miniswen
@@ -256,7 +257,7 @@ $ miniswen --model 'openrouter/openai/gpt-5.6-luna' --prompt 'Write hello-world
256
257
  ...
257
258
  ```
258
259
 
259
- Currently, it's a one-shot agent that doesn't ask any questions. It's mostly useful for playing with eval ideas before encoding them as Lemans tasks.
260
+ Currently, it's a one-shot agent that doesn't ask any questions. It's mostly useful for playing with eval ideas before encoding them as lemans tasks.
260
261
 
261
262
  ## Development
262
263
 
@@ -14,6 +14,8 @@ version: 1
14
14
  # commands: [bin/sandbox-setup]
15
15
 
16
16
  environment:
17
+ # Where to run sandboxes (Daytona or Docker)
18
+ # backend: daytona # or docker
17
19
  # The shared sandbox image, one of:
18
20
  # - a Dockerfile, built once per content digest and reused across trials.
19
21
  # This bench's environment/Dockerfile is the default — used here:
data/lib/lemans/cli.rb CHANGED
@@ -53,7 +53,7 @@ module Lemans
53
53
  option :attempts, type: :numeric, default: 1, aliases: "-k", desc: "Trials per task"
54
54
  option :concurrency, type: :numeric, default: 4, aliases: "-c", desc: "Trials in flight at once"
55
55
  option :runs_dir, default: "./runs", desc: "Where to write run directories"
56
- option :backend, default: "daytona", enum: Environments::BACKENDS.keys, desc: "Sandbox backend"
56
+ option :backend, enum: Environments::BACKENDS.keys, desc: "Sandbox backend (default: daytona)"
57
57
  option :resume, type: :boolean, default: false, desc: "Skip trials that already have a result"
58
58
  def run_bench
59
59
  # The bundled pricing registry ages faster than the gem: refresh once up
@@ -12,6 +12,7 @@ module Lemans
12
12
  return if data.nil?
13
13
 
14
14
  conf = new
15
+ conf.backend = data["backend"] if data["backend"]
15
16
  conf.image = data["image"] if data["image"]
16
17
  conf.dockerfile = data["dockerfile"] if data["dockerfile"]
17
18
  raise ConfigError, "environment.image and environment.dockerfile are mutually exclusive" if conf.image && conf.dockerfile
@@ -28,11 +29,13 @@ module Lemans
28
29
  end
29
30
  end
30
31
 
31
- attr_accessor :image, :dockerfile, :workdir, :resources, :build_timeout, :network
32
+ attr_accessor :image, :dockerfile, :workdir, :backend,
33
+ :resources, :build_timeout, :network
32
34
 
33
35
  def initialize
34
36
  @image = nil
35
37
  @dockerfile = nil
38
+ @backend = "daytona"
36
39
  @workdir = "/app"
37
40
  @resources = Resources.new(cpus: 2, memory: 2048, storage: 5120)
38
41
  @build_timeout = 10 * 60
data/lib/lemans/config.rb CHANGED
@@ -66,7 +66,7 @@ module Lemans
66
66
  @verifier.root = root
67
67
  @concurrency = 4
68
68
  @attempts = 1
69
- @backend = "daytona"
69
+ @backend = @environment.backend
70
70
  @tasks = parse_tasks
71
71
  end
72
72
 
@@ -75,7 +75,7 @@ module Lemans
75
75
  @agent.models = Array(model) if model
76
76
  @attempts = attempts if attempts
77
77
  @concurrency = concurrency if concurrency
78
- @backend = backend if backend
78
+ @backend = environment.backend = backend if backend
79
79
  end
80
80
 
81
81
  def agent_name = agent.name
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "securerandom"
5
+
6
+ module Lemans
7
+ module Environments
8
+ # Local containers driven through the docker CLI
9
+ class Docker < Environment
10
+ DEFAULT_BUILD_TIMEOUT = 600
11
+
12
+ HOUSEKEEPING_TIMEOUT = 60
13
+ MAX_OUTPUT_BYTES = 200_000
14
+ EXEC_SLACK = 30
15
+
16
+ attr_reader :container
17
+
18
+ def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout: nil)
19
+ super(image:, resources:, network:, env:, labels:,
20
+ build_timeout: build_timeout || DEFAULT_BUILD_TIMEOUT)
21
+ @logger = logger
22
+ @name = "lemans-#{SecureRandom.hex(6)}"
23
+ assert_policy_supported!(network)
24
+ end
25
+
26
+ def start
27
+ build_image! if image.built?
28
+ docker!("run", *run_args, timeout: build_timeout)
29
+ @container = @name
30
+ self
31
+ rescue InfrastructureError => e
32
+ remove
33
+ raise InfrastructureError, "docker: could not start container: #{e.message}"
34
+ end
35
+
36
+ def exec(command, timeout: nil, env: {})
37
+ timeout ||= DEFAULT_TIMEOUT
38
+ started = now
39
+ argv = [ "exec" ]
40
+ env.each { |key, value| argv += [ "--env", "#{key}=#{value}" ] }
41
+ # The in-container timeout is what actually kills the process
42
+ argv += [ container, "timeout", timeout.to_i.to_s, "sh", "-c", command ]
43
+
44
+ exit_code, output = capture("docker", *argv, timeout: timeout + EXEC_SLACK)
45
+ ExecResult.new(command:, exit_code:, output:, duration: (now - started).round(3))
46
+ end
47
+
48
+ def upload(local_path, remote_path)
49
+ docker!("exec", container, "mkdir", "-p", File.dirname(remote_path.to_s))
50
+ docker!("cp", local_path.to_s, "#{container}:#{remote_path}")
51
+ end
52
+
53
+ def download(remote_path, local_path)
54
+ local_path = Pathname(local_path)
55
+ local_path.dirname.mkpath
56
+ docker!("cp", "#{container}:#{remote_path}", local_path.to_s)
57
+ end
58
+
59
+ def switch_network_policy!(policy)
60
+ assert_policy_supported!(policy)
61
+
62
+ if policy.none?
63
+ connected_networks.each { docker!("network", "disconnect", it, container) }
64
+ else
65
+ networks = connected_networks
66
+ docker!("network", "disconnect", "none", container) if networks.include?("none")
67
+ docker!("network", "connect", "bridge", container) unless networks.include?("bridge")
68
+ end
69
+
70
+ @network = policy
71
+ end
72
+
73
+ def stop
74
+ return if container.nil?
75
+
76
+ exit_code, output = capture("docker", "rm", "--force", "--volumes", container, timeout: HOUSEKEEPING_TIMEOUT)
77
+ if exit_code.zero?
78
+ @container = nil
79
+ else
80
+ warn "lemans: container #{container} may still be running — remove failed: #{output.strip}"
81
+ end
82
+ rescue StandardError => e
83
+ warn "lemans: container #{container} may still be running — remove failed: #{e.class}: #{e.message}"
84
+ end
85
+
86
+ private
87
+
88
+ def assert_policy_supported!(policy)
89
+ return if policy.none? || policy.public?
90
+
91
+ raise ConfigError, "docker: #{policy.mode} is not supported (public and none only)"
92
+ end
93
+
94
+ # The tag is the content digest, so an existing image is the identical thing
95
+ def build_image!
96
+ exists, = capture("docker", "image", "inspect", image.name, timeout: HOUSEKEEPING_TIMEOUT)
97
+ return if exists.zero?
98
+
99
+ docker!("build", "--tag", image.name, image.context_dir.to_s, timeout: build_timeout, on_output: @logger)
100
+ end
101
+
102
+ def run_args
103
+ args = [ "--detach", "--init", "--name", @name,
104
+ "--cpus", resources.cpus.to_s, "--memory", "#{resources.memory}m",
105
+ "--entrypoint", "sh" ]
106
+ args += [ "--network", "none" ] if network.none?
107
+ env.each { |key, value| args += [ "--env", "#{key}=#{value}" ] }
108
+ labels.each { |key, value| args += [ "--label", "#{key}=#{value}" ] }
109
+ args + [ image.name, "-c", "tail -f /dev/null" ]
110
+ end
111
+
112
+ def connected_networks
113
+ docker!("inspect", "--format", "{{range $name, $_ := .NetworkSettings.Networks}}{{$name}} {{end}}", container).split
114
+ end
115
+
116
+ def remove
117
+ capture("docker", "rm", "--force", "--volumes", @name, timeout: HOUSEKEEPING_TIMEOUT)
118
+ rescue StandardError
119
+ nil
120
+ end
121
+
122
+ def docker!(*argv, timeout: HOUSEKEEPING_TIMEOUT, on_output: nil)
123
+ exit_code, output = capture("docker", *argv, timeout:, on_output:)
124
+ return output if exit_code.zero?
125
+
126
+ raise InfrastructureError, "docker #{argv.first(2).join(" ")} exited #{exit_code}: #{output[0, 2000]}"
127
+ end
128
+
129
+ def capture(*argv, timeout:, on_output: nil)
130
+ Open3.popen2e(*argv) do |stdin, pipe, wait|
131
+ stdin.close
132
+ deadline = now + timeout
133
+ output = +""
134
+ timed_out = false
135
+
136
+ loop do
137
+ remaining = deadline - now
138
+ if remaining <= 0
139
+ timed_out = true
140
+ kill(wait.pid)
141
+ break
142
+ end
143
+ next unless pipe.wait_readable(remaining)
144
+
145
+ chunk = pipe.read_nonblock(65_536, exception: false)
146
+ break if chunk.nil?
147
+ next if chunk == :wait_readable
148
+
149
+ on_output&.call(chunk)
150
+ output << chunk
151
+ output = output.byteslice(-MAX_OUTPUT_BYTES..) if output.bytesize > MAX_OUTPUT_BYTES
152
+ end
153
+
154
+ status = wait.value
155
+ [ timed_out ? 124 : (status.exitstatus || 1), output.scrub ]
156
+ end
157
+ rescue Errno::ENOENT
158
+ raise ConfigError, "docker: CLI not found — install Docker or pick another backend"
159
+ end
160
+
161
+ def kill(pid)
162
+ Process.kill("KILL", pid)
163
+ rescue Errno::ESRCH
164
+ nil
165
+ end
166
+
167
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
168
+ end
169
+ end
170
+ end
@@ -4,7 +4,7 @@ module Lemans
4
4
  # The backends `lemans run --backend` can name, and the one place a name
5
5
  # becomes a class. An unknown backend fails as a config mistake.
6
6
  module Environments
7
- BACKENDS = { "daytona" => "Daytona" }.freeze
7
+ BACKENDS = { "daytona" => "Daytona", "docker" => "Docker" }.freeze
8
8
 
9
9
  def self.build(backend, **)
10
10
  constant = BACKENDS[backend.to_s] or
data/lib/lemans/result.rb CHANGED
@@ -167,6 +167,8 @@ module Lemans
167
167
 
168
168
  def detail = outcome.detail
169
169
 
170
+ def pending? = outcome.pending?
171
+
170
172
  def scored? = outcome.scored?
171
173
 
172
174
  def invalid? = outcome.invalid?
@@ -49,7 +49,7 @@ module Lemans
49
49
  reporter&.record(:finished, result)
50
50
  result
51
51
  ensure
52
- store&.save(result)
52
+ store&.save(result) unless result.pending?
53
53
  end
54
54
 
55
55
  private
data/lib/lemans/trial.rb CHANGED
@@ -36,7 +36,7 @@ module Lemans
36
36
  build_timeout: task.environment.build_timeout,
37
37
  labels: {
38
38
  "lemans.task" => task.name,
39
- "lemans.trial" => result.id,
39
+ "lemans.trial" => self.result.id,
40
40
  "lemans.phase" => "agent"
41
41
  }
42
42
  )
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lemans
4
- VERSION = "1.0.0.rc.2"
4
+ VERSION = "1.0.0"
5
5
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Miniswen
4
- VERSION = "1.0.0.rc.1"
4
+ VERSION = "1.0.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.0.0.rc.2
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov
@@ -195,6 +195,7 @@ files:
195
195
  - lib/lemans/environments/daytona/sdk_tweaks.rb
196
196
  - lib/lemans/environments/daytona/shell.rb
197
197
  - lib/lemans/environments/daytona/snapshot_store.rb
198
+ - lib/lemans/environments/docker.rb
198
199
  - lib/lemans/result.rb
199
200
  - lib/lemans/runner.rb
200
201
  - lib/lemans/runner/executor.rb