miniswen 1.5.2 → 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: b72b4a7a4e82cc48a0a50e7a27c338f34685ee9e6fefdfc555213b8c5d8b2475
4
- data.tar.gz: 3d309e32ffc77990653f49768ee1dd1e6c581b56a93eddebd61f37d8ac9739dc
3
+ metadata.gz: 318e13712f3e46a9ad03b1105b7f5c5c6892d5b75a6566d0c272d196de54d491
4
+ data.tar.gz: 4f0c7dbd37fe212152e66a1abca5ee39513136d564a0293098920947a1a0a02f
5
5
  SHA512:
6
- metadata.gz: 2fd6ae2583048739c94ee8dc70e92c214f4163cb7a86e2201e450d14549abc3cb5d92aba5f1f3c6c1a934968f1b738f3d8561392a2523412393cf9f0c33e04d9
7
- data.tar.gz: 89ae33237c77e4025e9fe4bf5a5c5036b3098f32f9e32f91a4bd2d0925cd61cc366bd58a3de3b182b0fa4f31f6e0027a674f5b1d78a072cd2905bc0d1d3d52f5
6
+ metadata.gz: f0eb42558de2ad1e9db1a80b1c9f9fdf488dc7cdcc82e848a3086f8a517cc1a5ed5fe36a6901e86e81320d192ef8f218935346e343aea30ca6de8fbb7586f497
7
+ data.tar.gz: 82ad81a8753d0f5ba9c9415e4e6b4c198c8cb2692c539b05c47f9ed446752df96893f49922ca060ab5356820ad2e456bb5625087247b1d7040b63f55c035656c
data/README.md CHANGED
@@ -340,6 +340,7 @@ See [examples](./examples) for real-world extensions:
340
340
 
341
341
  - [Apple Container backend](./examples/apple-container.rb)
342
342
  - [Claude Code agent](./examples/claude-code.rb)
343
+ - [Codex agent](./examples/codex/README.md)
343
344
 
344
345
  ## CLI
345
346
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "securerandom"
5
+ require "shellwords"
5
6
  require "time"
6
7
  require "miniswen/version"
7
8
  require "miniswen/ruby_llm"
@@ -14,21 +15,6 @@ module Miniswen
14
15
  MAX_OBSERVATION_CHARS = 10_000
15
16
  MAX_CONSECUTIVE_FORMAT_ERRORS = 3
16
17
 
17
- # Both finish_reason dialects accepted raw: OpenAI-shaped providers say
18
- # "length"/"tool_calls", Anthropic says "max_tokens"/"tool_use".
19
- TRUNCATION_FINISH_REASONS = %w[length max_tokens model_context_window_exceeded].freeze
20
- CLAIMED_TOOL_FINISH_REASONS = %w[tool_calls tool_use].freeze
21
- # A safety stop, which arrives looking exactly like a model that forgot
22
- # to call the tool: no content, no tool call, and — since the provider
23
- # bills nothing for a turn it refused — no tokens either. Only the
24
- # finish reason tells the two apart, so the run is labelled by it. The
25
- # retry is unchanged: the nudge still goes back, because matching
26
- # mini-swe-agent turn for turn is what makes runs comparable.
27
- REFUSAL_FINISH_REASONS = %w[content_filter refusal safety].freeze
28
-
29
- # The breakpoint marker Anthropic reads, shaped the way OpenRouter forwards it.
30
- CACHE_CONTROL = { type: "ephemeral" }.freeze
31
-
32
18
  EXEC_ENV = {
33
19
  "PAGER" => "cat",
34
20
  "MANPAGER" => "cat",
@@ -37,9 +23,6 @@ module Miniswen
37
23
  "TQDM_DISABLE" => "1"
38
24
  }.freeze
39
25
 
40
- # Providers that serve local inference (and cost zero)
41
- LOCAL_PROVIDERS = %i[ollama gpustack].freeze
42
-
43
26
  SYSTEM_TEMPLATE = <<~PROMPT
44
27
  You are a helpful assistant that can interact with a computer.
45
28
  PROMPT
@@ -168,20 +151,10 @@ module Miniswen
168
151
  without any other command.
169
152
  MESSAGE
170
153
 
171
- # Thinking that also carries the provider's reasoning_details for verbatim replay.
172
- class VerbatimThinking < RubyLLM::Thinking
173
- attr_reader :details
174
-
175
- def initialize(text: nil, signature: nil, details: nil)
176
- super(text: text, signature: signature)
177
- @details = details
178
- end
179
- end
180
-
181
154
  # Only ever rendered into the request — the completion executes nothing — so no execute body.
182
155
  class BashTool < RubyLLM::Tool
183
156
  description "Execute a bash command"
184
- param :command, desc: "The bash command to execute"
157
+ parameter :command, description: "The bash command to execute"
185
158
 
186
159
  # ruby_llm would otherwise derive "lemans--miniswen--bash" from the class path.
187
160
  def name = "bash"
@@ -233,13 +206,13 @@ module Miniswen
233
206
 
234
207
  attr_reader :messages, :environment
235
208
 
236
- private attr_reader :max_steps, :max_time, :max_cost, :exec_timeout, :max_output_tokens,
209
+ private attr_reader :workdir, :max_steps, :max_time, :max_cost, :exec_timeout, :max_output_tokens,
237
210
  :cache_id, :clock, :reporter
238
211
 
239
212
  # `model` is a litellm-style name ("openrouter/z-ai/glm-5.2"), optionally
240
213
  # suffixed with a reasoning effort ("openrouter/openai/gpt-5.6-luna#xhigh").
241
214
  # Limits of 0 or nil are disabled.
242
- 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,
243
216
  exec_timeout: 30, max_output_tokens: 0,
244
217
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
245
218
  reporter: nil)
@@ -252,6 +225,7 @@ module Miniswen
252
225
 
253
226
  @model = model
254
227
  @environment = environment
228
+ @workdir = workdir
255
229
 
256
230
  @bash_tool = BashTool.new
257
231
 
@@ -339,6 +313,7 @@ module Miniswen
339
313
  @steps = 0
340
314
  @cost = 0.0
341
315
  @cost_known = true
316
+ @cost_reported = false
342
317
  @totals = { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 }
343
318
  @started_at = @clock.call
344
319
  end
@@ -350,6 +325,7 @@ module Miniswen
350
325
  @steps = history.steps.to_i
351
326
  @cost = history.cost_usd.to_f
352
327
  @cost_known = !history.cost_usd.nil?
328
+ @cost_reported = history.cost_source&.name == :provider_reported
353
329
  @totals = { input_tokens: history.input_tokens.to_i, output_tokens: history.output_tokens.to_i,
354
330
  cached_tokens: history.cached_tokens.to_i, thinking_tokens: history.thinking_tokens.to_i }
355
331
  @started_at = @clock.call - elapsed(history.messages)
@@ -371,6 +347,7 @@ module Miniswen
371
347
  end
372
348
 
373
349
  def execute(command)
350
+ command = "cd #{Shellwords.escape(workdir)}; #{command}" if workdir
374
351
  environment.exec(command, timeout: exec_timeout, env: EXEC_ENV)
375
352
  end
376
353
 
@@ -399,6 +376,7 @@ module Miniswen
399
376
  # content, no tool call and no billed tokens.
400
377
  entry[:finish_reason] = response[:finish_reason] if response[:finish_reason]
401
378
  entry[:thinking] = response[:thinking] if response[:thinking]
379
+ entry[:provider] = response[:provider] if response[:provider]
402
380
  # The provider's opaque handles on this turn's reasoning: replayed back
403
381
  # to the provider only, never into the trajectory.
404
382
  entry[:thinking_signature] = response[:thinking_signature] if response[:thinking_signature]
@@ -412,7 +390,7 @@ module Miniswen
412
390
  # message with unanswered tool calls is a request providers reject.
413
391
  entry[:invalid_tool_calls] = tool_calls if tool_calls.any?
414
392
  @consecutive_format_errors += 1
415
- @refused_turns += 1 if refused?(response)
393
+ @refused_turns += 1 if response[:refused]
416
394
 
417
395
  observe_message({ role: "user", content: format_error_message(error, response, tool_calls) })
418
396
 
@@ -425,8 +403,6 @@ module Miniswen
425
403
  tool_calls
426
404
  end
427
405
 
428
- def refused?(response) = REFUSAL_FINISH_REASONS.include?(response[:finish_reason].to_s)
429
-
430
406
  # An unpriced completion under a cost ceiling is fatal on the spot: only failing fast stops
431
407
  # the spend. Without a ceiling, unknown cost is just a fact to report.
432
408
  def track_cost(response)
@@ -441,6 +417,7 @@ module Miniswen
441
417
  else
442
418
  @cost += cost
443
419
  end
420
+ @cost_reported = true if response[:reported]
444
421
  end
445
422
 
446
423
  def actions_error(tool_calls)
@@ -466,10 +443,8 @@ module Miniswen
466
443
  end
467
444
 
468
445
  def format_error_message(error, response, tool_calls)
469
- finish_reason = response[:finish_reason].to_s
470
- if TRUNCATION_FINISH_REASONS.include?(finish_reason) ||
471
- (CLAIMED_TOOL_FINISH_REASONS.include?(finish_reason) && tool_calls.empty?)
472
- 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])
473
448
  else
474
449
  format(TOOL_CALL_ERROR_MESSAGE, error: error)
475
450
  end
@@ -548,11 +523,13 @@ module Miniswen
548
523
  def complete(messages)
549
524
  model_info, provider = resolved
550
525
  response = provider.complete(
551
- with_cache_breakpoints(messages.map { as_ruby_llm(it) }),
526
+ messages.map { as_ruby_llm(it) },
552
527
  tools: { bash: @bash_tool },
553
528
  temperature: nil,
554
529
  model: model_info,
555
- params: routing_params.merge(output_cap_params(model_info)),
530
+ provider_options: routing_params,
531
+ max_output_tokens: output_cap,
532
+ caching: {},
556
533
  thinking: (RubyLLM::Thinking::Config.new(effort: @effort) if @effort)
557
534
  )
558
535
  payload(response)
@@ -564,30 +541,10 @@ module Miniswen
564
541
  raise InfrastructureError, "miniswen: the model call failed: #{e.class}: #{e.message}"
565
542
  end
566
543
 
567
- # Anthropic bills every token fresh unless the request marks explicit cache
568
- # breakpoints
569
- def explicit_cache? = @provider == "openrouter" && @id.to_s.start_with?("anthropic/")
570
-
571
- def with_cache_breakpoints(messages)
572
- return messages unless explicit_cache?
573
-
574
- system = messages.find { it.role == :system }
575
- [ system, messages.last ].compact.uniq.each do |message|
576
- text = message.content
577
- next unless text.is_a?(String) && !text.empty?
578
-
579
- message.content = RubyLLM::Content::Raw.new(
580
- [ { type: "text", text: text, cache_control: CACHE_CONTROL } ]
581
- )
582
- end
583
- messages
584
- end
585
-
586
544
  def routing_params
587
545
  return {} unless @provider == "openrouter"
588
546
 
589
- params = {}
590
- params[:prompt_cache_key] = cache_id if @id.start_with?("meta/muse-")
547
+ params = { prompt_cache_key: cache_id }
591
548
  if (order = provider_order)
592
549
  params[:provider] = { order: order.split(",").map(&:strip), allow_fallbacks: false }
593
550
  end
@@ -596,20 +553,8 @@ module Miniswen
596
553
 
597
554
  def provider_order = ENV["LEMANS_PROVIDER_ORDER"] || ENV["OPENROUTER_PROVIDER_ORDER"]
598
555
 
599
- # OpenAI itself retired `max_tokens` for its reasoning models; the
600
- # OpenAI-compatible providers and Anthropic still read it.
601
- def output_cap_params(model_info)
602
- return {} if max_output_tokens.zero?
603
-
604
- cap = [ info&.max_tokens, max_output_tokens ].compact.min
605
- provider_class = RubyLLM::Provider.providers[model_info.provider.to_sym]
606
- if [ RubyLLM::Providers::OpenAI, RubyLLM::Providers::Azure ].include?(provider_class)
607
- { max_completion_tokens: cap }
608
- elsif provider_class <= RubyLLM::Providers::OpenAI || provider_class <= RubyLLM::Providers::Anthropic
609
- { max_tokens: cap }
610
- else
611
- {}
612
- end
556
+ def output_cap
557
+ [ info&.max_output_tokens, max_output_tokens ].compact.min unless max_output_tokens.zero?
613
558
  end
614
559
 
615
560
  def cost_source
@@ -618,12 +563,12 @@ module Miniswen
618
563
  priced_as: "#{@provider || info&.provider}/#{@id} ($0.00, local)",
619
564
  registry: nil)
620
565
  end
621
- return nil unless info
622
- if free?
623
- return CostSource.new(name: :free_model, model: @model,
624
- 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)",
625
569
  registry: Miniswen.registry_revision)
626
570
  end
571
+ return nil unless info
627
572
 
628
573
  CostSource.new(name: :model_registry, model: @model,
629
574
  priced_as: "#{info.provider}/#{info.id}",
@@ -631,14 +576,16 @@ module Miniswen
631
576
  end
632
577
 
633
578
  def resolved
634
- @resolved ||= RubyLLM::Models.resolve(@id, provider: @provider, assume_exists: !@provider.nil?)
579
+ @resolved ||= RubyLLM::Models.resolve(@id, provider: @provider, assume_model_exists: !@provider.nil?)
635
580
  end
636
581
 
637
582
  def as_ruby_llm(entry)
638
583
  case entry[:role]
639
584
  when "assistant"
640
- RubyLLM::Message.new(role: :assistant, content: entry[:content],
641
- 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 ],
642
589
  tool_calls: as_ruby_llm_tool_calls(entry[:tool_calls]))
643
590
  when "tool"
644
591
  RubyLLM::Message.new(role: :tool, content: entry[:content], tool_call_id: entry[:tool_call_id])
@@ -647,13 +594,11 @@ module Miniswen
647
594
  end
648
595
  end
649
596
 
650
- def as_ruby_llm_thinking(entry)
651
- details = entry[:reasoning_details]
652
- if details && !details.empty?
653
- VerbatimThinking.new(text: entry[:thinking], signature: entry[:thinking_signature], details: details)
654
- else
655
- RubyLLM::Thinking.build(text: entry[:thinking], signature: entry[:thinking_signature])
656
- 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)
657
602
  end
658
603
 
659
604
  def as_ruby_llm_tool_calls(tool_calls)
@@ -674,16 +619,20 @@ module Miniswen
674
619
  content: response.content.to_s,
675
620
  thinking: response.thinking&.text,
676
621
  thinking_signature: response.thinking&.signature,
677
- reasoning_details: reasoning_details_from(response),
622
+ reasoning_details: response.raw_reasoning,
678
623
  tool_calls: tool_calls_from(response),
679
- finish_reason: finish_reason_from(response),
624
+ finish_reason: response.finish_reason&.to_s,
625
+ truncated: response.max_tokens?,
626
+ refused: response.content_filtered?,
680
627
  # ruby_llm's input_tokens is the cache-miss remainder only; the
681
628
  # convention counts the whole prompt, cached and cache-write included.
682
- input_tokens: response.input_tokens.to_i + tokens&.cached.to_i + tokens&.cache_creation.to_i,
683
- output_tokens: response.output_tokens.to_i,
684
- cached_tokens: tokens&.cached.to_i,
685
- thinking_tokens: tokens&.thinking.to_i,
686
- 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")
687
636
  }
688
637
  end
689
638
 
@@ -704,27 +653,7 @@ module Miniswen
704
653
  arguments
705
654
  end
706
655
 
707
- def finish_reason_from(response)
708
- body = raw_body(response)
709
- body && (body.dig("choices", 0, "finish_reason") || body["stop_reason"])
710
- end
711
-
712
- def reasoning_details_from(response)
713
- details = raw_body(response)&.dig("choices", 0, "message", "reasoning_details")
714
- details.is_a?(Array) && !details.empty? ? details : nil
715
- end
716
-
717
- def raw_body(response)
718
- body = response.raw&.body
719
- body = JSON.parse(body) if body.is_a?(String)
720
- body.is_a?(Hash) ? body : nil
721
- rescue JSON::ParserError
722
- nil
723
- end
724
-
725
- def local? = LOCAL_PROVIDERS.include?((@provider || info&.provider)&.to_sym)
726
-
727
- 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
728
657
 
729
658
  def info
730
659
  return @info if defined?(@info)
@@ -733,28 +662,15 @@ module Miniswen
733
662
  end
734
663
 
735
664
  def find_model
736
- @provider ? RubyLLM.models.find(@id, @provider) : RubyLLM.models.find(@id)
665
+ RubyLLM.models.find(@id, provider: @provider)
737
666
  rescue RubyLLM::ModelNotFoundError
738
667
  nil
739
668
  end
740
669
 
741
670
  def price(response)
742
- return 0.0 if local? || free?
671
+ return 0.0 if local? || (response.tokens.to_h.empty? && response.tokens.reported_cost.nil?)
743
672
 
744
- input = info&.input_price_per_million
745
- output = info&.output_price_per_million
746
- return nil unless input.is_a?(Numeric) && output.is_a?(Numeric)
747
-
748
- tokens = response.tokens
749
- cache_read = info.cache_read_input_price_per_million || input
750
- cache_write = info.cache_write_input_price_per_million || input
751
- # Providers usually fold thinking into output_tokens; max() bills the
752
- # larger count once and can never double-bill.
753
- generated = [ response.output_tokens.to_i, tokens&.thinking.to_i ].max
754
- ((response.input_tokens.to_i * input) +
755
- (tokens&.cached.to_i * cache_read) +
756
- (tokens&.cache_creation.to_i * cache_write) +
757
- (generated * output)) / 1_000_000.0
673
+ info ? info.cost_for(response.tokens).total : response.tokens.reported_cost
758
674
  end
759
675
  end
760
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,36 +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
38
- end
39
-
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.
31
+ # A 200 cut off mid-body is a dropped connection, but ruby_llm doesn't retry it.
43
32
  module RetryTransientFailures
44
- def retry_exceptions = super + [ Faraday::SSLError, Faraday::ParsingError ]
33
+ def retry_exceptions = super + [ Faraday::ParsingError ]
45
34
  end
46
35
 
47
36
  # ruby_llm parses tool-call arguments while it builds the message, so one
48
37
  # malformed call would raise and take the whole completion with it. The raw
49
38
  # string goes through instead and bounces as a format error.
50
39
  module LenientToolArguments
51
- def parse_tool_call_arguments(tool_call)
40
+ def parse_tool_call_arguments(tool_call, **)
52
41
  super
53
- rescue JSON::ParserError
42
+ rescue RubyLLM::ToolCallParseError
54
43
  tool_call.dig("function", "arguments")
55
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
56
57
  end
57
58
  end
58
59
 
59
- RubyLLM::Providers::OpenRouter.prepend(Miniswen::VerbatimReasoningDetails)
60
- RubyLLM::Connection.prepend(Miniswen::RetryTransientFailures)
61
- RubyLLM::Providers::OpenAI::Tools.prepend(Miniswen::LenientToolArguments)
62
- RubyLLM::Providers::OpenAI::Tools.singleton_class.prepend(Miniswen::LenientToolArguments)
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.2"
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.2
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