lemans 1.0.0.rc.2 → 1.1.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: 44398d66bdcf062d7d5d41ad70e400e76a0031afdcbbdf2df83883ec60aefd33
4
+ data.tar.gz: 79dcdeff359498787b2535c5155ae00806c17572cb8359b51a27454f67a1c5d6
5
5
  SHA512:
6
- metadata.gz: b4c70f4a803a443dc8c7c11642fdae76ca5c932a81a2afab3c78f1a09af1c384b1033d78a28acb84cb4172bfe3481cb8192adfeb4d82f5b58808f929546ef0d4
7
- data.tar.gz: 33edaf57dd9d1d7ed92893c4c1550c28a67ffd8ab29366a384cd5263c9ca3fc53d2130a39ef7467f96b1289cac4a861a269c9e5ab56ce80a68bd8c918b53ccf5
6
+ metadata.gz: f6cb208dfd63c0c63631586e84db8e7cb3e1b9c1e9092d59c6c41d4d58d8057793ca15facf7b9ab15a56723606b878b8817ce97024ad852e3e4c1199429382cb
7
+ data.tar.gz: 251f9a0142b9464499c3c5219ddcc4c95684609048200f5d48a4c55e4e022370941746a3f8635594be3a53ef5f72fcf84d006caeb2b94af618bc38f4ec40c3f7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [1.0.0] - 2026-08-21
3
+ ## [1.1.0] - 2026-08-28
4
+
5
+ - A `#<effort>` model suffix (`openrouter/openai/gpt-5.6-luna#xhigh`) pins the reasoning effort; results land in `<model>-<effort>/`.
6
+
7
+ ## [1.0.0] - 2026-08-24
4
8
 
5
9
  - 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 }
@@ -75,6 +76,7 @@ environment:
75
76
  agent:
76
77
  name: miniswen-installed
77
78
  model: openrouter/z-ai/glm-5.2 # any model RubyLLM can reach; a list benchmarks several in one run
79
+ # append #<effort> (e.g. openrouter/openai/gpt-5.6-luna#xhigh) to pin the reasoning effort
78
80
  timeout: 30m
79
81
  step_limit: 100
80
82
  cost_limit: 5.0
@@ -163,10 +165,11 @@ An `environment.patch` next to `instruction.md` is always applied, declared or n
163
165
  ### 3. Set credentials
164
166
 
165
167
  ```bash
166
- export DAYTONA_API_KEY=... # or DAYTONA_TOKEN
168
+ export DAYTONA_API_KEY=... # or DAYTONA_TOKEN (if using Daytona)
167
169
  export OPENROUTER_API_KEY=... # or ANTHROPIC_API_KEY, OPENAI_API_KEY, ... — matching your model
168
170
 
169
- export LEMANS_PROVIDER_ORDER="Chutes" # [optional] Pin an OpenRouter model to named backends
171
+ export LEMANS_PROVIDER_ORDER="chutes" # [optional] Pin an OpenRouter model to provider slugs (alias: OPENROUTER_PROVIDER_ORDER)
172
+ export LEMANS_PROVIDER_ORDER="z-ai,chutes" # ...or try them in order
170
173
  ```
171
174
 
172
175
  ### 4. Prove the bench before benchmarking anything
@@ -244,9 +247,9 @@ gpt-5.6-luna ar-archive-book-access 2/2 2m 23s $0.0132 12.5 156905
244
247
  | `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
248
  | `lemans clobber` | Delete run results (`--task`, `--ttl 10m\|2h\|1d`, `--invalid`, `-f` to skip the confirmation) |
246
249
 
247
- ## Miniswen
250
+ ## miniswen
248
251
 
249
- Miniswen can be used independently of Lemans as a basic coding agent:
252
+ miniswen can be used independently of lemans as a basic coding agent:
250
253
 
251
254
  ```sh
252
255
  $ gem install miniswen
@@ -256,7 +259,7 @@ $ miniswen --model 'openrouter/openai/gpt-5.6-luna' --prompt 'Write hello-world
256
259
  ...
257
260
  ```
258
261
 
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.
262
+ 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
263
 
261
264
  ## Development
262
265
 
data/exe/lemans-remote CHANGED
@@ -21,6 +21,13 @@
21
21
  # exe/lemans-remote run --bench ../ai-evals --task hello-world
22
22
  # exe/lemans-remote run --bench ../ai-evals --model openrouter/z-ai/glm-5.2 --args="-k 2 -c 8"
23
23
  #
24
+ # Retry invalid local results (agent/infra errors, not negative rewards):
25
+ # launches one sandbox per invalid (task, model) group — a single retried
26
+ # attempt per task — and moves that result dir into runs/_trash; call again
27
+ # to retry the remaining invalid attempts of the same tasks:
28
+ #
29
+ # exe/lemans-remote run --bench ../ai-evals --retry-runs=./runs
30
+ #
24
31
  # Then watch, fetch, and clean up:
25
32
  #
26
33
  # exe/lemans-remote status [--history] [--running | --complete]
@@ -31,7 +38,8 @@
31
38
  # exe/lemans-remote deprovision [--name SNAPSHOT]
32
39
  #
33
40
  # Credentials come from the host ENV: DAYTONA_API_KEY (or DAYTONA_TOKEN),
34
- # OPENROUTER_API_KEY, LEMANS_PROVIDER_ORDER; forward extras with --env KEY.
41
+ # OPENROUTER_API_KEY, LEMANS_PROVIDER_ORDER (or OPENROUTER_PROVIDER_ORDER);
42
+ # forward extras with --env KEY.
35
43
 
36
44
  lib_path = File.expand_path("../lib", __dir__)
37
45
  $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
@@ -42,6 +50,7 @@ require "digest"
42
50
  require "fileutils"
43
51
  require "json"
44
52
  require "open3"
53
+ require "pathname"
45
54
  require "securerandom"
46
55
  require "shellwords"
47
56
  require "stringio"
@@ -76,7 +85,7 @@ module LemansRemote # :nodoc: all
76
85
  REMOTE_HOOKS = "/tmp/lemans-remote-hooks.rb"
77
86
  REMOTE_META = "/tmp/lemans-remote-run.json"
78
87
 
79
- ENV_ALLOWLIST = %w[OPENROUTER_API_KEY LEMANS_PROVIDER_ORDER].freeze
88
+ ENV_ALLOWLIST = %w[OPENROUTER_API_KEY LEMANS_PROVIDER_ORDER OPENROUTER_PROVIDER_ORDER].freeze
80
89
 
81
90
  Backend = Lemans::Environments::Daytona
82
91
 
@@ -230,6 +239,51 @@ module LemansRemote # :nodoc: all
230
239
  end
231
240
  end
232
241
 
242
+ class RetryPlanner
243
+ TRASH_DIR = "_trash"
244
+
245
+ Group = Struct.new(:task, :model, :dirs, keyword_init: true)
246
+
247
+ def initialize(runs_dir)
248
+ @root = Pathname(runs_dir)
249
+ end
250
+
251
+ def groups
252
+ @groups ||= invalids.group_by { |result, _dir| [ result.task, result.model ] }
253
+ .map { |(task, model), rows| Group.new(task:, model:, dirs: rows.map(&:last)) }
254
+ .sort_by { [ it.task.to_s, it.model.to_s ] }
255
+ end
256
+
257
+ def invalid_attempts = groups.sum { it.dirs.size }
258
+
259
+ def trash(group, count)
260
+ trash_root = @root.join(TRASH_DIR)
261
+ group.dirs.take(count).map do |dir|
262
+ target = trash_root.join(dir.relative_path_from(@root))
263
+ FileUtils.mkdir_p(target.dirname.to_s)
264
+ FileUtils.mv(dir.to_s, target.to_s)
265
+ target
266
+ end
267
+ end
268
+
269
+ private
270
+
271
+ def invalids
272
+ @root.glob("**/#{Lemans::Stores::FS::FILENAME}").filter_map do |path|
273
+ next if path.relative_path_from(@root).each_filename.include?(TRASH_DIR)
274
+
275
+ result = read_result(path)
276
+ [ result, path.dirname ] if result&.invalid?
277
+ end
278
+ end
279
+
280
+ def read_result(path)
281
+ Lemans::Result.from_json(JSON.parse(path.read, symbolize_names: true))
282
+ rescue JSON::ParserError, SystemCallError, IOError, Lemans::Result::IncompatibleError
283
+ nil
284
+ end
285
+ end
286
+
233
287
  class Vault
234
288
  NAME = "lemans-remote-runs"
235
289
  MOUNT = "/vault"
@@ -733,6 +787,8 @@ module LemansRemote # :nodoc: all
733
787
  option :bench, default: ".", desc: "Directory holding bench.yml"
734
788
  option :task, repeatable: true, desc: "Run task(s) by name (default: all)"
735
789
  option :model, repeatable: true, desc: "Override the model(s) from bench.yml"
790
+ option :retry_runs, desc: "Retry invalid results from this local runs directory " \
791
+ "(one attempt per task per call, retried results move to _trash)"
736
792
  option :args, default: "", desc: "Extra `lemans run` options, passed through verbatim"
737
793
  option :timeout, default: "6h", desc: "Give up on the remote run after this long"
738
794
  option :sync, type: :boolean, default: false, desc: "Wait for the run and download the results directly"
@@ -746,7 +802,21 @@ module LemansRemote # :nodoc: all
746
802
  desc: "Async mode: how many sandboxes to launch in parallel (1 to serialize)"
747
803
  def run_bench
748
804
  bench = Lemans::Config.load_file(options[:bench])
749
- tasks = options[:task] || []
805
+
806
+ planner = RetryPlanner.new(options[:retry_runs]) if options[:retry_runs]
807
+ retry_groups = nil
808
+ if planner
809
+ raise Thor::Error, "lemans-remote: --retry-runs picks the tasks itself — drop --task" if (options[:task] || []).any?
810
+ raise Thor::Error, "lemans-remote: --attempts does not combine with --retry-runs (one attempt per task per call)" if options[:attempts].to_i > 1
811
+
812
+ retry_groups = planner.groups
813
+ if retry_groups.empty?
814
+ say_status :retry, "no invalid runs in #{options[:retry_runs]}", :green
815
+ return
816
+ end
817
+ end
818
+
819
+ tasks = retry_groups ? retry_groups.map(&:task).uniq : (options[:task] || [])
750
820
  unknown = tasks - bench.tasks.map(&:name)
751
821
  raise Thor::Error, "lemans-remote: no such task(s): #{unknown.join(", ")}" if unknown.any?
752
822
 
@@ -759,21 +829,34 @@ module LemansRemote # :nodoc: all
759
829
  raise Thor::Error, "lemans-remote: --concurrency must be at least 1" if options[:concurrency].to_i < 1
760
830
 
761
831
  models = options[:model] || []
762
- batches = plan_batches(bench, tasks)
763
- jobs = batches.flat_map { |batch| (1..attempts).map { [ batch, attempts > 1 ? it : nil ] } }
832
+ jobs =
833
+ if retry_groups
834
+ retry_groups.each { say_status :retry, "#{it.task} (#{it.model}) — #{it.dirs.size} invalid attempt(s), retrying one" }
835
+ retry_groups.map { [ [ it.task ], models.any? ? models : [ it.model ].compact, nil, it ] }
836
+ else
837
+ plan_batches(bench, tasks).flat_map { |batch| (1..attempts).map { [ batch, models, attempts > 1 ? it : nil, nil ] } }
838
+ end
839
+ raise Thor::Error, "lemans-remote: --sync runs a single sandbox — #{jobs.size} invalid group(s) to retry" if options[:sync] && jobs.size > 1
840
+
764
841
  Vault.ensure unless options[:sync]
765
842
 
766
843
  if jobs.size == 1
767
- batch, attempt = jobs.first
768
- exit_code = launch_batch(bench, batch, models, provisioner, attempt:)
844
+ batch, job_models, attempt, group = jobs.first
845
+ exit_code = launch_batch(bench, batch, job_models, provisioner, attempt:)
846
+ finish_retry(planner, [ group ]) if group
769
847
  say_status :detached, "`lemans-remote status` to watch, `lemans-remote pull-runs` to fetch results" unless options[:sync]
770
848
  exit exit_code unless exit_code.zero?
771
849
  return
772
850
  end
773
851
 
774
- fanout = attempts > 1 ? "#{batches.size} task batch(es) × #{attempts} attempts" : "one per task"
852
+ fanout =
853
+ if retry_groups then "one per invalid (task, model) group"
854
+ elsif attempts > 1 then "#{jobs.size / attempts} task batch(es) × #{attempts} attempts"
855
+ else "one per task"
856
+ end
775
857
  say_status :fanout, "#{jobs.size} sandboxes, #{fanout}, #{options[:concurrency].to_i} at a time"
776
- failures = launch_batches(bench, jobs, models, provisioner)
858
+ failures, retried = launch_batches(bench, jobs, provisioner)
859
+ finish_retry(planner, retried) if retry_groups
777
860
  say_status :detached, "`lemans-remote status` to watch, `lemans-remote pull-runs` to fetch results"
778
861
  raise Thor::Error, "lemans-remote: failed to launch: #{failures.join("; ")}" if failures.any?
779
862
  rescue Lemans::ConfigError, RuntimeError => e
@@ -1000,6 +1083,14 @@ module LemansRemote # :nodoc: all
1000
1083
  Provisioner.new(cpus: options[:cpus].to_i, memory_gb: options[:memory].to_i, disk_gb: options[:disk].to_i)
1001
1084
  end
1002
1085
 
1086
+ def finish_retry(planner, retried)
1087
+ retried.each { |group| planner.trash(group, 1).each { say_status :trashed, it.to_s, :cyan } }
1088
+ leftover = planner.invalid_attempts - retried.size
1089
+ return unless leftover.positive?
1090
+
1091
+ say_status :retry, "#{leftover} invalid attempt(s) remain — run --retry-runs again", :yellow
1092
+ end
1093
+
1003
1094
  def plan_batches(bench, tasks)
1004
1095
  return [ tasks ] if options[:sync] || options[:run_in_band]
1005
1096
 
@@ -1024,15 +1115,16 @@ module LemansRemote # :nodoc: all
1024
1115
  ).call
1025
1116
  end
1026
1117
 
1027
- def launch_batches(bench, jobs, models, provisioner)
1118
+ def launch_batches(bench, jobs, provisioner)
1028
1119
  queue = Queue.new
1029
1120
  jobs.each { queue << it }
1030
1121
  failures = Queue.new
1122
+ launched = Queue.new
1031
1123
 
1032
1124
  threads = [ options[:concurrency].to_i, jobs.size ].min.times.map do
1033
1125
  Thread.new do
1034
1126
  loop do
1035
- batch, attempt =
1127
+ batch, models, attempt, group =
1036
1128
  begin
1037
1129
  queue.pop(true)
1038
1130
  rescue ThreadError
@@ -1040,6 +1132,7 @@ module LemansRemote # :nodoc: all
1040
1132
  end
1041
1133
  begin
1042
1134
  launch_batch(bench, batch, models, provisioner, attempt:)
1135
+ launched << group if group
1043
1136
  rescue StandardError => e
1044
1137
  label = batch.empty? ? "all" : batch.join(",")
1045
1138
  label += "/a#{attempt}" if attempt
@@ -1050,7 +1143,7 @@ module LemansRemote # :nodoc: all
1050
1143
  end
1051
1144
  threads.each(&:join)
1052
1145
 
1053
- Array.new(failures.size) { failures.pop }
1146
+ [ Array.new(failures.size) { failures.pop }, Array.new(launched.size) { launched.pop } ]
1054
1147
  end
1055
1148
 
1056
1149
  def build_run_id(bench, tasks, models, attempt = nil)
@@ -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:
@@ -55,7 +57,9 @@ agent:
55
57
 
56
58
  # Any model RubyLLM can reach. Export the matching credential
57
59
  # (OPENROUTER_API_KEY, ANTHROPIC_API_KEY, ...).
58
- # `lemans run --model ...` overrides per run.
60
+ # `lemans run --model ...` overrides per run. A `#<effort>` suffix pins the
61
+ # reasoning effort (openrouter/openai/gpt-5.6-luna#xhigh); the suffix is part
62
+ # of the model name in reports and run folders.
59
63
  model: openrouter/z-ai/glm-5.2
60
64
 
61
65
  # The budget of one attempt: wall clock, agent-loop steps, and dollars.
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
@@ -128,7 +128,7 @@ module Lemans
128
128
 
129
129
  # result.json is stored at <root>/<model-short>/<result-id>
130
130
  def result_dir(result)
131
- root.join((result.model || result.agent).to_s.split("/").last, result.id)
131
+ root.join((result.model || result.agent).to_s.split("/").last.tr("#", "-"), result.id)
132
132
  end
133
133
  end
134
134
  end
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.1.0"
5
5
  end
@@ -232,11 +232,14 @@ module Miniswen
232
232
  private attr_reader :max_steps, :max_time, :max_cost, :exec_timeout,
233
233
  :clock, :reporter
234
234
 
235
- # `model` is a litellm-style name ("openrouter/z-ai/glm-5.2"). Limits of 0 or nil are disabled.
235
+ # `model` is a litellm-style name ("openrouter/z-ai/glm-5.2"), optionally
236
+ # suffixed with a reasoning effort ("openrouter/openai/gpt-5.6-luna#xhigh").
237
+ # Limits of 0 or nil are disabled.
236
238
  def initialize(model:, environment:, max_steps: 0, max_time: 0, max_cost: nil,
237
239
  exec_timeout: 30, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
238
240
  reporter: nil)
239
- @provider, @id = model.split("/", 2)
241
+ name, @effort = model.split("#", 2)
242
+ @provider, @id = name.split("/", 2)
240
243
  unless @id
241
244
  @id = @provider
242
245
  @provider = nil
@@ -315,8 +318,8 @@ module Miniswen
315
318
  _, provider = resolved
316
319
  env = provider.configuration_requirements.to_h { [ it.to_s.upcase, RubyLLM.config.public_send(it) ] }.compact
317
320
 
318
- order = ENV["LEMANS_PROVIDER_ORDER"]
319
- env["LEMANS_PROVIDER_ORDER"] = order if order
321
+ order = provider_order
322
+ env["OPENROUTER_PROVIDER_ORDER"] = order if order
320
323
  env
321
324
  end
322
325
 
@@ -500,7 +503,8 @@ module Miniswen
500
503
  tools: { bash: @bash_tool },
501
504
  temperature: nil,
502
505
  model: model_info,
503
- params: routing_params
506
+ params: routing_params,
507
+ thinking: (RubyLLM::Thinking::Config.new(effort: @effort) if @effort)
504
508
  )
505
509
  payload(response)
506
510
  rescue RubyLLM::Error => e
@@ -531,12 +535,14 @@ module Miniswen
531
535
  end
532
536
 
533
537
  def routing_params
534
- order = ENV["LEMANS_PROVIDER_ORDER"]
538
+ order = provider_order
535
539
  return {} unless order && @provider == "openrouter"
536
540
 
537
541
  { provider: { order: order.split(",").map(&:strip), allow_fallbacks: false } }
538
542
  end
539
543
 
544
+ def provider_order = ENV["LEMANS_PROVIDER_ORDER"] || ENV["OPENROUTER_PROVIDER_ORDER"]
545
+
540
546
  def cost_source
541
547
  if local?
542
548
  return CostSource.new(name: :local_provider, model: @model,
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Miniswen
4
+ class CLI
5
+ # Prints messages and tool calls in real-time. The renderer deliberately
6
+ # keeps the captured (non-TTY) version plain, which makes it useful in CI
7
+ # and when piping a run to a log file too.
8
+ class Reporter
9
+ private attr_reader :io
10
+
11
+ # Tool output can be extremely noisy (for example, a recursive grep or
12
+ # a test runner dumping a log). Keep the normal report useful while
13
+ # allowing -vv to retain the complete output for debugging.
14
+ MAX_TOOL_OUTPUT_CHARS = 1_000
15
+
16
+ def initialize(io = $stdout, verbose: false, tool_output: verbose, reasoning: true)
17
+ @io = io
18
+ @verbose = verbose
19
+ @tool_output = tool_output
20
+ @reasoning = reasoning
21
+ end
22
+
23
+ def on_message(message)
24
+ case message[:role].to_s
25
+ when "assistant"
26
+ write_assistant(message)
27
+ when "tool"
28
+ write_tool(message)
29
+ when "user"
30
+ write_block("!", message[:content], :warning)
31
+ else
32
+ write_block("·", message[:content], :muted)
33
+ end
34
+ end
35
+
36
+ # Tool calls are reported separately so the command is visible before
37
+ # its output arrives. It is not added to the trajectory sent to the LLM.
38
+ def on_tool_call(call)
39
+ command = call.dig(:arguments, "command") || call.dig(:arguments, :command)
40
+ return if command.to_s.empty?
41
+
42
+ line = style("$ #{command}", :command)
43
+ io.puts(" #{line}")
44
+ end
45
+
46
+ def print_summary(result)
47
+ write_assistant(result.messages.last)
48
+ write_block("↳", "steps=#{result.steps} · cost=$#{result.cost_usd}", :muted)
49
+ end
50
+
51
+ def print_failure(result)
52
+ write_block("!", "Miniswen failed: #{result.status}", :warning)
53
+ end
54
+
55
+ private
56
+
57
+ def write_tool(message)
58
+ return write_block("↳", message[:content], :tool) if @tool_output
59
+
60
+ exit_code = message.dig(:observation, :exit_code)
61
+ return if exit_code.nil?
62
+
63
+ if exit_code.zero?
64
+ write_block("↳", "ok", :muted)
65
+ else
66
+ output = truncate(message.dig(:observation, :output).to_s.strip)
67
+ write_block("!", "not ok: exit #{exit_code}\n#{output}", :warning)
68
+ end
69
+ end
70
+
71
+ def write_assistant(message)
72
+ content = message[:content].to_s.strip
73
+ write_block("∴", message[:thinking], :thinking) if @reasoning && (@verbose || content.empty?)
74
+ write_block("●", content, :assistant)
75
+ end
76
+
77
+ def write_block(marker, content, tone)
78
+ text = content.to_s.strip
79
+ return if text.empty?
80
+
81
+ text = truncate(text) if %i[tool thinking].include?(tone)
82
+ lines = text.lines(chomp: true)
83
+ io.puts("#{style(marker, tone)} #{style(lines.shift, tone)}")
84
+ lines.each { |line| io.puts(" #{style(line, tone)}") }
85
+ end
86
+
87
+ def truncate(text)
88
+ return text if @verbose || text.length <= MAX_TOOL_OUTPUT_CHARS
89
+
90
+ head = MAX_TOOL_OUTPUT_CHARS / 2
91
+ tail = MAX_TOOL_OUTPUT_CHARS - head
92
+ omitted = text.length - head - tail
93
+ "#{text[0, head]}\n... [#{omitted} characters omitted] ...\n#{text[-tail, tail]}"
94
+ end
95
+
96
+ def style(text, tone)
97
+ return text unless io.respond_to?(:tty?) && io.tty?
98
+
99
+ colors = { assistant: 36, tool: 32, warning: 33, command: 35, muted: 90, thinking: 90 }
100
+ "\e[#{colors.fetch(tone)}m#{text}\e[0m"
101
+ end
102
+ end
103
+ end
104
+ end
data/lib/miniswen/cli.rb CHANGED
@@ -1,88 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require "fileutils"
4
5
 
5
6
  require "miniswen/version"
7
+ require "miniswen/cli/reporter"
6
8
 
7
9
  module Miniswen
8
10
  class CLI # :nodoc:
9
- # Prints messages and tool calls in real-time. The renderer deliberately
10
- # keeps the captured (non-TTY) version plain, which makes it useful in CI
11
- # and when piping a run to a log file too.
12
- class Reporter
13
- private attr_reader :io
14
-
15
- # Tool output can be extremely noisy (for example, a recursive grep or
16
- # a test runner dumping a log). Keep the normal report useful while
17
- # allowing -vv to retain the complete output for debugging.
18
- MAX_TOOL_OUTPUT_CHARS = 1_000
19
-
20
- def initialize(io = $stdout, verbose: false)
21
- @io = io
22
- @verbose = verbose
23
- end
24
-
25
- def on_message(message)
26
- case message[:role].to_s
27
- when "assistant"
28
- write_block("●", message[:content], :assistant)
29
- when "tool"
30
- write_block("↳", message[:content], :tool)
31
- when "user"
32
- write_block("!", message[:content], :warning)
33
- else
34
- write_block("·", message[:content], :muted)
35
- end
36
- end
37
-
38
- # Tool calls are reported separately so the command is visible before
39
- # its output arrives. It is not added to the trajectory sent to the LLM.
40
- def on_tool_call(call)
41
- command = call.dig(:arguments, "command") || call.dig(:arguments, :command)
42
- return if command.to_s.empty?
43
-
44
- line = style("$ #{command}", :command)
45
- io.puts(" #{line}")
46
- end
47
-
48
- def print_summary(result)
49
- write_block("●", result.messages.last[:content], :assistant)
50
- write_block("↳", "steps=#{result.steps} · cost=$#{result.cost_usd}", :muted)
51
- end
52
-
53
- def print_failure(result)
54
- write_block("!", "Miniswen failed: #{result.status}", :warning)
55
- end
56
-
57
- private
58
-
59
- def write_block(marker, content, tone)
60
- text = content.to_s.strip
61
- return if text.empty?
62
-
63
- text = truncate_tool_output(text) if tone == :tool
64
- lines = text.lines(chomp: true)
65
- io.puts("#{style(marker, tone)} #{style(lines.shift, tone)}")
66
- lines.each { |line| io.puts(" #{style(line, tone)}") }
67
- end
68
-
69
- def truncate_tool_output(text)
70
- return text if @verbose || text.length <= MAX_TOOL_OUTPUT_CHARS
71
-
72
- head = MAX_TOOL_OUTPUT_CHARS / 2
73
- tail = MAX_TOOL_OUTPUT_CHARS - head
74
- omitted = text.length - head - tail
75
- "#{text[0, head]}\n... [#{omitted} characters omitted] ...\n#{text[-tail, tail]}"
76
- end
77
-
78
- def style(text, tone)
79
- return text unless io.respond_to?(:tty?) && io.tty?
80
-
81
- colors = { assistant: 36, tool: 32, warning: 33, command: 35, muted: 90 }
82
- "\e[#{colors.fetch(tone)}m#{text}\e[0m"
83
- end
84
- end
85
-
86
11
  attr_reader :instruction, :model, :options
87
12
 
88
13
  def initialize
@@ -90,6 +15,8 @@ module Miniswen
90
15
  @model = ENV.fetch("MINISWEN_MODEL", nil)
91
16
  @options = {}
92
17
  @verbose = false
18
+ @show_output = false
19
+ @reasoning = true
93
20
  @quiet = false
94
21
  @results_path = nil
95
22
  @atif_path = nil
@@ -110,8 +37,16 @@ module Miniswen
110
37
 
111
38
  require "miniswen/local"
112
39
 
113
- reporter = @quiet ? nil : Reporter.new(verbose: @verbose)
114
- agent = Agent.new(model:, reporter:, environment: Local.new, **options)
40
+ reporter = @quiet ? nil : Reporter.new(verbose: @verbose, tool_output: @verbose || @show_output, reasoning: @reasoning)
41
+ environment =
42
+ if @docker_id
43
+ require "miniswen/environment/docker"
44
+ Environment::Docker.new(@docker_id)
45
+ else
46
+ Local.new
47
+ end
48
+
49
+ agent = Agent.new(model:, reporter:, environment:, **options)
115
50
 
116
51
  begin
117
52
  result = agent.run(instruction)
@@ -133,8 +68,15 @@ module Miniswen
133
68
  private
134
69
 
135
70
  def write_results(result)
136
- File.write(@results_path, JSON.generate(result.to_h)) if @results_path
137
- write_atif(result) if @atif_path
71
+ if @results_path
72
+ FileUtils.mkdir_p(File.dirname(@results_path))
73
+ File.write(@results_path, JSON.generate(result.to_h))
74
+ end
75
+
76
+ if @atif_path
77
+ FileUtils.mkdir_p(File.dirname(@atif_path))
78
+ write_atif(result)
79
+ end
138
80
  end
139
81
 
140
82
  def error_message(error)
@@ -162,7 +104,7 @@ module Miniswen
162
104
  end
163
105
 
164
106
  opts.on("-p INSTRUCTION", "--prompt=INSTRUCTION", String, "Instruction prompt") do |v|
165
- @instruction = v
107
+ @instruction = File.file?(v) ? File.read(v) : v
166
108
  end
167
109
 
168
110
  opts.on("--max-steps=STEPS", Integer, "Max steps count") do |v|
@@ -185,6 +127,14 @@ module Miniswen
185
127
  @quiet = true
186
128
  end
187
129
 
130
+ opts.on("--show-output", "Print tool output instead of just the exit status") do
131
+ @show_output = true
132
+ end
133
+
134
+ opts.on("--no-reasoning", "Hide the model's reasoning") do
135
+ @reasoning = false
136
+ end
137
+
188
138
  opts.on("--results-path=PATH", String, "Write the run result as JSON to PATH") do |v|
189
139
  @results_path = v
190
140
  end
@@ -193,6 +143,10 @@ module Miniswen
193
143
  @atif_path = v
194
144
  end
195
145
 
146
+ opts.on("--docker=ID", String, "Docker container ID to exec commands on") do |v|
147
+ @docker_id = v
148
+ end
149
+
196
150
  opts.on("--refresh-registry", "Refresh the model registry, persist it, and exit") do
197
151
  @refresh_registry = true
198
152
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ require "miniswen/environment"
6
+
7
+ module Miniswen
8
+ class Environment
9
+ class Docker < self
10
+ TIMEOUT_MARKED_EXIT_CODES = [ 124, 143 ].freeze
11
+
12
+ private attr_reader :id
13
+
14
+ def initialize(id)
15
+ @id = id
16
+ end
17
+
18
+ def exec(command, timeout: nil, env: nil)
19
+ argv = [ "docker", "exec" ]
20
+ env&.each { |key, value| argv += [ "--env", "#{key}=#{value}" ] }
21
+ argv << id
22
+ argv += [ "timeout", timeout.ceil.to_s ] if timeout&.positive?
23
+ argv += [ "sh", "-c", command ]
24
+
25
+ Open3.popen2e(*argv) do |stdin, pipe, wait|
26
+ stdin.close
27
+ deadline = (now + timeout + 10 if timeout&.positive?) # add some slack
28
+ output = +""
29
+ timed_out = false
30
+
31
+ loop do
32
+ remaining = deadline && deadline - now
33
+ if remaining && remaining <= 0
34
+ timed_out = true
35
+ kill(wait.pid)
36
+ break
37
+ end
38
+ next unless pipe.wait_readable(remaining)
39
+
40
+ chunk = pipe.read_nonblock(65_536, exception: false)
41
+ break if chunk.nil?
42
+ next if chunk == :wait_readable
43
+
44
+ output << chunk
45
+ end
46
+
47
+ status = wait.value
48
+ exit_code = timed_out ? 124 : (status.exitstatus || 1)
49
+ output = output.force_encoding(Encoding::UTF_8).scrub
50
+ if timeout&.positive? && (timed_out || TIMEOUT_MARKED_EXIT_CODES.include?(exit_code))
51
+ output = "#{output}\n<command timed out after #{timeout} seconds>"
52
+ end
53
+
54
+ ExecResult.new(exit_code:, output:)
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def kill(pid)
61
+ Process.kill("KILL", pid)
62
+ rescue Errno::ESRCH
63
+ nil
64
+ end
65
+
66
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ end
68
+ end
69
+ 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.1.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.1.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
@@ -214,7 +215,9 @@ files:
214
215
  - lib/miniswen.rb
215
216
  - lib/miniswen/agent.rb
216
217
  - lib/miniswen/cli.rb
218
+ - lib/miniswen/cli/reporter.rb
217
219
  - lib/miniswen/environment.rb
220
+ - lib/miniswen/environment/docker.rb
218
221
  - lib/miniswen/local.rb
219
222
  - lib/miniswen/ruby_llm.rb
220
223
  - lib/miniswen/testing.rb