miniswen 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2df0b59321d93fcea1ddb87d555323b3dd247eed780532bd645d1410e5a6a391
4
- data.tar.gz: 07c36283c59e9a311af76aa59b46118b92ff705a5478dca847f5bd321c03c7de
3
+ metadata.gz: 318e13712f3e46a9ad03b1105b7f5c5c6892d5b75a6566d0c272d196de54d491
4
+ data.tar.gz: 4f0c7dbd37fe212152e66a1abca5ee39513136d564a0293098920947a1a0a02f
5
5
  SHA512:
6
- metadata.gz: d481729c89f35e3aae9cbb02dc2c5db59327b292c49a4a30920e81a178480411d075e72bc59dc845bc0906e9194af7cd37d2833f2a092ae135a359b7460eb066
7
- data.tar.gz: 1eb136297aac2c4702efc6b2c6bc4829a53bdca70a85c582bd39150cc17e0cd7851f62adcb0acee53cb05f82461339efb9d7a1d5057657cc47fc45300aee051a
6
+ metadata.gz: f0eb42558de2ad1e9db1a80b1c9f9fdf488dc7cdcc82e848a3086f8a517cc1a5ed5fe36a6901e86e81320d192ef8f218935346e343aea30ca6de8fbb7586f497
7
+ data.tar.gz: 82ad81a8753d0f5ba9c9415e4e6b4c198c8cb2692c539b05c47f9ed446752df96893f49922ca060ab5356820ad2e456bb5625087247b1d7040b63f55c035656c
data/README.md CHANGED
@@ -6,6 +6,7 @@ lemans is a harness for benchmarking coding agents, the Ruby way:
6
6
  - **Conventional**, aka _boilerplate-free_: an instruction, a Docker environment, and a test script — that's all you need to describe an eval
7
7
  - **Trustworthy**: a grade can't be gamed by the agent, an infrastructure failure can't masquerade as a model failure, and every result carries enough digests to prove what it actually measured
8
8
  - Powered by **miniswen**, a Ruby version of mini-swe-agent (powered by [RubyLLM](https://rubyllm.com/), so it works with any LLM) and [Daytona](https://www.daytona.io) sandboxes.
9
+ - **Extensible**: load a custom agent or a sandboxing backend on-the-fly defined in a Ruby file or provided by a gem.
9
10
 
10
11
  > [!TIP]
11
12
  > Check [Rails AI Evals](https://github.com/rails/ai-evals) for a full-featured example.
@@ -263,6 +264,84 @@ gpt-5.6-luna ar-archive-book-access 2/2 2m 23s $0.0132 12.5 156905
263
264
  6 trials: 6 scored, 0 invalid, 6 solved (100%) · $0.0801 · pass@2 3/3 tasks (100%)
264
265
  ```
265
266
 
267
+ ### 6. Extensions
268
+
269
+ You can bring **custom agents and backends** to lemans. For that, you should:
270
+
271
+ - Write an agent or a backend implementation in a Ruby file/gem
272
+ - Load it using the `--require` / `-r` option specified **before the command**:
273
+
274
+ ```sh
275
+ lemans -r ./my-agent.rb run --agent my-agent ...
276
+ ```
277
+
278
+ - **IMPORTANT:** The extension must register itself in the corresponding registry and follow the leman's interface:
279
+
280
+ ```ruby
281
+ # my-agent.rb
282
+ class MyAgent < Lemans::Agent
283
+ NAME = "my-agent"
284
+
285
+ def install(_task, environment)
286
+ environment.upload("/path/to/my-agent", "/usr/local/bin/my-agent")
287
+ environment.exec!("chmod +x /usr/local/bin/my-agent")
288
+ end
289
+
290
+ def run(task, environment)
291
+ finished = environment.exec("cd #{task.environment.workdir} && my-agent --model #{model}", timeout:)
292
+ outcome = finished.exit_code == 124 ? :agent_timeout : :completed
293
+
294
+ # The returned object is a Lemans::Agent::Response.
295
+ # IMPORTANT: `usage` is required for correct work
296
+ Response.new(outcome: Lemans::Result::Outcome.new(outcome), usage: Lemans::Result::Usage.zero)
297
+ end
298
+ end
299
+
300
+ Lemans::Agents.register(MyAgent::NAME, MyAgent)
301
+
302
+ # my-backend.rb
303
+ class MyBackend < Lemans::Environment
304
+ NAME = "my-backend"
305
+
306
+ # Called with image:, resources:, network:, labels:, build_timeout:, and ttl: (see Lemans::Environment)
307
+ def start
308
+ @sandbox = MyCloud.create(image: image.name, cpus: resources.cpus, memory: resources.memory)
309
+ switch_network_policy!(network)
310
+ self
311
+ end
312
+
313
+ def exec(command, timeout: nil, env: {})
314
+ run = @sandbox.run(command, timeout: timeout || DEFAULT_TIMEOUT, env:)
315
+ ExecResult.new(command:, exit_code: run.exit_code, output: run.output, duration: run.duration)
316
+ end
317
+
318
+ def upload(local_path, remote_path) = @sandbox.put(local_path, remote_path)
319
+
320
+ def download(remote_path, local_path) = @sandbox.get(remote_path, local_path)
321
+
322
+ # Phases narrow the network: setup, then the agent's policy, then none for the verifier.
323
+ # IMPORTANT: raise if the policy cannot be enforced
324
+ def switch_network_policy!(policy)
325
+ @sandbox.set_network(policy.mode, hosts: policy.hosts)
326
+ @network = policy
327
+ end
328
+
329
+ def stop = @sandbox&.destroy
330
+ end
331
+
332
+ Lemans::Environments.register(MyBackend::NAME, MyBackend)
333
+ ```
334
+
335
+ ```sh
336
+ lemans -r ./my-backend.rb run --backend my-backend ...
337
+ ```
338
+
339
+ See [examples](./examples) for real-world extensions:
340
+
341
+ - [Apple Container backend](./examples/apple-container.rb)
342
+ - [Claude Code agent](./examples/claude-code.rb)
343
+ - [Codex agent](./examples/codex/README.md)
344
+
266
345
  ## CLI
267
346
 
268
347
  | Command | What it does |
@@ -271,7 +350,7 @@ gpt-5.6-luna ar-archive-book-access 2/2 2m 23s $0.0132 12.5 156905
271
350
  | `lemans tasks` | List the tasks in a bench (`--tag` to filter) |
272
351
  | `lemans run` | Run tasks and grade them (`--task`, `--tag`, `--agent`, `--model`, `--max-output-tokens`, `-k`, `-c`, `--resume`) |
273
352
  | `lemans restart <run>...` | Continue failed multistep runs from their last settled step in new runs (`-c`, `--recover` to continue the failed step's session, `--reverify` to grade again, `--allow-scored`, `--backend`, `--max-output-tokens`) |
274
- | `lemans report [RUNS_DIR]` | Summarize `runs/` (or `RUNS_DIR`) as a table or CSV (`--task`, `--tag`, `--metadata key:value` to filter, `-A [task-agent-model]` to aggregate, `-S <column>` to sort); repeated attempts add pass@k per model × task, fractional grading a `credit` column |
353
+ | `lemans report [RUNS_DIR]` | Summarize `runs/` (or `RUNS_DIR`) as a table or CSV (`--task`, `--tag`, `--model`, `--metadata key:value` to filter, `--skip-invalid` to leave out invalid trials, `-A [task-agent-model]` to aggregate, `-S <columns>` to sort, e.g. `-S score-credit`; numbers high to low, names A-Z, `^column` reverses that column; `--hide-columns steps-tokens` for a narrower table); repeated attempts add pass@k per model × task, fractional grading a `credit` column, multistep tasks a `progress` column (steps completed / task steps) |
275
354
  | `lemans clobber [RUNS_DIR]` | Delete run results under `runs/` (or `RUNS_DIR`) (`--task`, `--ttl 10m\|2h\|1d`, `--invalid`, `-f` to skip the confirmation) |
276
355
 
277
356
  ## miniswen
@@ -288,6 +367,13 @@ $ miniswen --model 'openrouter/openai/gpt-5.6-luna' --prompt 'Write hello-world
288
367
 
289
368
  Currently, it's a one-shot agent that doesn't ask any questions. It's mostly useful for playing with eval ideas before encoding them as lemans tasks.
290
369
 
370
+ ## More tools: lemans-remote and lemans-viewer
371
+
372
+ The gem also ships two convenient (and 100% AI-maintained) CLIs:
373
+
374
+ - `lemans-remote`: scale `lemans run`-s horizontally using Daytona sandboxes; schedule tasks and pull results later
375
+ - `lemans-viewer`: a web UI for browsing trial results (if you still need a pair of human eyes to look at them)
376
+
291
377
  ## Development
292
378
 
293
379
  After checking out the repo, run `bin/setup` to install dependencies, `rake test` to run the tests, and `bin/console` for an interactive prompt.
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "securerandom"
5
+ require "shellwords"
4
6
  require "time"
5
7
  require "miniswen/version"
6
8
  require "miniswen/ruby_llm"
@@ -13,21 +15,6 @@ module Miniswen
13
15
  MAX_OBSERVATION_CHARS = 10_000
14
16
  MAX_CONSECUTIVE_FORMAT_ERRORS = 3
15
17
 
16
- # Both finish_reason dialects accepted raw: OpenAI-shaped providers say
17
- # "length"/"tool_calls", Anthropic says "max_tokens"/"tool_use".
18
- TRUNCATION_FINISH_REASONS = %w[length max_tokens model_context_window_exceeded].freeze
19
- CLAIMED_TOOL_FINISH_REASONS = %w[tool_calls tool_use].freeze
20
- # A safety stop, which arrives looking exactly like a model that forgot
21
- # to call the tool: no content, no tool call, and — since the provider
22
- # bills nothing for a turn it refused — no tokens either. Only the
23
- # finish reason tells the two apart, so the run is labelled by it. The
24
- # retry is unchanged: the nudge still goes back, because matching
25
- # mini-swe-agent turn for turn is what makes runs comparable.
26
- REFUSAL_FINISH_REASONS = %w[content_filter refusal safety].freeze
27
-
28
- # The breakpoint marker Anthropic reads, shaped the way OpenRouter forwards it.
29
- CACHE_CONTROL = { type: "ephemeral" }.freeze
30
-
31
18
  EXEC_ENV = {
32
19
  "PAGER" => "cat",
33
20
  "MANPAGER" => "cat",
@@ -36,9 +23,6 @@ module Miniswen
36
23
  "TQDM_DISABLE" => "1"
37
24
  }.freeze
38
25
 
39
- # Providers that serve local inference (and cost zero)
40
- LOCAL_PROVIDERS = %i[ollama gpustack].freeze
41
-
42
26
  SYSTEM_TEMPLATE = <<~PROMPT
43
27
  You are a helpful assistant that can interact with a computer.
44
28
  PROMPT
@@ -167,20 +151,10 @@ module Miniswen
167
151
  without any other command.
168
152
  MESSAGE
169
153
 
170
- # Thinking that also carries the provider's reasoning_details for verbatim replay.
171
- class VerbatimThinking < RubyLLM::Thinking
172
- attr_reader :details
173
-
174
- def initialize(text: nil, signature: nil, details: nil)
175
- super(text: text, signature: signature)
176
- @details = details
177
- end
178
- end
179
-
180
154
  # Only ever rendered into the request — the completion executes nothing — so no execute body.
181
155
  class BashTool < RubyLLM::Tool
182
156
  description "Execute a bash command"
183
- param :command, desc: "The bash command to execute"
157
+ parameter :command, description: "The bash command to execute"
184
158
 
185
159
  # ruby_llm would otherwise derive "lemans--miniswen--bash" from the class path.
186
160
  def name = "bash"
@@ -188,7 +162,9 @@ module Miniswen
188
162
 
189
163
  Result = Data.define(:status, :submission, :messages, :steps, :cost_source,
190
164
  :input_tokens, :output_tokens, :cached_tokens, :thinking_tokens, :cost_usd,
191
- :error) do
165
+ :error, :cache_id) do
166
+ def initialize(cache_id: nil, **attributes) = super(cache_id:, **attributes)
167
+
192
168
  def success? = status == :submitted
193
169
 
194
170
  def to_h = super.merge(cost_source: cost_source&.to_h, version: Miniswen::VERSION)
@@ -206,7 +182,7 @@ module Miniswen
206
182
  input_tokens: data[:input_tokens], output_tokens: data[:output_tokens],
207
183
  cached_tokens: data[:cached_tokens], thinking_tokens: data[:thinking_tokens],
208
184
  cost_usd: data[:cost_usd],
209
- error: data[:error]
185
+ error: data[:error], cache_id: data[:cache_id]
210
186
  )
211
187
  end
212
188
 
@@ -230,13 +206,13 @@ module Miniswen
230
206
 
231
207
  attr_reader :messages, :environment
232
208
 
233
- private attr_reader :max_steps, :max_time, :max_cost, :exec_timeout, :max_output_tokens,
234
- :clock, :reporter
209
+ private attr_reader :workdir, :max_steps, :max_time, :max_cost, :exec_timeout, :max_output_tokens,
210
+ :cache_id, :clock, :reporter
235
211
 
236
212
  # `model` is a litellm-style name ("openrouter/z-ai/glm-5.2"), optionally
237
213
  # suffixed with a reasoning effort ("openrouter/openai/gpt-5.6-luna#xhigh").
238
214
  # Limits of 0 or nil are disabled.
239
- def initialize(model:, environment:, max_steps: 0, max_time: 0, max_cost: nil,
215
+ def initialize(model:, environment:, workdir: nil, max_steps: 0, max_time: 0, max_cost: nil,
240
216
  exec_timeout: 30, max_output_tokens: 0,
241
217
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
242
218
  reporter: nil)
@@ -249,6 +225,7 @@ module Miniswen
249
225
 
250
226
  @model = model
251
227
  @environment = environment
228
+ @workdir = workdir
252
229
 
253
230
  @bash_tool = BashTool.new
254
231
 
@@ -303,7 +280,7 @@ module Miniswen
303
280
  Result.new(
304
281
  status: :error, submission: nil, messages: @messages || [], steps: @steps.to_i,
305
282
  cost_source: cost_source, cost_usd: @cost_known == false ? nil : @cost.to_f,
306
- error: error,
283
+ error: error, cache_id:,
307
284
  **(@totals || { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 })
308
285
  )
309
286
  end
@@ -323,6 +300,7 @@ module Miniswen
323
300
  private
324
301
 
325
302
  def start(instruction)
303
+ @cache_id = SecureRandom.uuid
326
304
  uname = execute("uname -srvm").output.to_s.strip
327
305
  @messages = [
328
306
  { role: "system", content: SYSTEM_TEMPLATE },
@@ -335,16 +313,19 @@ module Miniswen
335
313
  @steps = 0
336
314
  @cost = 0.0
337
315
  @cost_known = true
316
+ @cost_reported = false
338
317
  @totals = { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 }
339
318
  @started_at = @clock.call
340
319
  end
341
320
 
342
321
  def resume(history)
322
+ @cache_id = history.cache_id || SecureRandom.uuid
343
323
  @messages = answered_messages(history.messages)
344
324
 
345
325
  @steps = history.steps.to_i
346
326
  @cost = history.cost_usd.to_f
347
327
  @cost_known = !history.cost_usd.nil?
328
+ @cost_reported = history.cost_source&.name == :provider_reported
348
329
  @totals = { input_tokens: history.input_tokens.to_i, output_tokens: history.output_tokens.to_i,
349
330
  cached_tokens: history.cached_tokens.to_i, thinking_tokens: history.thinking_tokens.to_i }
350
331
  @started_at = @clock.call - elapsed(history.messages)
@@ -366,6 +347,7 @@ module Miniswen
366
347
  end
367
348
 
368
349
  def execute(command)
350
+ command = "cd #{Shellwords.escape(workdir)}; #{command}" if workdir
369
351
  environment.exec(command, timeout: exec_timeout, env: EXEC_ENV)
370
352
  end
371
353
 
@@ -394,6 +376,7 @@ module Miniswen
394
376
  # content, no tool call and no billed tokens.
395
377
  entry[:finish_reason] = response[:finish_reason] if response[:finish_reason]
396
378
  entry[:thinking] = response[:thinking] if response[:thinking]
379
+ entry[:provider] = response[:provider] if response[:provider]
397
380
  # The provider's opaque handles on this turn's reasoning: replayed back
398
381
  # to the provider only, never into the trajectory.
399
382
  entry[:thinking_signature] = response[:thinking_signature] if response[:thinking_signature]
@@ -407,7 +390,7 @@ module Miniswen
407
390
  # message with unanswered tool calls is a request providers reject.
408
391
  entry[:invalid_tool_calls] = tool_calls if tool_calls.any?
409
392
  @consecutive_format_errors += 1
410
- @refused_turns += 1 if refused?(response)
393
+ @refused_turns += 1 if response[:refused]
411
394
 
412
395
  observe_message({ role: "user", content: format_error_message(error, response, tool_calls) })
413
396
 
@@ -420,8 +403,6 @@ module Miniswen
420
403
  tool_calls
421
404
  end
422
405
 
423
- def refused?(response) = REFUSAL_FINISH_REASONS.include?(response[:finish_reason].to_s)
424
-
425
406
  # An unpriced completion under a cost ceiling is fatal on the spot: only failing fast stops
426
407
  # the spend. Without a ceiling, unknown cost is just a fact to report.
427
408
  def track_cost(response)
@@ -436,6 +417,7 @@ module Miniswen
436
417
  else
437
418
  @cost += cost
438
419
  end
420
+ @cost_reported = true if response[:reported]
439
421
  end
440
422
 
441
423
  def actions_error(tool_calls)
@@ -445,7 +427,9 @@ module Miniswen
445
427
  error = +""
446
428
  error << "Unknown tool '#{call[:name]}'." if call[:name] != "bash"
447
429
  arguments = call[:arguments]
448
- if !arguments.is_a?(Hash) || !arguments["command"]
430
+ if arguments.is_a?(String)
431
+ error << "The bash tool call arguments are not valid JSON. Resend the call with a JSON object of the form {\"command\": \"...\"}."
432
+ elsif !arguments.is_a?(Hash) || !arguments["command"]
449
433
  error << "Missing 'command' argument in bash tool call."
450
434
  elsif arguments["command"].to_s.include?("\0")
451
435
  # Process.spawn rejects strings with NUL bytes, so the command could
@@ -459,10 +443,8 @@ module Miniswen
459
443
  end
460
444
 
461
445
  def format_error_message(error, response, tool_calls)
462
- finish_reason = response[:finish_reason].to_s
463
- if TRUNCATION_FINISH_REASONS.include?(finish_reason) ||
464
- (CLAIMED_TOOL_FINISH_REASONS.include?(finish_reason) && tool_calls.empty?)
465
- format(TRUNCATION_ERROR_MESSAGE, finish_reason: finish_reason)
446
+ if response[:truncated] || (response[:finish_reason] == "tool_calls" && tool_calls.empty?)
447
+ format(TRUNCATION_ERROR_MESSAGE, finish_reason: response[:finish_reason])
466
448
  else
467
449
  format(TOOL_CALL_ERROR_MESSAGE, error: error)
468
450
  end
@@ -533,7 +515,7 @@ module Miniswen
533
515
  def finish(status, submission: nil)
534
516
  Result.new(
535
517
  status: status, submission: submission, messages: @messages, steps: @steps,
536
- cost_source: cost_source, cost_usd: @cost_known ? @cost : nil, error: nil, **@totals
518
+ cost_source: cost_source, cost_usd: @cost_known ? @cost : nil, error: nil, cache_id:, **@totals
537
519
  )
538
520
  end
539
521
 
@@ -541,11 +523,13 @@ module Miniswen
541
523
  def complete(messages)
542
524
  model_info, provider = resolved
543
525
  response = provider.complete(
544
- with_cache_breakpoints(messages.map { as_ruby_llm(it) }),
526
+ messages.map { as_ruby_llm(it) },
545
527
  tools: { bash: @bash_tool },
546
528
  temperature: nil,
547
529
  model: model_info,
548
- params: routing_params.merge(output_cap_params(model_info)),
530
+ provider_options: routing_params,
531
+ max_output_tokens: output_cap,
532
+ caching: {},
549
533
  thinking: (RubyLLM::Thinking::Config.new(effort: @effort) if @effort)
550
534
  )
551
535
  payload(response)
@@ -557,48 +541,20 @@ module Miniswen
557
541
  raise InfrastructureError, "miniswen: the model call failed: #{e.class}: #{e.message}"
558
542
  end
559
543
 
560
- # Anthropic bills every token fresh unless the request marks explicit cache
561
- # breakpoints
562
- def explicit_cache? = @provider == "openrouter" && @id.to_s.start_with?("anthropic/")
563
-
564
- def with_cache_breakpoints(messages)
565
- return messages unless explicit_cache?
566
-
567
- system = messages.find { it.role == :system }
568
- [ system, messages.last ].compact.uniq.each do |message|
569
- text = message.content
570
- next unless text.is_a?(String) && !text.empty?
571
-
572
- message.content = RubyLLM::Content::Raw.new(
573
- [ { type: "text", text: text, cache_control: CACHE_CONTROL } ]
574
- )
575
- end
576
- messages
577
- end
578
-
579
544
  def routing_params
580
- order = provider_order
581
- return {} unless order && @provider == "openrouter"
545
+ return {} unless @provider == "openrouter"
582
546
 
583
- { provider: { order: order.split(",").map(&:strip), allow_fallbacks: false } }
547
+ params = { prompt_cache_key: cache_id }
548
+ if (order = provider_order)
549
+ params[:provider] = { order: order.split(",").map(&:strip), allow_fallbacks: false }
550
+ end
551
+ params
584
552
  end
585
553
 
586
554
  def provider_order = ENV["LEMANS_PROVIDER_ORDER"] || ENV["OPENROUTER_PROVIDER_ORDER"]
587
555
 
588
- # OpenAI itself retired `max_tokens` for its reasoning models; the
589
- # OpenAI-compatible providers and Anthropic still read it.
590
- def output_cap_params(model_info)
591
- return {} if max_output_tokens.zero?
592
-
593
- cap = [ info&.max_tokens, max_output_tokens ].compact.min
594
- provider_class = RubyLLM::Provider.providers[model_info.provider.to_sym]
595
- if [ RubyLLM::Providers::OpenAI, RubyLLM::Providers::Azure ].include?(provider_class)
596
- { max_completion_tokens: cap }
597
- elsif provider_class <= RubyLLM::Providers::OpenAI || provider_class <= RubyLLM::Providers::Anthropic
598
- { max_tokens: cap }
599
- else
600
- {}
601
- end
556
+ def output_cap
557
+ [ info&.max_output_tokens, max_output_tokens ].compact.min unless max_output_tokens.zero?
602
558
  end
603
559
 
604
560
  def cost_source
@@ -607,12 +563,12 @@ module Miniswen
607
563
  priced_as: "#{@provider || info&.provider}/#{@id} ($0.00, local)",
608
564
  registry: nil)
609
565
  end
610
- return nil unless info
611
- if free?
612
- return CostSource.new(name: :free_model, model: @model,
613
- priced_as: "#{info.provider}/#{info.id} ($0.00, free)",
566
+ if @cost_reported
567
+ return CostSource.new(name: :provider_reported, model: @model,
568
+ priced_as: "#{@provider || info&.provider}/#{@id} (as billed, else the registry price)",
614
569
  registry: Miniswen.registry_revision)
615
570
  end
571
+ return nil unless info
616
572
 
617
573
  CostSource.new(name: :model_registry, model: @model,
618
574
  priced_as: "#{info.provider}/#{info.id}",
@@ -620,14 +576,16 @@ module Miniswen
620
576
  end
621
577
 
622
578
  def resolved
623
- @resolved ||= RubyLLM::Models.resolve(@id, provider: @provider, assume_exists: !@provider.nil?)
579
+ @resolved ||= RubyLLM::Models.resolve(@id, provider: @provider, assume_model_exists: !@provider.nil?)
624
580
  end
625
581
 
626
582
  def as_ruby_llm(entry)
627
583
  case entry[:role]
628
584
  when "assistant"
629
- RubyLLM::Message.new(role: :assistant, content: entry[:content],
630
- thinking: as_ruby_llm_thinking(entry),
585
+ thinking = RubyLLM::Thinking.build(text: entry[:thinking], signature: entry[:thinking_signature])
586
+ RubyLLM::Message.new(role: :assistant, content: entry[:content], thinking:,
587
+ raw_reasoning: entry[:reasoning_details],
588
+ usage_entries: [ producing_entry ],
631
589
  tool_calls: as_ruby_llm_tool_calls(entry[:tool_calls]))
632
590
  when "tool"
633
591
  RubyLLM::Message.new(role: :tool, content: entry[:content], tool_call_id: entry[:tool_call_id])
@@ -636,13 +594,11 @@ module Miniswen
636
594
  end
637
595
  end
638
596
 
639
- def as_ruby_llm_thinking(entry)
640
- details = entry[:reasoning_details]
641
- if details && !details.empty?
642
- VerbatimThinking.new(text: entry[:thinking], signature: entry[:thinking_signature], details: details)
643
- else
644
- RubyLLM::Thinking.build(text: entry[:thinking], signature: entry[:thinking_signature])
645
- end
597
+ # A rebuilt turn must name its provider, or ruby_llm drops its thinking signature.
598
+ def producing_entry
599
+ model_info, provider = resolved
600
+ RubyLLM::Accounting::Usage::Entry.new(operation: :chat, provider: provider.slug, model: model_info.id,
601
+ status: :succeeded)
646
602
  end
647
603
 
648
604
  def as_ruby_llm_tool_calls(tool_calls)
@@ -663,16 +619,20 @@ module Miniswen
663
619
  content: response.content.to_s,
664
620
  thinking: response.thinking&.text,
665
621
  thinking_signature: response.thinking&.signature,
666
- reasoning_details: reasoning_details_from(response),
622
+ reasoning_details: response.raw_reasoning,
667
623
  tool_calls: tool_calls_from(response),
668
- finish_reason: finish_reason_from(response),
624
+ finish_reason: response.finish_reason&.to_s,
625
+ truncated: response.max_tokens?,
626
+ refused: response.content_filtered?,
669
627
  # ruby_llm's input_tokens is the cache-miss remainder only; the
670
628
  # convention counts the whole prompt, cached and cache-write included.
671
- input_tokens: response.input_tokens.to_i + tokens&.cached.to_i + tokens&.cache_creation.to_i,
672
- output_tokens: response.output_tokens.to_i,
673
- cached_tokens: tokens&.cached.to_i,
674
- thinking_tokens: tokens&.thinking.to_i,
675
- cost_usd: price(response)
629
+ input_tokens: tokens.input.to_i + tokens.cache_read.to_i + tokens.cache_write.to_i,
630
+ output_tokens: tokens.output.to_i,
631
+ cached_tokens: tokens.cache_read.to_i,
632
+ thinking_tokens: tokens.thinking.to_i,
633
+ cost_usd: price(response),
634
+ reported: !tokens.reported_cost.nil?,
635
+ provider: response.raw&.body&.dig("provider")
676
636
  }
677
637
  end
678
638
 
@@ -693,27 +653,7 @@ module Miniswen
693
653
  arguments
694
654
  end
695
655
 
696
- def finish_reason_from(response)
697
- body = raw_body(response)
698
- body && (body.dig("choices", 0, "finish_reason") || body["stop_reason"])
699
- end
700
-
701
- def reasoning_details_from(response)
702
- details = raw_body(response)&.dig("choices", 0, "message", "reasoning_details")
703
- details.is_a?(Array) && !details.empty? ? details : nil
704
- end
705
-
706
- def raw_body(response)
707
- body = response.raw&.body
708
- body = JSON.parse(body) if body.is_a?(String)
709
- body.is_a?(Hash) ? body : nil
710
- rescue JSON::ParserError
711
- nil
712
- end
713
-
714
- def local? = LOCAL_PROVIDERS.include?((@provider || info&.provider)&.to_sym)
715
-
716
- def free? = info&.provider == "openrouter" && info.input_price_per_million.nil? && info.output_price_per_million.nil?
656
+ def local? = RubyLLM::Provider.providers[(@provider || info&.provider)&.to_sym]&.local? || false
717
657
 
718
658
  def info
719
659
  return @info if defined?(@info)
@@ -722,28 +662,15 @@ module Miniswen
722
662
  end
723
663
 
724
664
  def find_model
725
- @provider ? RubyLLM.models.find(@id, @provider) : RubyLLM.models.find(@id)
665
+ RubyLLM.models.find(@id, provider: @provider)
726
666
  rescue RubyLLM::ModelNotFoundError
727
667
  nil
728
668
  end
729
669
 
730
670
  def price(response)
731
- return 0.0 if local? || free?
671
+ return 0.0 if local? || (response.tokens.to_h.empty? && response.tokens.reported_cost.nil?)
732
672
 
733
- input = info&.input_price_per_million
734
- output = info&.output_price_per_million
735
- return nil unless input.is_a?(Numeric) && output.is_a?(Numeric)
736
-
737
- tokens = response.tokens
738
- cache_read = info.cache_read_input_price_per_million || input
739
- cache_write = info.cache_write_input_price_per_million || input
740
- # Providers usually fold thinking into output_tokens; max() bills the
741
- # larger count once and can never double-bill.
742
- generated = [ response.output_tokens.to_i, tokens&.thinking.to_i ].max
743
- ((response.input_tokens.to_i * input) +
744
- (tokens&.cached.to_i * cache_read) +
745
- (tokens&.cache_creation.to_i * cache_write) +
746
- (generated * output)) / 1_000_000.0
673
+ info ? info.cost_for(response.tokens).total : response.tokens.reported_cost
747
674
  end
748
675
  end
749
676
  end
data/lib/miniswen/cli.rb CHANGED
@@ -99,7 +99,7 @@ module Miniswen
99
99
  end
100
100
 
101
101
  def refresh_registry_and_exit
102
- refreshed = Miniswen.refresh_registry!(persist: true)
102
+ refreshed = Miniswen.refresh_registry!
103
103
  $stdout.puts Miniswen.registry_revision if refreshed
104
104
  Kernel.exit(refreshed ? 0 : 1)
105
105
  end
@@ -37,7 +37,13 @@ module Miniswen
37
37
  def start
38
38
  FileUtils.rm_f(@socket)
39
39
  server = UNIXServer.new(@socket)
40
- @pid = fork { self.class.serve(server) { handle(it) } }
40
+ check, @lifeline = IO.pipe
41
+ @pid = fork do
42
+ @lifeline.close
43
+ Thread.new { check.wait_readable; exit! }
44
+ self.class.serve(server) { handle(it) }
45
+ end
46
+ check.close
41
47
  self
42
48
  end
43
49
 
data/lib/miniswen/jail.rb CHANGED
@@ -22,7 +22,8 @@ module Miniswen
22
22
  def start
23
23
  _, @stdout, @stderr, @holder = Open3.popen3(
24
24
  container_env,
25
- "unshare", "--net", "--mount", "--pid", "--fork", "--kill-child", "--mount-proc", "sh", "-c", setup,
25
+ "setpriv", "--pdeathsig=KILL", "unshare", "--net", "--mount", "--pid", "--fork", "--kill-child", "--mount-proc",
26
+ "sh", "-c", setup,
26
27
  pgroup: true, unsetenv_others: true
27
28
  )
28
29
  raise InfrastructureError, "jail did not start: #{@stderr.read}" unless @stdout.gets == "ready\n"
@@ -4,8 +4,8 @@ require "ruby_llm"
4
4
 
5
5
  # Logging configuration
6
6
  RubyLLM.configure do |config|
7
- config.log_level = ENV.fetch("RUBYLLM_LOG_LEVEL", "info").to_sym
8
- config.logger = Logger.new(IO::NULL) unless ENV["MINISWEN_DEBUG"] == "1"
7
+ config.log_level = ENV["RUBYLLM_LOG_LEVEL"].to_sym if ENV["RUBYLLM_LOG_LEVEL"]
8
+ config.logger = Logger.new(IO::NULL) unless ENV["MINISWEN_DEBUG"] == "1" || ENV["RUBYLLM_DEBUG"] == "1"
9
9
  end
10
10
 
11
11
  # About ten minutes of retries (1, 2, 4, ... 256s plus jitter): provider
@@ -13,6 +13,7 @@ end
13
13
  RubyLLM.configure do |config|
14
14
  config.max_retries = 9
15
15
  config.retry_interval = 1
16
+ config.retry_max_interval = 256
16
17
  end
17
18
 
18
19
  # ruby_llm reads no API keys from ENV on its own; the conventional variable is the provider's
@@ -27,23 +28,37 @@ RubyLLM.configure do |config|
27
28
  end
28
29
 
29
30
  module Miniswen
30
- # OpenRouter requires reasoning_details replayed exactly as received; ruby_llm
31
- # rebuilds them from its collapsed text+signature pair, and providers that
32
- # sign each block separately reject that as a corrupted thought signature.
33
- module VerbatimReasoningDetails
34
- def format_thinking(msg)
35
- details = msg.thinking.respond_to?(:details) ? msg.thinking.details : nil
36
- details && !details.empty? ? { reasoning_details: details } : super
37
- end
31
+ # A 200 cut off mid-body is a dropped connection, but ruby_llm doesn't retry it.
32
+ module RetryTransientFailures
33
+ def retry_exceptions = super + [ Faraday::ParsingError ]
38
34
  end
39
35
 
40
- # ruby_llm lists SSL errors as fatal, but a handshake reset never sent the
41
- # request; a 200 with a truncated JSON body is the same dropped connection
42
- # one step later.
43
- module RetryTransientFailures
44
- def retry_exceptions = super + [ Faraday::SSLError, Faraday::ParsingError ]
36
+ # ruby_llm parses tool-call arguments while it builds the message, so one
37
+ # malformed call would raise and take the whole completion with it. The raw
38
+ # string goes through instead and bounces as a format error.
39
+ module LenientToolArguments
40
+ def parse_tool_call_arguments(tool_call, **)
41
+ super
42
+ rescue RubyLLM::ToolCallParseError
43
+ tool_call.dig("function", "arguments")
44
+ end
45
+
46
+ def parse_function_call_arguments(arguments, **)
47
+ super
48
+ rescue RubyLLM::ToolCallParseError
49
+ arguments
50
+ end
51
+
52
+ def parse_arguments(arguments, *)
53
+ super
54
+ rescue RubyLLM::ToolCallParseError
55
+ arguments
56
+ end
45
57
  end
46
58
  end
47
59
 
48
- RubyLLM::Providers::OpenRouter.prepend(Miniswen::VerbatimReasoningDetails)
49
- RubyLLM::Connection.prepend(Miniswen::RetryTransientFailures)
60
+ RubyLLM::Transport::Connection.singleton_class.prepend(Miniswen::RetryTransientFailures)
61
+ [ RubyLLM::Protocols::ChatCompletions::Tools, RubyLLM::Protocols::Responses::Chat, RubyLLM::Protocols::Cohere::Tools ].each do |parser|
62
+ parser.prepend(Miniswen::LenientToolArguments)
63
+ parser.singleton_class.prepend(Miniswen::LenientToolArguments)
64
+ end
@@ -21,22 +21,22 @@ module Miniswen
21
21
 
22
22
  # $20/M input (fresh and cached) and $400/M output price the default answer
23
23
  # (95 fresh + 5 cached input tokens, 20 output tokens) at exactly $0.01.
24
- PRICED_MODEL = RubyLLM::Model::Info.new(
25
- id: "test", name: "Test", provider: "test",
24
+ PRICED_MODEL = RubyLLM::Model.new(
25
+ id: "test", name: "Test", provider: "test", max_output_tokens: 4096,
26
26
  capabilities: %w[function_calling],
27
27
  modalities: { input: %w[text], output: %w[text] },
28
28
  pricing: { text_tokens: { standard: {
29
- input_per_million: 20.0, cached_input_per_million: 20.0, output_per_million: 400.0
29
+ input_per_million: 20.0, cache_read_input_per_million: 20.0, output_per_million: 400.0
30
30
  } } }
31
31
  )
32
32
 
33
- UNPRICED_MODEL = RubyLLM::Model::Info.new(
33
+ UNPRICED_MODEL = RubyLLM::Model.new(
34
34
  id: "test-unpriced", name: "Test unpriced", provider: "test",
35
35
  capabilities: %w[function_calling],
36
36
  modalities: { input: %w[text], output: %w[text] }
37
37
  )
38
38
 
39
- FREE_MODEL = RubyLLM::Model::Info.new(
39
+ FREE_MODEL = RubyLLM::Model.new(
40
40
  id: "test-free", name: "Test free", provider: "openrouter",
41
41
  capabilities: %w[function_calling],
42
42
  modalities: { input: %w[text], output: %w[text] }
@@ -46,13 +46,11 @@ module Miniswen
46
46
 
47
47
  # Serves the test models from ruby_llm's registry lookups.
48
48
  module FindTestModels
49
- def find(model_id, provider = nil)
49
+ def find(model_id, **)
50
50
  MODELS[model_id] || super
51
51
  end
52
52
  end
53
53
 
54
- RawResponse = Data.define(:body)
55
-
56
54
  SUBMIT = { content: "Done.", cmd: "echo #{Agent::SUBMIT_MARKER}" }.freeze
57
55
 
58
56
  # A shell made of a Hash. `echo` behaves like echo, so the submit marker
@@ -71,6 +69,7 @@ module Miniswen
71
69
 
72
70
  def exec(command, timeout: nil, env: nil) # rubocop:disable Lint/UnusedMethodArgument
73
71
  @commands << command
72
+ command = command.sub(/\Acd .+?; /, "")
74
73
  return result(0, command.delete_prefix("echo ")) if command.start_with?("echo ")
75
74
 
76
75
  exit_code, output = @canned.fetch(command, [ 0, "" ])
@@ -91,7 +90,7 @@ module Miniswen
91
90
 
92
91
  # Each answer is a command string, or a hash with :cmd (one command or many)
93
92
  # and/or :content, plus optional overrides (:tool_calls, :finish_reason,
94
- # :thinking, token counts). Overrides given here apply to every answer.
93
+ # :thinking, :raw, token counts). Overrides given here apply to every answer.
95
94
  def stub_llm(*answers, **overrides)
96
95
  RubyLLM::Test.stub_responses(*answers.map { llm_answer(it, **overrides) })
97
96
  end
@@ -110,20 +109,17 @@ module Miniswen
110
109
  signature: answer[:thinking_signature]),
111
110
  input_tokens: answer.fetch(:input_tokens, 95),
112
111
  output_tokens: answer.fetch(:output_tokens, 20),
113
- cached_tokens: answer.fetch(:cached_tokens, 5),
112
+ cache_read_tokens: answer.fetch(:cached_tokens, 5),
114
113
  thinking_tokens: answer[:thinking] && 40,
115
- raw: RawResponse.new(body: raw_body_for(answer, calls))
114
+ raw_reasoning: answer[:reasoning_details],
115
+ reported_cost: answer[:reported_cost],
116
+ raw: answer[:raw],
117
+ finish_reason: answer.fetch(:finish_reason) { calls.empty? ? "stop" : "tool_calls" }
116
118
  )
117
119
  end
118
120
 
119
121
  private
120
122
 
121
- def raw_body_for(answer, calls)
122
- choice = { "finish_reason" => answer.fetch(:finish_reason) { calls.empty? ? "stop" : "tool_calls" } }
123
- choice["message"] = { "reasoning_details" => answer[:reasoning_details] } if answer[:reasoning_details]
124
- { "choices" => [ choice ] }
125
- end
126
-
127
123
  def tool_calls_for(answer)
128
124
  calls = answer[:tool_calls] || Array(answer[:cmd]).map { { name: "bash", arguments: { "command" => it } } }
129
125
  calls.to_h do |call|
@@ -78,7 +78,8 @@ module Miniswen
78
78
  step[:llm_call_count] = 1
79
79
  end
80
80
  # ATIF has no field for how a turn ended
81
- step[:extra] = { finish_reason: message[:finish_reason] } if message[:finish_reason]
81
+ extra = { finish_reason: message[:finish_reason], provider: message[:provider] }.compact
82
+ step[:extra] = extra unless extra.empty?
82
83
  step
83
84
  end
84
85
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Miniswen
4
- VERSION = "1.5.0"
4
+ VERSION = "1.6.0"
5
5
  end
data/lib/miniswen.rb CHANGED
@@ -16,14 +16,10 @@ module Miniswen # :nodoc:
16
16
  class AccountingError < Error; end
17
17
 
18
18
  class << self
19
- def refresh_registry!(persist: false)
19
+ def refresh_registry!
20
20
  return true if @ruby_llm_refreshed
21
21
 
22
- RubyLLM.models.refresh!
23
- # save_to_json writes to the registry file every boot loads from (the
24
- # gem's own models.json by default), so a persisted refresh outlives
25
- # this process — later runs in the same environment boot from it.
26
- RubyLLM.models.save_to_json if persist
22
+ RubyLLM.models.refresh
27
23
  @ruby_llm_refreshed = true
28
24
  rescue StandardError => e
29
25
  warn "Failed to refresh RubyLLM registry: #{e.message}"
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.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov
@@ -31,14 +31,14 @@ dependencies:
31
31
  requirements:
32
32
  - - "~>"
33
33
  - !ruby/object:Gem::Version
34
- version: '1.16'
34
+ version: 2.1.1
35
35
  type: :runtime
36
36
  prerelease: false
37
37
  version_requirements: !ruby/object:Gem::Requirement
38
38
  requirements:
39
39
  - - "~>"
40
40
  - !ruby/object:Gem::Version
41
- version: '1.16'
41
+ version: 2.1.1
42
42
  description: A Ruby port of mini-swe-agent.
43
43
  email:
44
44
  - me@skryukov.dev