miniswen 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c7d705f7a8ff5f3a1292283f806e0fc99db71e7b7dcdc56a7626e477db928716
4
+ data.tar.gz: d32ace58a0c80874b641fc731fbd22888f43c488933c38b9454e6fd2424c1c33
5
+ SHA512:
6
+ metadata.gz: 8de66a0555e1c54eb1f76284e207efd894a3f90ae7b30cc9d65b9b39732b513de600d5d8f45382d4dffa4a8b8877ec3e8ad1cb0f64df7164b23570f43525ae6c
7
+ data.tar.gz: 3aa903d4f0bb808df5bfdef39d22bf1a1b8a956be3dccc856e066680ab29823d580d23bd6186e5799780ab7e54620ac747fe8c502fb72442960b9007c5d6455c
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Rails Foundation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # Lemans
2
+
3
+ Lemans is a Ruby harness for benchmarking coding agents. You describe tasks — an instruction, a Docker environment, and a test script — and Lemans runs an agent against each one in a disposable cloud sandbox, seals the network, grades whatever the agent left behind, and reports rewards with per-trial cost.
4
+
5
+ Its focus is trustworthy numbers: 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.
6
+
7
+ - **Sealed grading.** Tests stay on the harness side while the agent works. Before grading, the sandbox's network is blocked, the tests are uploaded fresh, and any pre-written reward file is wiped — nothing the agent started can phone out or forge a grade.
8
+ - **Infrastructure failures don't score.** A crashed backend, agent adapter, or verifier becomes an invalid outcome, never a zero reward. A capability score is a statement about the model, not about your infrastructure.
9
+ - **Reproducible by construction.** Every result records the Lemans version, a digest of the frozen run profile (`bench.yml` plus every file it ships), the task's tree digest, and the bench git revision. Two rewards are only comparable if all of them agree.
10
+ - **Any model.** The built-in `miniswen` agent is a Ruby port of [mini-swe-agent](https://github.com/SWE-agent/mini-swe-agent)'s loop and speaks to any provider [ruby_llm](https://github.com/crmne/ruby_llm) supports (OpenRouter, Anthropic, OpenAI, …). Trajectories are written in ATIF v1.7.
11
+ - **Strict cost accounting.** Every trial reports tokens and dollars; a trial whose spend can't be priced is invalid rather than reported as $0.00, and `cost_limit` is enforced by the harness, not by the agent.
12
+ - **Self-validating benches.** The `oracle` agent runs each task's own solution (it must score 1.0, or the task is broken), and the `nop` agent does nothing (it must score 0, or the verifier is broken).
13
+ - **Run controls.** `-k` attempts per task, model sweeps, concurrent trials, and `--resume` for runs that die halfway.
14
+
15
+ ```console
16
+ $ lemans run --task hello-world --attempts 2
17
+ run hello-world attempt 1/2 hello-world__x9Kd21A
18
+ completed hello-world reward=1.0 84.2s
19
+ run hello-world attempt 2/2 hello-world__pQ4mN8z
20
+ completed hello-world reward=0.0 121.7s
21
+
22
+ task agent model reward outcome cost_usd steps duration_sec trial
23
+ hello-world miniswen openrouter/z-ai/glm-5.2 1.0 completed 0.1834 14 84.2 hello-world__x9Kd21A
24
+ hello-world miniswen openrouter/z-ai/glm-5.2 0 completed 0.2411 23 121.7 hello-world__pQ4mN8z
25
+ 2 trials: 2 scored, 0 invalid, 1 solved · $0.4245
26
+ ```
27
+
28
+ ## Getting started
29
+
30
+ ### 1. Install
31
+
32
+ ```bash
33
+ bundle add lemans
34
+ ```
35
+
36
+ Or without Bundler:
37
+
38
+ ```bash
39
+ gem install lemans
40
+ ```
41
+
42
+ Lemans requires Ruby 3.3+.
43
+
44
+ ### 2. Set credentials
45
+
46
+ Trials run in [Daytona](https://www.daytona.io) sandboxes, and the agent needs a model API key. Keys are read from the environment:
47
+
48
+ ```bash
49
+ export DAYTONA_API_KEY=... # or DAYTONA_TOKEN
50
+ export OPENROUTER_API_KEY=... # or ANTHROPIC_API_KEY, OPENAI_API_KEY, ... — matching your model
51
+ ```
52
+
53
+ ### 3. Write a bench
54
+
55
+ A bench is a directory with one `bench.yml` (the frozen run profile, identical for every trial) and one directory per task:
56
+
57
+ ```
58
+ my-bench/
59
+ ├── bench.yml
60
+ └── tasks/
61
+ └── hello-world/
62
+ ├── task.yml # name, description, difficulty, tags, metadata
63
+ ├── instruction.md # what the agent is asked to do
64
+ ├── environment/Dockerfile # the sandbox image (or use a shared image in bench.yml)
65
+ ├── tests/test.sh # grades the result, writes the reward
66
+ └── solution/solve.sh # a known-good solution, for the oracle
67
+ ```
68
+
69
+ A minimal `bench.yml`:
70
+
71
+ ```yaml
72
+ version: 1
73
+
74
+ environment:
75
+ resources: { cpus: 2, memory: 2GB, storage: 5GB }
76
+ build_timeout: 10m
77
+ network:
78
+ mode: allowlist
79
+ hosts: [deb.debian.org, pypi.org, files.pythonhosted.org]
80
+
81
+ agent:
82
+ name: miniswen
83
+ model: openrouter/z-ai/glm-5.2
84
+ timeout: 30m
85
+ step_limit: 100
86
+ cost_limit: 5.0
87
+ environment:
88
+ network:
89
+ mode: allowlist
90
+ hosts: [openrouter.ai]
91
+
92
+ verifier:
93
+ timeout: 10m
94
+ ```
95
+
96
+ The verifier contract: after the agent finishes, `tests/` is uploaded to `/tests` in the now-sealed sandbox and `bash /tests/test.sh` runs from the task's workdir (`/app` by default) with `$WORKDIR`, `$TESTS`, and `$LOGS` set. The script must write a reward between `0.0` and `1.0` to `$LOGS/reward.txt`:
97
+
98
+ ```bash
99
+ #!/bin/bash
100
+ cd "$WORKDIR"
101
+
102
+ if ruby /tests/verify.rb; then
103
+ echo 1 > "$LOGS/reward.txt"
104
+ else
105
+ echo 0 > "$LOGS/reward.txt"
106
+ fi
107
+ ```
108
+
109
+ Everything under `$LOGS` is downloaded and kept alongside the reward, so a grade never outlives its evidence.
110
+
111
+ ### 4. Prove the bench before benchmarking anything
112
+
113
+ ```bash
114
+ lemans run --bench my-bench --agent oracle # every task must score 1.0 — solvable
115
+ lemans run --bench my-bench --agent nop # every task must score 0 — verifier rejects an untouched tree
116
+ ```
117
+
118
+ ### 5. Run the run
119
+
120
+ ```bash
121
+ lemans run --bench my-bench --attempts 5 --concurrency 4
122
+ lemans report # table; --format csv for a spreadsheet
123
+ ```
124
+
125
+ Each trial writes `runs/<task>__<id>/` with `result.json` (reward, outcome, usage, digests), the agent's ATIF trajectory, and the verifier's output and logs. `lemans run --resume` skips trials that already have a scored result for the same agent and model.
126
+
127
+ ## CLI
128
+
129
+ | Command | What it does |
130
+ | --- | --- |
131
+ | `lemans tasks` | List the tasks in a bench |
132
+ | `lemans run` | Run tasks and grade them (`--task`, `--agent`, `--model`, `-k`, `-c`, `--resume`) |
133
+ | `lemans report` | Summarize `runs/` as a table or CSV |
134
+ | `lemans clobber` | Delete run results (`--task`, `--ttl 10m\|2h\|1d`, `-f` to skip the confirmation) |
135
+
136
+ Listing `model` in `bench.yml` as an array turns a run into a sweep: the whole task × attempt grid runs once per model.
137
+
138
+ ## Development
139
+
140
+ After checking out the repo, run `bin/setup` to install dependencies, `rake test` to run the tests, and `bin/console` for an interactive prompt.
141
+
142
+ ## License
143
+
144
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/exe/miniswen ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ lib_path = File.expand_path("../lib", __dir__)
5
+ $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
6
+
7
+ require "miniswen/cli"
8
+
9
+ begin
10
+ Miniswen::CLI.new.run
11
+ rescue => e # rubocop:disable Style/RescueStandardError
12
+ raise e if $DEBUG
13
+
14
+ warn e.message
15
+ warn e.backtrace.take(10).join("\n") if ENV["MINISWEN_DEBUG"] == "1"
16
+ exit 1
17
+ end
@@ -0,0 +1,568 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "miniswen/version"
5
+ require "miniswen/ruby_llm"
6
+
7
+ module Miniswen
8
+ # A Ruby port of mini-swe-agent's loop (mini.yaml at commit a83fcae): ask the
9
+ # model for bash tool calls, run them, repeat until it submits or a limit trips.
10
+ class Agent
11
+ SUBMIT_MARKER = "COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT"
12
+ MAX_OBSERVATION_CHARS = 10_000
13
+ MAX_CONSECUTIVE_FORMAT_ERRORS = 3
14
+
15
+ # Both finish_reason dialects accepted raw: OpenAI-shaped providers say
16
+ # "length"/"tool_calls", Anthropic says "max_tokens"/"tool_use".
17
+ TRUNCATION_FINISH_REASONS = %w[length max_tokens].freeze
18
+ CLAIMED_TOOL_FINISH_REASONS = %w[tool_calls tool_use].freeze
19
+
20
+ EXEC_ENV = {
21
+ "PAGER" => "cat",
22
+ "MANPAGER" => "cat",
23
+ "LESS" => "-R",
24
+ "PIP_PROGRESS_BAR" => "off",
25
+ "TQDM_DISABLE" => "1"
26
+ }.freeze
27
+
28
+ # Providers that serve local inference (and cost zero)
29
+ LOCAL_PROVIDERS = %i[ollama gpustack].freeze
30
+
31
+ SYSTEM_TEMPLATE = <<~PROMPT
32
+ You are a helpful assistant that can interact with a computer.
33
+ PROMPT
34
+
35
+ INSTANCE_TEMPLATE = <<~PROMPT.freeze
36
+ Please solve this issue: %<instruction>s
37
+
38
+ You can execute bash commands and edit files to implement the necessary changes.
39
+
40
+ ## Recommended Workflow
41
+
42
+ This workflow should be done step-by-step so that you can iterate on your changes and any possible problems.
43
+
44
+ 1. Analyze the codebase by finding and reading relevant files
45
+ 2. Create a script to reproduce the issue
46
+ 3. Edit the source code to resolve the issue
47
+ 4. Verify your fix works by running your script again
48
+ 5. Test edge cases to ensure your fix is robust
49
+ 6. Submit your changes and finish your work by issuing the following command: `echo #{SUBMIT_MARKER}`.
50
+ Do not combine it with any other command. <important>After this command, you cannot continue working on this task.</important>
51
+
52
+ ## Command Execution Rules
53
+
54
+ You are operating in an environment where
55
+
56
+ 1. You issue at least one command
57
+ 2. The system executes the command(s) in a subshell
58
+ 3. You see the result(s)
59
+ 4. You write your next command(s)
60
+
61
+ Each response should include:
62
+
63
+ 1. **Reasoning text** where you explain your analysis and plan
64
+ 2. At least one tool call with your command
65
+
66
+ **CRITICAL REQUIREMENTS:**
67
+
68
+ - Your response SHOULD include reasoning text explaining what you're doing
69
+ - Your response MUST include AT LEAST ONE bash tool call
70
+ - Directory or environment variable changes are not persistent. Every action is executed in a new subshell.
71
+ - However, you can prefix any action with `MY_ENV_VAR=MY_VALUE cd /path/to/working/dir && ...` or write/load environment variables from files
72
+ - Submit your changes and finish your work by issuing the following command: `echo #{SUBMIT_MARKER}`.
73
+ Do not combine it with any other command. <important>After this command, you cannot continue working on this task.</important>
74
+
75
+ Example of a CORRECT response:
76
+ <example_response>
77
+ I need to understand the structure of the repository first. Let me check what files are in the current directory to get a better understanding of the codebase.
78
+
79
+ [Makes bash tool call with {"command": "ls -la"} as arguments]
80
+ </example_response>
81
+
82
+ <system_information>
83
+ %<system_information>s
84
+ </system_information>
85
+
86
+ ## Useful command examples
87
+
88
+ ### Create a new file:
89
+
90
+ ```bash
91
+ cat <<'EOF' > newfile.py
92
+ import numpy as np
93
+ hello = "ciao"
94
+ print(hello)
95
+ EOF
96
+ ```
97
+
98
+ ### Edit files with sed:
99
+ %<macos_sed_note>s
100
+ ```bash
101
+ # Replace all occurrences
102
+ sed -i 's/old_string/new_string/g' filename.py
103
+
104
+ # Replace only first occurrence
105
+ sed -i 's/old_string/new_string/' filename.py
106
+
107
+ # Replace first occurrence on line 1
108
+ sed -i '1s/old_string/new_string/' filename.py
109
+
110
+ # Replace all occurrences in lines 1-10
111
+ sed -i '1,10s/old_string/new_string/g' filename.py
112
+ ```
113
+
114
+ ### View file content:
115
+
116
+ ```bash
117
+ # View specific lines with numbers
118
+ nl -ba filename.py | sed -n '10,20p'
119
+ ```
120
+
121
+ ### Any other command you want to run
122
+
123
+ ```bash
124
+ anything
125
+ ```
126
+ PROMPT
127
+
128
+ MACOS_SED_NOTE = <<~NOTE
129
+ <important>
130
+ You are on MacOS. For all the below examples, you need to use `sed -i ''` instead of `sed -i`.
131
+ </important>
132
+ NOTE
133
+
134
+ NO_TOOL_CALLS_ERROR = "No tool calls found in the response. Every response MUST include at least one tool call."
135
+
136
+ TRUNCATION_ERROR_MESSAGE = <<~MESSAGE
137
+ Your previous response reached the output token limit (finish_reason=%<finish_reason>s) before you produced a tool call, so it was cut off. Respond more concisely and finish with exactly one bash tool call. If you need to think more, do so briefly.
138
+ MESSAGE
139
+
140
+ TOOL_CALL_ERROR_MESSAGE = <<~MESSAGE.freeze
141
+ Tool call error:
142
+
143
+ <error>
144
+ %<error>s
145
+ </error>
146
+
147
+ Here is general guidance on how to submit correct toolcalls:
148
+
149
+ Every response needs to use the 'bash' tool at least once to execute commands.
150
+
151
+ Call the bash tool with your command as the argument:
152
+ - Tool: bash
153
+ - Arguments: {"command": "your_command_here"}
154
+
155
+ If you want to end the task, please issue the following command: `echo #{SUBMIT_MARKER}`
156
+ without any other command.
157
+ MESSAGE
158
+
159
+ # Only ever rendered into the request — the completion executes nothing — so no execute body.
160
+ class BashTool < RubyLLM::Tool
161
+ description "Execute a bash command"
162
+ param :command, desc: "The bash command to execute"
163
+
164
+ # ruby_llm would otherwise derive "lemans--miniswen--bash" from the class path.
165
+ def name = "bash"
166
+ end
167
+
168
+ Result = Data.define(:status, :submission, :messages, :steps, :cost_source,
169
+ :input_tokens, :output_tokens, :cached_tokens, :thinking_tokens, :cost_usd) do
170
+ def success? = status == :submitted
171
+
172
+ def to_h = super.merge(cost_source: cost_source&.to_h, version: Miniswen::VERSION)
173
+
174
+ def self.from_h(payload)
175
+ data = deep_symbolize(payload)
176
+ source = data[:cost_source]
177
+ new(
178
+ status: data[:status]&.to_sym,
179
+ submission: data[:submission],
180
+ messages: data[:messages] || [],
181
+ steps: data[:steps],
182
+ cost_source: source && CostSource.new(name: source[:name]&.to_sym, model: source[:model],
183
+ priced_as: source[:priced_as], registry: source[:registry]),
184
+ input_tokens: data[:input_tokens], output_tokens: data[:output_tokens],
185
+ cached_tokens: data[:cached_tokens], thinking_tokens: data[:thinking_tokens],
186
+ cost_usd: data[:cost_usd]
187
+ )
188
+ end
189
+
190
+ def self.deep_symbolize(value)
191
+ case value
192
+ when Hash
193
+ value.to_h do |key, item|
194
+ key = key.to_sym
195
+ # Tool-call arguments keep their provider-style string keys ("command").
196
+ [key, key == :arguments ? item : deep_symbolize(item)]
197
+ end
198
+ when Array then value.map { deep_symbolize(_1) }
199
+ else value
200
+ end
201
+ end
202
+ end
203
+
204
+ CostSource = Data.define(:name, :model, :priced_as, :registry) do
205
+ def to_h = { name: name, model: model, priced_as: priced_as, registry: registry }.compact
206
+ end
207
+
208
+ attr_reader :messages, :environment
209
+
210
+ private attr_reader :max_steps, :max_time, :max_cost, :exec_timeout,
211
+ :clock, :reporter
212
+
213
+ # `model` is a litellm-style name ("openrouter/z-ai/glm-5.2"). Limits of 0 or nil are disabled.
214
+ def initialize(model:, environment:, max_steps: 0, max_time: 0, max_cost: nil,
215
+ exec_timeout: 30, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
216
+ reporter: nil)
217
+ @provider, @id = model.split("/", 2)
218
+ unless @id
219
+ @id = @provider
220
+ @provider = nil
221
+ end
222
+
223
+ @model = model
224
+ @environment = environment
225
+
226
+ @bash_tool = BashTool.new
227
+
228
+ @max_steps = max_steps.to_i
229
+ @max_time = max_time.to_f
230
+ @max_cost = max_cost
231
+ @exec_timeout = exec_timeout
232
+
233
+ @clock = clock
234
+ @reporter = reporter
235
+ end
236
+
237
+ def run(instruction)
238
+ uname = execute("uname -srvm").output.to_s.strip
239
+ @messages = [
240
+ { role: "system", content: SYSTEM_TEMPLATE },
241
+ { role: "user", content: format(INSTANCE_TEMPLATE,
242
+ instruction: instruction,
243
+ system_information: uname,
244
+ macos_sed_note: uname.start_with?("Darwin") ? "\n#{MACOS_SED_NOTE}" : "") }
245
+ ]
246
+
247
+ @steps = 0
248
+ @cost = 0.0
249
+
250
+ @totals = { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 }
251
+
252
+ @cost_known = true
253
+ @consecutive_format_errors = 0
254
+ @started_at = @clock.call
255
+
256
+ loop do
257
+ (status = limit_reached) and return finish(status)
258
+
259
+ actions = next_actions
260
+ if actions.nil?
261
+ return finish(:format_error) if @consecutive_format_errors >= MAX_CONSECUTIVE_FORMAT_ERRORS
262
+
263
+ next
264
+ end
265
+
266
+ actions.each do |action|
267
+ reporter&.on_tool_call(action)
268
+ result = execute(action.fetch(:arguments).fetch("command"))
269
+ # The submit command's output is observed too, so the final tool
270
+ # call has a linked result in the trajectory.
271
+ observe(action, result)
272
+ return finish(:submitted, submission: submission_from(result)) if submitted?(result)
273
+ end
274
+ end
275
+ end
276
+
277
+ # The env a remote miniswen needs to drive this model: the resolved
278
+ # provider's required config options, named the way ruby_llm.rb reads
279
+ # them back from ENV on boot (the option upcased).
280
+ def provider_env
281
+ _, provider = resolved
282
+ provider.configuration_requirements.to_h { [_1.to_s.upcase, RubyLLM.config.public_send(_1)] }.compact
283
+ end
284
+
285
+ private
286
+
287
+ def execute(command)
288
+ environment.exec(command, timeout: exec_timeout, env: EXEC_ENV)
289
+ end
290
+
291
+ # Checked before the model is asked, so the tripping step is never paid for.
292
+ def limit_reached
293
+ return :step_limit if max_steps.positive? && @steps >= max_steps
294
+ return :time_limit if max_time.positive? && (@clock.call - @started_at) >= max_time
295
+ return :cost_limit if max_cost && @cost_known && @cost >= max_cost
296
+
297
+ nil
298
+ end
299
+
300
+ # One model turn. Returns the actions to run, or nil after appending a
301
+ # format-error message the model gets to react to on its next turn.
302
+ def next_actions
303
+ response = complete(@messages)
304
+ @steps += 1
305
+ @totals.each_key { @totals[_1] += response[_1].to_i }
306
+ track_cost(response)
307
+
308
+ entry = { role: "assistant", content: response[:content].to_s, metrics: metrics_from(response) }
309
+ # Thinking rides along for the trajectory only; it is never sent back to the model.
310
+ entry[:thinking] = response[:thinking] if response[:thinking]
311
+
312
+ observe_message entry
313
+
314
+ tool_calls = Array(response[:tool_calls])
315
+ if (error = actions_error(tool_calls))
316
+ # The bad calls are kept off the entry the llm replays: an assistant
317
+ # message with unanswered tool calls is a request providers reject.
318
+ entry[:invalid_tool_calls] = tool_calls if tool_calls.any?
319
+ @consecutive_format_errors += 1
320
+
321
+ observe_message({ role: "user", content: format_error_message(error, response, tool_calls) })
322
+
323
+ return nil
324
+ end
325
+
326
+ @consecutive_format_errors = 0
327
+ entry[:tool_calls] = tool_calls
328
+ tool_calls
329
+ end
330
+
331
+ # An unpriced completion under a cost ceiling is fatal on the spot: only failing fast stops
332
+ # the spend. Without a ceiling, unknown cost is just a fact to report.
333
+ def track_cost(response)
334
+ cost = response[:cost_usd]
335
+ if cost.nil?
336
+ if @max_cost
337
+ raise AccountingError,
338
+ "#{@model} returned an unpriced completion; cost_limit cannot be enforced"
339
+ end
340
+
341
+ @cost_known = false
342
+ else
343
+ @cost += cost
344
+ end
345
+ end
346
+
347
+ def actions_error(tool_calls)
348
+ return NO_TOOL_CALLS_ERROR if tool_calls.empty?
349
+
350
+ tool_calls.each do |call|
351
+ error = +""
352
+ error << "Unknown tool '#{call[:name]}'." if call[:name] != "bash"
353
+ arguments = call[:arguments]
354
+ error << "Missing 'command' argument in bash tool call." unless arguments.is_a?(Hash) && arguments["command"]
355
+ return error unless error.empty?
356
+ end
357
+ nil
358
+ end
359
+
360
+ def format_error_message(error, response, tool_calls)
361
+ finish_reason = response[:finish_reason].to_s
362
+ if TRUNCATION_FINISH_REASONS.include?(finish_reason) ||
363
+ (CLAIMED_TOOL_FINISH_REASONS.include?(finish_reason) && tool_calls.empty?)
364
+ format(TRUNCATION_ERROR_MESSAGE, finish_reason: finish_reason)
365
+ else
366
+ format(TOOL_CALL_ERROR_MESSAGE, error: error)
367
+ end
368
+ end
369
+
370
+ def metrics_from(response)
371
+ {
372
+ prompt_tokens: response[:input_tokens].to_i,
373
+ completion_tokens: response[:output_tokens].to_i,
374
+ cached_tokens: response[:cached_tokens].to_i,
375
+ thinking_tokens: response[:thinking_tokens].to_i,
376
+ cost_usd: response[:cost_usd]
377
+ }
378
+ end
379
+
380
+ def submitted?(result)
381
+ result.exit_code.zero? && result.output.to_s.lstrip.lines.first&.strip == SUBMIT_MARKER
382
+ end
383
+
384
+ def submission_from(result)
385
+ result.output.to_s.lstrip.lines.drop(1).join
386
+ end
387
+
388
+ def observe(action, result)
389
+ output = result.output.to_s
390
+ observe_message({
391
+ role: "tool",
392
+ tool_call_id: action[:id],
393
+ content: observation_content(result.exit_code, output),
394
+ observation: { exit_code: result.exit_code, output: truncate(output) }
395
+ })
396
+ end
397
+
398
+ def observe_message(msg)
399
+ @messages << msg
400
+ reporter&.on_message(msg)
401
+ end
402
+
403
+ def observation_content(exit_code, output)
404
+ if output.length < MAX_OBSERVATION_CHARS
405
+ <<~OBSERVATION.strip
406
+ {
407
+ "returncode": #{exit_code},
408
+ "output": #{output.to_json}
409
+ }
410
+ OBSERVATION
411
+ else
412
+ half = MAX_OBSERVATION_CHARS / 2
413
+ <<~OBSERVATION.strip
414
+ {
415
+ "returncode": #{exit_code},
416
+ "output_head": #{output[0, half].to_json},
417
+ "output_tail": #{output[-half, half].to_json},
418
+ "elided_chars": #{output.length - MAX_OBSERVATION_CHARS},
419
+ "warning": "Output too long."
420
+ }
421
+ OBSERVATION
422
+ end
423
+ end
424
+
425
+ def truncate(output)
426
+ return output if output.length <= MAX_OBSERVATION_CHARS
427
+
428
+ half = MAX_OBSERVATION_CHARS / 2
429
+ "#{output[0, half]}\n...[#{output.length - MAX_OBSERVATION_CHARS} characters omitted]...\n#{output[-half, half]}"
430
+ end
431
+
432
+ def finish(status, submission: nil)
433
+ Result.new(
434
+ status: status, submission: submission, messages: @messages, steps: @steps,
435
+ cost_source: cost_source, cost_usd: @cost_known ? @cost : nil, **@totals
436
+ )
437
+ end
438
+
439
+ # Provider#complete, not Chat: Chat runs its own loop, and the loop lives above.
440
+ def complete(messages)
441
+ model_info, provider = resolved
442
+ response = provider.complete(
443
+ messages.map { as_ruby_llm(_1) },
444
+ tools: { bash: @bash_tool },
445
+ temperature: nil,
446
+ model: model_info
447
+ )
448
+ payload(response)
449
+ rescue RubyLLM::Error => e
450
+ raise InfrastructureError, "miniswen: the model call failed: #{e.message}"
451
+ end
452
+
453
+ def cost_source
454
+ if local?
455
+ return CostSource.new(name: :local_provider, model: @model,
456
+ priced_as: "#{@provider || info&.provider}/#{@id} ($0.00, local)",
457
+ registry: nil)
458
+ end
459
+ return nil unless info
460
+
461
+ CostSource.new(name: :model_registry, model: @model,
462
+ priced_as: "#{info.provider}/#{info.id}",
463
+ registry: Miniswen.registry_revision)
464
+ end
465
+
466
+ def resolved
467
+ @resolved ||= RubyLLM::Models.resolve(@id, provider: @provider, assume_exists: !@provider.nil?)
468
+ end
469
+
470
+ def as_ruby_llm(entry)
471
+ case entry[:role]
472
+ when "assistant"
473
+ RubyLLM::Message.new(role: :assistant, content: entry[:content],
474
+ tool_calls: as_ruby_llm_tool_calls(entry[:tool_calls]))
475
+ when "tool"
476
+ RubyLLM::Message.new(role: :tool, content: entry[:content], tool_call_id: entry[:tool_call_id])
477
+ else
478
+ RubyLLM::Message.new(role: entry[:role].to_sym, content: entry[:content])
479
+ end
480
+ end
481
+
482
+ def as_ruby_llm_tool_calls(tool_calls)
483
+ return nil if tool_calls.nil? || tool_calls.empty?
484
+
485
+ tool_calls.to_h do |call|
486
+ [call[:id], RubyLLM::ToolCall.new(id: call[:id], name: call[:name], arguments: call[:arguments])]
487
+ end
488
+ end
489
+
490
+ def payload(response)
491
+ # Providers under load occasionally answer with no completion at all.
492
+ raise InfrastructureError, "miniswen: #{@model} returned an empty completion" if response.nil?
493
+
494
+ tokens = response.tokens
495
+ {
496
+ content: response.content.to_s,
497
+ thinking: response.thinking&.text,
498
+ tool_calls: tool_calls_from(response),
499
+ finish_reason: finish_reason_from(response),
500
+ # ruby_llm's input_tokens is the cache-miss remainder only; the
501
+ # convention counts the whole prompt, cached and cache-write included.
502
+ input_tokens: response.input_tokens.to_i + tokens&.cached.to_i + tokens&.cache_creation.to_i,
503
+ output_tokens: response.output_tokens.to_i,
504
+ cached_tokens: tokens&.cached.to_i,
505
+ thinking_tokens: tokens&.thinking.to_i,
506
+ cost_usd: price(response)
507
+ }
508
+ end
509
+
510
+ def tool_calls_from(response)
511
+ Array(response.tool_calls&.values).map do |call|
512
+ { id: call.id, name: call.name, arguments: normalize_arguments(call.arguments) }
513
+ end
514
+ end
515
+
516
+ # Providers hand arguments back parsed; a provider that didn't gets one
517
+ # parse attempt, anything else bounces as a format error.
518
+ def normalize_arguments(arguments)
519
+ arguments = JSON.parse(arguments) if arguments.is_a?(String)
520
+ arguments.is_a?(Hash) ? arguments.transform_keys(&:to_s) : arguments
521
+ rescue JSON::ParserError
522
+ arguments
523
+ end
524
+
525
+ def finish_reason_from(response)
526
+ body = response.raw&.body
527
+ body = JSON.parse(body) if body.is_a?(String)
528
+ return nil unless body.is_a?(Hash)
529
+
530
+ body.dig("choices", 0, "finish_reason") || body["stop_reason"]
531
+ rescue JSON::ParserError
532
+ nil
533
+ end
534
+
535
+ def local? = LOCAL_PROVIDERS.include?((@provider || info&.provider)&.to_sym)
536
+
537
+ def info
538
+ return @info if defined?(@info)
539
+
540
+ @info = find_model
541
+ end
542
+
543
+ def find_model
544
+ @provider ? RubyLLM.models.find(@id, @provider) : RubyLLM.models.find(@id)
545
+ rescue RubyLLM::ModelNotFoundError
546
+ nil
547
+ end
548
+
549
+ def price(response)
550
+ return 0.0 if local?
551
+
552
+ input = info&.input_price_per_million
553
+ output = info&.output_price_per_million
554
+ return nil unless input.is_a?(Numeric) && output.is_a?(Numeric)
555
+
556
+ tokens = response.tokens
557
+ cache_read = info.cache_read_input_price_per_million || input
558
+ cache_write = info.cache_write_input_price_per_million || input
559
+ # Providers usually fold thinking into output_tokens; max() bills the
560
+ # larger count once and can never double-bill.
561
+ generated = [response.output_tokens.to_i, tokens&.thinking.to_i].max
562
+ ((response.input_tokens.to_i * input) +
563
+ (tokens&.cached.to_i * cache_read) +
564
+ (tokens&.cache_creation.to_i * cache_write) +
565
+ (generated * output)) / 1_000_000.0
566
+ end
567
+ end
568
+ end
@@ -0,0 +1,200 @@
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
+ @refresh_registry = false
96
+ @skip_registry_refresh = false
97
+ end
98
+
99
+ def run
100
+ parse_args!
101
+
102
+ # Require the core library after parsing options,
103
+ # so env flags kick in
104
+ require "miniswen"
105
+
106
+ refresh_registry_and_exit if @refresh_registry
107
+
108
+ Miniswen.refresh_registry! unless @skip_registry_refresh
109
+
110
+ require "miniswen/local"
111
+
112
+ reporter = @quiet ? nil : Reporter.new(verbose: @verbose)
113
+ agent = Agent.new(model:, reporter:, environment: Local.new, **options)
114
+
115
+ result = agent.run(instruction)
116
+
117
+ File.write(@results_path, JSON.generate(result.to_h)) if @results_path
118
+
119
+ if result.success?
120
+ reporter&.print_summary(result)
121
+ else
122
+ reporter&.print_failure(result)
123
+ Kernel.exit(1)
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ def refresh_registry_and_exit
130
+ refreshed = Miniswen.refresh_registry!(persist: true)
131
+ $stdout.puts Miniswen.registry_revision if refreshed
132
+ Kernel.exit(refreshed ? 0 : 1)
133
+ end
134
+
135
+ def parse_args!
136
+ parser = OptionParser.new do |opts|
137
+ opts.banner = "Usage: miniswen -m MODEL -p INSTRUCTION [...options]"
138
+
139
+ opts.on("-m MODEL", "--model=MODEL", String,
140
+ "LLM to use (litellm format, e.g.: openrouter/openai/gpt-5.6-luna") do |v|
141
+ @model = v
142
+ end
143
+
144
+ opts.on("-p INSTRUCTION", "--prompt=INSTRUCTION", String, "Instruction prompt") do |v|
145
+ @instruction = v
146
+ end
147
+
148
+ opts.on("--max-steps=STEPS", Integer, "Max steps count") do |v|
149
+ options[:max_steps] = v
150
+ end
151
+
152
+ opts.on("--max-cost=COST", Integer, "Max cost") do |v|
153
+ options[:max_cost] = v
154
+ end
155
+
156
+ opts.on("--max-time=TIME", Integer, "Max inference duration (seconds)") do |v|
157
+ options[:max_time] = v
158
+ end
159
+
160
+ opts.on("--exec-timeout=TIMEOUT", Integer, "Tool execution timeout (seconds)") do |v|
161
+ options[:exec_timeout] = v
162
+ end
163
+
164
+ opts.on("-q", "--quiet", "Disable progress output") do
165
+ @quiet = true
166
+ end
167
+
168
+ opts.on("--results-path=PATH", String, "Write the run result as JSON to PATH") do |v|
169
+ @results_path = v
170
+ end
171
+
172
+ opts.on("--refresh-registry", "Refresh the model registry, persist it, and exit") do
173
+ @refresh_registry = true
174
+ end
175
+
176
+ opts.on("--no-refresh-registry", "Skip the model registry refresh on startup") do
177
+ @skip_registry_refresh = true
178
+ end
179
+
180
+ opts.on("-v", "--version", "Print version") do
181
+ $stdout.puts Miniswen::VERSION
182
+ exit 0
183
+ end
184
+
185
+ opts.on("-vv", "Print verbose logs") do
186
+ @verbose = true
187
+ ENV["MINISWEN_DEBUG"] = "1"
188
+ ENV["RUBYLLM_LOG_LEVEL"] = "debug"
189
+ end
190
+ end
191
+
192
+ parser.parse!
193
+
194
+ return if @refresh_registry
195
+
196
+ raise "Use -m to specify the model" unless @model
197
+ raise "Please, provide instructions via -p option" unless @instruction
198
+ end
199
+ end
200
+ 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,20 @@
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Miniswen
4
+ VERSION = "0.0.1"
5
+ end
data/lib/miniswen.rb ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "miniswen/version"
4
+ require "miniswen/agent"
5
+
6
+ module Miniswen # :nodoc:
7
+ class Error < StandardError; end
8
+
9
+ # A provider or environment failing in a way that is the harness's fault
10
+ # rather than the model's.
11
+ class InfrastructureError < Error; end
12
+
13
+ # Incomplete accounting: missing usage or cost data is invalid rather than
14
+ # silently under-reported.
15
+ class AccountingError < Error; end
16
+
17
+ class << self
18
+ def refresh_registry!(persist: false)
19
+ return true if @ruby_llm_refreshed
20
+
21
+ RubyLLM.models.refresh!
22
+ # save_to_json writes to the registry file every boot loads from (the
23
+ # gem's own models.json by default), so a persisted refresh outlives
24
+ # this process — later runs in the same environment boot from it.
25
+ RubyLLM.models.save_to_json if persist
26
+ @ruby_llm_refreshed = true
27
+ rescue StandardError => e
28
+ warn "Failed to refresh RubyLLM registry: #{e.message}"
29
+ false
30
+ end
31
+
32
+ def registry_revision
33
+ @registry_revision ||= "ruby_llm #{RubyLLM::VERSION}#{registry_stamp}"
34
+ end
35
+
36
+ private
37
+
38
+ # The registry file's mtime identifies the data revision: a persisted
39
+ # refresh moves it, while the gem's bundled file keeps its release date.
40
+ def registry_stamp
41
+ file = RubyLLM.config.model_registry_file
42
+ return "" unless File.exist?(file)
43
+
44
+ " (registry #{File.mtime(file).utc.strftime("%Y-%m-%dT%H:%M:%SZ")})"
45
+ end
46
+ end
47
+ end
metadata ADDED
@@ -0,0 +1,89 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: miniswen
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Svyatoslav Kryukov
8
+ - Artur Petrov
9
+ - Vladimir Dementyev
10
+ bindir: exe
11
+ cert_chain: []
12
+ date: 1980-01-02 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: logger
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: ruby_llm
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: '1.16'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: '1.16'
42
+ description: A Ruby port of mini-swe-agent.
43
+ email:
44
+ - me@skryukov.dev
45
+ - ardecvz@gmail.com
46
+ - dementiev.vm@gmail.com
47
+ executables:
48
+ - miniswen
49
+ extensions: []
50
+ extra_rdoc_files: []
51
+ files:
52
+ - LICENSE.txt
53
+ - README.md
54
+ - exe/miniswen
55
+ - lib/miniswen.rb
56
+ - lib/miniswen/agent.rb
57
+ - lib/miniswen/cli.rb
58
+ - lib/miniswen/environment.rb
59
+ - lib/miniswen/local.rb
60
+ - lib/miniswen/ruby_llm.rb
61
+ - lib/miniswen/version.rb
62
+ homepage: https://github.com/rails/lemans
63
+ licenses:
64
+ - MIT
65
+ metadata:
66
+ bug_tracker_uri: https://github.com/rails/lemans/issues
67
+ changelog_uri: https://github.com/rails/lemans/blob/main/CHANGELOG.md
68
+ documentation_uri: https://github.com/rails/lemans/blob/main/README.md
69
+ homepage_uri: https://github.com/rails/lemans
70
+ source_code_uri: https://github.com/rails/lemans
71
+ rubygems_mfa_required: 'true'
72
+ rdoc_options: []
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: 3.3.0
80
+ required_rubygems_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ requirements: []
86
+ rubygems_version: 3.6.9
87
+ specification_version: 4
88
+ summary: A Ruby port of mini-swe-agent
89
+ test_files: []