miniswen 1.5.0 → 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/README.md +86 -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: b72b4a7a4e82cc48a0a50e7a27c338f34685ee9e6fefdfc555213b8c5d8b2475
|
|
4
|
+
data.tar.gz: 3d309e32ffc77990653f49768ee1dd1e6c581b56a93eddebd61f37d8ac9739dc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2fd6ae2583048739c94ee8dc70e92c214f4163cb7a86e2201e450d14549abc3cb5d92aba5f1f3c6c1a934968f1b738f3d8561392a2523412393cf9f0c33e04d9
|
|
7
|
+
data.tar.gz: 89ae33237c77e4025e9fe4bf5a5c5036b3098f32f9e32f91a4bd2d0925cd61cc366bd58a3de3b182b0fa4f31f6e0027a674f5b1d78a072cd2905bc0d1d3d52f5
|
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, `-A [task-agent-model]` to aggregate, `-S <
|
|
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/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