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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +86 -1
- data/exe/lemans-remote +64 -8
- data/lib/lemans/agent.rb +5 -4
- data/lib/lemans/agents.rb +13 -5
- data/lib/lemans/cli/report/aggregate.rb +8 -5
- data/lib/lemans/cli/report.rb +16 -2
- data/lib/lemans/cli.rb +28 -7
- data/lib/lemans/environments/daytona/retries.rb +2 -2
- data/lib/lemans/environments/daytona.rb +7 -1
- data/lib/lemans/environments/docker/proxy/README.md +1 -1
- data/lib/lemans/environments/docker/proxy/server.rb +2 -1
- data/lib/lemans/environments/docker.rb +5 -1
- data/lib/lemans/environments.rb +14 -4
- data/lib/lemans/stores/fs.rb +5 -4
- data/lib/lemans/trial.rb +1 -0
- data/lib/lemans/version.rb +1 -1
- data/lib/miniswen/agent.rb +20 -9
- data/lib/miniswen/ruby_llm.rb +13 -0
- data/lib/miniswen/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 76cc9a613af13ed9f651f518b8410214a514df72d68b5034183a9e83b4997545
|
|
4
|
+
data.tar.gz: 3ad78386ced9049f0087b9fe99e1e160f60eb8f0fbebb2acce565a9cdbe4007e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 --
|
|
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,
|
|
9
|
-
# are optional — the trial persists
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
+
@registered = {}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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(
|
|
91
|
-
duration: median(
|
|
92
|
-
cost_usd: mean(
|
|
93
|
-
steps: mean(
|
|
94
|
-
tokens: mean(
|
|
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
|
|
data/lib/lemans/cli/report.rb
CHANGED
|
@@ -16,8 +16,10 @@ module Lemans
|
|
|
16
16
|
attr_reader :rows, :unreadable, :show_features, :hide_columns
|
|
17
17
|
|
|
18
18
|
class << self
|
|
19
|
-
|
|
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])}
|
|
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,
|
|
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,
|
|
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:
|
|
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 =
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
data/lib/lemans/environments.rb
CHANGED
|
@@ -6,11 +6,21 @@ module Lemans
|
|
|
6
6
|
module Environments
|
|
7
7
|
BACKENDS = { "daytona" => "Daytona", "docker" => "Docker" }.freeze
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
constant = BACKENDS[backend.to_s] or
|
|
11
|
-
raise ConfigError, "unknown backend #{backend.inspect} (known: #{BACKENDS.keys.join(", ")})"
|
|
9
|
+
@registered = {}
|
|
12
10
|
|
|
13
|
-
|
|
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
|
data/lib/lemans/stores/fs.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/lemans/version.rb
CHANGED
data/lib/miniswen/agent.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
581
|
-
return {} unless order && @provider == "openrouter"
|
|
587
|
+
return {} unless @provider == "openrouter"
|
|
582
588
|
|
|
583
|
-
|
|
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"]
|
data/lib/miniswen/ruby_llm.rb
CHANGED
|
@@ -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)
|
data/lib/miniswen/version.rb
CHANGED