lemans 1.5.2 → 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/CHANGELOG.md +20 -0
- data/README.md +1 -0
- data/exe/lemans-remote +5 -3
- data/lib/lemans/agents/miniswen.rb +5 -4
- data/lib/lemans/agents/miniswen_installed.rb +2 -1
- data/lib/lemans/environments/daytona/shell.rb +3 -1
- data/lib/lemans/environments/docker.rb +2 -2
- data/lib/lemans/secrets_filter.rb +2 -2
- data/lib/lemans/trial/patch.rb +8 -7
- data/lib/lemans/trial/setup.rb +5 -1
- data/lib/lemans/trial/snapshot.rb +3 -3
- data/lib/lemans/trial/verifier/assets/lemans_minitest_reporter.rb +4 -1
- data/lib/lemans/trial/verifier/test_scanner.rb +1 -1
- data/lib/lemans/trial/verifier.rb +12 -5
- data/lib/lemans/version.rb +1 -1
- data/lib/miniswen/agent.rb +49 -133
- 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 +24 -22
- 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 +11 -19
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c2d58a5beef57da26020d8d076d001fc508b676fa8156bd8bf8782f0a1cbf545
|
|
4
|
+
data.tar.gz: c647edc664022a6d2590da8790c532aa533e4df4cc8e4274628bed6e60a40b7a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a7c995c51cec8fc1fbe38a2d299762ea7908d74bc1136a7bcbaf85d3765f1534e304331ffd81aef1985ef3c87882c8decd32b4b276184497ced82fb421f41146
|
|
7
|
+
data.tar.gz: f227a2b18e947740abb0b838643df69ca26e95d6f9690780af66992bbf63c310501c68c01faef28be0e1e27bedaa2ea4319bada71f0e7b38952cef5c99e2a574
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.6.0] - 2026-11-03
|
|
4
|
+
|
|
5
|
+
- Miniswen: upgrade ruby_llm to 2.1.1 (use its new built-in caching, finish reasons and reasoning replay, keep the ~10 min retry window, honor `RUBYLLM_DEBUG`, allow JSON 3).
|
|
6
|
+
- Miniswen: send `prompt_cache_key` for every OpenRouter model, not only Muse, so a session keeps its cache without a pinned provider order.
|
|
7
|
+
- Miniswen: turn on prompt caching for every model, so models that need it (like Claude, including on the Anthropic API) cache, and those that cache automatically are unaffected.
|
|
8
|
+
- Miniswen: OpenRouter runs cost what OpenRouter actually billed (including discounts or a pricier provider), with registry prices as the rare fallback.
|
|
9
|
+
- Miniswen: trajectories record which upstream provider served each OpenRouter turn.
|
|
10
|
+
- `lemans-remote`: forward all provider keys RubyLLM reads, not only `OPENROUTER_API_KEY`.
|
|
11
|
+
- Daytona: don't hang until the timeout on unclosed heredocs or quotes.
|
|
12
|
+
- Miniswen: run commands in the task's `workdir`, not the image's `WORKDIR`.
|
|
13
|
+
- Setup: clear bootsnap caches when applying an environment patch, so a cache compiled before the patch can't hand the agent the removed code.
|
|
14
|
+
- Secrets filter: redact every `*_KEY` variable, not only `*_API_KEY` (e.g., `AWS_SECRET_ACCESS_KEY`).
|
|
15
|
+
- Trials keep their git state outside the app dir, so the agent's `.git` can't affect results.
|
|
16
|
+
- Miniswen: `--jail` stops even when miniswen is killed, so agent processes can't run into verification.
|
|
17
|
+
- Verifier: reject results when Minitest's core methods are redefined outside its gems.
|
|
18
|
+
- Verifier: recorded test failures score 0.0 even if the tests exit successfully.
|
|
19
|
+
- Verifier: tests that never ran score 0.0, so a run can't pass by skipping them.
|
|
20
|
+
- `miniswen-installed`: a step in a multistep task without a result fails instead of reusing the previous one.
|
|
21
|
+
- Docker: run commands as root, so non-root images work.
|
|
22
|
+
|
|
3
23
|
## [1.5.2] - 2026-10-14
|
|
4
24
|
|
|
5
25
|
- `lemans --require/-r [command]`: load agent, backend plugins (`lemans -r plugin.rb restart RUN`)
|
data/README.md
CHANGED
data/exe/lemans-remote
CHANGED
|
@@ -51,8 +51,9 @@
|
|
|
51
51
|
# exe/lemans-remote snapshots
|
|
52
52
|
# exe/lemans-remote deprovision [--name SNAPSHOT]
|
|
53
53
|
#
|
|
54
|
-
# Credentials come from the host ENV: DAYTONA_API_KEY (or DAYTONA_TOKEN),
|
|
55
|
-
# OPENROUTER_API_KEY,
|
|
54
|
+
# Credentials come from the host ENV: DAYTONA_API_KEY (or DAYTONA_TOKEN), every
|
|
55
|
+
# provider key RubyLLM reads (OPENROUTER_API_KEY, ANTHROPIC_API_KEY, ...),
|
|
56
|
+
# LEMANS_PROVIDER_ORDER (or OPENROUTER_PROVIDER_ORDER);
|
|
56
57
|
# forward extras with --env KEY.
|
|
57
58
|
|
|
58
59
|
lib_path = File.expand_path("../lib", __dir__)
|
|
@@ -101,7 +102,8 @@ module LemansRemote # :nodoc: all
|
|
|
101
102
|
REMOTE_META = "/tmp/lemans-remote-run.json"
|
|
102
103
|
REMOTE_REQUIRES_DIR = "/tmp/lemans-remote-requires"
|
|
103
104
|
|
|
104
|
-
ENV_ALLOWLIST =
|
|
105
|
+
ENV_ALLOWLIST = (RubyLLM::Provider.providers.values.flat_map(&:configuration_requirements).map { it.to_s.upcase } +
|
|
106
|
+
%w[LEMANS_PROVIDER_ORDER OPENROUTER_PROVIDER_ORDER]).uniq.freeze
|
|
105
107
|
|
|
106
108
|
Backend = Lemans::Environments::Daytona
|
|
107
109
|
|
|
@@ -44,7 +44,7 @@ module Lemans
|
|
|
44
44
|
private
|
|
45
45
|
|
|
46
46
|
def obtain_result(task, environment, history)
|
|
47
|
-
agent = agent_for(environment)
|
|
47
|
+
agent = agent_for(environment, workdir: task.environment.workdir)
|
|
48
48
|
begin
|
|
49
49
|
agent.run(task.instruction, history:)
|
|
50
50
|
rescue InfrastructureError, ::Miniswen::InfrastructureError => e
|
|
@@ -55,12 +55,13 @@ module Lemans
|
|
|
55
55
|
# The run as miniswen records it, signatures included: what a recovery continues from
|
|
56
56
|
def raw_result_for(run_result) = JSON.generate(run_result.to_h)
|
|
57
57
|
|
|
58
|
-
def agent_for(environment)
|
|
58
|
+
def agent_for(environment, workdir: nil)
|
|
59
59
|
raise ConfigError, "miniswen needs a model to drive" if model.to_s.empty?
|
|
60
60
|
|
|
61
61
|
::Miniswen::Agent.new(
|
|
62
62
|
model: model.to_s,
|
|
63
63
|
environment: environment,
|
|
64
|
+
workdir: workdir,
|
|
64
65
|
max_steps: profile.step_limit,
|
|
65
66
|
max_time: profile.timeout,
|
|
66
67
|
max_cost: profile.cost_limit,
|
|
@@ -74,8 +75,8 @@ module Lemans
|
|
|
74
75
|
when :format_error
|
|
75
76
|
"#{::Miniswen::Agent::MAX_CONSECUTIVE_FORMAT_ERRORS} consecutive responses without a valid bash tool call"
|
|
76
77
|
when :content_filter
|
|
77
|
-
"the provider stopped the model
|
|
78
|
-
"
|
|
78
|
+
"the provider stopped the model with content_filter: " \
|
|
79
|
+
"#{::Miniswen::Agent::MAX_CONSECUTIVE_FORMAT_ERRORS} consecutive turns ended with no valid tool call"
|
|
79
80
|
end
|
|
80
81
|
end
|
|
81
82
|
|
|
@@ -34,7 +34,8 @@ module Lemans
|
|
|
34
34
|
# comes from a file the sandbox wrote.
|
|
35
35
|
def obtain_result(task, environment, history)
|
|
36
36
|
upload_history(environment, history) if history
|
|
37
|
-
|
|
37
|
+
command = "rm -f #{RESULTS_PATH} && #{command_for(task, history:)}"
|
|
38
|
+
run = environment.exec(command, timeout: outer_timeout, env: provider_env(environment))
|
|
38
39
|
|
|
39
40
|
begin
|
|
40
41
|
Tempfile.create(%w[miniswen .result.json]) do |file|
|
|
@@ -75,13 +75,15 @@ module Lemans
|
|
|
75
75
|
status_file = "/tmp/lemans-#{run_id}.status"
|
|
76
76
|
log_file = "/tmp/lemans-#{run_id}.log"
|
|
77
77
|
exports = env.map { |key, value| "export #{key}=#{Shellwords.escape(value.to_s)}" }.join("\n")
|
|
78
|
+
# Encoded, so an unclosed heredoc or quote can't swallow the status line.
|
|
79
|
+
run = "bash -c \"$(printf %s #{[ command ].pack("m0")} | base64 -d)\" < /dev/null"
|
|
78
80
|
|
|
79
81
|
sandbox.process.execute_session_command(
|
|
80
82
|
session_id: @session_id,
|
|
81
83
|
req: ::Daytona::SessionExecuteRequest.new(
|
|
82
84
|
# A subshell, not a brace group: `set -e`/`exit` would take the
|
|
83
85
|
# session's own shell down and leave the status line unwritten.
|
|
84
|
-
command: "(\n#{exports}\n#{
|
|
86
|
+
command: "(\n#{exports}\n#{run}\n) > #{Shellwords.escape(log_file)} 2>&1\n" \
|
|
85
87
|
"echo $? > #{Shellwords.escape(status_file)}",
|
|
86
88
|
run_async: true
|
|
87
89
|
)
|
|
@@ -42,7 +42,7 @@ module Lemans
|
|
|
42
42
|
def exec(command, timeout: nil, env: {})
|
|
43
43
|
timeout ||= DEFAULT_TIMEOUT
|
|
44
44
|
started = now
|
|
45
|
-
argv = [ "exec" ]
|
|
45
|
+
argv = [ "exec", "--user", "0" ]
|
|
46
46
|
env = proxy_env.merge(env) if network.allowlist?
|
|
47
47
|
env.each { |key, value| argv += [ "--env", "#{key}=#{value}" ] }
|
|
48
48
|
# The in-container timeout is what actually kills the process
|
|
@@ -53,7 +53,7 @@ module Lemans
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def upload(local_path, remote_path)
|
|
56
|
-
docker!("exec", container, "mkdir", "-p", File.dirname(remote_path.to_s))
|
|
56
|
+
docker!("exec", "--user", "0", container, "mkdir", "-p", File.dirname(remote_path.to_s))
|
|
57
57
|
docker!("cp", local_path.to_s, "#{container}:#{remote_path}")
|
|
58
58
|
end
|
|
59
59
|
|
|
@@ -4,8 +4,8 @@ module Lemans
|
|
|
4
4
|
# Redacts known secret values (API credentials) from everything the store persists.
|
|
5
5
|
class SecretsFilter
|
|
6
6
|
# Credentials follow the <PROVIDER>_<KIND> environment convention
|
|
7
|
-
# (DAYTONA_API_KEY, OPENROUTER_API_KEY, DAYTONA_TOKEN, ...).
|
|
8
|
-
def self.default = new(ENV.filter_map { |name, value| value if name.match?(/_(
|
|
7
|
+
# (DAYTONA_API_KEY, OPENROUTER_API_KEY, AWS_SECRET_ACCESS_KEY, DAYTONA_TOKEN, ...).
|
|
8
|
+
def self.default = new(ENV.filter_map { |name, value| value if name.match?(/_(KEY|TOKEN|SECRET|JWT)\z/) })
|
|
9
9
|
|
|
10
10
|
def initialize(secrets, replacement: "<filtered>", min_length: 8)
|
|
11
11
|
@replacement = replacement
|
data/lib/lemans/trial/patch.rb
CHANGED
|
@@ -9,8 +9,11 @@ module Lemans
|
|
|
9
9
|
# The agent's work as one git patch, diffed against a baseline sealed before
|
|
10
10
|
# its first turn
|
|
11
11
|
class Patch
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
GIT_DIR = "/var/lib/lemans/git"
|
|
13
|
+
GIT = "GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git"
|
|
14
|
+
INIT = "#{GIT} init -q --bare #{GIT_DIR}".freeze
|
|
15
|
+
REMOTE_PATCH = "#{GIT_DIR}/agent.patch".freeze
|
|
16
|
+
REMOTE_INDEX = "#{GIT_DIR}/patch.idx".freeze
|
|
14
17
|
|
|
15
18
|
private attr_reader :task, :environment, :path, :workdir, :baseline, :savepoint, :timeout
|
|
16
19
|
|
|
@@ -77,7 +80,7 @@ module Lemans
|
|
|
77
80
|
return unless savepoint
|
|
78
81
|
|
|
79
82
|
environment.exec!(
|
|
80
|
-
"#{git} read-tree #{savepoint} && #{git} checkout-index -f -a && #{git} clean -fd
|
|
83
|
+
"#{git} read-tree #{savepoint} && #{git} checkout-index -f -a && #{git} clean -fd",
|
|
81
84
|
timeout:
|
|
82
85
|
)
|
|
83
86
|
end
|
|
@@ -112,16 +115,14 @@ module Lemans
|
|
|
112
115
|
nil
|
|
113
116
|
end
|
|
114
117
|
|
|
115
|
-
|
|
116
|
-
# than built it, and git refuses to read a repo it thinks is someone else's.
|
|
117
|
-
def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
|
|
118
|
+
def git = "#{GIT} -C #{Shellwords.escape(workdir)} --git-dir=#{GIT_DIR} --work-tree=."
|
|
118
119
|
|
|
119
120
|
# Untracked files only reach a diff through an index, so both sides are
|
|
120
121
|
# staged into a scratch one and hashed. Rebuilt from empty each time, so a
|
|
121
122
|
# stale entry cannot survive into the second tree.
|
|
122
123
|
def write_tree
|
|
123
124
|
result = environment.exec(
|
|
124
|
-
"rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
|
|
125
|
+
"#{INIT} && rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
|
|
125
126
|
"GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree",
|
|
126
127
|
timeout:
|
|
127
128
|
)
|
data/lib/lemans/trial/setup.rb
CHANGED
|
@@ -52,7 +52,7 @@ module Lemans
|
|
|
52
52
|
workdir = Shellwords.escape(task.environment.workdir)
|
|
53
53
|
environment.exec!(
|
|
54
54
|
"cd #{workdir} && git apply --binary --whitespace=nowarn #{Shellwords.escape(seed)} && " \
|
|
55
|
-
"
|
|
55
|
+
"#{clear_bootsnap_caches} && #{unify_mtimes} && " \
|
|
56
56
|
"rm -rf .git && git init -q && git add -A && " \
|
|
57
57
|
"git -c user.name=lemans -c user.email=lemans@localhost commit -qm 'Initial commit'",
|
|
58
58
|
timeout:
|
|
@@ -62,6 +62,10 @@ module Lemans
|
|
|
62
62
|
def run_commands!(environment)
|
|
63
63
|
commands.each { environment.exec!(it, timeout:) }
|
|
64
64
|
end
|
|
65
|
+
|
|
66
|
+
def clear_bootsnap_caches = 'rm -rf "${BOOTSNAP_CACHE_DIR:-tmp/cache}"/bootsnap*'
|
|
67
|
+
|
|
68
|
+
def unify_mtimes = "find . -path ./.git -prune -o -exec touch -h {} +"
|
|
65
69
|
end
|
|
66
70
|
end
|
|
67
71
|
end
|
|
@@ -7,7 +7,7 @@ module Lemans
|
|
|
7
7
|
# The sealed baseline of a task's graded surfaces: a git tree written before
|
|
8
8
|
# the agent's first turn, checked out over the live tree at verification.
|
|
9
9
|
class Snapshot
|
|
10
|
-
REMOTE_INDEX = "/
|
|
10
|
+
REMOTE_INDEX = "#{Patch::GIT_DIR}/baseline.idx".freeze
|
|
11
11
|
|
|
12
12
|
private attr_reader :task, :environment, :workdir, :paths, :timeout, :baseline
|
|
13
13
|
|
|
@@ -26,7 +26,7 @@ module Lemans
|
|
|
26
26
|
return if paths.empty?
|
|
27
27
|
|
|
28
28
|
result = environment.exec(
|
|
29
|
-
"rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
|
|
29
|
+
"#{Patch::INIT} && rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
|
|
30
30
|
"GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree && rm -f #{REMOTE_INDEX}",
|
|
31
31
|
timeout:
|
|
32
32
|
)
|
|
@@ -51,7 +51,7 @@ module Lemans
|
|
|
51
51
|
|
|
52
52
|
private
|
|
53
53
|
|
|
54
|
-
def git = "
|
|
54
|
+
def git = "#{Patch::GIT} -C #{Shellwords.escape(workdir)} --git-dir=#{Patch::GIT_DIR} --work-tree=."
|
|
55
55
|
end
|
|
56
56
|
end
|
|
57
57
|
end
|
|
@@ -36,7 +36,7 @@ module LemansReport
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def report
|
|
39
|
-
abort "lemans:
|
|
39
|
+
abort "lemans: Minitest was tampered with, so no result can be trusted" if tampered?
|
|
40
40
|
|
|
41
41
|
graded = @results.select { graded?(it) }
|
|
42
42
|
prior = existing.fetch("checks", {})
|
|
@@ -66,6 +66,9 @@ module LemansReport
|
|
|
66
66
|
private
|
|
67
67
|
|
|
68
68
|
def tampered?
|
|
69
|
+
gem = Gem.loaded_specs["minitest"].full_gem_path
|
|
70
|
+
return true unless Minitest::Test.instance_method(:run).source_location.first.start_with?("#{gem}/")
|
|
71
|
+
|
|
69
72
|
Minitest::Test.new("probe").assert(false)
|
|
70
73
|
true
|
|
71
74
|
rescue Minitest::Assertion
|
|
@@ -61,7 +61,7 @@ module Lemans
|
|
|
61
61
|
def visit_class_node(node) = scoped(node) { super }
|
|
62
62
|
|
|
63
63
|
def visit_def_node(node)
|
|
64
|
-
return super unless node.name.start_with?("test_")
|
|
64
|
+
return super unless node.name.start_with?("test_") && node.parameters.nil?
|
|
65
65
|
|
|
66
66
|
within("#{@scope.join("::")}##{node.name}", node) { super }
|
|
67
67
|
end
|
|
@@ -100,8 +100,8 @@ module Lemans
|
|
|
100
100
|
|
|
101
101
|
result = environment.exec(command, timeout:, env:)
|
|
102
102
|
|
|
103
|
-
reward = read_reward(result)
|
|
104
103
|
checks = read_checks
|
|
104
|
+
reward = read_reward(result, checks)
|
|
105
105
|
features = (self.class.features_from(checks.fetch("checks", {}), TestScanner.for(task)&.features) if checks && task.final_step?)
|
|
106
106
|
Verification.new(reward:, credit: credit_from(checks, reward), features:, logs: result.output.to_s)
|
|
107
107
|
end
|
|
@@ -110,10 +110,10 @@ module Lemans
|
|
|
110
110
|
[ task.verifier.preverify, task.verifier.command ].compact.map { "( #{it} )" }.join(" && ")
|
|
111
111
|
end
|
|
112
112
|
|
|
113
|
-
def read_reward(command_result)
|
|
113
|
+
def read_reward(command_result, checks)
|
|
114
114
|
reward_path = task.verifier.reward_path
|
|
115
115
|
present = environment.exec("test -e #{Shellwords.escape(reward_path)}")
|
|
116
|
-
return reward_from_exit(command_result) unless present.success?
|
|
116
|
+
return reward_from_exit(command_result, checks) unless present.success?
|
|
117
117
|
|
|
118
118
|
result = environment.exec("cat #{Shellwords.escape(reward_path)}")
|
|
119
119
|
raise VerifierError, "could not read #{reward_path}: #{result.output.to_s[0, 500]}" unless result.success?
|
|
@@ -154,9 +154,9 @@ module Lemans
|
|
|
154
154
|
(base_credit + (1 - base_credit) * (passed.to_f / total)).round(2)
|
|
155
155
|
end
|
|
156
156
|
|
|
157
|
-
def reward_from_exit(command_result)
|
|
157
|
+
def reward_from_exit(command_result, checks)
|
|
158
158
|
case command_result.exit_code
|
|
159
|
-
when 0 then
|
|
159
|
+
when 0 then clean_exit_reward(checks)
|
|
160
160
|
when 1 then 0.0
|
|
161
161
|
else
|
|
162
162
|
raise VerifierError,
|
|
@@ -164,6 +164,13 @@ module Lemans
|
|
|
164
164
|
end
|
|
165
165
|
end
|
|
166
166
|
|
|
167
|
+
def clean_exit_reward(checks)
|
|
168
|
+
return 0.0 if checks&.dig("failures")&.any?
|
|
169
|
+
return 0.0 if TestScanner.for(task)&.tests&.any? { !checks&.dig("checks")&.key?(it) }
|
|
170
|
+
|
|
171
|
+
1.0
|
|
172
|
+
end
|
|
173
|
+
|
|
167
174
|
def download_evidence(collector)
|
|
168
175
|
return unless collector
|
|
169
176
|
|
data/lib/lemans/version.rb
CHANGED
data/lib/miniswen/agent.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "securerandom"
|
|
5
|
+
require "shellwords"
|
|
5
6
|
require "time"
|
|
6
7
|
require "miniswen/version"
|
|
7
8
|
require "miniswen/ruby_llm"
|
|
@@ -14,21 +15,6 @@ module Miniswen
|
|
|
14
15
|
MAX_OBSERVATION_CHARS = 10_000
|
|
15
16
|
MAX_CONSECUTIVE_FORMAT_ERRORS = 3
|
|
16
17
|
|
|
17
|
-
# Both finish_reason dialects accepted raw: OpenAI-shaped providers say
|
|
18
|
-
# "length"/"tool_calls", Anthropic says "max_tokens"/"tool_use".
|
|
19
|
-
TRUNCATION_FINISH_REASONS = %w[length max_tokens model_context_window_exceeded].freeze
|
|
20
|
-
CLAIMED_TOOL_FINISH_REASONS = %w[tool_calls tool_use].freeze
|
|
21
|
-
# A safety stop, which arrives looking exactly like a model that forgot
|
|
22
|
-
# to call the tool: no content, no tool call, and — since the provider
|
|
23
|
-
# bills nothing for a turn it refused — no tokens either. Only the
|
|
24
|
-
# finish reason tells the two apart, so the run is labelled by it. The
|
|
25
|
-
# retry is unchanged: the nudge still goes back, because matching
|
|
26
|
-
# mini-swe-agent turn for turn is what makes runs comparable.
|
|
27
|
-
REFUSAL_FINISH_REASONS = %w[content_filter refusal safety].freeze
|
|
28
|
-
|
|
29
|
-
# The breakpoint marker Anthropic reads, shaped the way OpenRouter forwards it.
|
|
30
|
-
CACHE_CONTROL = { type: "ephemeral" }.freeze
|
|
31
|
-
|
|
32
18
|
EXEC_ENV = {
|
|
33
19
|
"PAGER" => "cat",
|
|
34
20
|
"MANPAGER" => "cat",
|
|
@@ -37,9 +23,6 @@ module Miniswen
|
|
|
37
23
|
"TQDM_DISABLE" => "1"
|
|
38
24
|
}.freeze
|
|
39
25
|
|
|
40
|
-
# Providers that serve local inference (and cost zero)
|
|
41
|
-
LOCAL_PROVIDERS = %i[ollama gpustack].freeze
|
|
42
|
-
|
|
43
26
|
SYSTEM_TEMPLATE = <<~PROMPT
|
|
44
27
|
You are a helpful assistant that can interact with a computer.
|
|
45
28
|
PROMPT
|
|
@@ -168,20 +151,10 @@ module Miniswen
|
|
|
168
151
|
without any other command.
|
|
169
152
|
MESSAGE
|
|
170
153
|
|
|
171
|
-
# Thinking that also carries the provider's reasoning_details for verbatim replay.
|
|
172
|
-
class VerbatimThinking < RubyLLM::Thinking
|
|
173
|
-
attr_reader :details
|
|
174
|
-
|
|
175
|
-
def initialize(text: nil, signature: nil, details: nil)
|
|
176
|
-
super(text: text, signature: signature)
|
|
177
|
-
@details = details
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
|
-
|
|
181
154
|
# Only ever rendered into the request — the completion executes nothing — so no execute body.
|
|
182
155
|
class BashTool < RubyLLM::Tool
|
|
183
156
|
description "Execute a bash command"
|
|
184
|
-
|
|
157
|
+
parameter :command, description: "The bash command to execute"
|
|
185
158
|
|
|
186
159
|
# ruby_llm would otherwise derive "lemans--miniswen--bash" from the class path.
|
|
187
160
|
def name = "bash"
|
|
@@ -233,13 +206,13 @@ module Miniswen
|
|
|
233
206
|
|
|
234
207
|
attr_reader :messages, :environment
|
|
235
208
|
|
|
236
|
-
private attr_reader :max_steps, :max_time, :max_cost, :exec_timeout, :max_output_tokens,
|
|
209
|
+
private attr_reader :workdir, :max_steps, :max_time, :max_cost, :exec_timeout, :max_output_tokens,
|
|
237
210
|
:cache_id, :clock, :reporter
|
|
238
211
|
|
|
239
212
|
# `model` is a litellm-style name ("openrouter/z-ai/glm-5.2"), optionally
|
|
240
213
|
# suffixed with a reasoning effort ("openrouter/openai/gpt-5.6-luna#xhigh").
|
|
241
214
|
# Limits of 0 or nil are disabled.
|
|
242
|
-
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,
|
|
243
216
|
exec_timeout: 30, max_output_tokens: 0,
|
|
244
217
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
245
218
|
reporter: nil)
|
|
@@ -252,6 +225,7 @@ module Miniswen
|
|
|
252
225
|
|
|
253
226
|
@model = model
|
|
254
227
|
@environment = environment
|
|
228
|
+
@workdir = workdir
|
|
255
229
|
|
|
256
230
|
@bash_tool = BashTool.new
|
|
257
231
|
|
|
@@ -339,6 +313,7 @@ module Miniswen
|
|
|
339
313
|
@steps = 0
|
|
340
314
|
@cost = 0.0
|
|
341
315
|
@cost_known = true
|
|
316
|
+
@cost_reported = false
|
|
342
317
|
@totals = { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 }
|
|
343
318
|
@started_at = @clock.call
|
|
344
319
|
end
|
|
@@ -350,6 +325,7 @@ module Miniswen
|
|
|
350
325
|
@steps = history.steps.to_i
|
|
351
326
|
@cost = history.cost_usd.to_f
|
|
352
327
|
@cost_known = !history.cost_usd.nil?
|
|
328
|
+
@cost_reported = history.cost_source&.name == :provider_reported
|
|
353
329
|
@totals = { input_tokens: history.input_tokens.to_i, output_tokens: history.output_tokens.to_i,
|
|
354
330
|
cached_tokens: history.cached_tokens.to_i, thinking_tokens: history.thinking_tokens.to_i }
|
|
355
331
|
@started_at = @clock.call - elapsed(history.messages)
|
|
@@ -371,6 +347,7 @@ module Miniswen
|
|
|
371
347
|
end
|
|
372
348
|
|
|
373
349
|
def execute(command)
|
|
350
|
+
command = "cd #{Shellwords.escape(workdir)}; #{command}" if workdir
|
|
374
351
|
environment.exec(command, timeout: exec_timeout, env: EXEC_ENV)
|
|
375
352
|
end
|
|
376
353
|
|
|
@@ -399,6 +376,7 @@ module Miniswen
|
|
|
399
376
|
# content, no tool call and no billed tokens.
|
|
400
377
|
entry[:finish_reason] = response[:finish_reason] if response[:finish_reason]
|
|
401
378
|
entry[:thinking] = response[:thinking] if response[:thinking]
|
|
379
|
+
entry[:provider] = response[:provider] if response[:provider]
|
|
402
380
|
# The provider's opaque handles on this turn's reasoning: replayed back
|
|
403
381
|
# to the provider only, never into the trajectory.
|
|
404
382
|
entry[:thinking_signature] = response[:thinking_signature] if response[:thinking_signature]
|
|
@@ -412,7 +390,7 @@ module Miniswen
|
|
|
412
390
|
# message with unanswered tool calls is a request providers reject.
|
|
413
391
|
entry[:invalid_tool_calls] = tool_calls if tool_calls.any?
|
|
414
392
|
@consecutive_format_errors += 1
|
|
415
|
-
@refused_turns += 1 if refused
|
|
393
|
+
@refused_turns += 1 if response[:refused]
|
|
416
394
|
|
|
417
395
|
observe_message({ role: "user", content: format_error_message(error, response, tool_calls) })
|
|
418
396
|
|
|
@@ -425,8 +403,6 @@ module Miniswen
|
|
|
425
403
|
tool_calls
|
|
426
404
|
end
|
|
427
405
|
|
|
428
|
-
def refused?(response) = REFUSAL_FINISH_REASONS.include?(response[:finish_reason].to_s)
|
|
429
|
-
|
|
430
406
|
# An unpriced completion under a cost ceiling is fatal on the spot: only failing fast stops
|
|
431
407
|
# the spend. Without a ceiling, unknown cost is just a fact to report.
|
|
432
408
|
def track_cost(response)
|
|
@@ -441,6 +417,7 @@ module Miniswen
|
|
|
441
417
|
else
|
|
442
418
|
@cost += cost
|
|
443
419
|
end
|
|
420
|
+
@cost_reported = true if response[:reported]
|
|
444
421
|
end
|
|
445
422
|
|
|
446
423
|
def actions_error(tool_calls)
|
|
@@ -466,10 +443,8 @@ module Miniswen
|
|
|
466
443
|
end
|
|
467
444
|
|
|
468
445
|
def format_error_message(error, response, tool_calls)
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
(CLAIMED_TOOL_FINISH_REASONS.include?(finish_reason) && tool_calls.empty?)
|
|
472
|
-
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])
|
|
473
448
|
else
|
|
474
449
|
format(TOOL_CALL_ERROR_MESSAGE, error: error)
|
|
475
450
|
end
|
|
@@ -548,11 +523,13 @@ module Miniswen
|
|
|
548
523
|
def complete(messages)
|
|
549
524
|
model_info, provider = resolved
|
|
550
525
|
response = provider.complete(
|
|
551
|
-
|
|
526
|
+
messages.map { as_ruby_llm(it) },
|
|
552
527
|
tools: { bash: @bash_tool },
|
|
553
528
|
temperature: nil,
|
|
554
529
|
model: model_info,
|
|
555
|
-
|
|
530
|
+
provider_options: routing_params,
|
|
531
|
+
max_output_tokens: output_cap,
|
|
532
|
+
caching: {},
|
|
556
533
|
thinking: (RubyLLM::Thinking::Config.new(effort: @effort) if @effort)
|
|
557
534
|
)
|
|
558
535
|
payload(response)
|
|
@@ -564,30 +541,10 @@ module Miniswen
|
|
|
564
541
|
raise InfrastructureError, "miniswen: the model call failed: #{e.class}: #{e.message}"
|
|
565
542
|
end
|
|
566
543
|
|
|
567
|
-
# Anthropic bills every token fresh unless the request marks explicit cache
|
|
568
|
-
# breakpoints
|
|
569
|
-
def explicit_cache? = @provider == "openrouter" && @id.to_s.start_with?("anthropic/")
|
|
570
|
-
|
|
571
|
-
def with_cache_breakpoints(messages)
|
|
572
|
-
return messages unless explicit_cache?
|
|
573
|
-
|
|
574
|
-
system = messages.find { it.role == :system }
|
|
575
|
-
[ system, messages.last ].compact.uniq.each do |message|
|
|
576
|
-
text = message.content
|
|
577
|
-
next unless text.is_a?(String) && !text.empty?
|
|
578
|
-
|
|
579
|
-
message.content = RubyLLM::Content::Raw.new(
|
|
580
|
-
[ { type: "text", text: text, cache_control: CACHE_CONTROL } ]
|
|
581
|
-
)
|
|
582
|
-
end
|
|
583
|
-
messages
|
|
584
|
-
end
|
|
585
|
-
|
|
586
544
|
def routing_params
|
|
587
545
|
return {} unless @provider == "openrouter"
|
|
588
546
|
|
|
589
|
-
params = {}
|
|
590
|
-
params[:prompt_cache_key] = cache_id if @id.start_with?("meta/muse-")
|
|
547
|
+
params = { prompt_cache_key: cache_id }
|
|
591
548
|
if (order = provider_order)
|
|
592
549
|
params[:provider] = { order: order.split(",").map(&:strip), allow_fallbacks: false }
|
|
593
550
|
end
|
|
@@ -596,20 +553,8 @@ module Miniswen
|
|
|
596
553
|
|
|
597
554
|
def provider_order = ENV["LEMANS_PROVIDER_ORDER"] || ENV["OPENROUTER_PROVIDER_ORDER"]
|
|
598
555
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
def output_cap_params(model_info)
|
|
602
|
-
return {} if max_output_tokens.zero?
|
|
603
|
-
|
|
604
|
-
cap = [ info&.max_tokens, max_output_tokens ].compact.min
|
|
605
|
-
provider_class = RubyLLM::Provider.providers[model_info.provider.to_sym]
|
|
606
|
-
if [ RubyLLM::Providers::OpenAI, RubyLLM::Providers::Azure ].include?(provider_class)
|
|
607
|
-
{ max_completion_tokens: cap }
|
|
608
|
-
elsif provider_class <= RubyLLM::Providers::OpenAI || provider_class <= RubyLLM::Providers::Anthropic
|
|
609
|
-
{ max_tokens: cap }
|
|
610
|
-
else
|
|
611
|
-
{}
|
|
612
|
-
end
|
|
556
|
+
def output_cap
|
|
557
|
+
[ info&.max_output_tokens, max_output_tokens ].compact.min unless max_output_tokens.zero?
|
|
613
558
|
end
|
|
614
559
|
|
|
615
560
|
def cost_source
|
|
@@ -618,12 +563,12 @@ module Miniswen
|
|
|
618
563
|
priced_as: "#{@provider || info&.provider}/#{@id} ($0.00, local)",
|
|
619
564
|
registry: nil)
|
|
620
565
|
end
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
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)",
|
|
625
569
|
registry: Miniswen.registry_revision)
|
|
626
570
|
end
|
|
571
|
+
return nil unless info
|
|
627
572
|
|
|
628
573
|
CostSource.new(name: :model_registry, model: @model,
|
|
629
574
|
priced_as: "#{info.provider}/#{info.id}",
|
|
@@ -631,14 +576,16 @@ module Miniswen
|
|
|
631
576
|
end
|
|
632
577
|
|
|
633
578
|
def resolved
|
|
634
|
-
@resolved ||= RubyLLM::Models.resolve(@id, provider: @provider,
|
|
579
|
+
@resolved ||= RubyLLM::Models.resolve(@id, provider: @provider, assume_model_exists: !@provider.nil?)
|
|
635
580
|
end
|
|
636
581
|
|
|
637
582
|
def as_ruby_llm(entry)
|
|
638
583
|
case entry[:role]
|
|
639
584
|
when "assistant"
|
|
640
|
-
RubyLLM::
|
|
641
|
-
|
|
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 ],
|
|
642
589
|
tool_calls: as_ruby_llm_tool_calls(entry[:tool_calls]))
|
|
643
590
|
when "tool"
|
|
644
591
|
RubyLLM::Message.new(role: :tool, content: entry[:content], tool_call_id: entry[:tool_call_id])
|
|
@@ -647,13 +594,11 @@ module Miniswen
|
|
|
647
594
|
end
|
|
648
595
|
end
|
|
649
596
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
RubyLLM::Thinking.build(text: entry[:thinking], signature: entry[:thinking_signature])
|
|
656
|
-
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)
|
|
657
602
|
end
|
|
658
603
|
|
|
659
604
|
def as_ruby_llm_tool_calls(tool_calls)
|
|
@@ -674,16 +619,20 @@ module Miniswen
|
|
|
674
619
|
content: response.content.to_s,
|
|
675
620
|
thinking: response.thinking&.text,
|
|
676
621
|
thinking_signature: response.thinking&.signature,
|
|
677
|
-
reasoning_details:
|
|
622
|
+
reasoning_details: response.raw_reasoning,
|
|
678
623
|
tool_calls: tool_calls_from(response),
|
|
679
|
-
finish_reason:
|
|
624
|
+
finish_reason: response.finish_reason&.to_s,
|
|
625
|
+
truncated: response.max_tokens?,
|
|
626
|
+
refused: response.content_filtered?,
|
|
680
627
|
# ruby_llm's input_tokens is the cache-miss remainder only; the
|
|
681
628
|
# convention counts the whole prompt, cached and cache-write included.
|
|
682
|
-
input_tokens:
|
|
683
|
-
output_tokens:
|
|
684
|
-
cached_tokens: tokens
|
|
685
|
-
thinking_tokens: tokens
|
|
686
|
-
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")
|
|
687
636
|
}
|
|
688
637
|
end
|
|
689
638
|
|
|
@@ -704,27 +653,7 @@ module Miniswen
|
|
|
704
653
|
arguments
|
|
705
654
|
end
|
|
706
655
|
|
|
707
|
-
def
|
|
708
|
-
body = raw_body(response)
|
|
709
|
-
body && (body.dig("choices", 0, "finish_reason") || body["stop_reason"])
|
|
710
|
-
end
|
|
711
|
-
|
|
712
|
-
def reasoning_details_from(response)
|
|
713
|
-
details = raw_body(response)&.dig("choices", 0, "message", "reasoning_details")
|
|
714
|
-
details.is_a?(Array) && !details.empty? ? details : nil
|
|
715
|
-
end
|
|
716
|
-
|
|
717
|
-
def raw_body(response)
|
|
718
|
-
body = response.raw&.body
|
|
719
|
-
body = JSON.parse(body) if body.is_a?(String)
|
|
720
|
-
body.is_a?(Hash) ? body : nil
|
|
721
|
-
rescue JSON::ParserError
|
|
722
|
-
nil
|
|
723
|
-
end
|
|
724
|
-
|
|
725
|
-
def local? = LOCAL_PROVIDERS.include?((@provider || info&.provider)&.to_sym)
|
|
726
|
-
|
|
727
|
-
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
|
|
728
657
|
|
|
729
658
|
def info
|
|
730
659
|
return @info if defined?(@info)
|
|
@@ -733,28 +662,15 @@ module Miniswen
|
|
|
733
662
|
end
|
|
734
663
|
|
|
735
664
|
def find_model
|
|
736
|
-
|
|
665
|
+
RubyLLM.models.find(@id, provider: @provider)
|
|
737
666
|
rescue RubyLLM::ModelNotFoundError
|
|
738
667
|
nil
|
|
739
668
|
end
|
|
740
669
|
|
|
741
670
|
def price(response)
|
|
742
|
-
return 0.0 if local? ||
|
|
671
|
+
return 0.0 if local? || (response.tokens.to_h.empty? && response.tokens.reported_cost.nil?)
|
|
743
672
|
|
|
744
|
-
|
|
745
|
-
output = info&.output_price_per_million
|
|
746
|
-
return nil unless input.is_a?(Numeric) && output.is_a?(Numeric)
|
|
747
|
-
|
|
748
|
-
tokens = response.tokens
|
|
749
|
-
cache_read = info.cache_read_input_price_per_million || input
|
|
750
|
-
cache_write = info.cache_write_input_price_per_million || input
|
|
751
|
-
# Providers usually fold thinking into output_tokens; max() bills the
|
|
752
|
-
# larger count once and can never double-bill.
|
|
753
|
-
generated = [ response.output_tokens.to_i, tokens&.thinking.to_i ].max
|
|
754
|
-
((response.input_tokens.to_i * input) +
|
|
755
|
-
(tokens&.cached.to_i * cache_read) +
|
|
756
|
-
(tokens&.cache_creation.to_i * cache_write) +
|
|
757
|
-
(generated * output)) / 1_000_000.0
|
|
673
|
+
info ? info.cost_for(response.tokens).total : response.tokens.reported_cost
|
|
758
674
|
end
|
|
759
675
|
end
|
|
760
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,36 +28,37 @@ RubyLLM.configure do |config|
|
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
module Miniswen
|
|
30
|
-
#
|
|
31
|
-
# rebuilds them from its collapsed text+signature pair, and providers that
|
|
32
|
-
# sign each block separately reject that as a corrupted thought signature.
|
|
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
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# ruby_llm lists SSL errors as fatal, but a handshake reset never sent the
|
|
41
|
-
# request; a 200 with a truncated JSON body is the same dropped connection
|
|
42
|
-
# one step later.
|
|
31
|
+
# A 200 cut off mid-body is a dropped connection, but ruby_llm doesn't retry it.
|
|
43
32
|
module RetryTransientFailures
|
|
44
|
-
def retry_exceptions = super + [ Faraday::
|
|
33
|
+
def retry_exceptions = super + [ Faraday::ParsingError ]
|
|
45
34
|
end
|
|
46
35
|
|
|
47
36
|
# ruby_llm parses tool-call arguments while it builds the message, so one
|
|
48
37
|
# malformed call would raise and take the whole completion with it. The raw
|
|
49
38
|
# string goes through instead and bounces as a format error.
|
|
50
39
|
module LenientToolArguments
|
|
51
|
-
def parse_tool_call_arguments(tool_call)
|
|
40
|
+
def parse_tool_call_arguments(tool_call, **)
|
|
52
41
|
super
|
|
53
|
-
rescue
|
|
42
|
+
rescue RubyLLM::ToolCallParseError
|
|
54
43
|
tool_call.dig("function", "arguments")
|
|
55
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
|
|
56
57
|
end
|
|
57
58
|
end
|
|
58
59
|
|
|
59
|
-
RubyLLM::
|
|
60
|
-
RubyLLM::
|
|
61
|
-
|
|
62
|
-
|
|
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: lemans
|
|
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
|
|
@@ -53,34 +53,26 @@ dependencies:
|
|
|
53
53
|
- - "~>"
|
|
54
54
|
- !ruby/object:Gem::Version
|
|
55
55
|
version: '0.203'
|
|
56
|
-
- !ruby/object:Gem::Dependency
|
|
57
|
-
name: json
|
|
58
|
-
requirement: !ruby/object:Gem::Requirement
|
|
59
|
-
requirements:
|
|
60
|
-
- - "<"
|
|
61
|
-
- !ruby/object:Gem::Version
|
|
62
|
-
version: '3'
|
|
63
|
-
type: :runtime
|
|
64
|
-
prerelease: false
|
|
65
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
66
|
-
requirements:
|
|
67
|
-
- - "<"
|
|
68
|
-
- !ruby/object:Gem::Version
|
|
69
|
-
version: '3'
|
|
70
56
|
- !ruby/object:Gem::Dependency
|
|
71
57
|
name: faraday
|
|
72
58
|
requirement: !ruby/object:Gem::Requirement
|
|
73
59
|
requirements:
|
|
74
60
|
- - "~>"
|
|
75
61
|
- !ruby/object:Gem::Version
|
|
76
|
-
version: '2.
|
|
62
|
+
version: '2.14'
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: 2.14.4
|
|
77
66
|
type: :runtime
|
|
78
67
|
prerelease: false
|
|
79
68
|
version_requirements: !ruby/object:Gem::Requirement
|
|
80
69
|
requirements:
|
|
81
70
|
- - "~>"
|
|
82
71
|
- !ruby/object:Gem::Version
|
|
83
|
-
version: '2.
|
|
72
|
+
version: '2.14'
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: 2.14.4
|
|
84
76
|
- !ruby/object:Gem::Dependency
|
|
85
77
|
name: faraday-multipart
|
|
86
78
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -143,14 +135,14 @@ dependencies:
|
|
|
143
135
|
requirements:
|
|
144
136
|
- - "~>"
|
|
145
137
|
- !ruby/object:Gem::Version
|
|
146
|
-
version:
|
|
138
|
+
version: 2.1.1
|
|
147
139
|
type: :runtime
|
|
148
140
|
prerelease: false
|
|
149
141
|
version_requirements: !ruby/object:Gem::Requirement
|
|
150
142
|
requirements:
|
|
151
143
|
- - "~>"
|
|
152
144
|
- !ruby/object:Gem::Version
|
|
153
|
-
version:
|
|
145
|
+
version: 2.1.1
|
|
154
146
|
- !ruby/object:Gem::Dependency
|
|
155
147
|
name: thor
|
|
156
148
|
requirement: !ruby/object:Gem::Requirement
|