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