lemans 1.0.0 → 1.2.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 +11 -0
- data/README.md +15 -3
- data/exe/lemans-remote +105 -12
- data/lib/lemans/agents/oracle.rb +13 -7
- data/lib/lemans/cli/templates/bench/README.md +7 -2
- data/lib/lemans/cli/templates/bench/bench.yml +3 -1
- data/lib/lemans/cli/templates/bench/tasks/example-task/instruction.md +20 -9
- data/lib/lemans/cli/templates/bench/tasks/example-task/solution.1.patch +17 -0
- data/lib/lemans/cli/templates/bench/tasks/example-task/solution.2.patch +17 -0
- data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.1.rb +31 -0
- data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.rb +1 -1
- data/lib/lemans/config/agent.rb +12 -0
- data/lib/lemans/config/environment.rb +28 -2
- data/lib/lemans/config/network_policy.rb +2 -0
- data/lib/lemans/config/setup.rb +6 -1
- data/lib/lemans/config/verifier.rb +18 -7
- data/lib/lemans/config.rb +44 -9
- data/lib/lemans/environments/docker.rb +1 -1
- data/lib/lemans/ext/deep_merge.rb +13 -0
- data/lib/lemans/result.rb +41 -1
- data/lib/lemans/runner.rb +1 -1
- data/lib/lemans/stores/fs.rb +1 -1
- data/lib/lemans/task_definition.rb +158 -10
- data/lib/lemans/trial/patch.rb +46 -8
- data/lib/lemans/trial/verifier/assets/lemans_minitest_reporter.rb +28 -3
- data/lib/lemans/trial.rb +83 -33
- data/lib/lemans/version.rb +1 -1
- data/lib/miniswen/agent.rb +12 -6
- data/lib/miniswen/cli/reporter.rb +104 -0
- data/lib/miniswen/cli.rb +36 -82
- data/lib/miniswen/environment/docker.rb +69 -0
- data/lib/miniswen/version.rb +1 -1
- metadata +7 -2
- data/lib/lemans/cli/templates/bench/tasks/example-task/solution.patch +0 -7
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
module LemansReport
|
|
6
|
+
# A check the task wants recorded but not graded
|
|
7
|
+
# Inherit from Skip to let the tests pass.
|
|
8
|
+
class AllowedFailure < Minitest::Skip; end
|
|
9
|
+
|
|
10
|
+
module Assertions
|
|
11
|
+
# Allow failing minitest assertions inside the block (but halt and record them as allowed failures not affected the grade)
|
|
12
|
+
def allow_failure
|
|
13
|
+
yield
|
|
14
|
+
rescue Minitest::Skip
|
|
15
|
+
raise
|
|
16
|
+
rescue Minitest::Assertion => e
|
|
17
|
+
raise AllowedFailure, e.message
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
6
21
|
# Appends every Minitest result to $LOGS/checks.json. Required by
|
|
7
22
|
# eport-lemans once Minitest is loaded; never load this file directly.
|
|
8
23
|
class Reporter < Minitest::AbstractReporter
|
|
@@ -22,16 +37,21 @@ module LemansReport
|
|
|
22
37
|
return if graded.empty? && prior.empty?
|
|
23
38
|
|
|
24
39
|
checks = prior.merge(graded.to_h { [ name(it), status(it) ] }).sort.to_h
|
|
40
|
+
allowed = existing.fetch("allowed_failures", {}).merge(graded.select { allowed?(it) }.to_h { [ name(it), it.failure.message ] }).sort.to_h
|
|
25
41
|
File.write(
|
|
26
42
|
File.join(@dir, "checks.json"),
|
|
27
|
-
JSON.pretty_generate(
|
|
43
|
+
JSON.pretty_generate(
|
|
44
|
+
checks: checks,
|
|
45
|
+
failures: checks.reject { |_, status| status == "pass" || status == ALLOWED }.keys,
|
|
46
|
+
allowed_failures: allowed
|
|
47
|
+
)
|
|
28
48
|
)
|
|
29
49
|
end
|
|
30
50
|
|
|
31
51
|
# A skip inside the harness-shipped tests is an unverified requirement and
|
|
32
52
|
# fails the run. The app's own suite keeps vanilla skip semantics.
|
|
33
53
|
def passed?
|
|
34
|
-
@results.none? { |result| graded?(result) && result.skipped? }
|
|
54
|
+
@results.none? { |result| graded?(result) && result.skipped? && !allowed?(result) }
|
|
35
55
|
end
|
|
36
56
|
|
|
37
57
|
private
|
|
@@ -50,8 +70,13 @@ module LemansReport
|
|
|
50
70
|
|
|
51
71
|
def name(result) = "#{result.klass}##{result.name}"
|
|
52
72
|
|
|
73
|
+
ALLOWED = "fail (allowed)"
|
|
74
|
+
|
|
75
|
+
def allowed?(result) = result.skipped? && result.failure.is_a?(AllowedFailure)
|
|
76
|
+
|
|
53
77
|
def status(result)
|
|
54
|
-
if result
|
|
78
|
+
if allowed?(result) then ALLOWED
|
|
79
|
+
elsif result.skipped? then "skip"
|
|
55
80
|
elsif result.error? then "error"
|
|
56
81
|
elsif result.passed? then "pass"
|
|
57
82
|
else "fail"
|
data/lib/lemans/trial.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "pathname"
|
|
5
|
+
require "shellwords"
|
|
5
6
|
require "time"
|
|
6
7
|
|
|
7
8
|
module Lemans
|
|
@@ -11,7 +12,7 @@ module Lemans
|
|
|
11
12
|
class Trial
|
|
12
13
|
attr_reader :task, :config, :model, :agent_name, :environment, :result
|
|
13
14
|
|
|
14
|
-
private attr_reader :agent, :store, :snapshot, :patch
|
|
15
|
+
private attr_reader :agent, :store, :snapshot, :patch, :current_step_index
|
|
15
16
|
|
|
16
17
|
def initialize(task, model = nil, result: nil, store: nil, agent: nil, environment: nil)
|
|
17
18
|
@task = task
|
|
@@ -44,6 +45,7 @@ module Lemans
|
|
|
44
45
|
|
|
45
46
|
@snapshot = nil
|
|
46
47
|
@patch = nil
|
|
48
|
+
@current_step_index = nil
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
def run
|
|
@@ -67,42 +69,60 @@ module Lemans
|
|
|
67
69
|
environment.switch_network_policy!(config.agent.environment.network)
|
|
68
70
|
end
|
|
69
71
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
agent
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
72
|
+
each_step do |step_task|
|
|
73
|
+
response =
|
|
74
|
+
phase(:agent) do
|
|
75
|
+
agent.run(step_task, environment)
|
|
76
|
+
rescue InfrastructureError, ::Miniswen::InfrastructureError => e
|
|
77
|
+
# Mark the failure here, where the agent phase is still known
|
|
78
|
+
result.failed!(:agent_error, e.message)
|
|
79
|
+
raise
|
|
80
|
+
end
|
|
78
81
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
# Whatever the agent brought back is evidence, a failed run's included
|
|
83
|
+
save_trajectory!(response.trajectory)
|
|
84
|
+
store&.save_artifact(result, response.raw_result, path: with_step_index("agent.result.json")) if response.raw_result
|
|
82
85
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
if response.error?
|
|
87
|
+
result.failed!(:agent_error, response.error)
|
|
88
|
+
return result
|
|
89
|
+
end
|
|
87
90
|
|
|
88
|
-
|
|
91
|
+
if task.multistep?
|
|
92
|
+
result.step_completed!(response.outcome, response.usage, duration: result.phases.last.duration)
|
|
93
|
+
else
|
|
94
|
+
result.completed!(response.outcome, response.usage)
|
|
95
|
+
end
|
|
89
96
|
|
|
90
|
-
|
|
97
|
+
check_cost_limit!
|
|
91
98
|
|
|
92
|
-
|
|
99
|
+
patch.collect!(result, store, path: with_step_index("agent.patch")) if store
|
|
100
|
+
if step_task.final_step?
|
|
101
|
+
patch.compile!(result, store) if task.multistep? && store
|
|
102
|
+
# Don't index the final verification
|
|
103
|
+
@current_step_index = nil
|
|
104
|
+
else
|
|
105
|
+
patch.savepoint!
|
|
106
|
+
end
|
|
93
107
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
108
|
+
if result.scored? && step_task.verifiable?
|
|
109
|
+
phase(:verifier) do
|
|
110
|
+
# The sandbox is sealed before the tests arrive
|
|
111
|
+
environment.switch_network_policy!(Config::NetworkPolicy.new("none"))
|
|
98
112
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
113
|
+
verification = Verifier.new(step_task, environment, snapshot).verify! do |evidence, path|
|
|
114
|
+
store&.save_artifact(result, evidence, path: with_step_index(path))
|
|
115
|
+
end
|
|
102
116
|
|
|
103
|
-
|
|
117
|
+
store&.save_artifact(result, verification.logs, path: with_step_index("verifier.log"))
|
|
104
118
|
|
|
105
|
-
|
|
119
|
+
if step_task.final_step?
|
|
120
|
+
result.graded!(verification.reward)
|
|
121
|
+
elsif verification.reward.zero?
|
|
122
|
+
result.graded!(0.0)
|
|
123
|
+
throw :halt
|
|
124
|
+
end
|
|
125
|
+
end
|
|
106
126
|
end
|
|
107
127
|
end
|
|
108
128
|
|
|
@@ -125,11 +145,41 @@ module Lemans
|
|
|
125
145
|
|
|
126
146
|
private
|
|
127
147
|
|
|
128
|
-
def save_trajectory(trajectory)
|
|
148
|
+
def save_trajectory!(trajectory)
|
|
129
149
|
return unless trajectory && store
|
|
130
150
|
|
|
131
|
-
|
|
132
|
-
|
|
151
|
+
path = with_step_index("trajectory.json")
|
|
152
|
+
session_id = with_step_index(result.id)
|
|
153
|
+
|
|
154
|
+
trajectory.session_id = session_id
|
|
155
|
+
store.save_artifact(result, JSON.pretty_generate(trajectory.to_atif), path:)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def each_step
|
|
159
|
+
return yield task unless task.multistep?
|
|
160
|
+
|
|
161
|
+
catch(:halt) do
|
|
162
|
+
1.upto(task.steps) do |index|
|
|
163
|
+
resume_agent! if index > 1
|
|
164
|
+
@current_step_index = index
|
|
165
|
+
yield task.for_step(index)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def resume_agent!
|
|
171
|
+
patch.restore!
|
|
172
|
+
environment.exec!("rm -rf #{Verifier::TESTS_DIR} #{Shellwords.escape(task.verifier.logs_dir)}")
|
|
173
|
+
environment.switch_network_policy!(config.agent.environment.network)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def with_step_index(path)
|
|
177
|
+
return path unless current_step_index
|
|
178
|
+
|
|
179
|
+
*pre, last = path.to_s.split(".")
|
|
180
|
+
return "#{last}.#{current_step_index}" if pre.empty?
|
|
181
|
+
|
|
182
|
+
[ *pre, current_step_index, last ].join(".")
|
|
133
183
|
end
|
|
134
184
|
|
|
135
185
|
def check_cost_limit!
|
|
@@ -144,10 +194,10 @@ module Lemans
|
|
|
144
194
|
end
|
|
145
195
|
|
|
146
196
|
def phase(name)
|
|
147
|
-
result.phase_started(name)
|
|
197
|
+
result.phase_started(with_step_index(name).to_sym)
|
|
148
198
|
yield
|
|
149
199
|
ensure
|
|
150
|
-
result.phase_finished(name)
|
|
200
|
+
result.phase_finished(with_step_index(name).to_sym)
|
|
151
201
|
end
|
|
152
202
|
end
|
|
153
203
|
end
|
data/lib/lemans/version.rb
CHANGED
data/lib/miniswen/agent.rb
CHANGED
|
@@ -232,11 +232,14 @@ module Miniswen
|
|
|
232
232
|
private attr_reader :max_steps, :max_time, :max_cost, :exec_timeout,
|
|
233
233
|
:clock, :reporter
|
|
234
234
|
|
|
235
|
-
# `model` is a litellm-style name ("openrouter/z-ai/glm-5.2")
|
|
235
|
+
# `model` is a litellm-style name ("openrouter/z-ai/glm-5.2"), optionally
|
|
236
|
+
# suffixed with a reasoning effort ("openrouter/openai/gpt-5.6-luna#xhigh").
|
|
237
|
+
# Limits of 0 or nil are disabled.
|
|
236
238
|
def initialize(model:, environment:, max_steps: 0, max_time: 0, max_cost: nil,
|
|
237
239
|
exec_timeout: 30, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
238
240
|
reporter: nil)
|
|
239
|
-
|
|
241
|
+
name, @effort = model.split("#", 2)
|
|
242
|
+
@provider, @id = name.split("/", 2)
|
|
240
243
|
unless @id
|
|
241
244
|
@id = @provider
|
|
242
245
|
@provider = nil
|
|
@@ -315,8 +318,8 @@ module Miniswen
|
|
|
315
318
|
_, provider = resolved
|
|
316
319
|
env = provider.configuration_requirements.to_h { [ it.to_s.upcase, RubyLLM.config.public_send(it) ] }.compact
|
|
317
320
|
|
|
318
|
-
order =
|
|
319
|
-
env["
|
|
321
|
+
order = provider_order
|
|
322
|
+
env["OPENROUTER_PROVIDER_ORDER"] = order if order
|
|
320
323
|
env
|
|
321
324
|
end
|
|
322
325
|
|
|
@@ -500,7 +503,8 @@ module Miniswen
|
|
|
500
503
|
tools: { bash: @bash_tool },
|
|
501
504
|
temperature: nil,
|
|
502
505
|
model: model_info,
|
|
503
|
-
params: routing_params
|
|
506
|
+
params: routing_params,
|
|
507
|
+
thinking: (RubyLLM::Thinking::Config.new(effort: @effort) if @effort)
|
|
504
508
|
)
|
|
505
509
|
payload(response)
|
|
506
510
|
rescue RubyLLM::Error => e
|
|
@@ -531,12 +535,14 @@ module Miniswen
|
|
|
531
535
|
end
|
|
532
536
|
|
|
533
537
|
def routing_params
|
|
534
|
-
order =
|
|
538
|
+
order = provider_order
|
|
535
539
|
return {} unless order && @provider == "openrouter"
|
|
536
540
|
|
|
537
541
|
{ provider: { order: order.split(",").map(&:strip), allow_fallbacks: false } }
|
|
538
542
|
end
|
|
539
543
|
|
|
544
|
+
def provider_order = ENV["LEMANS_PROVIDER_ORDER"] || ENV["OPENROUTER_PROVIDER_ORDER"]
|
|
545
|
+
|
|
540
546
|
def cost_source
|
|
541
547
|
if local?
|
|
542
548
|
return CostSource.new(name: :local_provider, model: @model,
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Miniswen
|
|
4
|
+
class CLI
|
|
5
|
+
# Prints messages and tool calls in real-time. The renderer deliberately
|
|
6
|
+
# keeps the captured (non-TTY) version plain, which makes it useful in CI
|
|
7
|
+
# and when piping a run to a log file too.
|
|
8
|
+
class Reporter
|
|
9
|
+
private attr_reader :io
|
|
10
|
+
|
|
11
|
+
# Tool output can be extremely noisy (for example, a recursive grep or
|
|
12
|
+
# a test runner dumping a log). Keep the normal report useful while
|
|
13
|
+
# allowing -vv to retain the complete output for debugging.
|
|
14
|
+
MAX_TOOL_OUTPUT_CHARS = 1_000
|
|
15
|
+
|
|
16
|
+
def initialize(io = $stdout, verbose: false, tool_output: verbose, reasoning: true)
|
|
17
|
+
@io = io
|
|
18
|
+
@verbose = verbose
|
|
19
|
+
@tool_output = tool_output
|
|
20
|
+
@reasoning = reasoning
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def on_message(message)
|
|
24
|
+
case message[:role].to_s
|
|
25
|
+
when "assistant"
|
|
26
|
+
write_assistant(message)
|
|
27
|
+
when "tool"
|
|
28
|
+
write_tool(message)
|
|
29
|
+
when "user"
|
|
30
|
+
write_block("!", message[:content], :warning)
|
|
31
|
+
else
|
|
32
|
+
write_block("·", message[:content], :muted)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Tool calls are reported separately so the command is visible before
|
|
37
|
+
# its output arrives. It is not added to the trajectory sent to the LLM.
|
|
38
|
+
def on_tool_call(call)
|
|
39
|
+
command = call.dig(:arguments, "command") || call.dig(:arguments, :command)
|
|
40
|
+
return if command.to_s.empty?
|
|
41
|
+
|
|
42
|
+
line = style("$ #{command}", :command)
|
|
43
|
+
io.puts(" #{line}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def print_summary(result)
|
|
47
|
+
write_assistant(result.messages.last)
|
|
48
|
+
write_block("↳", "steps=#{result.steps} · cost=$#{result.cost_usd}", :muted)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def print_failure(result)
|
|
52
|
+
write_block("!", "Miniswen failed: #{result.status}", :warning)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def write_tool(message)
|
|
58
|
+
return write_block("↳", message[:content], :tool) if @tool_output
|
|
59
|
+
|
|
60
|
+
exit_code = message.dig(:observation, :exit_code)
|
|
61
|
+
return if exit_code.nil?
|
|
62
|
+
|
|
63
|
+
if exit_code.zero?
|
|
64
|
+
write_block("↳", "ok", :muted)
|
|
65
|
+
else
|
|
66
|
+
output = truncate(message.dig(:observation, :output).to_s.strip)
|
|
67
|
+
write_block("!", "not ok: exit #{exit_code}\n#{output}", :warning)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def write_assistant(message)
|
|
72
|
+
content = message[:content].to_s.strip
|
|
73
|
+
write_block("∴", message[:thinking], :thinking) if @reasoning && (@verbose || content.empty?)
|
|
74
|
+
write_block("●", content, :assistant)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def write_block(marker, content, tone)
|
|
78
|
+
text = content.to_s.strip
|
|
79
|
+
return if text.empty?
|
|
80
|
+
|
|
81
|
+
text = truncate(text) if %i[tool thinking].include?(tone)
|
|
82
|
+
lines = text.lines(chomp: true)
|
|
83
|
+
io.puts("#{style(marker, tone)} #{style(lines.shift, tone)}")
|
|
84
|
+
lines.each { |line| io.puts(" #{style(line, tone)}") }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def truncate(text)
|
|
88
|
+
return text if @verbose || text.length <= MAX_TOOL_OUTPUT_CHARS
|
|
89
|
+
|
|
90
|
+
head = MAX_TOOL_OUTPUT_CHARS / 2
|
|
91
|
+
tail = MAX_TOOL_OUTPUT_CHARS - head
|
|
92
|
+
omitted = text.length - head - tail
|
|
93
|
+
"#{text[0, head]}\n... [#{omitted} characters omitted] ...\n#{text[-tail, tail]}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def style(text, tone)
|
|
97
|
+
return text unless io.respond_to?(:tty?) && io.tty?
|
|
98
|
+
|
|
99
|
+
colors = { assistant: 36, tool: 32, warning: 33, command: 35, muted: 90, thinking: 90 }
|
|
100
|
+
"\e[#{colors.fetch(tone)}m#{text}\e[0m"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/miniswen/cli.rb
CHANGED
|
@@ -1,88 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
|
+
require "fileutils"
|
|
4
5
|
|
|
5
6
|
require "miniswen/version"
|
|
7
|
+
require "miniswen/cli/reporter"
|
|
6
8
|
|
|
7
9
|
module Miniswen
|
|
8
10
|
class CLI # :nodoc:
|
|
9
|
-
# Prints messages and tool calls in real-time. The renderer deliberately
|
|
10
|
-
# keeps the captured (non-TTY) version plain, which makes it useful in CI
|
|
11
|
-
# and when piping a run to a log file too.
|
|
12
|
-
class Reporter
|
|
13
|
-
private attr_reader :io
|
|
14
|
-
|
|
15
|
-
# Tool output can be extremely noisy (for example, a recursive grep or
|
|
16
|
-
# a test runner dumping a log). Keep the normal report useful while
|
|
17
|
-
# allowing -vv to retain the complete output for debugging.
|
|
18
|
-
MAX_TOOL_OUTPUT_CHARS = 1_000
|
|
19
|
-
|
|
20
|
-
def initialize(io = $stdout, verbose: false)
|
|
21
|
-
@io = io
|
|
22
|
-
@verbose = verbose
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def on_message(message)
|
|
26
|
-
case message[:role].to_s
|
|
27
|
-
when "assistant"
|
|
28
|
-
write_block("●", message[:content], :assistant)
|
|
29
|
-
when "tool"
|
|
30
|
-
write_block("↳", message[:content], :tool)
|
|
31
|
-
when "user"
|
|
32
|
-
write_block("!", message[:content], :warning)
|
|
33
|
-
else
|
|
34
|
-
write_block("·", message[:content], :muted)
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# Tool calls are reported separately so the command is visible before
|
|
39
|
-
# its output arrives. It is not added to the trajectory sent to the LLM.
|
|
40
|
-
def on_tool_call(call)
|
|
41
|
-
command = call.dig(:arguments, "command") || call.dig(:arguments, :command)
|
|
42
|
-
return if command.to_s.empty?
|
|
43
|
-
|
|
44
|
-
line = style("$ #{command}", :command)
|
|
45
|
-
io.puts(" #{line}")
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def print_summary(result)
|
|
49
|
-
write_block("●", result.messages.last[:content], :assistant)
|
|
50
|
-
write_block("↳", "steps=#{result.steps} · cost=$#{result.cost_usd}", :muted)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def print_failure(result)
|
|
54
|
-
write_block("!", "Miniswen failed: #{result.status}", :warning)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
private
|
|
58
|
-
|
|
59
|
-
def write_block(marker, content, tone)
|
|
60
|
-
text = content.to_s.strip
|
|
61
|
-
return if text.empty?
|
|
62
|
-
|
|
63
|
-
text = truncate_tool_output(text) if tone == :tool
|
|
64
|
-
lines = text.lines(chomp: true)
|
|
65
|
-
io.puts("#{style(marker, tone)} #{style(lines.shift, tone)}")
|
|
66
|
-
lines.each { |line| io.puts(" #{style(line, tone)}") }
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def truncate_tool_output(text)
|
|
70
|
-
return text if @verbose || text.length <= MAX_TOOL_OUTPUT_CHARS
|
|
71
|
-
|
|
72
|
-
head = MAX_TOOL_OUTPUT_CHARS / 2
|
|
73
|
-
tail = MAX_TOOL_OUTPUT_CHARS - head
|
|
74
|
-
omitted = text.length - head - tail
|
|
75
|
-
"#{text[0, head]}\n... [#{omitted} characters omitted] ...\n#{text[-tail, tail]}"
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def style(text, tone)
|
|
79
|
-
return text unless io.respond_to?(:tty?) && io.tty?
|
|
80
|
-
|
|
81
|
-
colors = { assistant: 36, tool: 32, warning: 33, command: 35, muted: 90 }
|
|
82
|
-
"\e[#{colors.fetch(tone)}m#{text}\e[0m"
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
|
|
86
11
|
attr_reader :instruction, :model, :options
|
|
87
12
|
|
|
88
13
|
def initialize
|
|
@@ -90,6 +15,8 @@ module Miniswen
|
|
|
90
15
|
@model = ENV.fetch("MINISWEN_MODEL", nil)
|
|
91
16
|
@options = {}
|
|
92
17
|
@verbose = false
|
|
18
|
+
@show_output = false
|
|
19
|
+
@reasoning = true
|
|
93
20
|
@quiet = false
|
|
94
21
|
@results_path = nil
|
|
95
22
|
@atif_path = nil
|
|
@@ -110,8 +37,16 @@ module Miniswen
|
|
|
110
37
|
|
|
111
38
|
require "miniswen/local"
|
|
112
39
|
|
|
113
|
-
reporter = @quiet ? nil : Reporter.new(verbose: @verbose)
|
|
114
|
-
|
|
40
|
+
reporter = @quiet ? nil : Reporter.new(verbose: @verbose, tool_output: @verbose || @show_output, reasoning: @reasoning)
|
|
41
|
+
environment =
|
|
42
|
+
if @docker_id
|
|
43
|
+
require "miniswen/environment/docker"
|
|
44
|
+
Environment::Docker.new(@docker_id)
|
|
45
|
+
else
|
|
46
|
+
Local.new
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
agent = Agent.new(model:, reporter:, environment:, **options)
|
|
115
50
|
|
|
116
51
|
begin
|
|
117
52
|
result = agent.run(instruction)
|
|
@@ -133,8 +68,15 @@ module Miniswen
|
|
|
133
68
|
private
|
|
134
69
|
|
|
135
70
|
def write_results(result)
|
|
136
|
-
|
|
137
|
-
|
|
71
|
+
if @results_path
|
|
72
|
+
FileUtils.mkdir_p(File.dirname(@results_path))
|
|
73
|
+
File.write(@results_path, JSON.generate(result.to_h))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if @atif_path
|
|
77
|
+
FileUtils.mkdir_p(File.dirname(@atif_path))
|
|
78
|
+
write_atif(result)
|
|
79
|
+
end
|
|
138
80
|
end
|
|
139
81
|
|
|
140
82
|
def error_message(error)
|
|
@@ -162,7 +104,7 @@ module Miniswen
|
|
|
162
104
|
end
|
|
163
105
|
|
|
164
106
|
opts.on("-p INSTRUCTION", "--prompt=INSTRUCTION", String, "Instruction prompt") do |v|
|
|
165
|
-
@instruction = v
|
|
107
|
+
@instruction = File.file?(v) ? File.read(v) : v
|
|
166
108
|
end
|
|
167
109
|
|
|
168
110
|
opts.on("--max-steps=STEPS", Integer, "Max steps count") do |v|
|
|
@@ -185,6 +127,14 @@ module Miniswen
|
|
|
185
127
|
@quiet = true
|
|
186
128
|
end
|
|
187
129
|
|
|
130
|
+
opts.on("--show-output", "Print tool output instead of just the exit status") do
|
|
131
|
+
@show_output = true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
opts.on("--no-reasoning", "Hide the model's reasoning") do
|
|
135
|
+
@reasoning = false
|
|
136
|
+
end
|
|
137
|
+
|
|
188
138
|
opts.on("--results-path=PATH", String, "Write the run result as JSON to PATH") do |v|
|
|
189
139
|
@results_path = v
|
|
190
140
|
end
|
|
@@ -193,6 +143,10 @@ module Miniswen
|
|
|
193
143
|
@atif_path = v
|
|
194
144
|
end
|
|
195
145
|
|
|
146
|
+
opts.on("--docker=ID", String, "Docker container ID to exec commands on") do |v|
|
|
147
|
+
@docker_id = v
|
|
148
|
+
end
|
|
149
|
+
|
|
196
150
|
opts.on("--refresh-registry", "Refresh the model registry, persist it, and exit") do
|
|
197
151
|
@refresh_registry = true
|
|
198
152
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
require "miniswen/environment"
|
|
6
|
+
|
|
7
|
+
module Miniswen
|
|
8
|
+
class Environment
|
|
9
|
+
class Docker < self
|
|
10
|
+
TIMEOUT_MARKED_EXIT_CODES = [ 124, 143 ].freeze
|
|
11
|
+
|
|
12
|
+
private attr_reader :id
|
|
13
|
+
|
|
14
|
+
def initialize(id)
|
|
15
|
+
@id = id
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def exec(command, timeout: nil, env: nil)
|
|
19
|
+
argv = [ "docker", "exec" ]
|
|
20
|
+
env&.each { |key, value| argv += [ "--env", "#{key}=#{value}" ] }
|
|
21
|
+
argv << id
|
|
22
|
+
argv += [ "timeout", timeout.ceil.to_s ] if timeout&.positive?
|
|
23
|
+
argv += [ "sh", "-c", command ]
|
|
24
|
+
|
|
25
|
+
Open3.popen2e(*argv) do |stdin, pipe, wait|
|
|
26
|
+
stdin.close
|
|
27
|
+
deadline = (now + timeout + 10 if timeout&.positive?) # add some slack
|
|
28
|
+
output = +""
|
|
29
|
+
timed_out = false
|
|
30
|
+
|
|
31
|
+
loop do
|
|
32
|
+
remaining = deadline && deadline - now
|
|
33
|
+
if remaining && remaining <= 0
|
|
34
|
+
timed_out = true
|
|
35
|
+
kill(wait.pid)
|
|
36
|
+
break
|
|
37
|
+
end
|
|
38
|
+
next unless pipe.wait_readable(remaining)
|
|
39
|
+
|
|
40
|
+
chunk = pipe.read_nonblock(65_536, exception: false)
|
|
41
|
+
break if chunk.nil?
|
|
42
|
+
next if chunk == :wait_readable
|
|
43
|
+
|
|
44
|
+
output << chunk
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
status = wait.value
|
|
48
|
+
exit_code = timed_out ? 124 : (status.exitstatus || 1)
|
|
49
|
+
output = output.force_encoding(Encoding::UTF_8).scrub
|
|
50
|
+
if timeout&.positive? && (timed_out || TIMEOUT_MARKED_EXIT_CODES.include?(exit_code))
|
|
51
|
+
output = "#{output}\n<command timed out after #{timeout} seconds>"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
ExecResult.new(exit_code:, output:)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def kill(pid)
|
|
61
|
+
Process.kill("KILL", pid)
|
|
62
|
+
rescue Errno::ESRCH
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/miniswen/version.rb
CHANGED
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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Svyatoslav Kryukov
|
|
@@ -170,7 +170,9 @@ files:
|
|
|
170
170
|
- lib/lemans/cli/templates/bench/environment/Dockerfile
|
|
171
171
|
- lib/lemans/cli/templates/bench/tasks/example-task/environment.patch
|
|
172
172
|
- lib/lemans/cli/templates/bench/tasks/example-task/instruction.md
|
|
173
|
-
- lib/lemans/cli/templates/bench/tasks/example-task/solution.patch
|
|
173
|
+
- lib/lemans/cli/templates/bench/tasks/example-task/solution.1.patch
|
|
174
|
+
- lib/lemans/cli/templates/bench/tasks/example-task/solution.2.patch
|
|
175
|
+
- lib/lemans/cli/templates/bench/tasks/example-task/verification_test.1.rb
|
|
174
176
|
- lib/lemans/cli/templates/bench/tasks/example-task/verification_test.rb
|
|
175
177
|
- lib/lemans/cli/templates/bench/tasks/hello-world/environment.patch
|
|
176
178
|
- lib/lemans/cli/templates/bench/tasks/hello-world/instruction.md
|
|
@@ -196,6 +198,7 @@ files:
|
|
|
196
198
|
- lib/lemans/environments/daytona/shell.rb
|
|
197
199
|
- lib/lemans/environments/daytona/snapshot_store.rb
|
|
198
200
|
- lib/lemans/environments/docker.rb
|
|
201
|
+
- lib/lemans/ext/deep_merge.rb
|
|
199
202
|
- lib/lemans/result.rb
|
|
200
203
|
- lib/lemans/runner.rb
|
|
201
204
|
- lib/lemans/runner/executor.rb
|
|
@@ -215,7 +218,9 @@ files:
|
|
|
215
218
|
- lib/miniswen.rb
|
|
216
219
|
- lib/miniswen/agent.rb
|
|
217
220
|
- lib/miniswen/cli.rb
|
|
221
|
+
- lib/miniswen/cli/reporter.rb
|
|
218
222
|
- lib/miniswen/environment.rb
|
|
223
|
+
- lib/miniswen/environment/docker.rb
|
|
219
224
|
- lib/miniswen/local.rb
|
|
220
225
|
- lib/miniswen/ruby_llm.rb
|
|
221
226
|
- lib/miniswen/testing.rb
|