lemans 0.0.0.pre → 0.2.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +228 -0
  5. data/exe/lemans +17 -0
  6. data/lib/lemans/agents/base.rb +30 -0
  7. data/lib/lemans/agents/miniswen.rb +119 -0
  8. data/lib/lemans/agents/miniswen_installed.rb +67 -0
  9. data/lib/lemans/agents/nop.rb +15 -0
  10. data/lib/lemans/agents/oracle.rb +53 -0
  11. data/lib/lemans/agents.rb +21 -0
  12. data/lib/lemans/bench.rb +280 -0
  13. data/lib/lemans/cli/board_reporter.rb +135 -0
  14. data/lib/lemans/cli/progress_reporter.rb +67 -0
  15. data/lib/lemans/cli.rb +181 -0
  16. data/lib/lemans/clobber.rb +79 -0
  17. data/lib/lemans/environments/base.rb +55 -0
  18. data/lib/lemans/environments/daytona/retries.rb +49 -0
  19. data/lib/lemans/environments/daytona/sdk_tweaks.rb +50 -0
  20. data/lib/lemans/environments/daytona/shell.rb +142 -0
  21. data/lib/lemans/environments/daytona/snapshot_store.rb +163 -0
  22. data/lib/lemans/environments/daytona.rb +175 -0
  23. data/lib/lemans/environments.rb +16 -0
  24. data/lib/lemans/network_policy.rb +66 -0
  25. data/lib/lemans/patch.rb +70 -0
  26. data/lib/lemans/restore_paths.rb +21 -0
  27. data/lib/lemans/results/aggregate.rb +114 -0
  28. data/lib/lemans/results/cost_source.rb +13 -0
  29. data/lib/lemans/results/outcome.rb +36 -0
  30. data/lib/lemans/results/report.rb +149 -0
  31. data/lib/lemans/results/sorting.rb +24 -0
  32. data/lib/lemans/results/tally.rb +19 -0
  33. data/lib/lemans/results/usage.rb +24 -0
  34. data/lib/lemans/run.rb +152 -0
  35. data/lib/lemans/setup.rb +59 -0
  36. data/lib/lemans/setup_files.rb +36 -0
  37. data/lib/lemans/snapshot.rb +55 -0
  38. data/lib/lemans/task.rb +207 -0
  39. data/lib/lemans/tree_digest.rb +24 -0
  40. data/lib/lemans/trial.rb +187 -0
  41. data/lib/lemans/units.rb +44 -0
  42. data/lib/lemans/verifier/assets/eport-lemans.rb +36 -0
  43. data/lib/lemans/verifier/assets/lemans_minitest_reporter.rb +61 -0
  44. data/lib/lemans/verifier.rb +199 -0
  45. data/lib/lemans/version.rb +5 -0
  46. data/lib/lemans.rb +29 -0
  47. data/lib/miniswen/agent.rb +669 -0
  48. data/lib/miniswen/cli.rb +224 -0
  49. data/lib/miniswen/environment.rb +14 -0
  50. data/lib/miniswen/local.rb +42 -0
  51. data/lib/miniswen/ruby_llm.rb +42 -0
  52. data/lib/miniswen/testing.rb +134 -0
  53. data/lib/miniswen/trajectory.rb +110 -0
  54. data/lib/miniswen/version.rb +5 -0
  55. data/lib/miniswen.rb +48 -0
  56. metadata +160 -7
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require "miniswen/version"
6
+
7
+ module Miniswen
8
+ class CLI # :nodoc:
9
+ # Prints messages and tool calls in real-time. The renderer deliberately
10
+ # keeps the captured (non-TTY) version plain, which makes it useful in CI
11
+ # and when piping a run to a log file too.
12
+ class Reporter
13
+ private attr_reader :io
14
+
15
+ # Tool output can be extremely noisy (for example, a recursive grep or
16
+ # a test runner dumping a log). Keep the normal report useful while
17
+ # allowing -vv to retain the complete output for debugging.
18
+ MAX_TOOL_OUTPUT_CHARS = 1_000
19
+
20
+ def initialize(io = $stdout, verbose: false)
21
+ @io = io
22
+ @verbose = verbose
23
+ end
24
+
25
+ def on_message(message)
26
+ case message[:role].to_s
27
+ when "assistant"
28
+ write_block("●", message[:content], :assistant)
29
+ when "tool"
30
+ write_block("↳", message[:content], :tool)
31
+ when "user"
32
+ write_block("!", message[:content], :warning)
33
+ else
34
+ write_block("·", message[:content], :muted)
35
+ end
36
+ end
37
+
38
+ # Tool calls are reported separately so the command is visible before
39
+ # its output arrives. It is not added to the trajectory sent to the LLM.
40
+ def on_tool_call(call)
41
+ command = call.dig(:arguments, "command") || call.dig(:arguments, :command)
42
+ return if command.to_s.empty?
43
+
44
+ line = style("$ #{command}", :command)
45
+ io.puts(" #{line}")
46
+ end
47
+
48
+ def print_summary(result)
49
+ write_block("●", result.messages.last[:content], :assistant)
50
+ write_block("↳", "steps=#{result.steps} · cost=$#{result.cost_usd}", :muted)
51
+ end
52
+
53
+ def print_failure(result)
54
+ write_block("!", "Miniswen failed: #{result.status}", :warning)
55
+ end
56
+
57
+ private
58
+
59
+ def write_block(marker, content, tone)
60
+ text = content.to_s.strip
61
+ return if text.empty?
62
+
63
+ text = truncate_tool_output(text) if tone == :tool
64
+ lines = text.lines(chomp: true)
65
+ io.puts("#{style(marker, tone)} #{style(lines.shift, tone)}")
66
+ lines.each { |line| io.puts(" #{style(line, tone)}") }
67
+ end
68
+
69
+ def truncate_tool_output(text)
70
+ return text if @verbose || text.length <= MAX_TOOL_OUTPUT_CHARS
71
+
72
+ head = MAX_TOOL_OUTPUT_CHARS / 2
73
+ tail = MAX_TOOL_OUTPUT_CHARS - head
74
+ omitted = text.length - head - tail
75
+ "#{text[0, head]}\n... [#{omitted} characters omitted] ...\n#{text[-tail, tail]}"
76
+ end
77
+
78
+ def style(text, tone)
79
+ return text unless io.respond_to?(:tty?) && io.tty?
80
+
81
+ colors = { assistant: 36, tool: 32, warning: 33, command: 35, muted: 90 }
82
+ "\e[#{colors.fetch(tone)}m#{text}\e[0m"
83
+ end
84
+ end
85
+
86
+ attr_reader :instruction, :model, :options
87
+
88
+ def initialize
89
+ @instruction = nil
90
+ @model = ENV.fetch("MINISWEN_MODEL", nil)
91
+ @options = {}
92
+ @verbose = false
93
+ @quiet = false
94
+ @results_path = nil
95
+ @atif_path = nil
96
+ @refresh_registry = false
97
+ @skip_registry_refresh = false
98
+ end
99
+
100
+ def run
101
+ parse_args!
102
+
103
+ # Require the core library after parsing options,
104
+ # so env flags kick in
105
+ require "miniswen"
106
+
107
+ refresh_registry_and_exit if @refresh_registry
108
+
109
+ Miniswen.refresh_registry! unless @skip_registry_refresh
110
+
111
+ require "miniswen/local"
112
+
113
+ reporter = @quiet ? nil : Reporter.new(verbose: @verbose)
114
+ agent = Agent.new(model:, reporter:, environment: Local.new, **options)
115
+
116
+ begin
117
+ result = agent.run(instruction)
118
+ rescue StandardError => e
119
+ write_results(agent.partial_result(error_message(e)))
120
+ raise
121
+ end
122
+
123
+ write_results(result)
124
+
125
+ if result.success?
126
+ reporter&.print_summary(result)
127
+ else
128
+ reporter&.print_failure(result)
129
+ Kernel.exit(1)
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def write_results(result)
136
+ File.write(@results_path, JSON.generate(result.to_h)) if @results_path
137
+ write_atif(result) if @atif_path
138
+ end
139
+
140
+ def error_message(error)
141
+ error.is_a?(Miniswen::Error) ? error.message : "#{error.class}: #{error.message}"
142
+ end
143
+
144
+ def write_atif(result)
145
+ trajectory = Trajectory.from(result, model: model)
146
+ File.write(@atif_path, JSON.pretty_generate(trajectory.to_atif))
147
+ end
148
+
149
+ def refresh_registry_and_exit
150
+ refreshed = Miniswen.refresh_registry!(persist: true)
151
+ $stdout.puts Miniswen.registry_revision if refreshed
152
+ Kernel.exit(refreshed ? 0 : 1)
153
+ end
154
+
155
+ def parse_args!
156
+ parser = OptionParser.new do |opts|
157
+ opts.banner = "Usage: miniswen -m MODEL -p INSTRUCTION [...options]"
158
+
159
+ opts.on("-m MODEL", "--model=MODEL", String,
160
+ "LLM to use (litellm format, e.g.: openrouter/openai/gpt-5.6-luna") do |v|
161
+ @model = v
162
+ end
163
+
164
+ opts.on("-p INSTRUCTION", "--prompt=INSTRUCTION", String, "Instruction prompt") do |v|
165
+ @instruction = v
166
+ end
167
+
168
+ opts.on("--max-steps=STEPS", Integer, "Max steps count") do |v|
169
+ options[:max_steps] = v
170
+ end
171
+
172
+ opts.on("--max-cost=COST", Float, "Max cost (USD)") do |v|
173
+ options[:max_cost] = v
174
+ end
175
+
176
+ opts.on("--max-time=TIME", Float, "Max inference duration (seconds)") do |v|
177
+ options[:max_time] = v
178
+ end
179
+
180
+ opts.on("--exec-timeout=TIMEOUT", Float, "Tool execution timeout (seconds)") do |v|
181
+ options[:exec_timeout] = v
182
+ end
183
+
184
+ opts.on("-q", "--quiet", "Disable progress output") do
185
+ @quiet = true
186
+ end
187
+
188
+ opts.on("--results-path=PATH", String, "Write the run result as JSON to PATH") do |v|
189
+ @results_path = v
190
+ end
191
+
192
+ opts.on("--atif-path=PATH", String, "Write the ATIF trajectory to PATH") do |v|
193
+ @atif_path = v
194
+ end
195
+
196
+ opts.on("--refresh-registry", "Refresh the model registry, persist it, and exit") do
197
+ @refresh_registry = true
198
+ end
199
+
200
+ opts.on("--no-refresh-registry", "Skip the model registry refresh on startup") do
201
+ @skip_registry_refresh = true
202
+ end
203
+
204
+ opts.on("-v", "--version", "Print version") do
205
+ $stdout.puts Miniswen::VERSION
206
+ exit 0
207
+ end
208
+
209
+ opts.on("-vv", "Print verbose logs") do
210
+ @verbose = true
211
+ ENV["MINISWEN_DEBUG"] = "1"
212
+ ENV["RUBYLLM_LOG_LEVEL"] = "debug"
213
+ end
214
+ end
215
+
216
+ parser.parse!
217
+
218
+ return if @refresh_registry
219
+
220
+ raise "Use -m to specify the model" unless @model
221
+ raise "Please, provide instructions via -p option" unless @instruction
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Miniswen
4
+ # Represents a current runtime environment for an agent (the one
5
+ # where instructions must be executed)
6
+ class Environment
7
+ ExecResult = Data.define(:exit_code, :output) do
8
+ def success? = exit_code.zero?
9
+ end
10
+
11
+ # Execute a shell command
12
+ def exec(cmd, timeout: nil, env: nil) = raise NotImplementedError
13
+ end
14
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ require "miniswen/environment"
6
+
7
+ module Miniswen
8
+ # Local execution environment (current machine)
9
+ class Local < Environment
10
+ TIMEOUT_EXIT_CODE = 124
11
+
12
+ def exec(command, timeout: nil, env: nil)
13
+ Open3.popen2e(env || {}, command, pgroup: true) do |stdin, io, wait_thr|
14
+ stdin.close
15
+ reader = Thread.new { io.read }
16
+
17
+ if timeout&.positive? && wait_thr.join(timeout).nil?
18
+ kill_group(wait_thr.pid)
19
+ wait_thr.join
20
+ output = "#{scrub(reader.value)}\n<command timed out after #{timeout} seconds>"
21
+ return ExecResult.new(exit_code: TIMEOUT_EXIT_CODE, output:)
22
+ end
23
+
24
+ ExecResult.new(exit_code: exit_code(wait_thr.value), output: scrub(reader.value))
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def kill_group(pid)
31
+ Process.kill(:KILL, -pid)
32
+ rescue Errno::ESRCH, Errno::EPERM
33
+ nil
34
+ end
35
+
36
+ def exit_code(status)
37
+ status.exitstatus || (status.termsig ? 128 + status.termsig : 1)
38
+ end
39
+
40
+ def scrub(output) = output.to_s.force_encoding(Encoding::UTF_8).scrub
41
+ end
42
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ # Logging configuration
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"
9
+ end
10
+
11
+ # ruby_llm reads no API keys from ENV on its own; the conventional variable is the provider's
12
+ # config option upcased.
13
+ RubyLLM.configure do |config|
14
+ RubyLLM::Provider.providers.each_value do |provider|
15
+ provider.configuration_requirements.each do |option|
16
+ value = ENV.fetch(option.to_s.upcase, nil)
17
+ config.public_send(:"#{option}=", value) if value
18
+ end
19
+ end
20
+ end
21
+
22
+ module Miniswen
23
+ # OpenRouter requires reasoning_details replayed exactly as received; ruby_llm
24
+ # rebuilds them from its collapsed text+signature pair, and providers that
25
+ # sign each block separately reject that as a corrupted thought signature.
26
+ module VerbatimReasoningDetails
27
+ def format_thinking(msg)
28
+ details = msg.thinking.respond_to?(:details) ? msg.thinking.details : nil
29
+ details && !details.empty? ? { reasoning_details: details } : super
30
+ end
31
+ end
32
+
33
+ # A handshake reset never sent the request, so retrying is as safe as the
34
+ # ConnectionFailed retries ruby_llm already does; it only lists SSL errors
35
+ # as fatal.
36
+ module RetryTransientSSL
37
+ def retry_exceptions = super + [Faraday::SSLError]
38
+ end
39
+ end
40
+
41
+ RubyLLM::Providers::OpenRouter.prepend(Miniswen::VerbatimReasoningDetails)
42
+ RubyLLM::Connection.prepend(Miniswen::RetryTransientSSL)
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm/test"
4
+
5
+ require "miniswen/agent"
6
+ require "miniswen/environment"
7
+
8
+ module Miniswen
9
+ # Test support for callers driving Miniswen::Agent without a real provider:
10
+ # a "test" model family resolving through ruby_llm's real path, an in-process
11
+ # FakeEnv shell, and stub_llm/llm_answer helpers on top of RubyLLM::Test's
12
+ # response queue. Include into a Minitest case; stubs reset before each test.
13
+ module Testing
14
+ # Registered under the :test slug; completions never reach it, RubyLLM::Test
15
+ # intercepts them at resolve time.
16
+ class Provider < RubyLLM::Provider
17
+ def self.slug = "test"
18
+
19
+ def api_base = "http://test.invalid"
20
+ end
21
+
22
+ # $20/M input (fresh and cached) and $400/M output price the default answer
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",
26
+ capabilities: %w[function_calling],
27
+ modalities: { input: %w[text], output: %w[text] },
28
+ pricing: { text_tokens: { standard: {
29
+ input_per_million: 20.0, cached_input_per_million: 20.0, output_per_million: 400.0
30
+ } } }
31
+ )
32
+
33
+ UNPRICED_MODEL = RubyLLM::Model::Info.new(
34
+ id: "test-unpriced", name: "Test unpriced", provider: "test",
35
+ capabilities: %w[function_calling],
36
+ modalities: { input: %w[text], output: %w[text] }
37
+ )
38
+
39
+ MODELS = { PRICED_MODEL.id => PRICED_MODEL, UNPRICED_MODEL.id => UNPRICED_MODEL }.freeze
40
+
41
+ # Serves the test models from ruby_llm's registry lookups.
42
+ module FindTestModels
43
+ def find(model_id, provider = nil)
44
+ MODELS[model_id] || super
45
+ end
46
+ end
47
+
48
+ RawResponse = Data.define(:body)
49
+
50
+ SUBMIT = { content: "Done.", cmd: "echo #{Agent::SUBMIT_MARKER}" }.freeze
51
+
52
+ # A shell made of a Hash. `echo` behaves like echo, so the submit marker
53
+ # works the way it does in a real sandbox; anything else is looked up.
54
+ class FakeEnv
55
+ attr_reader :commands
56
+
57
+ def initialize(canned = {})
58
+ @canned = canned
59
+ @commands = []
60
+ end
61
+
62
+ def on(command, output, exit_code: 0)
63
+ @canned[command] = [exit_code, output]
64
+ end
65
+
66
+ def exec(command, timeout: nil, env: nil) # rubocop:disable Lint/UnusedMethodArgument
67
+ @commands << command
68
+ return result(0, command.delete_prefix("echo ")) if command.start_with?("echo ")
69
+
70
+ exit_code, output = @canned.fetch(command, [0, ""])
71
+ result(exit_code, output)
72
+ end
73
+
74
+ private
75
+
76
+ def result(exit_code, output)
77
+ Environment::ExecResult.new(exit_code: exit_code, output: output)
78
+ end
79
+ end
80
+
81
+ def before_setup
82
+ super
83
+ RubyLLM::Test.reset
84
+ end
85
+
86
+ # Each answer is a command string, or a hash with :cmd (one command or many)
87
+ # and/or :content, plus optional overrides (:tool_calls, :finish_reason,
88
+ # :thinking, token counts). Overrides given here apply to every answer.
89
+ def stub_llm(*answers, **overrides)
90
+ RubyLLM::Test.stub_responses(*answers.map { llm_answer(_1, **overrides) })
91
+ end
92
+
93
+ def llm_answer(answer, **overrides)
94
+ answer = { cmd: answer } if answer.is_a?(String)
95
+ answer = answer.merge(overrides)
96
+ raise ArgumentError, "an answer needs :cmd or :content" unless answer[:cmd] || answer[:content]
97
+
98
+ calls = tool_calls_for(answer)
99
+ RubyLLM::Message.new(
100
+ role: :assistant,
101
+ content: answer.fetch(:content, "Let me try."),
102
+ tool_calls: calls,
103
+ thinking: answer[:thinking] && RubyLLM::Thinking.new(text: answer[:thinking],
104
+ signature: answer[:thinking_signature]),
105
+ input_tokens: answer.fetch(:input_tokens, 95),
106
+ output_tokens: answer.fetch(:output_tokens, 20),
107
+ cached_tokens: answer.fetch(:cached_tokens, 5),
108
+ thinking_tokens: answer[:thinking] && 40,
109
+ raw: RawResponse.new(body: raw_body_for(answer, calls))
110
+ )
111
+ end
112
+
113
+ private
114
+
115
+ def raw_body_for(answer, calls)
116
+ choice = { "finish_reason" => answer.fetch(:finish_reason) { calls.empty? ? "stop" : "tool_calls" } }
117
+ choice["message"] = { "reasoning_details" => answer[:reasoning_details] } if answer[:reasoning_details]
118
+ { "choices" => [choice] }
119
+ end
120
+
121
+ def tool_calls_for(answer)
122
+ calls = answer[:tool_calls] || Array(answer[:cmd]).map { { name: "bash", arguments: { "command" => _1 } } }
123
+ calls.to_h do |call|
124
+ id = call[:id] || "call_#{@llm_answer_ids = @llm_answer_ids.to_i + 1}"
125
+ [id, RubyLLM::ToolCall.new(id: id, name: call[:name], arguments: call[:arguments],
126
+ thought_signature: call[:thought_signature])]
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ RubyLLM::Provider.register(:test, Miniswen::Testing::Provider)
133
+ RubyLLM::Models.prepend(Miniswen::Testing::FindTestModels)
134
+ RubyLLM::Models.singleton_class.prepend(RubyLLM::Test::ResolveWithTestProvider)
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Miniswen
4
+ # Serializes a finished Agent::Result into an ATIF-v1.7 document.
5
+ class Trajectory
6
+ SCHEMA_VERSION = "ATIF-v1.7"
7
+
8
+ # ATIF has no "tool" source: a tool result is not a step of its own but
9
+ # an observation on the step whose call produced it, so `tool` entries
10
+ # are folded rather than mapped. Everything left is a step.
11
+ SOURCE = { "system" => "system", "user" => "user", "assistant" => "agent" }.freeze
12
+
13
+ NOTES = "total_steps counts model turns; the steps array also carries the system and task " \
14
+ "messages, and each tool result rides as an observation on the step that called it"
15
+
16
+ def self.from(result, model:, session_id: nil, agent: {})
17
+ new(result: result, model: model, session_id: session_id, agent: agent)
18
+ end
19
+
20
+ def initialize(result:, model:, session_id: nil, agent: {})
21
+ @result = result
22
+ @model = model
23
+ @session_id = session_id
24
+ @agent = agent || {}
25
+ end
26
+
27
+ def to_atif
28
+ {
29
+ schema_version: SCHEMA_VERSION,
30
+ session_id: session_id,
31
+ agent: agent_info,
32
+ steps: steps,
33
+ notes: NOTES,
34
+ final_metrics: final_metrics,
35
+ extra: { status: result.status, submission: result.submission, error: result.error }.compact
36
+ }.compact
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :result, :model, :session_id, :agent
42
+
43
+ def agent_info
44
+ {
45
+ name: agent.fetch(:name, "miniswen"),
46
+ version: agent.fetch(:version, VERSION),
47
+ model_name: model,
48
+ extra: agent[:extra]
49
+ }.compact
50
+ end
51
+
52
+ def steps
53
+ result.messages.each_with_object([]) do |message, acc|
54
+ next acc << step_for(message, acc.size + 1) unless message[:role] == "tool"
55
+ next if acc.last.nil?
56
+
57
+ step = acc.last
58
+ (step[:observation] ||= { results: [] })[:results] << {
59
+ source_call_id: message[:tool_call_id],
60
+ content: message[:content],
61
+ extra: { exit_code: message.dig(:observation, :exit_code) }.compact
62
+ }.compact
63
+ end
64
+ end
65
+
66
+ def step_for(message, step_id)
67
+ step = { step_id: step_id }
68
+ step[:timestamp] = message[:timestamp] if message[:timestamp]
69
+ step[:source] = SOURCE.fetch(message[:role])
70
+ step[:message] = message[:content]
71
+ step[:reasoning_content] = message[:thinking] if message[:thinking]
72
+ step[:tool_calls] = tool_calls_for(message[:tool_calls]) if message[:tool_calls]
73
+ if (metrics = message[:metrics])
74
+ step[:model_name] = model
75
+ step[:metrics] = metrics_for(metrics)
76
+ step[:llm_call_count] = 1
77
+ end
78
+ # ATIF has no field for how a turn ended
79
+ step[:extra] = { finish_reason: message[:finish_reason] } if message[:finish_reason]
80
+ step
81
+ end
82
+
83
+ def tool_calls_for(calls)
84
+ calls.map { { tool_call_id: _1[:id], function_name: _1[:name], arguments: _1[:arguments] } }
85
+ end
86
+
87
+ def metrics_for(metrics)
88
+ thinking = metrics[:thinking_tokens].to_i
89
+ cached = metrics[:cached_tokens].to_i
90
+ named = metrics.except(:thinking_tokens).compact
91
+ extra = {}
92
+ extra[:completion_tokens_details] = { reasoning_tokens: thinking } if thinking.positive?
93
+ extra[:prompt_tokens_details] = { cached_tokens: cached } if cached.positive?
94
+ extra.empty? ? named : named.merge(extra: extra)
95
+ end
96
+
97
+ def final_metrics
98
+ totals = {
99
+ total_prompt_tokens: result.input_tokens,
100
+ total_completion_tokens: result.output_tokens,
101
+ total_cached_tokens: result.cached_tokens,
102
+ total_cost_usd: result.cost_usd,
103
+ total_steps: result.steps
104
+ }.compact
105
+ return totals unless result.thinking_tokens.positive?
106
+
107
+ totals.merge(extra: { total_reasoning_tokens: result.thinking_tokens })
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Miniswen
4
+ VERSION = "0.1.0"
5
+ end
data/lib/miniswen.rb ADDED
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "miniswen/version"
4
+ require "miniswen/agent"
5
+ require "miniswen/trajectory"
6
+
7
+ module Miniswen # :nodoc:
8
+ class Error < StandardError; end
9
+
10
+ # A provider or environment failing in a way that is the harness's fault
11
+ # rather than the model's.
12
+ class InfrastructureError < Error; end
13
+
14
+ # Incomplete accounting: missing usage or cost data is invalid rather than
15
+ # silently under-reported.
16
+ class AccountingError < Error; end
17
+
18
+ class << self
19
+ def refresh_registry!(persist: false)
20
+ return true if @ruby_llm_refreshed
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
27
+ @ruby_llm_refreshed = true
28
+ rescue StandardError => e
29
+ warn "Failed to refresh RubyLLM registry: #{e.message}"
30
+ false
31
+ end
32
+
33
+ def registry_revision
34
+ @registry_revision ||= "ruby_llm #{RubyLLM::VERSION}#{registry_stamp}"
35
+ end
36
+
37
+ private
38
+
39
+ # The registry file's mtime identifies the data revision: a persisted
40
+ # refresh moves it, while the gem's bundled file keeps its release date.
41
+ def registry_stamp
42
+ file = RubyLLM.config.model_registry_file
43
+ return "" unless File.exist?(file)
44
+
45
+ " (registry #{File.mtime(file).utc.strftime("%Y-%m-%dT%H:%M:%SZ")})"
46
+ end
47
+ end
48
+ end