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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2df0b59321d93fcea1ddb87d555323b3dd247eed780532bd645d1410e5a6a391
4
- data.tar.gz: 07c36283c59e9a311af76aa59b46118b92ff705a5478dca847f5bd321c03c7de
3
+ metadata.gz: b72b4a7a4e82cc48a0a50e7a27c338f34685ee9e6fefdfc555213b8c5d8b2475
4
+ data.tar.gz: 3d309e32ffc77990653f49768ee1dd1e6c581b56a93eddebd61f37d8ac9739dc
5
5
  SHA512:
6
- metadata.gz: d481729c89f35e3aae9cbb02dc2c5db59327b292c49a4a30920e81a178480411d075e72bc59dc845bc0906e9194af7cd37d2833f2a092ae135a359b7460eb066
7
- data.tar.gz: 1eb136297aac2c4702efc6b2c6bc4829a53bdca70a85c582bd39150cc17e0cd7851f62adcb0acee53cb05f82461339efb9d7a1d5057657cc47fc45300aee051a
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 <column>` to sort); repeated attempts add pass@k per model × task, fractional grading a `credit` column |
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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "securerandom"
4
5
  require "time"
5
6
  require "miniswen/version"
6
7
  require "miniswen/ruby_llm"
@@ -188,7 +189,9 @@ module Miniswen
188
189
 
189
190
  Result = Data.define(:status, :submission, :messages, :steps, :cost_source,
190
191
  :input_tokens, :output_tokens, :cached_tokens, :thinking_tokens, :cost_usd,
191
- :error) do
192
+ :error, :cache_id) do
193
+ def initialize(cache_id: nil, **attributes) = super(cache_id:, **attributes)
194
+
192
195
  def success? = status == :submitted
193
196
 
194
197
  def to_h = super.merge(cost_source: cost_source&.to_h, version: Miniswen::VERSION)
@@ -206,7 +209,7 @@ module Miniswen
206
209
  input_tokens: data[:input_tokens], output_tokens: data[:output_tokens],
207
210
  cached_tokens: data[:cached_tokens], thinking_tokens: data[:thinking_tokens],
208
211
  cost_usd: data[:cost_usd],
209
- error: data[:error]
212
+ error: data[:error], cache_id: data[:cache_id]
210
213
  )
211
214
  end
212
215
 
@@ -231,7 +234,7 @@ module Miniswen
231
234
  attr_reader :messages, :environment
232
235
 
233
236
  private attr_reader :max_steps, :max_time, :max_cost, :exec_timeout, :max_output_tokens,
234
- :clock, :reporter
237
+ :cache_id, :clock, :reporter
235
238
 
236
239
  # `model` is a litellm-style name ("openrouter/z-ai/glm-5.2"), optionally
237
240
  # suffixed with a reasoning effort ("openrouter/openai/gpt-5.6-luna#xhigh").
@@ -303,7 +306,7 @@ module Miniswen
303
306
  Result.new(
304
307
  status: :error, submission: nil, messages: @messages || [], steps: @steps.to_i,
305
308
  cost_source: cost_source, cost_usd: @cost_known == false ? nil : @cost.to_f,
306
- error: error,
309
+ error: error, cache_id:,
307
310
  **(@totals || { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 })
308
311
  )
309
312
  end
@@ -323,6 +326,7 @@ module Miniswen
323
326
  private
324
327
 
325
328
  def start(instruction)
329
+ @cache_id = SecureRandom.uuid
326
330
  uname = execute("uname -srvm").output.to_s.strip
327
331
  @messages = [
328
332
  { role: "system", content: SYSTEM_TEMPLATE },
@@ -340,6 +344,7 @@ module Miniswen
340
344
  end
341
345
 
342
346
  def resume(history)
347
+ @cache_id = history.cache_id || SecureRandom.uuid
343
348
  @messages = answered_messages(history.messages)
344
349
 
345
350
  @steps = history.steps.to_i
@@ -445,7 +450,9 @@ module Miniswen
445
450
  error = +""
446
451
  error << "Unknown tool '#{call[:name]}'." if call[:name] != "bash"
447
452
  arguments = call[:arguments]
448
- if !arguments.is_a?(Hash) || !arguments["command"]
453
+ if arguments.is_a?(String)
454
+ error << "The bash tool call arguments are not valid JSON. Resend the call with a JSON object of the form {\"command\": \"...\"}."
455
+ elsif !arguments.is_a?(Hash) || !arguments["command"]
449
456
  error << "Missing 'command' argument in bash tool call."
450
457
  elsif arguments["command"].to_s.include?("\0")
451
458
  # Process.spawn rejects strings with NUL bytes, so the command could
@@ -533,7 +540,7 @@ module Miniswen
533
540
  def finish(status, submission: nil)
534
541
  Result.new(
535
542
  status: status, submission: submission, messages: @messages, steps: @steps,
536
- cost_source: cost_source, cost_usd: @cost_known ? @cost : nil, error: nil, **@totals
543
+ cost_source: cost_source, cost_usd: @cost_known ? @cost : nil, error: nil, cache_id:, **@totals
537
544
  )
538
545
  end
539
546
 
@@ -577,10 +584,14 @@ module Miniswen
577
584
  end
578
585
 
579
586
  def routing_params
580
- order = provider_order
581
- return {} unless order && @provider == "openrouter"
587
+ return {} unless @provider == "openrouter"
582
588
 
583
- { provider: { order: order.split(",").map(&:strip), allow_fallbacks: false } }
589
+ params = {}
590
+ params[:prompt_cache_key] = cache_id if @id.start_with?("meta/muse-")
591
+ if (order = provider_order)
592
+ params[:provider] = { order: order.split(",").map(&:strip), allow_fallbacks: false }
593
+ end
594
+ params
584
595
  end
585
596
 
586
597
  def provider_order = ENV["LEMANS_PROVIDER_ORDER"] || ENV["OPENROUTER_PROVIDER_ORDER"]
@@ -43,7 +43,20 @@ module Miniswen
43
43
  module RetryTransientFailures
44
44
  def retry_exceptions = super + [ Faraday::SSLError, Faraday::ParsingError ]
45
45
  end
46
+
47
+ # ruby_llm parses tool-call arguments while it builds the message, so one
48
+ # malformed call would raise and take the whole completion with it. The raw
49
+ # string goes through instead and bounces as a format error.
50
+ module LenientToolArguments
51
+ def parse_tool_call_arguments(tool_call)
52
+ super
53
+ rescue JSON::ParserError
54
+ tool_call.dig("function", "arguments")
55
+ end
56
+ end
46
57
  end
47
58
 
48
59
  RubyLLM::Providers::OpenRouter.prepend(Miniswen::VerbatimReasoningDetails)
49
60
  RubyLLM::Connection.prepend(Miniswen::RetryTransientFailures)
61
+ RubyLLM::Providers::OpenAI::Tools.prepend(Miniswen::LenientToolArguments)
62
+ RubyLLM::Providers::OpenAI::Tools.singleton_class.prepend(Miniswen::LenientToolArguments)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Miniswen
4
- VERSION = "1.5.0"
4
+ VERSION = "1.5.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: miniswen
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov