lemans 1.5.1 → 1.5.2

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: 684bdca58989cce6016fe717fed47d6f64966db4a915ce8345f3cf31debbcc48
4
- data.tar.gz: d15a17a53a85d1ee17324dcef201e2b3279ff469de518c85518eb95bb0e8571a
3
+ metadata.gz: 76cc9a613af13ed9f651f518b8410214a514df72d68b5034183a9e83b4997545
4
+ data.tar.gz: 3ad78386ced9049f0087b9fe99e1e160f60eb8f0fbebb2acce565a9cdbe4007e
5
5
  SHA512:
6
- metadata.gz: c4a317c1de7a6a5fe1ee2748925b5c26def63a01becbff8d314b7e069b49951464caef2bbd1d971efa738af600f484f105726b560c193ccfb35518ce2544666b
7
- data.tar.gz: a786e7118d2879ff472c5cda73d94e75c08a2947fc9448e5142daa38489ba685a50853ad0e9f669857c4186bb723843c0190202edba3160a77d34d1ba22e3dbd
6
+ metadata.gz: 656ffa9f0ea81a73c82876e7d06fbd1ee3692b46a86f8383ad6bb11797474fdda3d402f25094b7f49ea9ba260afd3c00924d524104865b1438bd16c4e37e2c66
7
+ data.tar.gz: 2bef818afe5e980fe808a3a63349ed4e8b116bf7cc09d05797a477528565d5c09050ea561011e03eb12d5badb9474f22e6372e0ccff8ab50a8b530acd13bf287
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.5.2] - 2026-10-14
4
+
5
+ - `lemans --require/-r [command]`: load agent, backend plugins (`lemans -r plugin.rb restart RUN`)
6
+ - `lemant report` normalizes metrics (credit, cost, steps, and tokens) within each task individually before combining them in the final report.
7
+ - `lemans report`: add credit to summary lines.
8
+ - `lemans report --model x [--model y]` filters runs by model (full id or short name).
9
+ - miniswen: improve Muse caching on OpenRouter with a unique `prompt_cache_key` for each session, preserved on recovery.
10
+
3
11
  ## [1.5.1] - 2026-10-06
4
12
 
5
13
  - `lemans report --skip-invalid --hide-columns steps-tokens-trial`.
data/README.md CHANGED
@@ -6,6 +6,7 @@ lemans is a harness for benchmarking coding agents, the Ruby way:
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
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
+ - **Extensible**: load a custom agent or a sandboxing backend on-the-fly defined in a Ruby file or provided by a gem.
9
10
 
10
11
  > [!TIP]
11
12
  > Check [Rails AI Evals](https://github.com/rails/ai-evals) for a full-featured example.
@@ -263,6 +264,83 @@ gpt-5.6-luna ar-archive-book-access 2/2 2m 23s $0.0132 12.5 156905
263
264
  6 trials: 6 scored, 0 invalid, 6 solved (100%) · $0.0801 · pass@2 3/3 tasks (100%)
264
265
  ```
265
266
 
267
+ ### 6. Extensions
268
+
269
+ You can bring **custom agents and backends** to lemans. For that, you should:
270
+
271
+ - Write an agent or a backend implementation in a Ruby file/gem
272
+ - Load it using the `--require` / `-r` option specified **before the command**:
273
+
274
+ ```sh
275
+ lemans -r ./my-agent.rb run --agent my-agent ...
276
+ ```
277
+
278
+ - **IMPORTANT:** The extension must register itself in the corresponding registry and follow the leman's interface:
279
+
280
+ ```ruby
281
+ # my-agent.rb
282
+ class MyAgent < Lemans::Agent
283
+ NAME = "my-agent"
284
+
285
+ def install(_task, environment)
286
+ environment.upload("/path/to/my-agent", "/usr/local/bin/my-agent")
287
+ environment.exec!("chmod +x /usr/local/bin/my-agent")
288
+ end
289
+
290
+ def run(task, environment)
291
+ finished = environment.exec("cd #{task.environment.workdir} && my-agent --model #{model}", timeout:)
292
+ outcome = finished.exit_code == 124 ? :agent_timeout : :completed
293
+
294
+ # The returned object is a Lemans::Agent::Response.
295
+ # IMPORTANT: `usage` is required for correct work
296
+ Response.new(outcome: Lemans::Result::Outcome.new(outcome), usage: Lemans::Result::Usage.zero)
297
+ end
298
+ end
299
+
300
+ Lemans::Agents.register(MyAgent::NAME, MyAgent)
301
+
302
+ # my-backend.rb
303
+ class MyBackend < Lemans::Environment
304
+ NAME = "my-backend"
305
+
306
+ # Called with image:, resources:, network:, labels:, build_timeout:, and ttl: (see Lemans::Environment)
307
+ def start
308
+ @sandbox = MyCloud.create(image: image.name, cpus: resources.cpus, memory: resources.memory)
309
+ switch_network_policy!(network)
310
+ self
311
+ end
312
+
313
+ def exec(command, timeout: nil, env: {})
314
+ run = @sandbox.run(command, timeout: timeout || DEFAULT_TIMEOUT, env:)
315
+ ExecResult.new(command:, exit_code: run.exit_code, output: run.output, duration: run.duration)
316
+ end
317
+
318
+ def upload(local_path, remote_path) = @sandbox.put(local_path, remote_path)
319
+
320
+ def download(remote_path, local_path) = @sandbox.get(remote_path, local_path)
321
+
322
+ # Phases narrow the network: setup, then the agent's policy, then none for the verifier.
323
+ # IMPORTANT: raise if the policy cannot be enforced
324
+ def switch_network_policy!(policy)
325
+ @sandbox.set_network(policy.mode, hosts: policy.hosts)
326
+ @network = policy
327
+ end
328
+
329
+ def stop = @sandbox&.destroy
330
+ end
331
+
332
+ Lemans::Environments.register(MyBackend::NAME, MyBackend)
333
+ ```
334
+
335
+ ```sh
336
+ lemans -r ./my-backend.rb run --backend my-backend ...
337
+ ```
338
+
339
+ See [examples](./examples) for real-world extensions:
340
+
341
+ - [Apple Container backend](./examples/apple-container.rb)
342
+ - [Claude Code agent](./examples/claude-code.rb)
343
+
266
344
  ## CLI
267
345
 
268
346
  | Command | What it does |
@@ -271,7 +349,7 @@ gpt-5.6-luna ar-archive-book-access 2/2 2m 23s $0.0132 12.5 156905
271
349
  | `lemans tasks` | List the tasks in a bench (`--tag` to filter) |
272
350
  | `lemans run` | Run tasks and grade them (`--task`, `--tag`, `--agent`, `--model`, `--max-output-tokens`, `-k`, `-c`, `--resume`) |
273
351
  | `lemans restart <run>...` | Continue failed multistep runs from their last settled step in new runs (`-c`, `--recover` to continue the failed step's session, `--reverify` to grade again, `--allow-scored`, `--backend`, `--max-output-tokens`) |
274
- | `lemans report [RUNS_DIR]` | Summarize `runs/` (or `RUNS_DIR`) as a table or CSV (`--task`, `--tag`, `--metadata key:value` to filter, `--skip-invalid` to leave out invalid trials, `-A [task-agent-model]` to aggregate, `-S <columns>` to sort, e.g. `-S score-credit`; numbers high to low, names A-Z, `^column` reverses that column; `--hide-columns steps-tokens` for a narrower table); repeated attempts add pass@k per model × task, fractional grading a `credit` column, multistep tasks a `progress` column (steps completed / task steps) |
352
+ | `lemans report [RUNS_DIR]` | Summarize `runs/` (or `RUNS_DIR`) as a table or CSV (`--task`, `--tag`, `--model`, `--metadata key:value` to filter, `--skip-invalid` to leave out invalid trials, `-A [task-agent-model]` to aggregate, `-S <columns>` to sort, e.g. `-S score-credit`; numbers high to low, names A-Z, `^column` reverses that column; `--hide-columns steps-tokens` for a narrower table); repeated attempts add pass@k per model × task, fractional grading a `credit` column, multistep tasks a `progress` column (steps completed / task steps) |
275
353
  | `lemans clobber [RUNS_DIR]` | Delete run results under `runs/` (or `RUNS_DIR`) (`--task`, `--ttl 10m\|2h\|1d`, `--invalid`, `-f` to skip the confirmation) |
276
354
 
277
355
  ## miniswen
@@ -288,6 +366,13 @@ $ miniswen --model 'openrouter/openai/gpt-5.6-luna' --prompt 'Write hello-world
288
366
 
289
367
  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.
290
368
 
369
+ ## More tools: lemans-remote and lemans-viewer
370
+
371
+ The gem also ships two convenient (and 100% AI-maintained) CLIs:
372
+
373
+ - `lemans-remote`: scale `lemans run`-s horizontally using Daytona sandboxes; schedule tasks and pull results later
374
+ - `lemans-viewer`: a web UI for browsing trial results (if you still need a pair of human eyes to look at them)
375
+
291
376
  ## Development
292
377
 
293
378
  After checking out the repo, run `bin/setup` to install dependencies, `rake test` to run the tests, and `bin/console` for an interactive prompt.
data/exe/lemans-remote CHANGED
@@ -22,6 +22,11 @@
22
22
  # exe/lemans-remote run --bench ../ai-evals --task hello-world
23
23
  # exe/lemans-remote run --bench ../ai-evals --model openrouter/z-ai/glm-5.2 --model openrouter/qwen/qwen3.5-coder --args="-k 2 -c 8"
24
24
  #
25
+ # Ship agent/backend plugins with -r FILE (run and restart): each file is
26
+ # uploaded and passed to the remote lemans as --require:
27
+ #
28
+ # exe/lemans-remote run --bench ../ai-evals -r ./claude-code.rb --args="--agent claude-code" --env CLAUDE_CODE_OAUTH_TOKEN
29
+ #
25
30
  # Retry invalid local results (agent/infra errors, not negative rewards):
26
31
  # launches one sandbox per invalid (task, model) group — a single retried
27
32
  # attempt per task — and moves that result dir into runs/_trash; call again
@@ -42,7 +47,7 @@
42
47
  # exe/lemans-remote logs RUN_ID [--tail N]
43
48
  # exe/lemans-remote pull-runs [RUN_ID ...] [--match SUBSTR] [--all] [--dry-run]
44
49
  # exe/lemans-remote drop-orphans [--min-age 10m]
45
- # exe/lemans-remote clobber RUN_ID ... | --all
50
+ # exe/lemans-remote clobber RUN_ID ... | --all | --stopped
46
51
  # exe/lemans-remote snapshots
47
52
  # exe/lemans-remote deprovision [--name SNAPSHOT]
48
53
  #
@@ -94,6 +99,7 @@ module LemansRemote # :nodoc: all
94
99
  REMOTE_WRAPPER = "/tmp/lemans-remote-wrapper.sh"
95
100
  REMOTE_HOOKS = "/tmp/lemans-remote-hooks.rb"
96
101
  REMOTE_META = "/tmp/lemans-remote-run.json"
102
+ REMOTE_REQUIRES_DIR = "/tmp/lemans-remote-requires"
97
103
 
98
104
  ENV_ALLOWLIST = %w[OPENROUTER_API_KEY LEMANS_PROVIDER_ORDER OPENROUTER_PROVIDER_ORDER].freeze
99
105
 
@@ -101,6 +107,10 @@ module LemansRemote # :nodoc: all
101
107
 
102
108
  def self.client = Backend.client
103
109
 
110
+ def self.remote_require(path) = "#{REMOTE_REQUIRES_DIR}/#{File.basename(path)}"
111
+
112
+ def self.require_args(paths) = paths.flat_map { [ "--require", remote_require(it) ] }
113
+
104
114
  class Provisioner
105
115
  include Backend::Retries
106
116
 
@@ -558,7 +568,7 @@ module LemansRemote # :nodoc: all
558
568
  # are run dirs (relative to `runs_dir`) shipped into the sandbox's runs/
559
569
  # for the command to read, and dropped again before the results leave it.
560
570
  def initialize(bench:, snapshot:, run_id:, tasks:, models:, extra_args:, extra_env:,
561
- timeout_sec:, runs_dir:, keep:, sync:, shell:, launch_interval: 0, command: nil, sources: [])
571
+ timeout_sec:, runs_dir:, keep:, sync:, shell:, launch_interval: 0, command: nil, sources: [], requires: [])
562
572
  @bench = bench
563
573
  @snapshot = snapshot
564
574
  @run_id = run_id
@@ -574,6 +584,7 @@ module LemansRemote # :nodoc: all
574
584
  @launch_interval = launch_interval
575
585
  @command = command
576
586
  @sources = sources
587
+ @requires = requires
577
588
  @started_at = Time.now.utc.iso8601
578
589
  end
579
590
 
@@ -680,6 +691,13 @@ module LemansRemote # :nodoc: all
680
691
  "tar -xzf #{REMOTE_BENCH_ARCHIVE} -C #{REMOTE_BENCH_DIR} && " \
681
692
  "rm -f #{REMOTE_BENCH_ARCHIVE}")
682
693
  upload_sources(sandbox) if @sources.any?
694
+ upload_requires(sandbox) if @requires.any?
695
+ end
696
+
697
+ def upload_requires(sandbox)
698
+ say :upload, @requires.join(", ")
699
+ exec!(sandbox, "mkdir -p #{REMOTE_REQUIRES_DIR}")
700
+ @requires.each { upload_blob(sandbox, File.read(it), LemansRemote.remote_require(it)) }
683
701
  end
684
702
 
685
703
  def upload_sources(sandbox)
@@ -723,7 +741,7 @@ module LemansRemote # :nodoc: all
723
741
  def remote_command
724
742
  return @command if @command
725
743
 
726
- command = %w[lemans run --bench .]
744
+ command = %w[lemans run --bench .] + LemansRemote.require_args(@requires)
727
745
  @tasks.each { command += [ "--task", it ] }
728
746
  @models.each { command += [ "--model", it ] }
729
747
  command = command.shelljoin
@@ -941,6 +959,8 @@ module LemansRemote # :nodoc: all
941
959
  option :retry_runs, desc: "Retry invalid results from this local runs directory " \
942
960
  "(one attempt per task per call, retried results move to _trash)"
943
961
  option :args, default: "", desc: "Extra `lemans run` options, passed through verbatim"
962
+ option :require, aliases: "-r", banner: "FILE", repeatable: true,
963
+ desc: "Load a Ruby file (an agent or backend plugin) here and in the sandbox's lemans"
944
964
  option :timeout, default: "6h", desc: "Give up on the remote run after this long"
945
965
  option :sync, type: :boolean, default: false, desc: "Wait for the run and download the results directly"
946
966
  option :run_in_band, type: :boolean, default: false, aliases: %w[--runInBand],
@@ -954,6 +974,7 @@ module LemansRemote # :nodoc: all
954
974
  option :launch_interval, type: :numeric, default: 3,
955
975
  desc: "Async mode: seconds between consecutive launches (0 to disable)"
956
976
  def run_bench
977
+ load_requires!
957
978
  bench = Lemans::Config.load_file(options[:bench])
958
979
 
959
980
  planner = RetryPlanner.new(options[:retry_runs]) if options[:retry_runs]
@@ -1025,6 +1046,8 @@ module LemansRemote # :nodoc: all
1025
1046
  desc: "Grade the last verified step again (with the current tests) and go on from there"
1026
1047
  option :allow_scored, type: :boolean, default: false, desc: "Restart a scored run (--reverify always may)"
1027
1048
  option :max_output_tokens, type: :numeric, banner: "TOKENS", desc: "Cap the agent's output per model call"
1049
+ option :require, aliases: "-r", banner: "FILE", repeatable: true,
1050
+ desc: "Load a Ruby file (an agent or backend plugin) here and in the sandbox's lemans"
1028
1051
  option :concurrency, type: :numeric, aliases: "-c", desc: "Restarts in flight at once inside a sandbox (with --run-in-band)"
1029
1052
  option :timeout, default: "6h", desc: "Give up on the remote run after this long"
1030
1053
  option :sync, type: :boolean, default: false, desc: "Restart every run in one sandbox, wait, and download the results"
@@ -1039,6 +1062,7 @@ module LemansRemote # :nodoc: all
1039
1062
  raise Thor::Error, "lemans-remote: --recover and --reverify exclude each other" if options[:recover] && options[:reverify]
1040
1063
  raise Thor::Error, "lemans-remote: --launch-interval must not be negative" if options[:launch_interval].to_f.negative?
1041
1064
 
1065
+ load_requires!
1042
1066
  bench = Lemans::Config.load_file(options[:bench])
1043
1067
  runs_root = Pathname(options[:runs_dir])
1044
1068
  store = Lemans::Stores::FS.new(runs_root)
@@ -1183,11 +1207,13 @@ module LemansRemote # :nodoc: all
1183
1207
  raise Thor::Error, "lemans-remote: #{e.message}"
1184
1208
  end
1185
1209
 
1186
- desc "clobber [RUN_IDS...]", "Delete archived runs from the vault (--all wipes the whole volume)"
1210
+ desc "clobber [RUN_IDS...]", "Delete archived runs from the vault (--all wipes the whole volume, --stopped drops stopped sandboxes)"
1187
1211
  option :all, type: :boolean, default: false, desc: "Delete every archived run and the volume itself"
1212
+ option :stopped, type: :boolean, default: false, desc: "Delete stopped run sandboxes only (the vault archives stay)"
1188
1213
  option :force, type: :boolean, default: false, aliases: "-f", desc: "Skip the confirmation"
1189
1214
  def clobber(*run_ids)
1190
- raise Thor::Error, "lemans-remote: pass run id(s) or --all" if run_ids.empty? && !options[:all]
1215
+ raise Thor::Error, "lemans-remote: pass run id(s), --all, or --stopped" if run_ids.empty? && !options[:all] && !options[:stopped]
1216
+ return clobber_stopped if options[:stopped]
1191
1217
 
1192
1218
  live = Fleet.live
1193
1219
  if options[:all]
@@ -1359,7 +1385,8 @@ module LemansRemote # :nodoc: all
1359
1385
  keep: options[:keep],
1360
1386
  sync: options[:sync],
1361
1387
  shell: shell,
1362
- launch_interval: options[:launch_interval].to_f
1388
+ launch_interval: options[:launch_interval].to_f,
1389
+ requires:
1363
1390
  ).call
1364
1391
  end
1365
1392
 
@@ -1405,12 +1432,25 @@ module LemansRemote # :nodoc: all
1405
1432
  shell: shell,
1406
1433
  launch_interval: options[:launch_interval].to_f,
1407
1434
  command: restart_command(sources),
1408
- sources: sources.map { |source| runs_root.glob("**/#{source.id}").find(&:directory?).relative_path_from(runs_root).to_s }
1435
+ sources: sources.map { |source| runs_root.glob("**/#{source.id}").find(&:directory?).relative_path_from(runs_root).to_s },
1436
+ requires:
1409
1437
  ).call
1410
1438
  end
1411
1439
 
1440
+ def requires = (options[:require] || []).map { File.expand_path(it) }
1441
+
1442
+ # The remote copies share one directory, by file name
1443
+ def load_requires!
1444
+ clashes = requires.group_by { File.basename(it) }.select { |_, paths| paths.uniq.size > 1 }.keys
1445
+ raise Thor::Error, "lemans-remote: --require files must have distinct names (#{clashes.join(", ")})" if clashes.any?
1446
+
1447
+ requires.each { require it }
1448
+ rescue LoadError => e
1449
+ raise Thor::Error, "lemans-remote: #{e.message}"
1450
+ end
1451
+
1412
1452
  def restart_command(sources)
1413
- argv = [ "lemans", "restart", *sources.map(&:id), "--bench", ".", "--runs-dir", "runs" ]
1453
+ argv = [ "lemans", "restart", *sources.map(&:id), "--bench", ".", "--runs-dir", "runs", *LemansRemote.require_args(requires) ]
1414
1454
  argv << "--recover" if options[:recover]
1415
1455
  argv << "--reverify" if options[:reverify]
1416
1456
  argv << "--allow-scored" if options[:allow_scored]
@@ -1458,6 +1498,22 @@ module LemansRemote # :nodoc: all
1458
1498
  vault.with_helper { |sandbox| block.call(vault, sandbox) }
1459
1499
  end
1460
1500
 
1501
+ def clobber_stopped
1502
+ stopped = Fleet.rows.select { it.state == DaytonaApiClient::SandboxState::STOPPED }
1503
+ if stopped.empty?
1504
+ say_status :clean, "no stopped run sandboxes", :green
1505
+ return
1506
+ end
1507
+
1508
+ stopped.each { say_status :stopped, "#{it.run_id} (#{it.sandbox.id}, #{it.status})", :yellow }
1509
+ return unless options[:force] || yes?("Delete #{stopped.size} stopped sandbox(es)? [y/N]")
1510
+
1511
+ stopped.each do |row|
1512
+ safe_delete_sandbox(row.sandbox)
1513
+ say_status :clobbered, row.run_id, :green
1514
+ end
1515
+ end
1516
+
1461
1517
  def safe_delete_sandbox(sandbox)
1462
1518
  sandbox.delete
1463
1519
  rescue StandardError => e
data/lib/lemans/agent.rb CHANGED
@@ -5,10 +5,11 @@ module Lemans
5
5
  # task. The name-to-class registry lives on the Agents module.
6
6
  class Agent
7
7
  # What one run of an agent produced. Outcome and usage are the core
8
- # Result's parts; trajectory, the raw self-reported result, and the error
9
- # are optional — the trial persists them, agents only carry them.
10
- Response = Data.define(:outcome, :usage, :trajectory, :error, :raw_result) do
11
- def initialize(outcome: nil, usage: nil, trajectory: nil, error: nil, raw_result: nil) = super
8
+ # Result's parts; trajectory, the raw self-reported result, the error, and
9
+ # extra artifacts (file name => contents) are optional — the trial persists
10
+ # them, agents only carry them.
11
+ Response = Data.define(:outcome, :usage, :trajectory, :error, :raw_result, :artifacts) do
12
+ def initialize(outcome: nil, usage: nil, trajectory: nil, error: nil, raw_result: nil, artifacts: {}) = super
12
13
 
13
14
  def error? = !error.nil?
14
15
  end
data/lib/lemans/agents.rb CHANGED
@@ -11,13 +11,21 @@ module Lemans
11
11
  "miniswen-installed" => "MiniswenInstalled"
12
12
  }.freeze
13
13
 
14
- def self.build(name, profile:, model: nil) = lookup(name).new(profile: profile, model: model)
14
+ @registered = {}
15
15
 
16
- def self.lookup(name)
17
- constant = REGISTRY[name] or
18
- raise ConfigError, "unknown agent #{name.inspect} (known: #{REGISTRY.keys.join(", ")})"
16
+ class << self
17
+ def register(name, agent_class) = @registered[name] = agent_class
19
18
 
20
- const_get(constant)
19
+ def unregister(name) = @registered.delete(name)
20
+
21
+ def names = REGISTRY.keys | @registered.keys
22
+
23
+ def build(name, profile:, model: nil) = lookup(name).new(profile: profile, model: model)
24
+
25
+ def lookup(name)
26
+ @registered[name] || (REGISTRY[name] && const_get(REGISTRY[name])) or
27
+ raise ConfigError, "unknown agent #{name.inspect} (known: #{names.join(", ")})"
28
+ end
21
29
  end
22
30
  end
23
31
  end
@@ -84,14 +84,15 @@ module Lemans
84
84
  # Attempts count every run; means and the median skip runs that never
85
85
  # measured the value, so one invalid trial cannot zero out a cell.
86
86
  def build(values, group)
87
+ tasks = group.group_by { it[:task] }.values
87
88
  keys.zip(values).to_h.merge(
88
89
  solved: Report.tally(group)[:solved],
89
90
  attempts: group.size,
90
- credit: mean(group.filter_map { it[:credit] }),
91
- duration: median(group.filter_map { it[:duration] }),
92
- cost_usd: mean(group.filter_map { it[:cost_usd] }),
93
- steps: mean(group.filter_map { it[:steps] }),
94
- tokens: mean(group.filter_map { it[:tokens] }),
91
+ credit: mean(task_means(tasks, :credit)),
92
+ duration: median(task_means(tasks, :duration)),
93
+ cost_usd: mean(task_means(tasks, :cost_usd)),
94
+ steps: mean(task_means(tasks, :steps)),
95
+ tokens: mean(task_means(tasks, :tokens)),
95
96
  **features_sum(group),
96
97
  **report.feature_columns.to_h { [ it, feature_tally(group, it) ] }
97
98
  )
@@ -127,6 +128,8 @@ module Lemans
127
128
 
128
129
  def mean(values) = values.empty? ? nil : values.sum(0.0) / values.size
129
130
 
131
+ def task_means(tasks, metric) = tasks.filter_map { |rows| mean(rows.filter_map { it[metric] }) }
132
+
130
133
  def median(values)
131
134
  return nil if values.empty?
132
135
 
@@ -16,8 +16,10 @@ module Lemans
16
16
  attr_reader :rows, :unreadable, :show_features, :hide_columns
17
17
 
18
18
  class << self
19
- def load(store, tags: nil, names: nil, metadata: nil, skip_invalid: false, **)
19
+ # Models match by full id or by the short name the table shows
20
+ def load(store, tags: nil, names: nil, models: nil, metadata: nil, skip_invalid: false, **)
20
21
  rows = store.query(task: names, tags:, metadata:).map { row_from(it) }
22
+ rows = rows.select { models.intersect?([ it[:model], short_model(it[:model]) ]) } if models
21
23
  rows = rows.select { it[:scored] } if skip_invalid
22
24
  new(rows.sort_by { [ it[:task].to_s, it[:started_at].to_s, it[:trial].to_s ] },
23
25
  unreadable: store.unreadable.size, **)
@@ -292,7 +294,19 @@ module Lemans
292
294
  totals = self.class.tally(group).merge(cost_usd: group.sum { it[:cost_usd].to_f })
293
295
  rank = totals[:scored].positive? ? " (#{(100.0 * totals[:solved] / totals[:scored]).round}%)" : ""
294
296
  "#{totals[:total]} trials: #{totals[:scored]} scored, #{totals[:invalid]} invalid, " \
295
- "#{totals[:solved]} solved#{rank} · $#{format("%.4f", totals[:cost_usd])}#{pass_at_k(group)}"
297
+ "#{totals[:solved]} solved#{rank}#{mean_credit(group)} · $#{format("%.4f", totals[:cost_usd])}" \
298
+ "#{pass_at_k(group)}"
299
+ end
300
+
301
+ # Each task weighs the same, as in the aggregate view
302
+ def mean_credit(group)
303
+ return "" unless fractional?
304
+
305
+ means = group.group_by { it[:task] }.values.filter_map do |rows|
306
+ credits = rows.filter_map { it[:credit] }
307
+ credits.sum(0.0) / credits.size unless credits.empty?
308
+ end
309
+ means.empty? ? "" : " · credit #{format("%.2f", means.sum / means.size)}"
296
310
  end
297
311
 
298
312
  def pass_at_k(group)
data/lib/lemans/cli.rb CHANGED
@@ -13,8 +13,26 @@ module Lemans
13
13
 
14
14
  source_root File.expand_path("cli/templates", __dir__)
15
15
 
16
+ class_option :require, aliases: "-r", banner: "FILE", repeatable: true,
17
+ desc: "Load a Ruby file first, e.g. one that registers an agent or a backend"
18
+
16
19
  def self.exit_on_failure? = true
17
20
 
21
+ # `lemans -r plugin.rb run` reads as `lemans run -r plugin.rb`
22
+ def self.start(given_args = ARGV, config = {})
23
+ args = given_args.dup
24
+ leading = []
25
+ while (flag = args.first)&.match?(/\A(-r|--require)(=|\z)/)
26
+ leading.push(*args.shift(flag.include?("=") ? 1 : 2))
27
+ end
28
+ super(args.insert([ args.size, 1 ].min, *leading), config)
29
+ end
30
+
31
+ def initialize(*)
32
+ super
33
+ Array(options[:require]).each { require File.expand_path(it) }
34
+ end
35
+
18
36
  map %w[-v --version] => :version
19
37
  desc "version", "Print the lemans version"
20
38
  def version
@@ -48,14 +66,14 @@ module Lemans
48
66
  option :bench, default: ".", desc: "Directory holding bench.yml"
49
67
  option :task, desc: "Run task(s) by name", repeatable: true
50
68
  option :tag, desc: "Run every task carrying this tag(s)", repeatable: true
51
- option :agent, desc: "Override the agent from bench.yml (miniswen, miniswen-installed, oracle, nop)"
69
+ option :agent, desc: "Override the agent from bench.yml (miniswen, miniswen-installed, oracle, nop, or one registered by --require)"
52
70
  option :model, desc: "Override the model(s) from bench.yml", repeatable: true
53
71
  option :max_output_tokens, type: :numeric, banner: "TOKENS",
54
72
  desc: "Cap the agent's output per model call (default: the provider's)"
55
73
  option :attempts, type: :numeric, default: 1, aliases: "-k", desc: "Trials per task"
56
74
  option :concurrency, type: :numeric, default: 4, aliases: "-c", desc: "Trials in flight at once"
57
75
  option :runs_dir, default: "./runs", desc: "Where to write run directories"
58
- option :backend, enum: Environments::BACKENDS.keys, desc: "Sandbox backend (default: daytona)"
76
+ option :backend, desc: "Sandbox backend: daytona (default), docker, or one registered by --require"
59
77
  option :resume, type: :boolean, default: false, desc: "Skip trials that already have a result"
60
78
  def run_bench
61
79
  # The bundled pricing registry ages faster than the gem: refresh once up
@@ -96,7 +114,7 @@ module Lemans
96
114
  option :bench, default: ".", desc: "Directory holding bench.yml"
97
115
  option :runs_dir, default: "./runs", desc: "Directory holding the runs; the new runs go there too"
98
116
  option :concurrency, type: :numeric, aliases: "-c", desc: "Restarts in flight at once (default: the bench's)"
99
- option :backend, enum: Environments::BACKENDS.keys, desc: "Sandbox backend (default: daytona)"
117
+ option :backend, desc: "Sandbox backend: daytona (default), docker, or one registered by --require"
100
118
  option :max_output_tokens, type: :numeric, banner: "TOKENS",
101
119
  desc: "Cap the agent's output per model call (default: the provider's)"
102
120
  option :recover, type: :boolean, default: false,
@@ -197,9 +215,10 @@ module Lemans
197
215
  end
198
216
 
199
217
  desc "report [RUNS_DIR]", "Summarize run results as a table or CSV"
200
- option :runs_dir, default: "runs", desc: "Directory holding run directories (or pass it as RUNS_DIR)"
218
+ option :runs_dir, default: [], desc: "Directories holding run results (or pass it as RUNS_DIR)", repeatable: true
201
219
  option :tag, desc: "Only runs whose result carries this tag", repeatable: true
202
220
  option :task, desc: "Only these tasks' runs", repeatable: true
221
+ option :model, desc: "Only these models' runs (full id or the short name the table shows)", repeatable: true
203
222
  option :metadata, banner: "KEY:VALUE", desc: "Only runs whose task metadata has this value (every pair must match)",
204
223
  repeatable: true
205
224
  option :format, default: "table", enum: %w[table csv], desc: "Output format"
@@ -210,9 +229,11 @@ module Lemans
210
229
  option :skip_invalid, type: :boolean, default: false, desc: "Leave out trials with an invalid outcome"
211
230
  option :show_features, type: :boolean, default: false, desc: "Add a feat:<name> column per tracked feature"
212
231
  option :hide_columns, banner: "COLUMNS", desc: "Leave dash-joined columns out of the table, e.g. steps-tokens-trial"
213
- def report(runs_dir = options[:runs_dir])
214
- store = Stores::FS.new(runs_dir)
215
- results = Report.load(store, tags: options[:tag], names: options[:task],
232
+ def report(runs_dir = nil)
233
+ runs_dir = ([ runs_dir ] + options[:runs_dir]).compact
234
+ runs_dir = [ "runs" ] if runs_dir.empty?
235
+ store = Stores::FS.new(*runs_dir)
236
+ results = Report.load(store, tags: options[:tag], names: options[:task], models: options[:model],
216
237
  metadata: Report.metadata_filter(options[:metadata]),
217
238
  skip_invalid: options[:skip_invalid], show_features: options[:show_features],
218
239
  hide_columns: options[:hide_columns])
@@ -19,13 +19,13 @@ module Lemans
19
19
 
20
20
  private
21
21
 
22
- def with_read_retries
22
+ def with_read_retries(conflicts: false)
23
23
  attempts = 0
24
24
  begin
25
25
  yield
26
26
  rescue *SDK_ERRORS => e
27
27
  attempts += 1
28
- raise if attempts >= READ_ATTEMPTS || !retryable?(e)
28
+ raise if attempts >= READ_ATTEMPTS || !(retryable?(e) || (conflicts && status_code(e) == 409))
29
29
 
30
30
  sleep RETRY_DELAY_SEC * 2**(attempts - 1)
31
31
  retry
@@ -90,8 +90,14 @@ module Lemans
90
90
  raise InfrastructureError, "daytona: could not download #{remote_path}: #{e.message}"
91
91
  end
92
92
 
93
+ # Daytona answers 409 while an earlier operation on the sandbox is still
94
+ # settling; a policy update sets the whole policy, so it repeats freely.
93
95
  def switch_network_policy!(policy)
94
- sandbox.update_network_settings(**network_kwargs(policy, for_update: true))
96
+ return if policy.to_h == network.to_h
97
+
98
+ with_read_retries(conflicts: true) do
99
+ sandbox.update_network_settings(**network_kwargs(policy, for_update: true))
100
+ end
95
101
  @network = policy
96
102
  rescue *Retries::SDK_ERRORS => e
97
103
  raise InfrastructureError, "daytona: could not apply #{policy.mode} policy: #{e.message}"
@@ -7,7 +7,7 @@ The proxy supports two request types:
7
7
  - `CONNECT host:port` tunnels (https).
8
8
  - Absolute-form requests (`GET http://host/path`) for plain http.
9
9
 
10
- The proxy refuses a host that is not on the list with `403 Forbidden`. Host names must match exactly. IP ranges are not supported.
10
+ The proxy refuses a host that is not on the list with `403 Forbidden`. Host names must match exactly. IP ranges are not supported. The host `*` lets every host through.
11
11
 
12
12
  ## How the Docker backend uses it
13
13
 
@@ -5,6 +5,7 @@ require "uri"
5
5
 
6
6
  # An HTTP proxy that lets clients reach the allowed hosts only:
7
7
  # CONNECT tunnels for https, absolute-form requests for plain http.
8
+ # A "*" host lets every host through.
8
9
  class AllowlistProxy
9
10
  HOP_HEADERS = /\A(?:connection|keep-alive|proxy-[\w-]+):/i
10
11
 
@@ -55,7 +56,7 @@ class AllowlistProxy
55
56
  pump(client, upstream)
56
57
  end
57
58
 
58
- def allowed?(host) = @hosts.include?(host.to_s.downcase)
59
+ def allowed?(host) = @hosts.include?("*") || @hosts.include?(host.to_s.downcase)
59
60
 
60
61
  def deny(client) = client.write("HTTP/1.1 403 Forbidden\r\n\r\n")
61
62
 
@@ -16,6 +16,10 @@ module Lemans
16
16
 
17
17
  attr_reader :container
18
18
 
19
+ def self.proxy_image
20
+ @proxy_image ||= Config::ImageSpec.dockerfile(Pathname(__dir__).join("docker/proxy/Dockerfile"), slug: "proxy")
21
+ end
22
+
19
23
  def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout: nil, ttl: nil)
20
24
  super(image:, resources:, network:, env:, labels:, ttl:,
21
25
  build_timeout: build_timeout || DEFAULT_BUILD_TIMEOUT)
@@ -108,7 +112,7 @@ module Lemans
108
112
  # The task container sits on an internal network with no route out; the
109
113
  # proxy is on it and on bridge, so the hosts it allows are the only way out.
110
114
  def start_proxy!(hosts)
111
- image = Config::ImageSpec.dockerfile(Pathname(__dir__).join("docker/proxy/Dockerfile"), slug: "proxy")
115
+ image = self.class.proxy_image
112
116
  build_image!(image)
113
117
  create_internal_network!
114
118
  docker!("run", "--detach", "--name", proxy_name, "--network", internal_network,
@@ -6,11 +6,21 @@ module Lemans
6
6
  module Environments
7
7
  BACKENDS = { "daytona" => "Daytona", "docker" => "Docker" }.freeze
8
8
 
9
- def self.build(backend, **)
10
- constant = BACKENDS[backend.to_s] or
11
- raise ConfigError, "unknown backend #{backend.inspect} (known: #{BACKENDS.keys.join(", ")})"
9
+ @registered = {}
12
10
 
13
- const_get(constant).new(**)
11
+ class << self
12
+ def register(name, environment_class) = @registered[name] = environment_class
13
+
14
+ def unregister(name) = @registered.delete(name)
15
+
16
+ def names = BACKENDS.keys | @registered.keys
17
+
18
+ def build(backend, **) = lookup(backend.to_s).new(**)
19
+
20
+ def lookup(name)
21
+ @registered[name] || (BACKENDS[name] && const_get(BACKENDS[name])) or
22
+ raise ConfigError, "unknown backend #{name.inspect} (known: #{names.join(", ")})"
23
+ end
14
24
  end
15
25
  end
16
26
  end
@@ -11,11 +11,12 @@ module Lemans
11
11
  class FS < Store
12
12
  FILENAME = "result.json"
13
13
 
14
- private attr_reader :root, :filterer
14
+ private attr_reader :root, :read_roots, :filterer
15
15
 
16
- def initialize(root, filterer: nil)
16
+ def initialize(root, *read_roots, filterer: nil)
17
17
  super()
18
18
  @root = Pathname(root)
19
+ @read_roots = [ @root ] + read_roots.map { Pathname(it) }
19
20
  @filterer = filterer
20
21
  end
21
22
 
@@ -26,7 +27,7 @@ module Lemans
26
27
  end
27
28
 
28
29
  def fetch
29
- root.glob("**/#{FILENAME}").filter_map { to_record(it) }
30
+ read_roots.flat_map { it.glob("**/#{FILENAME}") }.filter_map { to_record(it) }
30
31
  end
31
32
 
32
33
  # The file system keeps no index, so filtering happens in memory.
@@ -43,7 +44,7 @@ module Lemans
43
44
  RESULT_ID = /\A(?<task>.+)__[A-Za-z0-9]{7}\z/
44
45
 
45
46
  def unreadable
46
- root.glob("**/#{FILENAME}").filter_map do |path|
47
+ read_roots.flat_map { it.glob("**/#{FILENAME}") }.filter_map do |path|
47
48
  next if to_record(path)
48
49
 
49
50
  id = path.dirname.basename.to_s
data/lib/lemans/trial.rb CHANGED
@@ -102,6 +102,7 @@ module Lemans
102
102
  # Whatever the agent brought back is evidence, a failed run's included
103
103
  save_trajectory!(response.trajectory)
104
104
  store&.save_artifact(result, response.raw_result, path: with_step_index("agent.result.json")) if response.raw_result
105
+ response.artifacts.each { |path, contents| store&.save_artifact(result, contents, path: with_step_index(path)) }
105
106
 
106
107
  if response.error?
107
108
  result.failed!(:agent_error, response.error)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lemans
4
- VERSION = "1.5.1"
4
+ VERSION = "1.5.2"
5
5
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "securerandom"
4
5
  require "time"
5
6
  require "miniswen/version"
6
7
  require "miniswen/ruby_llm"
@@ -188,7 +189,9 @@ module Miniswen
188
189
 
189
190
  Result = Data.define(:status, :submission, :messages, :steps, :cost_source,
190
191
  :input_tokens, :output_tokens, :cached_tokens, :thinking_tokens, :cost_usd,
191
- :error) do
192
+ :error, :cache_id) do
193
+ def initialize(cache_id: nil, **attributes) = super(cache_id:, **attributes)
194
+
192
195
  def success? = status == :submitted
193
196
 
194
197
  def to_h = super.merge(cost_source: cost_source&.to_h, version: Miniswen::VERSION)
@@ -206,7 +209,7 @@ module Miniswen
206
209
  input_tokens: data[:input_tokens], output_tokens: data[:output_tokens],
207
210
  cached_tokens: data[:cached_tokens], thinking_tokens: data[:thinking_tokens],
208
211
  cost_usd: data[:cost_usd],
209
- error: data[:error]
212
+ error: data[:error], cache_id: data[:cache_id]
210
213
  )
211
214
  end
212
215
 
@@ -231,7 +234,7 @@ module Miniswen
231
234
  attr_reader :messages, :environment
232
235
 
233
236
  private attr_reader :max_steps, :max_time, :max_cost, :exec_timeout, :max_output_tokens,
234
- :clock, :reporter
237
+ :cache_id, :clock, :reporter
235
238
 
236
239
  # `model` is a litellm-style name ("openrouter/z-ai/glm-5.2"), optionally
237
240
  # suffixed with a reasoning effort ("openrouter/openai/gpt-5.6-luna#xhigh").
@@ -303,7 +306,7 @@ module Miniswen
303
306
  Result.new(
304
307
  status: :error, submission: nil, messages: @messages || [], steps: @steps.to_i,
305
308
  cost_source: cost_source, cost_usd: @cost_known == false ? nil : @cost.to_f,
306
- error: error,
309
+ error: error, cache_id:,
307
310
  **(@totals || { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 })
308
311
  )
309
312
  end
@@ -323,6 +326,7 @@ module Miniswen
323
326
  private
324
327
 
325
328
  def start(instruction)
329
+ @cache_id = SecureRandom.uuid
326
330
  uname = execute("uname -srvm").output.to_s.strip
327
331
  @messages = [
328
332
  { role: "system", content: SYSTEM_TEMPLATE },
@@ -340,6 +344,7 @@ module Miniswen
340
344
  end
341
345
 
342
346
  def resume(history)
347
+ @cache_id = history.cache_id || SecureRandom.uuid
343
348
  @messages = answered_messages(history.messages)
344
349
 
345
350
  @steps = history.steps.to_i
@@ -445,7 +450,9 @@ module Miniswen
445
450
  error = +""
446
451
  error << "Unknown tool '#{call[:name]}'." if call[:name] != "bash"
447
452
  arguments = call[:arguments]
448
- if !arguments.is_a?(Hash) || !arguments["command"]
453
+ if arguments.is_a?(String)
454
+ error << "The bash tool call arguments are not valid JSON. Resend the call with a JSON object of the form {\"command\": \"...\"}."
455
+ elsif !arguments.is_a?(Hash) || !arguments["command"]
449
456
  error << "Missing 'command' argument in bash tool call."
450
457
  elsif arguments["command"].to_s.include?("\0")
451
458
  # Process.spawn rejects strings with NUL bytes, so the command could
@@ -533,7 +540,7 @@ module Miniswen
533
540
  def finish(status, submission: nil)
534
541
  Result.new(
535
542
  status: status, submission: submission, messages: @messages, steps: @steps,
536
- cost_source: cost_source, cost_usd: @cost_known ? @cost : nil, error: nil, **@totals
543
+ cost_source: cost_source, cost_usd: @cost_known ? @cost : nil, error: nil, cache_id:, **@totals
537
544
  )
538
545
  end
539
546
 
@@ -577,10 +584,14 @@ module Miniswen
577
584
  end
578
585
 
579
586
  def routing_params
580
- order = provider_order
581
- return {} unless order && @provider == "openrouter"
587
+ return {} unless @provider == "openrouter"
582
588
 
583
- { provider: { order: order.split(",").map(&:strip), allow_fallbacks: false } }
589
+ params = {}
590
+ params[:prompt_cache_key] = cache_id if @id.start_with?("meta/muse-")
591
+ if (order = provider_order)
592
+ params[:provider] = { order: order.split(",").map(&:strip), allow_fallbacks: false }
593
+ end
594
+ params
584
595
  end
585
596
 
586
597
  def provider_order = ENV["LEMANS_PROVIDER_ORDER"] || ENV["OPENROUTER_PROVIDER_ORDER"]
@@ -43,7 +43,20 @@ module Miniswen
43
43
  module RetryTransientFailures
44
44
  def retry_exceptions = super + [ Faraday::SSLError, Faraday::ParsingError ]
45
45
  end
46
+
47
+ # ruby_llm parses tool-call arguments while it builds the message, so one
48
+ # malformed call would raise and take the whole completion with it. The raw
49
+ # string goes through instead and bounces as a format error.
50
+ module LenientToolArguments
51
+ def parse_tool_call_arguments(tool_call)
52
+ super
53
+ rescue JSON::ParserError
54
+ tool_call.dig("function", "arguments")
55
+ end
56
+ end
46
57
  end
47
58
 
48
59
  RubyLLM::Providers::OpenRouter.prepend(Miniswen::VerbatimReasoningDetails)
49
60
  RubyLLM::Connection.prepend(Miniswen::RetryTransientFailures)
61
+ RubyLLM::Providers::OpenAI::Tools.prepend(Miniswen::LenientToolArguments)
62
+ RubyLLM::Providers::OpenAI::Tools.singleton_class.prepend(Miniswen::LenientToolArguments)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Miniswen
4
- VERSION = "1.5.0"
4
+ VERSION = "1.5.2"
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.5.1
4
+ version: 1.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov