lemans 1.5.1 → 1.6.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: 684bdca58989cce6016fe717fed47d6f64966db4a915ce8345f3cf31debbcc48
4
- data.tar.gz: d15a17a53a85d1ee17324dcef201e2b3279ff469de518c85518eb95bb0e8571a
3
+ metadata.gz: c2d58a5beef57da26020d8d076d001fc508b676fa8156bd8bf8782f0a1cbf545
4
+ data.tar.gz: c647edc664022a6d2590da8790c532aa533e4df4cc8e4274628bed6e60a40b7a
5
5
  SHA512:
6
- metadata.gz: c4a317c1de7a6a5fe1ee2748925b5c26def63a01becbff8d314b7e069b49951464caef2bbd1d971efa738af600f484f105726b560c193ccfb35518ce2544666b
7
- data.tar.gz: a786e7118d2879ff472c5cda73d94e75c08a2947fc9448e5142daa38489ba685a50853ad0e9f669857c4186bb723843c0190202edba3160a77d34d1ba22e3dbd
6
+ metadata.gz: a7c995c51cec8fc1fbe38a2d299762ea7908d74bc1136a7bcbaf85d3765f1534e304331ffd81aef1985ef3c87882c8decd32b4b276184497ced82fb421f41146
7
+ data.tar.gz: f227a2b18e947740abb0b838643df69ca26e95d6f9690780af66992bbf63c310501c68c01faef28be0e1e27bedaa2ea4319bada71f0e7b38952cef5c99e2a574
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.6.0] - 2026-11-03
4
+
5
+ - Miniswen: upgrade ruby_llm to 2.1.1 (use its new built-in caching, finish reasons and reasoning replay, keep the ~10 min retry window, honor `RUBYLLM_DEBUG`, allow JSON 3).
6
+ - Miniswen: send `prompt_cache_key` for every OpenRouter model, not only Muse, so a session keeps its cache without a pinned provider order.
7
+ - Miniswen: turn on prompt caching for every model, so models that need it (like Claude, including on the Anthropic API) cache, and those that cache automatically are unaffected.
8
+ - Miniswen: OpenRouter runs cost what OpenRouter actually billed (including discounts or a pricier provider), with registry prices as the rare fallback.
9
+ - Miniswen: trajectories record which upstream provider served each OpenRouter turn.
10
+ - `lemans-remote`: forward all provider keys RubyLLM reads, not only `OPENROUTER_API_KEY`.
11
+ - Daytona: don't hang until the timeout on unclosed heredocs or quotes.
12
+ - Miniswen: run commands in the task's `workdir`, not the image's `WORKDIR`.
13
+ - Setup: clear bootsnap caches when applying an environment patch, so a cache compiled before the patch can't hand the agent the removed code.
14
+ - Secrets filter: redact every `*_KEY` variable, not only `*_API_KEY` (e.g., `AWS_SECRET_ACCESS_KEY`).
15
+ - Trials keep their git state outside the app dir, so the agent's `.git` can't affect results.
16
+ - Miniswen: `--jail` stops even when miniswen is killed, so agent processes can't run into verification.
17
+ - Verifier: reject results when Minitest's core methods are redefined outside its gems.
18
+ - Verifier: recorded test failures score 0.0 even if the tests exit successfully.
19
+ - Verifier: tests that never ran score 0.0, so a run can't pass by skipping them.
20
+ - `miniswen-installed`: a step in a multistep task without a result fails instead of reusing the previous one.
21
+ - Docker: run commands as root, so non-root images work.
22
+
23
+ ## [1.5.2] - 2026-10-14
24
+
25
+ - `lemans --require/-r [command]`: load agent, backend plugins (`lemans -r plugin.rb restart RUN`)
26
+ - `lemant report` normalizes metrics (credit, cost, steps, and tokens) within each task individually before combining them in the final report.
27
+ - `lemans report`: add credit to summary lines.
28
+ - `lemans report --model x [--model y]` filters runs by model (full id or short name).
29
+ - miniswen: improve Muse caching on OpenRouter with a unique `prompt_cache_key` for each session, preserved on recovery.
30
+
3
31
  ## [1.5.1] - 2026-10-06
4
32
 
5
33
  - `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,84 @@ 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
+ - [Codex agent](./examples/codex/README.md)
344
+
266
345
  ## CLI
267
346
 
268
347
  | Command | What it does |
@@ -271,7 +350,7 @@ gpt-5.6-luna ar-archive-book-access 2/2 2m 23s $0.0132 12.5 156905
271
350
  | `lemans tasks` | List the tasks in a bench (`--tag` to filter) |
272
351
  | `lemans run` | Run tasks and grade them (`--task`, `--tag`, `--agent`, `--model`, `--max-output-tokens`, `-k`, `-c`, `--resume`) |
273
352
  | `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) |
353
+ | `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
354
  | `lemans clobber [RUNS_DIR]` | Delete run results under `runs/` (or `RUNS_DIR`) (`--task`, `--ttl 10m\|2h\|1d`, `--invalid`, `-f` to skip the confirmation) |
276
355
 
277
356
  ## miniswen
@@ -288,6 +367,13 @@ $ miniswen --model 'openrouter/openai/gpt-5.6-luna' --prompt 'Write hello-world
288
367
 
289
368
  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
369
 
370
+ ## More tools: lemans-remote and lemans-viewer
371
+
372
+ The gem also ships two convenient (and 100% AI-maintained) CLIs:
373
+
374
+ - `lemans-remote`: scale `lemans run`-s horizontally using Daytona sandboxes; schedule tasks and pull results later
375
+ - `lemans-viewer`: a web UI for browsing trial results (if you still need a pair of human eyes to look at them)
376
+
291
377
  ## Development
292
378
 
293
379
  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,12 +47,13 @@
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
  #
49
- # Credentials come from the host ENV: DAYTONA_API_KEY (or DAYTONA_TOKEN),
50
- # OPENROUTER_API_KEY, LEMANS_PROVIDER_ORDER (or OPENROUTER_PROVIDER_ORDER);
54
+ # Credentials come from the host ENV: DAYTONA_API_KEY (or DAYTONA_TOKEN), every
55
+ # provider key RubyLLM reads (OPENROUTER_API_KEY, ANTHROPIC_API_KEY, ...),
56
+ # LEMANS_PROVIDER_ORDER (or OPENROUTER_PROVIDER_ORDER);
51
57
  # forward extras with --env KEY.
52
58
 
53
59
  lib_path = File.expand_path("../lib", __dir__)
@@ -94,13 +100,19 @@ module LemansRemote # :nodoc: all
94
100
  REMOTE_WRAPPER = "/tmp/lemans-remote-wrapper.sh"
95
101
  REMOTE_HOOKS = "/tmp/lemans-remote-hooks.rb"
96
102
  REMOTE_META = "/tmp/lemans-remote-run.json"
103
+ REMOTE_REQUIRES_DIR = "/tmp/lemans-remote-requires"
97
104
 
98
- ENV_ALLOWLIST = %w[OPENROUTER_API_KEY LEMANS_PROVIDER_ORDER OPENROUTER_PROVIDER_ORDER].freeze
105
+ ENV_ALLOWLIST = (RubyLLM::Provider.providers.values.flat_map(&:configuration_requirements).map { it.to_s.upcase } +
106
+ %w[LEMANS_PROVIDER_ORDER OPENROUTER_PROVIDER_ORDER]).uniq.freeze
99
107
 
100
108
  Backend = Lemans::Environments::Daytona
101
109
 
102
110
  def self.client = Backend.client
103
111
 
112
+ def self.remote_require(path) = "#{REMOTE_REQUIRES_DIR}/#{File.basename(path)}"
113
+
114
+ def self.require_args(paths) = paths.flat_map { [ "--require", remote_require(it) ] }
115
+
104
116
  class Provisioner
105
117
  include Backend::Retries
106
118
 
@@ -558,7 +570,7 @@ module LemansRemote # :nodoc: all
558
570
  # are run dirs (relative to `runs_dir`) shipped into the sandbox's runs/
559
571
  # for the command to read, and dropped again before the results leave it.
560
572
  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: [])
573
+ timeout_sec:, runs_dir:, keep:, sync:, shell:, launch_interval: 0, command: nil, sources: [], requires: [])
562
574
  @bench = bench
563
575
  @snapshot = snapshot
564
576
  @run_id = run_id
@@ -574,6 +586,7 @@ module LemansRemote # :nodoc: all
574
586
  @launch_interval = launch_interval
575
587
  @command = command
576
588
  @sources = sources
589
+ @requires = requires
577
590
  @started_at = Time.now.utc.iso8601
578
591
  end
579
592
 
@@ -680,6 +693,13 @@ module LemansRemote # :nodoc: all
680
693
  "tar -xzf #{REMOTE_BENCH_ARCHIVE} -C #{REMOTE_BENCH_DIR} && " \
681
694
  "rm -f #{REMOTE_BENCH_ARCHIVE}")
682
695
  upload_sources(sandbox) if @sources.any?
696
+ upload_requires(sandbox) if @requires.any?
697
+ end
698
+
699
+ def upload_requires(sandbox)
700
+ say :upload, @requires.join(", ")
701
+ exec!(sandbox, "mkdir -p #{REMOTE_REQUIRES_DIR}")
702
+ @requires.each { upload_blob(sandbox, File.read(it), LemansRemote.remote_require(it)) }
683
703
  end
684
704
 
685
705
  def upload_sources(sandbox)
@@ -723,7 +743,7 @@ module LemansRemote # :nodoc: all
723
743
  def remote_command
724
744
  return @command if @command
725
745
 
726
- command = %w[lemans run --bench .]
746
+ command = %w[lemans run --bench .] + LemansRemote.require_args(@requires)
727
747
  @tasks.each { command += [ "--task", it ] }
728
748
  @models.each { command += [ "--model", it ] }
729
749
  command = command.shelljoin
@@ -941,6 +961,8 @@ module LemansRemote # :nodoc: all
941
961
  option :retry_runs, desc: "Retry invalid results from this local runs directory " \
942
962
  "(one attempt per task per call, retried results move to _trash)"
943
963
  option :args, default: "", desc: "Extra `lemans run` options, passed through verbatim"
964
+ option :require, aliases: "-r", banner: "FILE", repeatable: true,
965
+ desc: "Load a Ruby file (an agent or backend plugin) here and in the sandbox's lemans"
944
966
  option :timeout, default: "6h", desc: "Give up on the remote run after this long"
945
967
  option :sync, type: :boolean, default: false, desc: "Wait for the run and download the results directly"
946
968
  option :run_in_band, type: :boolean, default: false, aliases: %w[--runInBand],
@@ -954,6 +976,7 @@ module LemansRemote # :nodoc: all
954
976
  option :launch_interval, type: :numeric, default: 3,
955
977
  desc: "Async mode: seconds between consecutive launches (0 to disable)"
956
978
  def run_bench
979
+ load_requires!
957
980
  bench = Lemans::Config.load_file(options[:bench])
958
981
 
959
982
  planner = RetryPlanner.new(options[:retry_runs]) if options[:retry_runs]
@@ -1025,6 +1048,8 @@ module LemansRemote # :nodoc: all
1025
1048
  desc: "Grade the last verified step again (with the current tests) and go on from there"
1026
1049
  option :allow_scored, type: :boolean, default: false, desc: "Restart a scored run (--reverify always may)"
1027
1050
  option :max_output_tokens, type: :numeric, banner: "TOKENS", desc: "Cap the agent's output per model call"
1051
+ option :require, aliases: "-r", banner: "FILE", repeatable: true,
1052
+ desc: "Load a Ruby file (an agent or backend plugin) here and in the sandbox's lemans"
1028
1053
  option :concurrency, type: :numeric, aliases: "-c", desc: "Restarts in flight at once inside a sandbox (with --run-in-band)"
1029
1054
  option :timeout, default: "6h", desc: "Give up on the remote run after this long"
1030
1055
  option :sync, type: :boolean, default: false, desc: "Restart every run in one sandbox, wait, and download the results"
@@ -1039,6 +1064,7 @@ module LemansRemote # :nodoc: all
1039
1064
  raise Thor::Error, "lemans-remote: --recover and --reverify exclude each other" if options[:recover] && options[:reverify]
1040
1065
  raise Thor::Error, "lemans-remote: --launch-interval must not be negative" if options[:launch_interval].to_f.negative?
1041
1066
 
1067
+ load_requires!
1042
1068
  bench = Lemans::Config.load_file(options[:bench])
1043
1069
  runs_root = Pathname(options[:runs_dir])
1044
1070
  store = Lemans::Stores::FS.new(runs_root)
@@ -1183,11 +1209,13 @@ module LemansRemote # :nodoc: all
1183
1209
  raise Thor::Error, "lemans-remote: #{e.message}"
1184
1210
  end
1185
1211
 
1186
- desc "clobber [RUN_IDS...]", "Delete archived runs from the vault (--all wipes the whole volume)"
1212
+ desc "clobber [RUN_IDS...]", "Delete archived runs from the vault (--all wipes the whole volume, --stopped drops stopped sandboxes)"
1187
1213
  option :all, type: :boolean, default: false, desc: "Delete every archived run and the volume itself"
1214
+ option :stopped, type: :boolean, default: false, desc: "Delete stopped run sandboxes only (the vault archives stay)"
1188
1215
  option :force, type: :boolean, default: false, aliases: "-f", desc: "Skip the confirmation"
1189
1216
  def clobber(*run_ids)
1190
- raise Thor::Error, "lemans-remote: pass run id(s) or --all" if run_ids.empty? && !options[:all]
1217
+ raise Thor::Error, "lemans-remote: pass run id(s), --all, or --stopped" if run_ids.empty? && !options[:all] && !options[:stopped]
1218
+ return clobber_stopped if options[:stopped]
1191
1219
 
1192
1220
  live = Fleet.live
1193
1221
  if options[:all]
@@ -1359,7 +1387,8 @@ module LemansRemote # :nodoc: all
1359
1387
  keep: options[:keep],
1360
1388
  sync: options[:sync],
1361
1389
  shell: shell,
1362
- launch_interval: options[:launch_interval].to_f
1390
+ launch_interval: options[:launch_interval].to_f,
1391
+ requires:
1363
1392
  ).call
1364
1393
  end
1365
1394
 
@@ -1405,12 +1434,25 @@ module LemansRemote # :nodoc: all
1405
1434
  shell: shell,
1406
1435
  launch_interval: options[:launch_interval].to_f,
1407
1436
  command: restart_command(sources),
1408
- sources: sources.map { |source| runs_root.glob("**/#{source.id}").find(&:directory?).relative_path_from(runs_root).to_s }
1437
+ sources: sources.map { |source| runs_root.glob("**/#{source.id}").find(&:directory?).relative_path_from(runs_root).to_s },
1438
+ requires:
1409
1439
  ).call
1410
1440
  end
1411
1441
 
1442
+ def requires = (options[:require] || []).map { File.expand_path(it) }
1443
+
1444
+ # The remote copies share one directory, by file name
1445
+ def load_requires!
1446
+ clashes = requires.group_by { File.basename(it) }.select { |_, paths| paths.uniq.size > 1 }.keys
1447
+ raise Thor::Error, "lemans-remote: --require files must have distinct names (#{clashes.join(", ")})" if clashes.any?
1448
+
1449
+ requires.each { require it }
1450
+ rescue LoadError => e
1451
+ raise Thor::Error, "lemans-remote: #{e.message}"
1452
+ end
1453
+
1412
1454
  def restart_command(sources)
1413
- argv = [ "lemans", "restart", *sources.map(&:id), "--bench", ".", "--runs-dir", "runs" ]
1455
+ argv = [ "lemans", "restart", *sources.map(&:id), "--bench", ".", "--runs-dir", "runs", *LemansRemote.require_args(requires) ]
1414
1456
  argv << "--recover" if options[:recover]
1415
1457
  argv << "--reverify" if options[:reverify]
1416
1458
  argv << "--allow-scored" if options[:allow_scored]
@@ -1458,6 +1500,22 @@ module LemansRemote # :nodoc: all
1458
1500
  vault.with_helper { |sandbox| block.call(vault, sandbox) }
1459
1501
  end
1460
1502
 
1503
+ def clobber_stopped
1504
+ stopped = Fleet.rows.select { it.state == DaytonaApiClient::SandboxState::STOPPED }
1505
+ if stopped.empty?
1506
+ say_status :clean, "no stopped run sandboxes", :green
1507
+ return
1508
+ end
1509
+
1510
+ stopped.each { say_status :stopped, "#{it.run_id} (#{it.sandbox.id}, #{it.status})", :yellow }
1511
+ return unless options[:force] || yes?("Delete #{stopped.size} stopped sandbox(es)? [y/N]")
1512
+
1513
+ stopped.each do |row|
1514
+ safe_delete_sandbox(row.sandbox)
1515
+ say_status :clobbered, row.run_id, :green
1516
+ end
1517
+ end
1518
+
1461
1519
  def safe_delete_sandbox(sandbox)
1462
1520
  sandbox.delete
1463
1521
  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
@@ -44,7 +44,7 @@ module Lemans
44
44
  private
45
45
 
46
46
  def obtain_result(task, environment, history)
47
- agent = agent_for(environment)
47
+ agent = agent_for(environment, workdir: task.environment.workdir)
48
48
  begin
49
49
  agent.run(task.instruction, history:)
50
50
  rescue InfrastructureError, ::Miniswen::InfrastructureError => e
@@ -55,12 +55,13 @@ module Lemans
55
55
  # The run as miniswen records it, signatures included: what a recovery continues from
56
56
  def raw_result_for(run_result) = JSON.generate(run_result.to_h)
57
57
 
58
- def agent_for(environment)
58
+ def agent_for(environment, workdir: nil)
59
59
  raise ConfigError, "miniswen needs a model to drive" if model.to_s.empty?
60
60
 
61
61
  ::Miniswen::Agent.new(
62
62
  model: model.to_s,
63
63
  environment: environment,
64
+ workdir: workdir,
64
65
  max_steps: profile.step_limit,
65
66
  max_time: profile.timeout,
66
67
  max_cost: profile.cost_limit,
@@ -74,8 +75,8 @@ module Lemans
74
75
  when :format_error
75
76
  "#{::Miniswen::Agent::MAX_CONSECUTIVE_FORMAT_ERRORS} consecutive responses without a valid bash tool call"
76
77
  when :content_filter
77
- "the provider stopped the model: #{::Miniswen::Agent::MAX_CONSECUTIVE_FORMAT_ERRORS} consecutive turns " \
78
- "ended with #{::Miniswen::Agent::REFUSAL_FINISH_REASONS.join("/")} and no tool call"
78
+ "the provider stopped the model with content_filter: " \
79
+ "#{::Miniswen::Agent::MAX_CONSECUTIVE_FORMAT_ERRORS} consecutive turns ended with no valid tool call"
79
80
  end
80
81
  end
81
82
 
@@ -34,7 +34,8 @@ module Lemans
34
34
  # comes from a file the sandbox wrote.
35
35
  def obtain_result(task, environment, history)
36
36
  upload_history(environment, history) if history
37
- run = environment.exec(command_for(task, history:), timeout: outer_timeout, env: provider_env(environment))
37
+ command = "rm -f #{RESULTS_PATH} && #{command_for(task, history:)}"
38
+ run = environment.exec(command, timeout: outer_timeout, env: provider_env(environment))
38
39
 
39
40
  begin
40
41
  Tempfile.create(%w[miniswen .result.json]) do |file|
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
@@ -75,13 +75,15 @@ module Lemans
75
75
  status_file = "/tmp/lemans-#{run_id}.status"
76
76
  log_file = "/tmp/lemans-#{run_id}.log"
77
77
  exports = env.map { |key, value| "export #{key}=#{Shellwords.escape(value.to_s)}" }.join("\n")
78
+ # Encoded, so an unclosed heredoc or quote can't swallow the status line.
79
+ run = "bash -c \"$(printf %s #{[ command ].pack("m0")} | base64 -d)\" < /dev/null"
78
80
 
79
81
  sandbox.process.execute_session_command(
80
82
  session_id: @session_id,
81
83
  req: ::Daytona::SessionExecuteRequest.new(
82
84
  # A subshell, not a brace group: `set -e`/`exit` would take the
83
85
  # session's own shell down and leave the status line unwritten.
84
- command: "(\n#{exports}\n#{command}\n) > #{Shellwords.escape(log_file)} 2>&1\n" \
86
+ command: "(\n#{exports}\n#{run}\n) > #{Shellwords.escape(log_file)} 2>&1\n" \
85
87
  "echo $? > #{Shellwords.escape(status_file)}",
86
88
  run_async: true
87
89
  )
@@ -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}"