lemans 1.5.1 → 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.
@@ -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}"