miniswen 1.4.0 → 1.5.2
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 +4 -4
- data/README.md +102 -2
- data/lib/miniswen/agent.rb +72 -25
- data/lib/miniswen/cli.rb +8 -3
- data/lib/miniswen/environment/docker.rb +1 -1
- data/lib/miniswen/jail.rb +1 -1
- data/lib/miniswen/local.rb +1 -1
- data/lib/miniswen/ruby_llm.rb +13 -0
- data/lib/miniswen/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b72b4a7a4e82cc48a0a50e7a27c338f34685ee9e6fefdfc555213b8c5d8b2475
|
|
4
|
+
data.tar.gz: 3d309e32ffc77990653f49768ee1dd1e6c581b56a93eddebd61f37d8ac9739dc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2fd6ae2583048739c94ee8dc70e92c214f4163cb7a86e2201e450d14549abc3cb5d92aba5f1f3c6c1a934968f1b738f3d8561392a2523412393cf9f0c33e04d9
|
|
7
|
+
data.tar.gz: 89ae33237c77e4025e9fe4bf5a5c5036b3098f32f9e32f91a4bd2d0925cd61cc366bd58a3de3b182b0fa4f31f6e0027a674f5b1d78a072cd2905bc0d1d3d52f5
|
data/README.md
CHANGED
|
@@ -6,6 +6,7 @@ lemans is a harness for benchmarking coding agents, the Ruby way:
|
|
|
6
6
|
- **Conventional**, aka _boilerplate-free_: an instruction, a Docker environment, and a test script — that's all you need to describe an eval
|
|
7
7
|
- **Trustworthy**: 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
|
|
8
8
|
- Powered by **miniswen**, a Ruby version of mini-swe-agent (powered by [RubyLLM](https://rubyllm.com/), so it works with any LLM) and [Daytona](https://www.daytona.io) sandboxes.
|
|
9
|
+
- **Extensible**: load a custom agent or a sandboxing backend on-the-fly defined in a Ruby file or provided by a gem.
|
|
9
10
|
|
|
10
11
|
> [!TIP]
|
|
11
12
|
> Check [Rails AI Evals](https://github.com/rails/ai-evals) for a full-featured example.
|
|
@@ -15,6 +16,7 @@ lemans is a harness for benchmarking coding agents, the Ruby way:
|
|
|
15
16
|
- Ruby 3.4+ is required to run `lemans`
|
|
16
17
|
- Daytona account (API token) or Docker (for local sandboxes)
|
|
17
18
|
- Some LLM provider/proxy credentials (e.g., OpenRouter)
|
|
19
|
+
- Bash in the sandbox image for command execution
|
|
18
20
|
- iproute2 in the sandbox image, so a jailed agent keeps loopback while cut off the internet
|
|
19
21
|
|
|
20
22
|
## Getting started
|
|
@@ -102,6 +104,8 @@ verifier:
|
|
|
102
104
|
# restore: [test, bin] # [optional] paths restored from the pre-agent snapshot, so grading can't be tampered with
|
|
103
105
|
```
|
|
104
106
|
|
|
107
|
+
On the Docker backend, `allowlist` sends traffic through a proxy container. Only tools that use `http_proxy`/`https_proxy` can reach the listed hosts. Host names must match exactly, and IP ranges are not supported. See [the proxy README](lib/lemans/environments/docker/proxy/README.md).
|
|
108
|
+
|
|
105
109
|
A minimal task example—checking whether an agent can write "Hello, world" into a file:
|
|
106
110
|
|
|
107
111
|
- `instruction.md`: the task itself (note the preamble about the training corpora and the frontmatter)
|
|
@@ -231,6 +235,17 @@ Each trial writes a flat `runs/<model>/<task>__<id>/` directory:
|
|
|
231
235
|
|
|
232
236
|
Every `result.json` is stamped with the `lemans_version` that wrote it, and newer lemans keeps reading runs produced by older releases. `lemans run --resume` skips trials that already have a scored result for the same agent and model.
|
|
233
237
|
|
|
238
|
+
`lemans restart <run>` continues a multistep run that failed for reasons outside the model, such as a provider outage. Give it the run directories or the trial ids: `lemans restart RUN [RUN...]` restarts every run under the same options, `-c` at once, and runs none if any is refused. The new run replays the agent patches of the settled steps in a fresh sandbox and starts at the next step. It copies the settled steps' usage, phases, and artifacts, and records the source in `restarted_from`. The failed run stays as it is. It restarts even if the task or the bench changed since; the new run records the current digests.
|
|
239
|
+
|
|
240
|
+
Two modes restart inside a step:
|
|
241
|
+
|
|
242
|
+
- `--recover` continues the failed step's agent session. The new run also replays the step's partial patch, and the agent goes on from the saved `agent.result.json`. The session keeps the steps, tokens, cost, and time it already spent. Only `miniswen` and `miniswen-installed` can recover.
|
|
243
|
+
- `--reverify` runs the last verification again with the current tests. Use it after you change the grading or the harness. The new run replays the graded step's patch and does not run its agent. If the verification was an intermediate gate and it now passes, the trial goes on to the next step. A reverification accepts scored runs.
|
|
244
|
+
|
|
245
|
+
A scored run restarts in the other modes only with `--allow-scored`.
|
|
246
|
+
|
|
247
|
+
Only the files that git tracks come back: ignored files (databases, logs, installed dependencies) are not in the patches.
|
|
248
|
+
|
|
234
249
|
You can also run `lemans report` with various flags to see aggregated results, e.g.:
|
|
235
250
|
|
|
236
251
|
```sh
|
|
@@ -249,6 +264,83 @@ gpt-5.6-luna ar-archive-book-access 2/2 2m 23s $0.0132 12.5 156905
|
|
|
249
264
|
6 trials: 6 scored, 0 invalid, 6 solved (100%) · $0.0801 · pass@2 3/3 tasks (100%)
|
|
250
265
|
```
|
|
251
266
|
|
|
267
|
+
### 6. Extensions
|
|
268
|
+
|
|
269
|
+
You can bring **custom agents and backends** to lemans. For that, you should:
|
|
270
|
+
|
|
271
|
+
- Write an agent or a backend implementation in a Ruby file/gem
|
|
272
|
+
- Load it using the `--require` / `-r` option specified **before the command**:
|
|
273
|
+
|
|
274
|
+
```sh
|
|
275
|
+
lemans -r ./my-agent.rb run --agent my-agent ...
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
- **IMPORTANT:** The extension must register itself in the corresponding registry and follow the leman's interface:
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
# my-agent.rb
|
|
282
|
+
class MyAgent < Lemans::Agent
|
|
283
|
+
NAME = "my-agent"
|
|
284
|
+
|
|
285
|
+
def install(_task, environment)
|
|
286
|
+
environment.upload("/path/to/my-agent", "/usr/local/bin/my-agent")
|
|
287
|
+
environment.exec!("chmod +x /usr/local/bin/my-agent")
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def run(task, environment)
|
|
291
|
+
finished = environment.exec("cd #{task.environment.workdir} && my-agent --model #{model}", timeout:)
|
|
292
|
+
outcome = finished.exit_code == 124 ? :agent_timeout : :completed
|
|
293
|
+
|
|
294
|
+
# The returned object is a Lemans::Agent::Response.
|
|
295
|
+
# IMPORTANT: `usage` is required for correct work
|
|
296
|
+
Response.new(outcome: Lemans::Result::Outcome.new(outcome), usage: Lemans::Result::Usage.zero)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
Lemans::Agents.register(MyAgent::NAME, MyAgent)
|
|
301
|
+
|
|
302
|
+
# my-backend.rb
|
|
303
|
+
class MyBackend < Lemans::Environment
|
|
304
|
+
NAME = "my-backend"
|
|
305
|
+
|
|
306
|
+
# Called with image:, resources:, network:, labels:, build_timeout:, and ttl: (see Lemans::Environment)
|
|
307
|
+
def start
|
|
308
|
+
@sandbox = MyCloud.create(image: image.name, cpus: resources.cpus, memory: resources.memory)
|
|
309
|
+
switch_network_policy!(network)
|
|
310
|
+
self
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def exec(command, timeout: nil, env: {})
|
|
314
|
+
run = @sandbox.run(command, timeout: timeout || DEFAULT_TIMEOUT, env:)
|
|
315
|
+
ExecResult.new(command:, exit_code: run.exit_code, output: run.output, duration: run.duration)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def upload(local_path, remote_path) = @sandbox.put(local_path, remote_path)
|
|
319
|
+
|
|
320
|
+
def download(remote_path, local_path) = @sandbox.get(remote_path, local_path)
|
|
321
|
+
|
|
322
|
+
# Phases narrow the network: setup, then the agent's policy, then none for the verifier.
|
|
323
|
+
# IMPORTANT: raise if the policy cannot be enforced
|
|
324
|
+
def switch_network_policy!(policy)
|
|
325
|
+
@sandbox.set_network(policy.mode, hosts: policy.hosts)
|
|
326
|
+
@network = policy
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def stop = @sandbox&.destroy
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
Lemans::Environments.register(MyBackend::NAME, MyBackend)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
```sh
|
|
336
|
+
lemans -r ./my-backend.rb run --backend my-backend ...
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
See [examples](./examples) for real-world extensions:
|
|
340
|
+
|
|
341
|
+
- [Apple Container backend](./examples/apple-container.rb)
|
|
342
|
+
- [Claude Code agent](./examples/claude-code.rb)
|
|
343
|
+
|
|
252
344
|
## CLI
|
|
253
345
|
|
|
254
346
|
| Command | What it does |
|
|
@@ -256,8 +348,9 @@ gpt-5.6-luna ar-archive-book-access 2/2 2m 23s $0.0132 12.5 156905
|
|
|
256
348
|
| `lemans init` | Scaffold a new bench directory: an annotated `bench.yml` and two example tasks |
|
|
257
349
|
| `lemans tasks` | List the tasks in a bench (`--tag` to filter) |
|
|
258
350
|
| `lemans run` | Run tasks and grade them (`--task`, `--tag`, `--agent`, `--model`, `--max-output-tokens`, `-k`, `-c`, `--resume`) |
|
|
259
|
-
| `lemans
|
|
260
|
-
| `lemans
|
|
351
|
+
| `lemans restart <run>...` | Continue failed multistep runs from their last settled step in new runs (`-c`, `--recover` to continue the failed step's session, `--reverify` to grade again, `--allow-scored`, `--backend`, `--max-output-tokens`) |
|
|
352
|
+
| `lemans report [RUNS_DIR]` | Summarize `runs/` (or `RUNS_DIR`) as a table or CSV (`--task`, `--tag`, `--model`, `--metadata key:value` to filter, `--skip-invalid` to leave out invalid trials, `-A [task-agent-model]` to aggregate, `-S <columns>` to sort, e.g. `-S score-credit`; numbers high to low, names A-Z, `^column` reverses that column; `--hide-columns steps-tokens` for a narrower table); repeated attempts add pass@k per model × task, fractional grading a `credit` column, multistep tasks a `progress` column (steps completed / task steps) |
|
|
353
|
+
| `lemans clobber [RUNS_DIR]` | Delete run results under `runs/` (or `RUNS_DIR`) (`--task`, `--ttl 10m\|2h\|1d`, `--invalid`, `-f` to skip the confirmation) |
|
|
261
354
|
|
|
262
355
|
## miniswen
|
|
263
356
|
|
|
@@ -273,6 +366,13 @@ $ miniswen --model 'openrouter/openai/gpt-5.6-luna' --prompt 'Write hello-world
|
|
|
273
366
|
|
|
274
367
|
Currently, it's a one-shot agent that doesn't ask any questions. It's mostly useful for playing with eval ideas before encoding them as lemans tasks.
|
|
275
368
|
|
|
369
|
+
## More tools: lemans-remote and lemans-viewer
|
|
370
|
+
|
|
371
|
+
The gem also ships two convenient (and 100% AI-maintained) CLIs:
|
|
372
|
+
|
|
373
|
+
- `lemans-remote`: scale `lemans run`-s horizontally using Daytona sandboxes; schedule tasks and pull results later
|
|
374
|
+
- `lemans-viewer`: a web UI for browsing trial results (if you still need a pair of human eyes to look at them)
|
|
375
|
+
|
|
276
376
|
## Development
|
|
277
377
|
|
|
278
378
|
After checking out the repo, run `bin/setup` to install dependencies, `rake test` to run the tests, and `bin/console` for an interactive prompt.
|
data/lib/miniswen/agent.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
4
6
|
require "miniswen/version"
|
|
5
7
|
require "miniswen/ruby_llm"
|
|
6
8
|
|
|
@@ -187,7 +189,9 @@ module Miniswen
|
|
|
187
189
|
|
|
188
190
|
Result = Data.define(:status, :submission, :messages, :steps, :cost_source,
|
|
189
191
|
:input_tokens, :output_tokens, :cached_tokens, :thinking_tokens, :cost_usd,
|
|
190
|
-
:error) do
|
|
192
|
+
:error, :cache_id) do
|
|
193
|
+
def initialize(cache_id: nil, **attributes) = super(cache_id:, **attributes)
|
|
194
|
+
|
|
191
195
|
def success? = status == :submitted
|
|
192
196
|
|
|
193
197
|
def to_h = super.merge(cost_source: cost_source&.to_h, version: Miniswen::VERSION)
|
|
@@ -205,7 +209,7 @@ module Miniswen
|
|
|
205
209
|
input_tokens: data[:input_tokens], output_tokens: data[:output_tokens],
|
|
206
210
|
cached_tokens: data[:cached_tokens], thinking_tokens: data[:thinking_tokens],
|
|
207
211
|
cost_usd: data[:cost_usd],
|
|
208
|
-
error: data[:error]
|
|
212
|
+
error: data[:error], cache_id: data[:cache_id]
|
|
209
213
|
)
|
|
210
214
|
end
|
|
211
215
|
|
|
@@ -230,7 +234,7 @@ module Miniswen
|
|
|
230
234
|
attr_reader :messages, :environment
|
|
231
235
|
|
|
232
236
|
private attr_reader :max_steps, :max_time, :max_cost, :exec_timeout, :max_output_tokens,
|
|
233
|
-
:clock, :reporter
|
|
237
|
+
:cache_id, :clock, :reporter
|
|
234
238
|
|
|
235
239
|
# `model` is a litellm-style name ("openrouter/z-ai/glm-5.2"), optionally
|
|
236
240
|
# suffixed with a reasoning effort ("openrouter/openai/gpt-5.6-luna#xhigh").
|
|
@@ -261,25 +265,17 @@ module Miniswen
|
|
|
261
265
|
@reporter = reporter
|
|
262
266
|
end
|
|
263
267
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
]
|
|
273
|
-
|
|
274
|
-
@steps = 0
|
|
275
|
-
@cost = 0.0
|
|
276
|
-
|
|
277
|
-
@totals = { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 }
|
|
268
|
+
# With a `history` (the Result of an interrupted run), the session goes on
|
|
269
|
+
# where it stopped, under the budget the earlier part already spent.
|
|
270
|
+
def run(instruction = nil, history: nil)
|
|
271
|
+
if history
|
|
272
|
+
resume(history)
|
|
273
|
+
else
|
|
274
|
+
start(instruction)
|
|
275
|
+
end
|
|
278
276
|
|
|
279
|
-
@cost_known = true
|
|
280
277
|
@consecutive_format_errors = 0
|
|
281
278
|
@refused_turns = 0
|
|
282
|
-
@started_at = @clock.call
|
|
283
279
|
|
|
284
280
|
loop do
|
|
285
281
|
(status = limit_reached) and return finish(status)
|
|
@@ -310,7 +306,7 @@ module Miniswen
|
|
|
310
306
|
Result.new(
|
|
311
307
|
status: :error, submission: nil, messages: @messages || [], steps: @steps.to_i,
|
|
312
308
|
cost_source: cost_source, cost_usd: @cost_known == false ? nil : @cost.to_f,
|
|
313
|
-
error: error,
|
|
309
|
+
error: error, cache_id:,
|
|
314
310
|
**(@totals || { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 })
|
|
315
311
|
)
|
|
316
312
|
end
|
|
@@ -329,6 +325,51 @@ module Miniswen
|
|
|
329
325
|
|
|
330
326
|
private
|
|
331
327
|
|
|
328
|
+
def start(instruction)
|
|
329
|
+
@cache_id = SecureRandom.uuid
|
|
330
|
+
uname = execute("uname -srvm").output.to_s.strip
|
|
331
|
+
@messages = [
|
|
332
|
+
{ role: "system", content: SYSTEM_TEMPLATE },
|
|
333
|
+
{ role: "user", content: format(INSTANCE_TEMPLATE,
|
|
334
|
+
instruction: instruction,
|
|
335
|
+
system_information: uname,
|
|
336
|
+
macos_sed_note: uname.start_with?("Darwin") ? "\n#{MACOS_SED_NOTE}" : "") }
|
|
337
|
+
]
|
|
338
|
+
|
|
339
|
+
@steps = 0
|
|
340
|
+
@cost = 0.0
|
|
341
|
+
@cost_known = true
|
|
342
|
+
@totals = { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 }
|
|
343
|
+
@started_at = @clock.call
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def resume(history)
|
|
347
|
+
@cache_id = history.cache_id || SecureRandom.uuid
|
|
348
|
+
@messages = answered_messages(history.messages)
|
|
349
|
+
|
|
350
|
+
@steps = history.steps.to_i
|
|
351
|
+
@cost = history.cost_usd.to_f
|
|
352
|
+
@cost_known = !history.cost_usd.nil?
|
|
353
|
+
@totals = { input_tokens: history.input_tokens.to_i, output_tokens: history.output_tokens.to_i,
|
|
354
|
+
cached_tokens: history.cached_tokens.to_i, thinking_tokens: history.thinking_tokens.to_i }
|
|
355
|
+
@started_at = @clock.call - elapsed(history.messages)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# A turn whose calls did not all get a result cannot go back to a provider:
|
|
359
|
+
# it is dropped, and the model decides again from the tree it left.
|
|
360
|
+
def answered_messages(messages)
|
|
361
|
+
turn = messages.rindex { it[:role] == "assistant" && it[:tool_calls] }
|
|
362
|
+
return messages.dup unless turn
|
|
363
|
+
|
|
364
|
+
answered = messages[(turn + 1)..].filter_map { it[:tool_call_id] if it[:role] == "tool" }
|
|
365
|
+
messages[turn][:tool_calls].all? { answered.include?(it[:id]) } ? messages.dup : messages[0...turn]
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def elapsed(messages)
|
|
369
|
+
times = messages.filter_map { Time.parse(it[:timestamp]) if it[:role] == "assistant" && it[:timestamp] }
|
|
370
|
+
times.empty? ? 0.0 : times.max - times.min
|
|
371
|
+
end
|
|
372
|
+
|
|
332
373
|
def execute(command)
|
|
333
374
|
environment.exec(command, timeout: exec_timeout, env: EXEC_ENV)
|
|
334
375
|
end
|
|
@@ -409,7 +450,9 @@ module Miniswen
|
|
|
409
450
|
error = +""
|
|
410
451
|
error << "Unknown tool '#{call[:name]}'." if call[:name] != "bash"
|
|
411
452
|
arguments = call[:arguments]
|
|
412
|
-
if
|
|
453
|
+
if arguments.is_a?(String)
|
|
454
|
+
error << "The bash tool call arguments are not valid JSON. Resend the call with a JSON object of the form {\"command\": \"...\"}."
|
|
455
|
+
elsif !arguments.is_a?(Hash) || !arguments["command"]
|
|
413
456
|
error << "Missing 'command' argument in bash tool call."
|
|
414
457
|
elsif arguments["command"].to_s.include?("\0")
|
|
415
458
|
# Process.spawn rejects strings with NUL bytes, so the command could
|
|
@@ -497,7 +540,7 @@ module Miniswen
|
|
|
497
540
|
def finish(status, submission: nil)
|
|
498
541
|
Result.new(
|
|
499
542
|
status: status, submission: submission, messages: @messages, steps: @steps,
|
|
500
|
-
cost_source: cost_source, cost_usd: @cost_known ? @cost : nil, error: nil, **@totals
|
|
543
|
+
cost_source: cost_source, cost_usd: @cost_known ? @cost : nil, error: nil, cache_id:, **@totals
|
|
501
544
|
)
|
|
502
545
|
end
|
|
503
546
|
|
|
@@ -541,10 +584,14 @@ module Miniswen
|
|
|
541
584
|
end
|
|
542
585
|
|
|
543
586
|
def routing_params
|
|
544
|
-
|
|
545
|
-
return {} unless order && @provider == "openrouter"
|
|
587
|
+
return {} unless @provider == "openrouter"
|
|
546
588
|
|
|
547
|
-
|
|
589
|
+
params = {}
|
|
590
|
+
params[:prompt_cache_key] = cache_id if @id.start_with?("meta/muse-")
|
|
591
|
+
if (order = provider_order)
|
|
592
|
+
params[:provider] = { order: order.split(",").map(&:strip), allow_fallbacks: false }
|
|
593
|
+
end
|
|
594
|
+
params
|
|
548
595
|
end
|
|
549
596
|
|
|
550
597
|
def provider_order = ENV["LEMANS_PROVIDER_ORDER"] || ENV["OPENROUTER_PROVIDER_ORDER"]
|
data/lib/miniswen/cli.rb
CHANGED
|
@@ -25,6 +25,7 @@ module Miniswen
|
|
|
25
25
|
@jail = false
|
|
26
26
|
@allowed_hosts = nil
|
|
27
27
|
@workdir = nil
|
|
28
|
+
@history = nil
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
def run
|
|
@@ -56,7 +57,7 @@ module Miniswen
|
|
|
56
57
|
agent = Agent.new(model:, reporter:, environment:, **options)
|
|
57
58
|
|
|
58
59
|
begin
|
|
59
|
-
result = agent.run(instruction)
|
|
60
|
+
result = agent.run(instruction, history: @history && Agent::Result.from_h(JSON.parse(@history)))
|
|
60
61
|
rescue StandardError => e
|
|
61
62
|
write_results(agent.partial_result(error_message(e)))
|
|
62
63
|
raise
|
|
@@ -105,7 +106,7 @@ module Miniswen
|
|
|
105
106
|
|
|
106
107
|
def parse_args!
|
|
107
108
|
parser = OptionParser.new do |opts|
|
|
108
|
-
opts.banner = "Usage: miniswen -m MODEL -p INSTRUCTION [...options]"
|
|
109
|
+
opts.banner = "Usage: miniswen -m MODEL (-p INSTRUCTION | --continue-from PATH) [...options]"
|
|
109
110
|
|
|
110
111
|
opts.on("-m MODEL", "--model=MODEL", String,
|
|
111
112
|
"LLM to use (litellm format, e.g.: openrouter/openai/gpt-5.6-luna") do |v|
|
|
@@ -116,6 +117,10 @@ module Miniswen
|
|
|
116
117
|
@instruction = File.file?(v) ? File.read(v) : v
|
|
117
118
|
end
|
|
118
119
|
|
|
120
|
+
opts.on("--continue-from=PATH", String, "Continue the session saved by --results-path at PATH (no -p needed)") do |v|
|
|
121
|
+
@history = File.read(v)
|
|
122
|
+
end
|
|
123
|
+
|
|
119
124
|
opts.on("--max-steps=STEPS", Integer, "Max steps count") do |v|
|
|
120
125
|
options[:max_steps] = v
|
|
121
126
|
end
|
|
@@ -197,7 +202,7 @@ module Miniswen
|
|
|
197
202
|
return if @refresh_registry
|
|
198
203
|
|
|
199
204
|
raise "Use -m to specify the model" unless @model
|
|
200
|
-
raise "Please, provide instructions via -p option" unless @instruction
|
|
205
|
+
raise "Please, provide instructions via -p option" unless @instruction || @history
|
|
201
206
|
end
|
|
202
207
|
end
|
|
203
208
|
end
|
|
@@ -20,7 +20,7 @@ module Miniswen
|
|
|
20
20
|
env&.each { |key, value| argv += [ "--env", "#{key}=#{value}" ] }
|
|
21
21
|
argv << id
|
|
22
22
|
argv += [ "timeout", timeout.ceil.to_s ] if timeout&.positive?
|
|
23
|
-
argv += [ "
|
|
23
|
+
argv += [ "bash", "-c", command ]
|
|
24
24
|
|
|
25
25
|
Open3.popen2e(*argv) do |stdin, pipe, wait|
|
|
26
26
|
stdin.close
|
data/lib/miniswen/jail.rb
CHANGED
|
@@ -61,7 +61,7 @@ module Miniswen
|
|
|
61
61
|
def spawn_arguments(command, env)
|
|
62
62
|
[ command_env(env),
|
|
63
63
|
"nsenter", "--target", @holder.pid.to_s, "--net", "--mount", "--pid=/proc/#{@holder.pid}/ns/pid_for_children", "--wd=#{@workdir}",
|
|
64
|
-
"--", "setpriv", "--bounding-set=-all", "--inh-caps=-all", "--no-new-privs", "--", "
|
|
64
|
+
"--", "setpriv", "--bounding-set=-all", "--inh-caps=-all", "--no-new-privs", "--", "bash", "-c", command ]
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
def spawn_options = super.merge(unsetenv_others: true)
|
data/lib/miniswen/local.rb
CHANGED
data/lib/miniswen/ruby_llm.rb
CHANGED
|
@@ -43,7 +43,20 @@ module Miniswen
|
|
|
43
43
|
module RetryTransientFailures
|
|
44
44
|
def retry_exceptions = super + [ Faraday::SSLError, Faraday::ParsingError ]
|
|
45
45
|
end
|
|
46
|
+
|
|
47
|
+
# ruby_llm parses tool-call arguments while it builds the message, so one
|
|
48
|
+
# malformed call would raise and take the whole completion with it. The raw
|
|
49
|
+
# string goes through instead and bounces as a format error.
|
|
50
|
+
module LenientToolArguments
|
|
51
|
+
def parse_tool_call_arguments(tool_call)
|
|
52
|
+
super
|
|
53
|
+
rescue JSON::ParserError
|
|
54
|
+
tool_call.dig("function", "arguments")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
46
57
|
end
|
|
47
58
|
|
|
48
59
|
RubyLLM::Providers::OpenRouter.prepend(Miniswen::VerbatimReasoningDetails)
|
|
49
60
|
RubyLLM::Connection.prepend(Miniswen::RetryTransientFailures)
|
|
61
|
+
RubyLLM::Providers::OpenAI::Tools.prepend(Miniswen::LenientToolArguments)
|
|
62
|
+
RubyLLM::Providers::OpenAI::Tools.singleton_class.prepend(Miniswen::LenientToolArguments)
|
data/lib/miniswen/version.rb
CHANGED