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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +144 -0
- data/exe/miniswen +17 -0
- data/lib/miniswen/agent.rb +568 -0
- data/lib/miniswen/cli.rb +200 -0
- data/lib/miniswen/environment.rb +14 -0
- data/lib/miniswen/local.rb +42 -0
- data/lib/miniswen/ruby_llm.rb +20 -0
- data/lib/miniswen/version.rb +5 -0
- data/lib/miniswen.rb +47 -0
- metadata +89 -0
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
|
data/lib/miniswen/cli.rb
ADDED
|
@@ -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
|
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: []
|