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 +4 -4
- data/CHANGELOG.md +1 -1
- data/README.md +10 -9
- data/lib/lemans/cli/templates/bench/bench.yml +2 -0
- data/lib/lemans/cli.rb +1 -1
- data/lib/lemans/config/environment.rb +4 -1
- data/lib/lemans/config.rb +2 -2
- data/lib/lemans/environments/docker.rb +170 -0
- data/lib/lemans/environments.rb +1 -1
- data/lib/lemans/result.rb +2 -0
- data/lib/lemans/runner/task.rb +1 -1
- data/lib/lemans/trial.rb +1 -1
- data/lib/lemans/version.rb +1 -1
- data/lib/miniswen/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5fb516d2427e7ccef8507dc31a9bc774f9b7c9df726419a4cae1f94e8cb2ac4f
|
|
4
|
+
data.tar.gz: 242a5682996f39841cb2c1530b8b0418acb46e523a1074e60d4751960e69daaf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 842df498109f5ac2fb3dd427f429f84938f66c853242b8e376a101a752b2670913f239310e0a55b5b3adb833dedb3324fc66eaaff532214c64444d3dffe002c3
|
|
7
|
+
data.tar.gz: ee056ec66e59ed51c8998be640997295b3fa4877f21823f64b7cabf66b66d58110f3681f6e859a9a80f6ec48748dc2eb5bb00d506a871cbced609a394a520c9a
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
#
|
|
1
|
+
# lemans
|
|
2
2
|
|
|
3
|
-
|
|
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 **
|
|
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
|
|
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
|
-
##
|
|
248
|
+
## miniswen
|
|
248
249
|
|
|
249
|
-
|
|
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
|
|
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,
|
|
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, :
|
|
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 =
|
|
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
|
data/lib/lemans/environments.rb
CHANGED
|
@@ -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
data/lib/lemans/runner/task.rb
CHANGED
data/lib/lemans/trial.rb
CHANGED
data/lib/lemans/version.rb
CHANGED
data/lib/miniswen/version.rb
CHANGED
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
|
|
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
|