miniswen 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +87 -1
- data/lib/miniswen/agent.rb +67 -140
- data/lib/miniswen/cli.rb +1 -1
- data/lib/miniswen/jail/proxy.rb +7 -1
- data/lib/miniswen/jail.rb +2 -1
- data/lib/miniswen/ruby_llm.rb +32 -17
- data/lib/miniswen/testing.rb +13 -17
- data/lib/miniswen/trajectory.rb +2 -1
- data/lib/miniswen/version.rb +1 -1
- data/lib/miniswen.rb +2 -6
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 318e13712f3e46a9ad03b1105b7f5c5c6892d5b75a6566d0c272d196de54d491
|
|
4
|
+
data.tar.gz: 4f0c7dbd37fe212152e66a1abca5ee39513136d564a0293098920947a1a0a02f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f0eb42558de2ad1e9db1a80b1c9f9fdf488dc7cdcc82e848a3086f8a517cc1a5ed5fe36a6901e86e81320d192ef8f218935346e343aea30ca6de8fbb7586f497
|
|
7
|
+
data.tar.gz: 82ad81a8753d0f5ba9c9415e4e6b4c198c8cb2692c539b05c47f9ed446752df96893f49922ca060ab5356820ad2e456bb5625087247b1d7040b63f55c035656c
|
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.
|
|
@@ -263,6 +264,84 @@ gpt-5.6-luna ar-archive-book-access 2/2 2m 23s $0.0132 12.5 156905
|
|
|
263
264
|
6 trials: 6 scored, 0 invalid, 6 solved (100%) · $0.0801 · pass@2 3/3 tasks (100%)
|
|
264
265
|
```
|
|
265
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
|
+
- [Codex agent](./examples/codex/README.md)
|
|
344
|
+
|
|
266
345
|
## CLI
|
|
267
346
|
|
|
268
347
|
| Command | What it does |
|
|
@@ -271,7 +350,7 @@ gpt-5.6-luna ar-archive-book-access 2/2 2m 23s $0.0132 12.5 156905
|
|
|
271
350
|
| `lemans tasks` | List the tasks in a bench (`--tag` to filter) |
|
|
272
351
|
| `lemans run` | Run tasks and grade them (`--task`, `--tag`, `--agent`, `--model`, `--max-output-tokens`, `-k`, `-c`, `--resume`) |
|
|
273
352
|
| `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`) |
|
|
274
|
-
| `lemans report [RUNS_DIR]` | Summarize `runs/` (or `RUNS_DIR`) as a table or CSV (`--task`, `--tag`, `--metadata key:value` to filter, `-A [task-agent-model]` to aggregate, `-S <
|
|
353
|
+
| `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) |
|
|
275
354
|
| `lemans clobber [RUNS_DIR]` | Delete run results under `runs/` (or `RUNS_DIR`) (`--task`, `--ttl 10m\|2h\|1d`, `--invalid`, `-f` to skip the confirmation) |
|
|
276
355
|
|
|
277
356
|
## miniswen
|
|
@@ -288,6 +367,13 @@ $ miniswen --model 'openrouter/openai/gpt-5.6-luna' --prompt 'Write hello-world
|
|
|
288
367
|
|
|
289
368
|
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.
|
|
290
369
|
|
|
370
|
+
## More tools: lemans-remote and lemans-viewer
|
|
371
|
+
|
|
372
|
+
The gem also ships two convenient (and 100% AI-maintained) CLIs:
|
|
373
|
+
|
|
374
|
+
- `lemans-remote`: scale `lemans run`-s horizontally using Daytona sandboxes; schedule tasks and pull results later
|
|
375
|
+
- `lemans-viewer`: a web UI for browsing trial results (if you still need a pair of human eyes to look at them)
|
|
376
|
+
|
|
291
377
|
## Development
|
|
292
378
|
|
|
293
379
|
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 "shellwords"
|
|
4
6
|
require "time"
|
|
5
7
|
require "miniswen/version"
|
|
6
8
|
require "miniswen/ruby_llm"
|
|
@@ -13,21 +15,6 @@ module Miniswen
|
|
|
13
15
|
MAX_OBSERVATION_CHARS = 10_000
|
|
14
16
|
MAX_CONSECUTIVE_FORMAT_ERRORS = 3
|
|
15
17
|
|
|
16
|
-
# Both finish_reason dialects accepted raw: OpenAI-shaped providers say
|
|
17
|
-
# "length"/"tool_calls", Anthropic says "max_tokens"/"tool_use".
|
|
18
|
-
TRUNCATION_FINISH_REASONS = %w[length max_tokens model_context_window_exceeded].freeze
|
|
19
|
-
CLAIMED_TOOL_FINISH_REASONS = %w[tool_calls tool_use].freeze
|
|
20
|
-
# A safety stop, which arrives looking exactly like a model that forgot
|
|
21
|
-
# to call the tool: no content, no tool call, and — since the provider
|
|
22
|
-
# bills nothing for a turn it refused — no tokens either. Only the
|
|
23
|
-
# finish reason tells the two apart, so the run is labelled by it. The
|
|
24
|
-
# retry is unchanged: the nudge still goes back, because matching
|
|
25
|
-
# mini-swe-agent turn for turn is what makes runs comparable.
|
|
26
|
-
REFUSAL_FINISH_REASONS = %w[content_filter refusal safety].freeze
|
|
27
|
-
|
|
28
|
-
# The breakpoint marker Anthropic reads, shaped the way OpenRouter forwards it.
|
|
29
|
-
CACHE_CONTROL = { type: "ephemeral" }.freeze
|
|
30
|
-
|
|
31
18
|
EXEC_ENV = {
|
|
32
19
|
"PAGER" => "cat",
|
|
33
20
|
"MANPAGER" => "cat",
|
|
@@ -36,9 +23,6 @@ module Miniswen
|
|
|
36
23
|
"TQDM_DISABLE" => "1"
|
|
37
24
|
}.freeze
|
|
38
25
|
|
|
39
|
-
# Providers that serve local inference (and cost zero)
|
|
40
|
-
LOCAL_PROVIDERS = %i[ollama gpustack].freeze
|
|
41
|
-
|
|
42
26
|
SYSTEM_TEMPLATE = <<~PROMPT
|
|
43
27
|
You are a helpful assistant that can interact with a computer.
|
|
44
28
|
PROMPT
|
|
@@ -167,20 +151,10 @@ module Miniswen
|
|
|
167
151
|
without any other command.
|
|
168
152
|
MESSAGE
|
|
169
153
|
|
|
170
|
-
# Thinking that also carries the provider's reasoning_details for verbatim replay.
|
|
171
|
-
class VerbatimThinking < RubyLLM::Thinking
|
|
172
|
-
attr_reader :details
|
|
173
|
-
|
|
174
|
-
def initialize(text: nil, signature: nil, details: nil)
|
|
175
|
-
super(text: text, signature: signature)
|
|
176
|
-
@details = details
|
|
177
|
-
end
|
|
178
|
-
end
|
|
179
|
-
|
|
180
154
|
# Only ever rendered into the request — the completion executes nothing — so no execute body.
|
|
181
155
|
class BashTool < RubyLLM::Tool
|
|
182
156
|
description "Execute a bash command"
|
|
183
|
-
|
|
157
|
+
parameter :command, description: "The bash command to execute"
|
|
184
158
|
|
|
185
159
|
# ruby_llm would otherwise derive "lemans--miniswen--bash" from the class path.
|
|
186
160
|
def name = "bash"
|
|
@@ -188,7 +162,9 @@ module Miniswen
|
|
|
188
162
|
|
|
189
163
|
Result = Data.define(:status, :submission, :messages, :steps, :cost_source,
|
|
190
164
|
:input_tokens, :output_tokens, :cached_tokens, :thinking_tokens, :cost_usd,
|
|
191
|
-
:error) do
|
|
165
|
+
:error, :cache_id) do
|
|
166
|
+
def initialize(cache_id: nil, **attributes) = super(cache_id:, **attributes)
|
|
167
|
+
|
|
192
168
|
def success? = status == :submitted
|
|
193
169
|
|
|
194
170
|
def to_h = super.merge(cost_source: cost_source&.to_h, version: Miniswen::VERSION)
|
|
@@ -206,7 +182,7 @@ module Miniswen
|
|
|
206
182
|
input_tokens: data[:input_tokens], output_tokens: data[:output_tokens],
|
|
207
183
|
cached_tokens: data[:cached_tokens], thinking_tokens: data[:thinking_tokens],
|
|
208
184
|
cost_usd: data[:cost_usd],
|
|
209
|
-
error: data[:error]
|
|
185
|
+
error: data[:error], cache_id: data[:cache_id]
|
|
210
186
|
)
|
|
211
187
|
end
|
|
212
188
|
|
|
@@ -230,13 +206,13 @@ module Miniswen
|
|
|
230
206
|
|
|
231
207
|
attr_reader :messages, :environment
|
|
232
208
|
|
|
233
|
-
private attr_reader :max_steps, :max_time, :max_cost, :exec_timeout, :max_output_tokens,
|
|
234
|
-
:clock, :reporter
|
|
209
|
+
private attr_reader :workdir, :max_steps, :max_time, :max_cost, :exec_timeout, :max_output_tokens,
|
|
210
|
+
:cache_id, :clock, :reporter
|
|
235
211
|
|
|
236
212
|
# `model` is a litellm-style name ("openrouter/z-ai/glm-5.2"), optionally
|
|
237
213
|
# suffixed with a reasoning effort ("openrouter/openai/gpt-5.6-luna#xhigh").
|
|
238
214
|
# Limits of 0 or nil are disabled.
|
|
239
|
-
def initialize(model:, environment:, max_steps: 0, max_time: 0, max_cost: nil,
|
|
215
|
+
def initialize(model:, environment:, workdir: nil, max_steps: 0, max_time: 0, max_cost: nil,
|
|
240
216
|
exec_timeout: 30, max_output_tokens: 0,
|
|
241
217
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
242
218
|
reporter: nil)
|
|
@@ -249,6 +225,7 @@ module Miniswen
|
|
|
249
225
|
|
|
250
226
|
@model = model
|
|
251
227
|
@environment = environment
|
|
228
|
+
@workdir = workdir
|
|
252
229
|
|
|
253
230
|
@bash_tool = BashTool.new
|
|
254
231
|
|
|
@@ -303,7 +280,7 @@ module Miniswen
|
|
|
303
280
|
Result.new(
|
|
304
281
|
status: :error, submission: nil, messages: @messages || [], steps: @steps.to_i,
|
|
305
282
|
cost_source: cost_source, cost_usd: @cost_known == false ? nil : @cost.to_f,
|
|
306
|
-
error: error,
|
|
283
|
+
error: error, cache_id:,
|
|
307
284
|
**(@totals || { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 })
|
|
308
285
|
)
|
|
309
286
|
end
|
|
@@ -323,6 +300,7 @@ module Miniswen
|
|
|
323
300
|
private
|
|
324
301
|
|
|
325
302
|
def start(instruction)
|
|
303
|
+
@cache_id = SecureRandom.uuid
|
|
326
304
|
uname = execute("uname -srvm").output.to_s.strip
|
|
327
305
|
@messages = [
|
|
328
306
|
{ role: "system", content: SYSTEM_TEMPLATE },
|
|
@@ -335,16 +313,19 @@ module Miniswen
|
|
|
335
313
|
@steps = 0
|
|
336
314
|
@cost = 0.0
|
|
337
315
|
@cost_known = true
|
|
316
|
+
@cost_reported = false
|
|
338
317
|
@totals = { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 }
|
|
339
318
|
@started_at = @clock.call
|
|
340
319
|
end
|
|
341
320
|
|
|
342
321
|
def resume(history)
|
|
322
|
+
@cache_id = history.cache_id || SecureRandom.uuid
|
|
343
323
|
@messages = answered_messages(history.messages)
|
|
344
324
|
|
|
345
325
|
@steps = history.steps.to_i
|
|
346
326
|
@cost = history.cost_usd.to_f
|
|
347
327
|
@cost_known = !history.cost_usd.nil?
|
|
328
|
+
@cost_reported = history.cost_source&.name == :provider_reported
|
|
348
329
|
@totals = { input_tokens: history.input_tokens.to_i, output_tokens: history.output_tokens.to_i,
|
|
349
330
|
cached_tokens: history.cached_tokens.to_i, thinking_tokens: history.thinking_tokens.to_i }
|
|
350
331
|
@started_at = @clock.call - elapsed(history.messages)
|
|
@@ -366,6 +347,7 @@ module Miniswen
|
|
|
366
347
|
end
|
|
367
348
|
|
|
368
349
|
def execute(command)
|
|
350
|
+
command = "cd #{Shellwords.escape(workdir)}; #{command}" if workdir
|
|
369
351
|
environment.exec(command, timeout: exec_timeout, env: EXEC_ENV)
|
|
370
352
|
end
|
|
371
353
|
|
|
@@ -394,6 +376,7 @@ module Miniswen
|
|
|
394
376
|
# content, no tool call and no billed tokens.
|
|
395
377
|
entry[:finish_reason] = response[:finish_reason] if response[:finish_reason]
|
|
396
378
|
entry[:thinking] = response[:thinking] if response[:thinking]
|
|
379
|
+
entry[:provider] = response[:provider] if response[:provider]
|
|
397
380
|
# The provider's opaque handles on this turn's reasoning: replayed back
|
|
398
381
|
# to the provider only, never into the trajectory.
|
|
399
382
|
entry[:thinking_signature] = response[:thinking_signature] if response[:thinking_signature]
|
|
@@ -407,7 +390,7 @@ module Miniswen
|
|
|
407
390
|
# message with unanswered tool calls is a request providers reject.
|
|
408
391
|
entry[:invalid_tool_calls] = tool_calls if tool_calls.any?
|
|
409
392
|
@consecutive_format_errors += 1
|
|
410
|
-
@refused_turns += 1 if refused
|
|
393
|
+
@refused_turns += 1 if response[:refused]
|
|
411
394
|
|
|
412
395
|
observe_message({ role: "user", content: format_error_message(error, response, tool_calls) })
|
|
413
396
|
|
|
@@ -420,8 +403,6 @@ module Miniswen
|
|
|
420
403
|
tool_calls
|
|
421
404
|
end
|
|
422
405
|
|
|
423
|
-
def refused?(response) = REFUSAL_FINISH_REASONS.include?(response[:finish_reason].to_s)
|
|
424
|
-
|
|
425
406
|
# An unpriced completion under a cost ceiling is fatal on the spot: only failing fast stops
|
|
426
407
|
# the spend. Without a ceiling, unknown cost is just a fact to report.
|
|
427
408
|
def track_cost(response)
|
|
@@ -436,6 +417,7 @@ module Miniswen
|
|
|
436
417
|
else
|
|
437
418
|
@cost += cost
|
|
438
419
|
end
|
|
420
|
+
@cost_reported = true if response[:reported]
|
|
439
421
|
end
|
|
440
422
|
|
|
441
423
|
def actions_error(tool_calls)
|
|
@@ -445,7 +427,9 @@ module Miniswen
|
|
|
445
427
|
error = +""
|
|
446
428
|
error << "Unknown tool '#{call[:name]}'." if call[:name] != "bash"
|
|
447
429
|
arguments = call[:arguments]
|
|
448
|
-
if
|
|
430
|
+
if arguments.is_a?(String)
|
|
431
|
+
error << "The bash tool call arguments are not valid JSON. Resend the call with a JSON object of the form {\"command\": \"...\"}."
|
|
432
|
+
elsif !arguments.is_a?(Hash) || !arguments["command"]
|
|
449
433
|
error << "Missing 'command' argument in bash tool call."
|
|
450
434
|
elsif arguments["command"].to_s.include?("\0")
|
|
451
435
|
# Process.spawn rejects strings with NUL bytes, so the command could
|
|
@@ -459,10 +443,8 @@ module Miniswen
|
|
|
459
443
|
end
|
|
460
444
|
|
|
461
445
|
def format_error_message(error, response, tool_calls)
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
(CLAIMED_TOOL_FINISH_REASONS.include?(finish_reason) && tool_calls.empty?)
|
|
465
|
-
format(TRUNCATION_ERROR_MESSAGE, finish_reason: finish_reason)
|
|
446
|
+
if response[:truncated] || (response[:finish_reason] == "tool_calls" && tool_calls.empty?)
|
|
447
|
+
format(TRUNCATION_ERROR_MESSAGE, finish_reason: response[:finish_reason])
|
|
466
448
|
else
|
|
467
449
|
format(TOOL_CALL_ERROR_MESSAGE, error: error)
|
|
468
450
|
end
|
|
@@ -533,7 +515,7 @@ module Miniswen
|
|
|
533
515
|
def finish(status, submission: nil)
|
|
534
516
|
Result.new(
|
|
535
517
|
status: status, submission: submission, messages: @messages, steps: @steps,
|
|
536
|
-
cost_source: cost_source, cost_usd: @cost_known ? @cost : nil, error: nil, **@totals
|
|
518
|
+
cost_source: cost_source, cost_usd: @cost_known ? @cost : nil, error: nil, cache_id:, **@totals
|
|
537
519
|
)
|
|
538
520
|
end
|
|
539
521
|
|
|
@@ -541,11 +523,13 @@ module Miniswen
|
|
|
541
523
|
def complete(messages)
|
|
542
524
|
model_info, provider = resolved
|
|
543
525
|
response = provider.complete(
|
|
544
|
-
|
|
526
|
+
messages.map { as_ruby_llm(it) },
|
|
545
527
|
tools: { bash: @bash_tool },
|
|
546
528
|
temperature: nil,
|
|
547
529
|
model: model_info,
|
|
548
|
-
|
|
530
|
+
provider_options: routing_params,
|
|
531
|
+
max_output_tokens: output_cap,
|
|
532
|
+
caching: {},
|
|
549
533
|
thinking: (RubyLLM::Thinking::Config.new(effort: @effort) if @effort)
|
|
550
534
|
)
|
|
551
535
|
payload(response)
|
|
@@ -557,48 +541,20 @@ module Miniswen
|
|
|
557
541
|
raise InfrastructureError, "miniswen: the model call failed: #{e.class}: #{e.message}"
|
|
558
542
|
end
|
|
559
543
|
|
|
560
|
-
# Anthropic bills every token fresh unless the request marks explicit cache
|
|
561
|
-
# breakpoints
|
|
562
|
-
def explicit_cache? = @provider == "openrouter" && @id.to_s.start_with?("anthropic/")
|
|
563
|
-
|
|
564
|
-
def with_cache_breakpoints(messages)
|
|
565
|
-
return messages unless explicit_cache?
|
|
566
|
-
|
|
567
|
-
system = messages.find { it.role == :system }
|
|
568
|
-
[ system, messages.last ].compact.uniq.each do |message|
|
|
569
|
-
text = message.content
|
|
570
|
-
next unless text.is_a?(String) && !text.empty?
|
|
571
|
-
|
|
572
|
-
message.content = RubyLLM::Content::Raw.new(
|
|
573
|
-
[ { type: "text", text: text, cache_control: CACHE_CONTROL } ]
|
|
574
|
-
)
|
|
575
|
-
end
|
|
576
|
-
messages
|
|
577
|
-
end
|
|
578
|
-
|
|
579
544
|
def routing_params
|
|
580
|
-
|
|
581
|
-
return {} unless order && @provider == "openrouter"
|
|
545
|
+
return {} unless @provider == "openrouter"
|
|
582
546
|
|
|
583
|
-
|
|
547
|
+
params = { prompt_cache_key: cache_id }
|
|
548
|
+
if (order = provider_order)
|
|
549
|
+
params[:provider] = { order: order.split(",").map(&:strip), allow_fallbacks: false }
|
|
550
|
+
end
|
|
551
|
+
params
|
|
584
552
|
end
|
|
585
553
|
|
|
586
554
|
def provider_order = ENV["LEMANS_PROVIDER_ORDER"] || ENV["OPENROUTER_PROVIDER_ORDER"]
|
|
587
555
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
def output_cap_params(model_info)
|
|
591
|
-
return {} if max_output_tokens.zero?
|
|
592
|
-
|
|
593
|
-
cap = [ info&.max_tokens, max_output_tokens ].compact.min
|
|
594
|
-
provider_class = RubyLLM::Provider.providers[model_info.provider.to_sym]
|
|
595
|
-
if [ RubyLLM::Providers::OpenAI, RubyLLM::Providers::Azure ].include?(provider_class)
|
|
596
|
-
{ max_completion_tokens: cap }
|
|
597
|
-
elsif provider_class <= RubyLLM::Providers::OpenAI || provider_class <= RubyLLM::Providers::Anthropic
|
|
598
|
-
{ max_tokens: cap }
|
|
599
|
-
else
|
|
600
|
-
{}
|
|
601
|
-
end
|
|
556
|
+
def output_cap
|
|
557
|
+
[ info&.max_output_tokens, max_output_tokens ].compact.min unless max_output_tokens.zero?
|
|
602
558
|
end
|
|
603
559
|
|
|
604
560
|
def cost_source
|
|
@@ -607,12 +563,12 @@ module Miniswen
|
|
|
607
563
|
priced_as: "#{@provider || info&.provider}/#{@id} ($0.00, local)",
|
|
608
564
|
registry: nil)
|
|
609
565
|
end
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
priced_as: "#{info.provider}/#{info.id} ($0.00, free)",
|
|
566
|
+
if @cost_reported
|
|
567
|
+
return CostSource.new(name: :provider_reported, model: @model,
|
|
568
|
+
priced_as: "#{@provider || info&.provider}/#{@id} (as billed, else the registry price)",
|
|
614
569
|
registry: Miniswen.registry_revision)
|
|
615
570
|
end
|
|
571
|
+
return nil unless info
|
|
616
572
|
|
|
617
573
|
CostSource.new(name: :model_registry, model: @model,
|
|
618
574
|
priced_as: "#{info.provider}/#{info.id}",
|
|
@@ -620,14 +576,16 @@ module Miniswen
|
|
|
620
576
|
end
|
|
621
577
|
|
|
622
578
|
def resolved
|
|
623
|
-
@resolved ||= RubyLLM::Models.resolve(@id, provider: @provider,
|
|
579
|
+
@resolved ||= RubyLLM::Models.resolve(@id, provider: @provider, assume_model_exists: !@provider.nil?)
|
|
624
580
|
end
|
|
625
581
|
|
|
626
582
|
def as_ruby_llm(entry)
|
|
627
583
|
case entry[:role]
|
|
628
584
|
when "assistant"
|
|
629
|
-
RubyLLM::
|
|
630
|
-
|
|
585
|
+
thinking = RubyLLM::Thinking.build(text: entry[:thinking], signature: entry[:thinking_signature])
|
|
586
|
+
RubyLLM::Message.new(role: :assistant, content: entry[:content], thinking:,
|
|
587
|
+
raw_reasoning: entry[:reasoning_details],
|
|
588
|
+
usage_entries: [ producing_entry ],
|
|
631
589
|
tool_calls: as_ruby_llm_tool_calls(entry[:tool_calls]))
|
|
632
590
|
when "tool"
|
|
633
591
|
RubyLLM::Message.new(role: :tool, content: entry[:content], tool_call_id: entry[:tool_call_id])
|
|
@@ -636,13 +594,11 @@ module Miniswen
|
|
|
636
594
|
end
|
|
637
595
|
end
|
|
638
596
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
RubyLLM::Thinking.build(text: entry[:thinking], signature: entry[:thinking_signature])
|
|
645
|
-
end
|
|
597
|
+
# A rebuilt turn must name its provider, or ruby_llm drops its thinking signature.
|
|
598
|
+
def producing_entry
|
|
599
|
+
model_info, provider = resolved
|
|
600
|
+
RubyLLM::Accounting::Usage::Entry.new(operation: :chat, provider: provider.slug, model: model_info.id,
|
|
601
|
+
status: :succeeded)
|
|
646
602
|
end
|
|
647
603
|
|
|
648
604
|
def as_ruby_llm_tool_calls(tool_calls)
|
|
@@ -663,16 +619,20 @@ module Miniswen
|
|
|
663
619
|
content: response.content.to_s,
|
|
664
620
|
thinking: response.thinking&.text,
|
|
665
621
|
thinking_signature: response.thinking&.signature,
|
|
666
|
-
reasoning_details:
|
|
622
|
+
reasoning_details: response.raw_reasoning,
|
|
667
623
|
tool_calls: tool_calls_from(response),
|
|
668
|
-
finish_reason:
|
|
624
|
+
finish_reason: response.finish_reason&.to_s,
|
|
625
|
+
truncated: response.max_tokens?,
|
|
626
|
+
refused: response.content_filtered?,
|
|
669
627
|
# ruby_llm's input_tokens is the cache-miss remainder only; the
|
|
670
628
|
# convention counts the whole prompt, cached and cache-write included.
|
|
671
|
-
input_tokens:
|
|
672
|
-
output_tokens:
|
|
673
|
-
cached_tokens: tokens
|
|
674
|
-
thinking_tokens: tokens
|
|
675
|
-
cost_usd: price(response)
|
|
629
|
+
input_tokens: tokens.input.to_i + tokens.cache_read.to_i + tokens.cache_write.to_i,
|
|
630
|
+
output_tokens: tokens.output.to_i,
|
|
631
|
+
cached_tokens: tokens.cache_read.to_i,
|
|
632
|
+
thinking_tokens: tokens.thinking.to_i,
|
|
633
|
+
cost_usd: price(response),
|
|
634
|
+
reported: !tokens.reported_cost.nil?,
|
|
635
|
+
provider: response.raw&.body&.dig("provider")
|
|
676
636
|
}
|
|
677
637
|
end
|
|
678
638
|
|
|
@@ -693,27 +653,7 @@ module Miniswen
|
|
|
693
653
|
arguments
|
|
694
654
|
end
|
|
695
655
|
|
|
696
|
-
def
|
|
697
|
-
body = raw_body(response)
|
|
698
|
-
body && (body.dig("choices", 0, "finish_reason") || body["stop_reason"])
|
|
699
|
-
end
|
|
700
|
-
|
|
701
|
-
def reasoning_details_from(response)
|
|
702
|
-
details = raw_body(response)&.dig("choices", 0, "message", "reasoning_details")
|
|
703
|
-
details.is_a?(Array) && !details.empty? ? details : nil
|
|
704
|
-
end
|
|
705
|
-
|
|
706
|
-
def raw_body(response)
|
|
707
|
-
body = response.raw&.body
|
|
708
|
-
body = JSON.parse(body) if body.is_a?(String)
|
|
709
|
-
body.is_a?(Hash) ? body : nil
|
|
710
|
-
rescue JSON::ParserError
|
|
711
|
-
nil
|
|
712
|
-
end
|
|
713
|
-
|
|
714
|
-
def local? = LOCAL_PROVIDERS.include?((@provider || info&.provider)&.to_sym)
|
|
715
|
-
|
|
716
|
-
def free? = info&.provider == "openrouter" && info.input_price_per_million.nil? && info.output_price_per_million.nil?
|
|
656
|
+
def local? = RubyLLM::Provider.providers[(@provider || info&.provider)&.to_sym]&.local? || false
|
|
717
657
|
|
|
718
658
|
def info
|
|
719
659
|
return @info if defined?(@info)
|
|
@@ -722,28 +662,15 @@ module Miniswen
|
|
|
722
662
|
end
|
|
723
663
|
|
|
724
664
|
def find_model
|
|
725
|
-
|
|
665
|
+
RubyLLM.models.find(@id, provider: @provider)
|
|
726
666
|
rescue RubyLLM::ModelNotFoundError
|
|
727
667
|
nil
|
|
728
668
|
end
|
|
729
669
|
|
|
730
670
|
def price(response)
|
|
731
|
-
return 0.0 if local? ||
|
|
671
|
+
return 0.0 if local? || (response.tokens.to_h.empty? && response.tokens.reported_cost.nil?)
|
|
732
672
|
|
|
733
|
-
|
|
734
|
-
output = info&.output_price_per_million
|
|
735
|
-
return nil unless input.is_a?(Numeric) && output.is_a?(Numeric)
|
|
736
|
-
|
|
737
|
-
tokens = response.tokens
|
|
738
|
-
cache_read = info.cache_read_input_price_per_million || input
|
|
739
|
-
cache_write = info.cache_write_input_price_per_million || input
|
|
740
|
-
# Providers usually fold thinking into output_tokens; max() bills the
|
|
741
|
-
# larger count once and can never double-bill.
|
|
742
|
-
generated = [ response.output_tokens.to_i, tokens&.thinking.to_i ].max
|
|
743
|
-
((response.input_tokens.to_i * input) +
|
|
744
|
-
(tokens&.cached.to_i * cache_read) +
|
|
745
|
-
(tokens&.cache_creation.to_i * cache_write) +
|
|
746
|
-
(generated * output)) / 1_000_000.0
|
|
673
|
+
info ? info.cost_for(response.tokens).total : response.tokens.reported_cost
|
|
747
674
|
end
|
|
748
675
|
end
|
|
749
676
|
end
|
data/lib/miniswen/cli.rb
CHANGED
|
@@ -99,7 +99,7 @@ module Miniswen
|
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
def refresh_registry_and_exit
|
|
102
|
-
refreshed = Miniswen.refresh_registry!
|
|
102
|
+
refreshed = Miniswen.refresh_registry!
|
|
103
103
|
$stdout.puts Miniswen.registry_revision if refreshed
|
|
104
104
|
Kernel.exit(refreshed ? 0 : 1)
|
|
105
105
|
end
|
data/lib/miniswen/jail/proxy.rb
CHANGED
|
@@ -37,7 +37,13 @@ module Miniswen
|
|
|
37
37
|
def start
|
|
38
38
|
FileUtils.rm_f(@socket)
|
|
39
39
|
server = UNIXServer.new(@socket)
|
|
40
|
-
@
|
|
40
|
+
check, @lifeline = IO.pipe
|
|
41
|
+
@pid = fork do
|
|
42
|
+
@lifeline.close
|
|
43
|
+
Thread.new { check.wait_readable; exit! }
|
|
44
|
+
self.class.serve(server) { handle(it) }
|
|
45
|
+
end
|
|
46
|
+
check.close
|
|
41
47
|
self
|
|
42
48
|
end
|
|
43
49
|
|
data/lib/miniswen/jail.rb
CHANGED
|
@@ -22,7 +22,8 @@ module Miniswen
|
|
|
22
22
|
def start
|
|
23
23
|
_, @stdout, @stderr, @holder = Open3.popen3(
|
|
24
24
|
container_env,
|
|
25
|
-
"unshare", "--net", "--mount", "--pid", "--fork", "--kill-child", "--mount-proc",
|
|
25
|
+
"setpriv", "--pdeathsig=KILL", "unshare", "--net", "--mount", "--pid", "--fork", "--kill-child", "--mount-proc",
|
|
26
|
+
"sh", "-c", setup,
|
|
26
27
|
pgroup: true, unsetenv_others: true
|
|
27
28
|
)
|
|
28
29
|
raise InfrastructureError, "jail did not start: #{@stderr.read}" unless @stdout.gets == "ready\n"
|
data/lib/miniswen/ruby_llm.rb
CHANGED
|
@@ -4,8 +4,8 @@ require "ruby_llm"
|
|
|
4
4
|
|
|
5
5
|
# Logging configuration
|
|
6
6
|
RubyLLM.configure do |config|
|
|
7
|
-
config.log_level = ENV
|
|
8
|
-
config.logger = Logger.new(IO::NULL) unless ENV["MINISWEN_DEBUG"] == "1"
|
|
7
|
+
config.log_level = ENV["RUBYLLM_LOG_LEVEL"].to_sym if ENV["RUBYLLM_LOG_LEVEL"]
|
|
8
|
+
config.logger = Logger.new(IO::NULL) unless ENV["MINISWEN_DEBUG"] == "1" || ENV["RUBYLLM_DEBUG"] == "1"
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
# About ten minutes of retries (1, 2, 4, ... 256s plus jitter): provider
|
|
@@ -13,6 +13,7 @@ end
|
|
|
13
13
|
RubyLLM.configure do |config|
|
|
14
14
|
config.max_retries = 9
|
|
15
15
|
config.retry_interval = 1
|
|
16
|
+
config.retry_max_interval = 256
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
# ruby_llm reads no API keys from ENV on its own; the conventional variable is the provider's
|
|
@@ -27,23 +28,37 @@ RubyLLM.configure do |config|
|
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
module Miniswen
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
module VerbatimReasoningDetails
|
|
34
|
-
def format_thinking(msg)
|
|
35
|
-
details = msg.thinking.respond_to?(:details) ? msg.thinking.details : nil
|
|
36
|
-
details && !details.empty? ? { reasoning_details: details } : super
|
|
37
|
-
end
|
|
31
|
+
# A 200 cut off mid-body is a dropped connection, but ruby_llm doesn't retry it.
|
|
32
|
+
module RetryTransientFailures
|
|
33
|
+
def retry_exceptions = super + [ Faraday::ParsingError ]
|
|
38
34
|
end
|
|
39
35
|
|
|
40
|
-
# ruby_llm
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
module
|
|
44
|
-
def
|
|
36
|
+
# ruby_llm parses tool-call arguments while it builds the message, so one
|
|
37
|
+
# malformed call would raise and take the whole completion with it. The raw
|
|
38
|
+
# string goes through instead and bounces as a format error.
|
|
39
|
+
module LenientToolArguments
|
|
40
|
+
def parse_tool_call_arguments(tool_call, **)
|
|
41
|
+
super
|
|
42
|
+
rescue RubyLLM::ToolCallParseError
|
|
43
|
+
tool_call.dig("function", "arguments")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse_function_call_arguments(arguments, **)
|
|
47
|
+
super
|
|
48
|
+
rescue RubyLLM::ToolCallParseError
|
|
49
|
+
arguments
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def parse_arguments(arguments, *)
|
|
53
|
+
super
|
|
54
|
+
rescue RubyLLM::ToolCallParseError
|
|
55
|
+
arguments
|
|
56
|
+
end
|
|
45
57
|
end
|
|
46
58
|
end
|
|
47
59
|
|
|
48
|
-
RubyLLM::
|
|
49
|
-
RubyLLM::
|
|
60
|
+
RubyLLM::Transport::Connection.singleton_class.prepend(Miniswen::RetryTransientFailures)
|
|
61
|
+
[ RubyLLM::Protocols::ChatCompletions::Tools, RubyLLM::Protocols::Responses::Chat, RubyLLM::Protocols::Cohere::Tools ].each do |parser|
|
|
62
|
+
parser.prepend(Miniswen::LenientToolArguments)
|
|
63
|
+
parser.singleton_class.prepend(Miniswen::LenientToolArguments)
|
|
64
|
+
end
|
data/lib/miniswen/testing.rb
CHANGED
|
@@ -21,22 +21,22 @@ module Miniswen
|
|
|
21
21
|
|
|
22
22
|
# $20/M input (fresh and cached) and $400/M output price the default answer
|
|
23
23
|
# (95 fresh + 5 cached input tokens, 20 output tokens) at exactly $0.01.
|
|
24
|
-
PRICED_MODEL = RubyLLM::Model
|
|
25
|
-
id: "test", name: "Test", provider: "test",
|
|
24
|
+
PRICED_MODEL = RubyLLM::Model.new(
|
|
25
|
+
id: "test", name: "Test", provider: "test", max_output_tokens: 4096,
|
|
26
26
|
capabilities: %w[function_calling],
|
|
27
27
|
modalities: { input: %w[text], output: %w[text] },
|
|
28
28
|
pricing: { text_tokens: { standard: {
|
|
29
|
-
input_per_million: 20.0,
|
|
29
|
+
input_per_million: 20.0, cache_read_input_per_million: 20.0, output_per_million: 400.0
|
|
30
30
|
} } }
|
|
31
31
|
)
|
|
32
32
|
|
|
33
|
-
UNPRICED_MODEL = RubyLLM::Model
|
|
33
|
+
UNPRICED_MODEL = RubyLLM::Model.new(
|
|
34
34
|
id: "test-unpriced", name: "Test unpriced", provider: "test",
|
|
35
35
|
capabilities: %w[function_calling],
|
|
36
36
|
modalities: { input: %w[text], output: %w[text] }
|
|
37
37
|
)
|
|
38
38
|
|
|
39
|
-
FREE_MODEL = RubyLLM::Model
|
|
39
|
+
FREE_MODEL = RubyLLM::Model.new(
|
|
40
40
|
id: "test-free", name: "Test free", provider: "openrouter",
|
|
41
41
|
capabilities: %w[function_calling],
|
|
42
42
|
modalities: { input: %w[text], output: %w[text] }
|
|
@@ -46,13 +46,11 @@ module Miniswen
|
|
|
46
46
|
|
|
47
47
|
# Serves the test models from ruby_llm's registry lookups.
|
|
48
48
|
module FindTestModels
|
|
49
|
-
def find(model_id,
|
|
49
|
+
def find(model_id, **)
|
|
50
50
|
MODELS[model_id] || super
|
|
51
51
|
end
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
RawResponse = Data.define(:body)
|
|
55
|
-
|
|
56
54
|
SUBMIT = { content: "Done.", cmd: "echo #{Agent::SUBMIT_MARKER}" }.freeze
|
|
57
55
|
|
|
58
56
|
# A shell made of a Hash. `echo` behaves like echo, so the submit marker
|
|
@@ -71,6 +69,7 @@ module Miniswen
|
|
|
71
69
|
|
|
72
70
|
def exec(command, timeout: nil, env: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
73
71
|
@commands << command
|
|
72
|
+
command = command.sub(/\Acd .+?; /, "")
|
|
74
73
|
return result(0, command.delete_prefix("echo ")) if command.start_with?("echo ")
|
|
75
74
|
|
|
76
75
|
exit_code, output = @canned.fetch(command, [ 0, "" ])
|
|
@@ -91,7 +90,7 @@ module Miniswen
|
|
|
91
90
|
|
|
92
91
|
# Each answer is a command string, or a hash with :cmd (one command or many)
|
|
93
92
|
# and/or :content, plus optional overrides (:tool_calls, :finish_reason,
|
|
94
|
-
# :thinking, token counts). Overrides given here apply to every answer.
|
|
93
|
+
# :thinking, :raw, token counts). Overrides given here apply to every answer.
|
|
95
94
|
def stub_llm(*answers, **overrides)
|
|
96
95
|
RubyLLM::Test.stub_responses(*answers.map { llm_answer(it, **overrides) })
|
|
97
96
|
end
|
|
@@ -110,20 +109,17 @@ module Miniswen
|
|
|
110
109
|
signature: answer[:thinking_signature]),
|
|
111
110
|
input_tokens: answer.fetch(:input_tokens, 95),
|
|
112
111
|
output_tokens: answer.fetch(:output_tokens, 20),
|
|
113
|
-
|
|
112
|
+
cache_read_tokens: answer.fetch(:cached_tokens, 5),
|
|
114
113
|
thinking_tokens: answer[:thinking] && 40,
|
|
115
|
-
|
|
114
|
+
raw_reasoning: answer[:reasoning_details],
|
|
115
|
+
reported_cost: answer[:reported_cost],
|
|
116
|
+
raw: answer[:raw],
|
|
117
|
+
finish_reason: answer.fetch(:finish_reason) { calls.empty? ? "stop" : "tool_calls" }
|
|
116
118
|
)
|
|
117
119
|
end
|
|
118
120
|
|
|
119
121
|
private
|
|
120
122
|
|
|
121
|
-
def raw_body_for(answer, calls)
|
|
122
|
-
choice = { "finish_reason" => answer.fetch(:finish_reason) { calls.empty? ? "stop" : "tool_calls" } }
|
|
123
|
-
choice["message"] = { "reasoning_details" => answer[:reasoning_details] } if answer[:reasoning_details]
|
|
124
|
-
{ "choices" => [ choice ] }
|
|
125
|
-
end
|
|
126
|
-
|
|
127
123
|
def tool_calls_for(answer)
|
|
128
124
|
calls = answer[:tool_calls] || Array(answer[:cmd]).map { { name: "bash", arguments: { "command" => it } } }
|
|
129
125
|
calls.to_h do |call|
|
data/lib/miniswen/trajectory.rb
CHANGED
|
@@ -78,7 +78,8 @@ module Miniswen
|
|
|
78
78
|
step[:llm_call_count] = 1
|
|
79
79
|
end
|
|
80
80
|
# ATIF has no field for how a turn ended
|
|
81
|
-
|
|
81
|
+
extra = { finish_reason: message[:finish_reason], provider: message[:provider] }.compact
|
|
82
|
+
step[:extra] = extra unless extra.empty?
|
|
82
83
|
step
|
|
83
84
|
end
|
|
84
85
|
|
data/lib/miniswen/version.rb
CHANGED
data/lib/miniswen.rb
CHANGED
|
@@ -16,14 +16,10 @@ module Miniswen # :nodoc:
|
|
|
16
16
|
class AccountingError < Error; end
|
|
17
17
|
|
|
18
18
|
class << self
|
|
19
|
-
def refresh_registry!
|
|
19
|
+
def refresh_registry!
|
|
20
20
|
return true if @ruby_llm_refreshed
|
|
21
21
|
|
|
22
|
-
RubyLLM.models.refresh
|
|
23
|
-
# save_to_json writes to the registry file every boot loads from (the
|
|
24
|
-
# gem's own models.json by default), so a persisted refresh outlives
|
|
25
|
-
# this process — later runs in the same environment boot from it.
|
|
26
|
-
RubyLLM.models.save_to_json if persist
|
|
22
|
+
RubyLLM.models.refresh
|
|
27
23
|
@ruby_llm_refreshed = true
|
|
28
24
|
rescue StandardError => e
|
|
29
25
|
warn "Failed to refresh RubyLLM registry: #{e.message}"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: miniswen
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Svyatoslav Kryukov
|
|
@@ -31,14 +31,14 @@ dependencies:
|
|
|
31
31
|
requirements:
|
|
32
32
|
- - "~>"
|
|
33
33
|
- !ruby/object:Gem::Version
|
|
34
|
-
version:
|
|
34
|
+
version: 2.1.1
|
|
35
35
|
type: :runtime
|
|
36
36
|
prerelease: false
|
|
37
37
|
version_requirements: !ruby/object:Gem::Requirement
|
|
38
38
|
requirements:
|
|
39
39
|
- - "~>"
|
|
40
40
|
- !ruby/object:Gem::Version
|
|
41
|
-
version:
|
|
41
|
+
version: 2.1.1
|
|
42
42
|
description: A Ruby port of mini-swe-agent.
|
|
43
43
|
email:
|
|
44
44
|
- me@skryukov.dev
|