lemans 1.0.0.rc.1 → 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 +57 -37
- data/exe/lemans-remote +42 -40
- data/lib/lemans/agents/miniswen_installed.rb +4 -4
- data/lib/lemans/cli/board_reporter.rb +8 -8
- data/lib/lemans/cli/progress_reporter.rb +1 -1
- data/lib/lemans/cli/report/aggregate.rb +2 -2
- data/lib/lemans/cli/report.rb +6 -6
- data/lib/lemans/cli/templates/bench/README.md +41 -0
- data/lib/lemans/cli/templates/bench/bench.yml +110 -0
- data/lib/lemans/cli/templates/bench/environment/Dockerfile +10 -0
- data/lib/lemans/cli/templates/bench/tasks/example-task/environment.patch +7 -0
- data/lib/lemans/cli/templates/bench/tasks/example-task/instruction.md +32 -0
- data/lib/lemans/cli/templates/bench/tasks/example-task/solution.patch +7 -0
- data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.rb +34 -0
- data/lib/lemans/cli/templates/bench/tasks/hello-world/environment.patch +7 -0
- data/lib/lemans/cli/templates/bench/tasks/hello-world/instruction.md +11 -0
- data/lib/lemans/cli/templates/bench/tasks/hello-world/solution.patch +7 -0
- data/lib/lemans/cli/templates/bench/tasks/hello-world/verification_test.rb +10 -0
- data/lib/lemans/cli.rb +14 -3
- data/lib/lemans/config/environment.rb +8 -1
- data/lib/lemans/config/revision.rb +2 -0
- data/lib/lemans/config/setup.rb +1 -15
- data/lib/lemans/config/tree_digest.rb +1 -1
- data/lib/lemans/config/verifier.rb +1 -1
- data/lib/lemans/config.rb +10 -2
- data/lib/lemans/environments/daytona/faraday_transfer.rb +1 -1
- data/lib/lemans/environments/daytona/retries.rb +1 -1
- data/lib/lemans/environments/daytona/sdk_tweaks.rb +2 -2
- data/lib/lemans/environments/daytona/shell.rb +1 -1
- data/lib/lemans/environments/daytona/snapshot_store.rb +2 -2
- data/lib/lemans/environments/daytona.rb +1 -1
- data/lib/lemans/environments/docker.rb +170 -0
- data/lib/lemans/environments.rb +1 -1
- data/lib/lemans/result.rb +3 -1
- data/lib/lemans/runner/task.rb +1 -1
- data/lib/lemans/task_definition.rb +10 -8
- data/lib/lemans/trial/verifier/assets/lemans_minitest_reporter.rb +2 -2
- data/lib/lemans/trial/verifier.rb +3 -3
- data/lib/lemans/trial.rb +2 -2
- data/lib/lemans/version.rb +1 -1
- data/lib/lemans.rb +2 -0
- data/lib/miniswen/agent.rb +11 -11
- data/lib/miniswen/ruby_llm.rb +7 -1
- data/lib/miniswen/testing.rb +7 -7
- data/lib/miniswen/trajectory.rb +1 -1
- data/lib/miniswen/version.rb +1 -1
- metadata +15 -1
|
@@ -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
|
@@ -68,7 +68,7 @@ module Lemans
|
|
|
68
68
|
def Usage.from_json(data)
|
|
69
69
|
# Older files carry a partial cost_source (just the name).
|
|
70
70
|
if (source = data[:cost_source])
|
|
71
|
-
cost_source = CostSource.new(**CostSource.members.to_h { [it, nil] }, **source)
|
|
71
|
+
cost_source = CostSource.new(**CostSource.members.to_h { [ it, nil ] }, **source)
|
|
72
72
|
end
|
|
73
73
|
new(
|
|
74
74
|
input_tokens: data[:input_tokens],
|
|
@@ -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?
|
data/lib/lemans/runner/task.rb
CHANGED
|
@@ -75,12 +75,12 @@ module Lemans
|
|
|
75
75
|
"what has to vary belongs in bench.yml, where it varies for every trial"
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
if (extras = data["verifier"].is_a?(Hash) ? data["verifier"].keys - ["setup"] : nil) && extras.any?
|
|
78
|
+
if (extras = data["verifier"].is_a?(Hash) ? data["verifier"].keys - [ "setup" ] : nil) && extras.any?
|
|
79
79
|
raise ConfigError, "#{task.dir}: a task may only override verifier.setup, not verifier.#{extras.first}"
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
raise ConfigError, "#{task.dir}: #{ENVIRONMENT_DIR}/Dockerfile is required when bench
|
|
83
|
-
task.config.environment.image || task.environment_dockerfile.file?
|
|
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?
|
|
84
84
|
|
|
85
85
|
return unless task.test_files.empty?
|
|
86
86
|
|
|
@@ -164,10 +164,12 @@ module Lemans
|
|
|
164
164
|
def environment_dockerfile = dir.join(ENVIRONMENT_DIR, "Dockerfile")
|
|
165
165
|
|
|
166
166
|
def environment_image
|
|
167
|
-
if
|
|
167
|
+
if environment_dockerfile.file?
|
|
168
|
+
Config::ImageSpec.dockerfile(environment_dockerfile, slug: name)
|
|
169
|
+
elsif environment.image
|
|
168
170
|
Config::ImageSpec.registry(environment.image)
|
|
169
171
|
else
|
|
170
|
-
Config::ImageSpec.dockerfile(
|
|
172
|
+
Config::ImageSpec.dockerfile(environment.dockerfile, slug: "shared")
|
|
171
173
|
end
|
|
172
174
|
end
|
|
173
175
|
|
|
@@ -177,18 +179,18 @@ module Lemans
|
|
|
177
179
|
declared = @declared_setup || Config::Setup.new
|
|
178
180
|
return declared unless seed? && declared.files.none? { |_, remote| remote == FLAT_SEED }
|
|
179
181
|
|
|
180
|
-
declared.merge(Config::Setup.new.tap { it.files = [[dir.join(FLAT_SEED), FLAT_SEED]] })
|
|
182
|
+
declared.merge(Config::Setup.new.tap { it.files = [ [ dir.join(FLAT_SEED), FLAT_SEED ] ] })
|
|
181
183
|
end
|
|
182
184
|
|
|
183
185
|
# Dotfiles included, matching TreeDigest: the files a digest records are
|
|
184
186
|
# exactly the files that ship.
|
|
185
187
|
def expand(root)
|
|
186
|
-
root.glob("**/*", File::FNM_DOTMATCH).select(&:file?).map { [it, it.relative_path_from(root).to_s] }
|
|
188
|
+
root.glob("**/*", File::FNM_DOTMATCH).select(&:file?).map { [ it, it.relative_path_from(root).to_s ] }
|
|
187
189
|
end
|
|
188
190
|
|
|
189
191
|
def flat(filename)
|
|
190
192
|
path = dir.join(filename)
|
|
191
|
-
path.file? ? [[path, filename]] : []
|
|
193
|
+
path.file? ? [ [ path, filename ] ] : []
|
|
192
194
|
end
|
|
193
195
|
end
|
|
194
196
|
end
|
|
@@ -17,11 +17,11 @@ module LemansReport
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def report
|
|
20
|
-
graded = @results.select { graded?(
|
|
20
|
+
graded = @results.select { graded?(it) }
|
|
21
21
|
prior = existing.fetch("checks", {})
|
|
22
22
|
return if graded.empty? && prior.empty?
|
|
23
23
|
|
|
24
|
-
checks = prior.merge(graded.to_h { [name(
|
|
24
|
+
checks = prior.merge(graded.to_h { [ name(it), status(it) ] }).sort.to_h
|
|
25
25
|
File.write(
|
|
26
26
|
File.join(@dir, "checks.json"),
|
|
27
27
|
JSON.pretty_generate(checks: checks, failures: checks.reject { |_, status| status == "pass" }.keys)
|
|
@@ -64,8 +64,8 @@ module Lemans
|
|
|
64
64
|
def upload_tests!
|
|
65
65
|
environment.exec!("rm -rf #{TESTS_DIR} && mkdir -p #{TESTS_DIR}")
|
|
66
66
|
|
|
67
|
-
uploads = task.verifier.files.to_h { |local, remote| [remote, local] }
|
|
68
|
-
.merge(task.test_files.to_h { |local, remote| [remote, local] })
|
|
67
|
+
uploads = task.verifier.files.to_h { |local, remote| [ remote, local ] }
|
|
68
|
+
.merge(task.test_files.to_h { |local, remote| [ remote, local ] })
|
|
69
69
|
|
|
70
70
|
uploads.each { |remote, local| environment.upload(local, "#{TESTS_DIR}/#{remote}") }
|
|
71
71
|
ASSETS.glob("*.rb").each { |asset| environment.upload(asset, "#{TESTS_DIR}/#{asset.basename}") }
|
|
@@ -102,7 +102,7 @@ module Lemans
|
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
def verifier_script
|
|
105
|
-
[task.verifier.preverify, task.verifier.command].compact.map { "( #{it} )" }.join(" && ")
|
|
105
|
+
[ task.verifier.preverify, task.verifier.command ].compact.map { "( #{it} )" }.join(" && ")
|
|
106
106
|
end
|
|
107
107
|
|
|
108
108
|
def read_reward(command_result)
|
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
|
)
|
|
@@ -118,7 +118,7 @@ module Lemans
|
|
|
118
118
|
raise
|
|
119
119
|
rescue StandardError => e
|
|
120
120
|
# A harness bug must leave evidence.
|
|
121
|
-
result.failed!(:harness_crash, ["#{e.class}: #{e.message}", *Array(e.backtrace).first(5)].join("\n"))
|
|
121
|
+
result.failed!(:harness_crash, [ "#{e.class}: #{e.message}", *Array(e.backtrace).first(5) ].join("\n"))
|
|
122
122
|
ensure
|
|
123
123
|
environment&.stop
|
|
124
124
|
end
|
data/lib/lemans/version.rb
CHANGED
data/lib/lemans.rb
CHANGED
|
@@ -9,6 +9,8 @@ loader.inflector.inflect("cli" => "CLI", "atif" => "ATIF", "fs" => "FS",
|
|
|
9
9
|
loader.ignore("#{__dir__}/miniswen.rb", "#{__dir__}/miniswen")
|
|
10
10
|
# assets/ holds files uploaded into sandboxes, not Ruby the harness loads.
|
|
11
11
|
loader.ignore("#{__dir__}/lemans/trial/verifier/assets")
|
|
12
|
+
# templates/ holds the files `lemans init` scaffolds, not Ruby the harness loads.
|
|
13
|
+
loader.ignore("#{__dir__}/lemans/cli/templates")
|
|
12
14
|
loader.setup
|
|
13
15
|
|
|
14
16
|
require "miniswen"
|
data/lib/miniswen/agent.rb
CHANGED
|
@@ -215,9 +215,9 @@ module Miniswen
|
|
|
215
215
|
value.to_h do |key, item|
|
|
216
216
|
key = key.to_sym
|
|
217
217
|
# Tool-call arguments and reasoning details keep their provider-style string keys.
|
|
218
|
-
[key, %i[arguments reasoning_details].include?(key) ? item : deep_symbolize(item)]
|
|
218
|
+
[ key, %i[arguments reasoning_details].include?(key) ? item : deep_symbolize(item) ]
|
|
219
219
|
end
|
|
220
|
-
when Array then value.map { deep_symbolize(
|
|
220
|
+
when Array then value.map { deep_symbolize(it) }
|
|
221
221
|
else value
|
|
222
222
|
end
|
|
223
223
|
end
|
|
@@ -313,7 +313,7 @@ module Miniswen
|
|
|
313
313
|
# them back from ENV on boot (the option upcased).
|
|
314
314
|
def provider_env
|
|
315
315
|
_, provider = resolved
|
|
316
|
-
env = provider.configuration_requirements.to_h { [
|
|
316
|
+
env = provider.configuration_requirements.to_h { [ it.to_s.upcase, RubyLLM.config.public_send(it) ] }.compact
|
|
317
317
|
|
|
318
318
|
order = ENV["LEMANS_PROVIDER_ORDER"]
|
|
319
319
|
env["LEMANS_PROVIDER_ORDER"] = order if order
|
|
@@ -340,7 +340,7 @@ module Miniswen
|
|
|
340
340
|
def next_actions
|
|
341
341
|
response = complete(@messages)
|
|
342
342
|
@steps += 1
|
|
343
|
-
@totals.each_key { @totals[
|
|
343
|
+
@totals.each_key { @totals[it] += response[it].to_i }
|
|
344
344
|
track_cost(response)
|
|
345
345
|
|
|
346
346
|
entry = { role: "assistant", timestamp: Time.now.utc.iso8601, content: response[:content].to_s,
|
|
@@ -496,7 +496,7 @@ module Miniswen
|
|
|
496
496
|
def complete(messages)
|
|
497
497
|
model_info, provider = resolved
|
|
498
498
|
response = provider.complete(
|
|
499
|
-
with_cache_breakpoints(messages.map { as_ruby_llm(
|
|
499
|
+
with_cache_breakpoints(messages.map { as_ruby_llm(it) }),
|
|
500
500
|
tools: { bash: @bash_tool },
|
|
501
501
|
temperature: nil,
|
|
502
502
|
model: model_info,
|
|
@@ -518,13 +518,13 @@ module Miniswen
|
|
|
518
518
|
def with_cache_breakpoints(messages)
|
|
519
519
|
return messages unless explicit_cache?
|
|
520
520
|
|
|
521
|
-
system = messages.find {
|
|
522
|
-
[system, messages.last].compact.uniq.each do |message|
|
|
521
|
+
system = messages.find { it.role == :system }
|
|
522
|
+
[ system, messages.last ].compact.uniq.each do |message|
|
|
523
523
|
text = message.content
|
|
524
524
|
next unless text.is_a?(String) && !text.empty?
|
|
525
525
|
|
|
526
526
|
message.content = RubyLLM::Content::Raw.new(
|
|
527
|
-
[{ type: "text", text: text, cache_control: CACHE_CONTROL }]
|
|
527
|
+
[ { type: "text", text: text, cache_control: CACHE_CONTROL } ]
|
|
528
528
|
)
|
|
529
529
|
end
|
|
530
530
|
messages
|
|
@@ -580,8 +580,8 @@ module Miniswen
|
|
|
580
580
|
return nil if tool_calls.nil? || tool_calls.empty?
|
|
581
581
|
|
|
582
582
|
tool_calls.to_h do |call|
|
|
583
|
-
[call[:id], RubyLLM::ToolCall.new(id: call[:id], name: call[:name], arguments: call[:arguments],
|
|
584
|
-
thought_signature: call[:thought_signature])]
|
|
583
|
+
[ call[:id], RubyLLM::ToolCall.new(id: call[:id], name: call[:name], arguments: call[:arguments],
|
|
584
|
+
thought_signature: call[:thought_signature]) ]
|
|
585
585
|
end
|
|
586
586
|
end
|
|
587
587
|
|
|
@@ -668,7 +668,7 @@ module Miniswen
|
|
|
668
668
|
cache_write = info.cache_write_input_price_per_million || input
|
|
669
669
|
# Providers usually fold thinking into output_tokens; max() bills the
|
|
670
670
|
# larger count once and can never double-bill.
|
|
671
|
-
generated = [response.output_tokens.to_i, tokens&.thinking.to_i].max
|
|
671
|
+
generated = [ response.output_tokens.to_i, tokens&.thinking.to_i ].max
|
|
672
672
|
((response.input_tokens.to_i * input) +
|
|
673
673
|
(tokens&.cached.to_i * cache_read) +
|
|
674
674
|
(tokens&.cache_creation.to_i * cache_write) +
|
data/lib/miniswen/ruby_llm.rb
CHANGED
|
@@ -8,6 +8,12 @@ RubyLLM.configure do |config|
|
|
|
8
8
|
config.logger = Logger.new(IO::NULL) unless ENV["MINISWEN_DEBUG"] == "1"
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
+
# Increase retry window to handle egress network issues
|
|
12
|
+
RubyLLM.configure do |config|
|
|
13
|
+
config.max_retries = 3
|
|
14
|
+
config.retry_interval = 1
|
|
15
|
+
end
|
|
16
|
+
|
|
11
17
|
# ruby_llm reads no API keys from ENV on its own; the conventional variable is the provider's
|
|
12
18
|
# config option upcased.
|
|
13
19
|
RubyLLM.configure do |config|
|
|
@@ -34,7 +40,7 @@ module Miniswen
|
|
|
34
40
|
# ConnectionFailed retries ruby_llm already does; it only lists SSL errors
|
|
35
41
|
# as fatal.
|
|
36
42
|
module RetryTransientSSL
|
|
37
|
-
def retry_exceptions = super + [Faraday::SSLError]
|
|
43
|
+
def retry_exceptions = super + [ Faraday::SSLError ]
|
|
38
44
|
end
|
|
39
45
|
end
|
|
40
46
|
|
data/lib/miniswen/testing.rb
CHANGED
|
@@ -60,14 +60,14 @@ module Miniswen
|
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
def on(command, output, exit_code: 0)
|
|
63
|
-
@canned[command] = [exit_code, output]
|
|
63
|
+
@canned[command] = [ exit_code, output ]
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
def exec(command, timeout: nil, env: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
67
67
|
@commands << command
|
|
68
68
|
return result(0, command.delete_prefix("echo ")) if command.start_with?("echo ")
|
|
69
69
|
|
|
70
|
-
exit_code, output = @canned.fetch(command, [0, ""])
|
|
70
|
+
exit_code, output = @canned.fetch(command, [ 0, "" ])
|
|
71
71
|
result(exit_code, output)
|
|
72
72
|
end
|
|
73
73
|
|
|
@@ -87,7 +87,7 @@ module Miniswen
|
|
|
87
87
|
# and/or :content, plus optional overrides (:tool_calls, :finish_reason,
|
|
88
88
|
# :thinking, token counts). Overrides given here apply to every answer.
|
|
89
89
|
def stub_llm(*answers, **overrides)
|
|
90
|
-
RubyLLM::Test.stub_responses(*answers.map { llm_answer(
|
|
90
|
+
RubyLLM::Test.stub_responses(*answers.map { llm_answer(it, **overrides) })
|
|
91
91
|
end
|
|
92
92
|
|
|
93
93
|
def llm_answer(answer, **overrides)
|
|
@@ -115,15 +115,15 @@ module Miniswen
|
|
|
115
115
|
def raw_body_for(answer, calls)
|
|
116
116
|
choice = { "finish_reason" => answer.fetch(:finish_reason) { calls.empty? ? "stop" : "tool_calls" } }
|
|
117
117
|
choice["message"] = { "reasoning_details" => answer[:reasoning_details] } if answer[:reasoning_details]
|
|
118
|
-
{ "choices" => [choice] }
|
|
118
|
+
{ "choices" => [ choice ] }
|
|
119
119
|
end
|
|
120
120
|
|
|
121
121
|
def tool_calls_for(answer)
|
|
122
|
-
calls = answer[:tool_calls] || Array(answer[:cmd]).map { { name: "bash", arguments: { "command" =>
|
|
122
|
+
calls = answer[:tool_calls] || Array(answer[:cmd]).map { { name: "bash", arguments: { "command" => it } } }
|
|
123
123
|
calls.to_h do |call|
|
|
124
124
|
id = call[:id] || "call_#{@llm_answer_ids = @llm_answer_ids.to_i + 1}"
|
|
125
|
-
[id, RubyLLM::ToolCall.new(id: id, name: call[:name], arguments: call[:arguments],
|
|
126
|
-
thought_signature: call[:thought_signature])]
|
|
125
|
+
[ id, RubyLLM::ToolCall.new(id: id, name: call[:name], arguments: call[:arguments],
|
|
126
|
+
thought_signature: call[:thought_signature]) ]
|
|
127
127
|
end
|
|
128
128
|
end
|
|
129
129
|
end
|
data/lib/miniswen/trajectory.rb
CHANGED
|
@@ -83,7 +83,7 @@ module Miniswen
|
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
def tool_calls_for(calls)
|
|
86
|
-
calls.map { { tool_call_id:
|
|
86
|
+
calls.map { { tool_call_id: it[:id], function_name: it[:name], arguments: it[:arguments] } }
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
def metrics_for(metrics)
|
data/lib/miniswen/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
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
|
|
8
8
|
- Artur Petrov
|
|
9
|
+
- Vladimir Dementyev
|
|
9
10
|
bindir: exe
|
|
10
11
|
cert_chain: []
|
|
11
12
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
@@ -140,6 +141,7 @@ description: Lemans runs tasks against a coding agent in a sandbox.
|
|
|
140
141
|
email:
|
|
141
142
|
- me@skryukov.dev
|
|
142
143
|
- ardecvz@gmail.com
|
|
144
|
+
- dementiev.vm@gmail.com
|
|
143
145
|
executables:
|
|
144
146
|
- lemans
|
|
145
147
|
- lemans-remote
|
|
@@ -163,6 +165,17 @@ files:
|
|
|
163
165
|
- lib/lemans/cli/progress_reporter.rb
|
|
164
166
|
- lib/lemans/cli/report.rb
|
|
165
167
|
- lib/lemans/cli/report/aggregate.rb
|
|
168
|
+
- lib/lemans/cli/templates/bench/README.md
|
|
169
|
+
- lib/lemans/cli/templates/bench/bench.yml
|
|
170
|
+
- lib/lemans/cli/templates/bench/environment/Dockerfile
|
|
171
|
+
- lib/lemans/cli/templates/bench/tasks/example-task/environment.patch
|
|
172
|
+
- 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/verification_test.rb
|
|
175
|
+
- lib/lemans/cli/templates/bench/tasks/hello-world/environment.patch
|
|
176
|
+
- lib/lemans/cli/templates/bench/tasks/hello-world/instruction.md
|
|
177
|
+
- lib/lemans/cli/templates/bench/tasks/hello-world/solution.patch
|
|
178
|
+
- lib/lemans/cli/templates/bench/tasks/hello-world/verification_test.rb
|
|
166
179
|
- lib/lemans/clobber.rb
|
|
167
180
|
- lib/lemans/config.rb
|
|
168
181
|
- lib/lemans/config/agent.rb
|
|
@@ -182,6 +195,7 @@ files:
|
|
|
182
195
|
- lib/lemans/environments/daytona/sdk_tweaks.rb
|
|
183
196
|
- lib/lemans/environments/daytona/shell.rb
|
|
184
197
|
- lib/lemans/environments/daytona/snapshot_store.rb
|
|
198
|
+
- lib/lemans/environments/docker.rb
|
|
185
199
|
- lib/lemans/result.rb
|
|
186
200
|
- lib/lemans/runner.rb
|
|
187
201
|
- lib/lemans/runner/executor.rb
|