lemans 0.0.0.pre → 0.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 +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +228 -0
- data/exe/lemans +17 -0
- data/lib/lemans/agents/base.rb +30 -0
- data/lib/lemans/agents/miniswen.rb +119 -0
- data/lib/lemans/agents/miniswen_installed.rb +67 -0
- data/lib/lemans/agents/nop.rb +15 -0
- data/lib/lemans/agents/oracle.rb +53 -0
- data/lib/lemans/agents.rb +21 -0
- data/lib/lemans/bench.rb +280 -0
- data/lib/lemans/cli/board_reporter.rb +135 -0
- data/lib/lemans/cli/progress_reporter.rb +67 -0
- data/lib/lemans/cli.rb +181 -0
- data/lib/lemans/clobber.rb +79 -0
- data/lib/lemans/environments/base.rb +55 -0
- data/lib/lemans/environments/daytona/retries.rb +49 -0
- data/lib/lemans/environments/daytona/sdk_tweaks.rb +50 -0
- data/lib/lemans/environments/daytona/shell.rb +142 -0
- data/lib/lemans/environments/daytona/snapshot_store.rb +163 -0
- data/lib/lemans/environments/daytona.rb +175 -0
- data/lib/lemans/environments.rb +16 -0
- data/lib/lemans/network_policy.rb +66 -0
- data/lib/lemans/patch.rb +70 -0
- data/lib/lemans/restore_paths.rb +21 -0
- data/lib/lemans/results/aggregate.rb +114 -0
- data/lib/lemans/results/cost_source.rb +13 -0
- data/lib/lemans/results/outcome.rb +36 -0
- data/lib/lemans/results/report.rb +149 -0
- data/lib/lemans/results/sorting.rb +24 -0
- data/lib/lemans/results/tally.rb +19 -0
- data/lib/lemans/results/usage.rb +24 -0
- data/lib/lemans/run.rb +152 -0
- data/lib/lemans/setup.rb +59 -0
- data/lib/lemans/setup_files.rb +36 -0
- data/lib/lemans/snapshot.rb +55 -0
- data/lib/lemans/task.rb +207 -0
- data/lib/lemans/tree_digest.rb +24 -0
- data/lib/lemans/trial.rb +187 -0
- data/lib/lemans/units.rb +44 -0
- data/lib/lemans/verifier/assets/eport-lemans.rb +36 -0
- data/lib/lemans/verifier/assets/lemans_minitest_reporter.rb +61 -0
- data/lib/lemans/verifier.rb +199 -0
- data/lib/lemans/version.rb +5 -0
- data/lib/lemans.rb +29 -0
- data/lib/miniswen/agent.rb +669 -0
- data/lib/miniswen/cli.rb +224 -0
- data/lib/miniswen/environment.rb +14 -0
- data/lib/miniswen/local.rb +42 -0
- data/lib/miniswen/ruby_llm.rb +42 -0
- data/lib/miniswen/testing.rb +134 -0
- data/lib/miniswen/trajectory.rb +110 -0
- data/lib/miniswen/version.rb +5 -0
- data/lib/miniswen.rb +48 -0
- metadata +160 -7
data/lib/miniswen/cli.rb
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
require "miniswen/version"
|
|
6
|
+
|
|
7
|
+
module Miniswen
|
|
8
|
+
class CLI # :nodoc:
|
|
9
|
+
# Prints messages and tool calls in real-time. The renderer deliberately
|
|
10
|
+
# keeps the captured (non-TTY) version plain, which makes it useful in CI
|
|
11
|
+
# and when piping a run to a log file too.
|
|
12
|
+
class Reporter
|
|
13
|
+
private attr_reader :io
|
|
14
|
+
|
|
15
|
+
# Tool output can be extremely noisy (for example, a recursive grep or
|
|
16
|
+
# a test runner dumping a log). Keep the normal report useful while
|
|
17
|
+
# allowing -vv to retain the complete output for debugging.
|
|
18
|
+
MAX_TOOL_OUTPUT_CHARS = 1_000
|
|
19
|
+
|
|
20
|
+
def initialize(io = $stdout, verbose: false)
|
|
21
|
+
@io = io
|
|
22
|
+
@verbose = verbose
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def on_message(message)
|
|
26
|
+
case message[:role].to_s
|
|
27
|
+
when "assistant"
|
|
28
|
+
write_block("●", message[:content], :assistant)
|
|
29
|
+
when "tool"
|
|
30
|
+
write_block("↳", message[:content], :tool)
|
|
31
|
+
when "user"
|
|
32
|
+
write_block("!", message[:content], :warning)
|
|
33
|
+
else
|
|
34
|
+
write_block("·", message[:content], :muted)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Tool calls are reported separately so the command is visible before
|
|
39
|
+
# its output arrives. It is not added to the trajectory sent to the LLM.
|
|
40
|
+
def on_tool_call(call)
|
|
41
|
+
command = call.dig(:arguments, "command") || call.dig(:arguments, :command)
|
|
42
|
+
return if command.to_s.empty?
|
|
43
|
+
|
|
44
|
+
line = style("$ #{command}", :command)
|
|
45
|
+
io.puts(" #{line}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def print_summary(result)
|
|
49
|
+
write_block("●", result.messages.last[:content], :assistant)
|
|
50
|
+
write_block("↳", "steps=#{result.steps} · cost=$#{result.cost_usd}", :muted)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def print_failure(result)
|
|
54
|
+
write_block("!", "Miniswen failed: #{result.status}", :warning)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def write_block(marker, content, tone)
|
|
60
|
+
text = content.to_s.strip
|
|
61
|
+
return if text.empty?
|
|
62
|
+
|
|
63
|
+
text = truncate_tool_output(text) if tone == :tool
|
|
64
|
+
lines = text.lines(chomp: true)
|
|
65
|
+
io.puts("#{style(marker, tone)} #{style(lines.shift, tone)}")
|
|
66
|
+
lines.each { |line| io.puts(" #{style(line, tone)}") }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def truncate_tool_output(text)
|
|
70
|
+
return text if @verbose || text.length <= MAX_TOOL_OUTPUT_CHARS
|
|
71
|
+
|
|
72
|
+
head = MAX_TOOL_OUTPUT_CHARS / 2
|
|
73
|
+
tail = MAX_TOOL_OUTPUT_CHARS - head
|
|
74
|
+
omitted = text.length - head - tail
|
|
75
|
+
"#{text[0, head]}\n... [#{omitted} characters omitted] ...\n#{text[-tail, tail]}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def style(text, tone)
|
|
79
|
+
return text unless io.respond_to?(:tty?) && io.tty?
|
|
80
|
+
|
|
81
|
+
colors = { assistant: 36, tool: 32, warning: 33, command: 35, muted: 90 }
|
|
82
|
+
"\e[#{colors.fetch(tone)}m#{text}\e[0m"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
attr_reader :instruction, :model, :options
|
|
87
|
+
|
|
88
|
+
def initialize
|
|
89
|
+
@instruction = nil
|
|
90
|
+
@model = ENV.fetch("MINISWEN_MODEL", nil)
|
|
91
|
+
@options = {}
|
|
92
|
+
@verbose = false
|
|
93
|
+
@quiet = false
|
|
94
|
+
@results_path = nil
|
|
95
|
+
@atif_path = nil
|
|
96
|
+
@refresh_registry = false
|
|
97
|
+
@skip_registry_refresh = false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def run
|
|
101
|
+
parse_args!
|
|
102
|
+
|
|
103
|
+
# Require the core library after parsing options,
|
|
104
|
+
# so env flags kick in
|
|
105
|
+
require "miniswen"
|
|
106
|
+
|
|
107
|
+
refresh_registry_and_exit if @refresh_registry
|
|
108
|
+
|
|
109
|
+
Miniswen.refresh_registry! unless @skip_registry_refresh
|
|
110
|
+
|
|
111
|
+
require "miniswen/local"
|
|
112
|
+
|
|
113
|
+
reporter = @quiet ? nil : Reporter.new(verbose: @verbose)
|
|
114
|
+
agent = Agent.new(model:, reporter:, environment: Local.new, **options)
|
|
115
|
+
|
|
116
|
+
begin
|
|
117
|
+
result = agent.run(instruction)
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
write_results(agent.partial_result(error_message(e)))
|
|
120
|
+
raise
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
write_results(result)
|
|
124
|
+
|
|
125
|
+
if result.success?
|
|
126
|
+
reporter&.print_summary(result)
|
|
127
|
+
else
|
|
128
|
+
reporter&.print_failure(result)
|
|
129
|
+
Kernel.exit(1)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def write_results(result)
|
|
136
|
+
File.write(@results_path, JSON.generate(result.to_h)) if @results_path
|
|
137
|
+
write_atif(result) if @atif_path
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def error_message(error)
|
|
141
|
+
error.is_a?(Miniswen::Error) ? error.message : "#{error.class}: #{error.message}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def write_atif(result)
|
|
145
|
+
trajectory = Trajectory.from(result, model: model)
|
|
146
|
+
File.write(@atif_path, JSON.pretty_generate(trajectory.to_atif))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def refresh_registry_and_exit
|
|
150
|
+
refreshed = Miniswen.refresh_registry!(persist: true)
|
|
151
|
+
$stdout.puts Miniswen.registry_revision if refreshed
|
|
152
|
+
Kernel.exit(refreshed ? 0 : 1)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def parse_args!
|
|
156
|
+
parser = OptionParser.new do |opts|
|
|
157
|
+
opts.banner = "Usage: miniswen -m MODEL -p INSTRUCTION [...options]"
|
|
158
|
+
|
|
159
|
+
opts.on("-m MODEL", "--model=MODEL", String,
|
|
160
|
+
"LLM to use (litellm format, e.g.: openrouter/openai/gpt-5.6-luna") do |v|
|
|
161
|
+
@model = v
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
opts.on("-p INSTRUCTION", "--prompt=INSTRUCTION", String, "Instruction prompt") do |v|
|
|
165
|
+
@instruction = v
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
opts.on("--max-steps=STEPS", Integer, "Max steps count") do |v|
|
|
169
|
+
options[:max_steps] = v
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
opts.on("--max-cost=COST", Float, "Max cost (USD)") do |v|
|
|
173
|
+
options[:max_cost] = v
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
opts.on("--max-time=TIME", Float, "Max inference duration (seconds)") do |v|
|
|
177
|
+
options[:max_time] = v
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
opts.on("--exec-timeout=TIMEOUT", Float, "Tool execution timeout (seconds)") do |v|
|
|
181
|
+
options[:exec_timeout] = v
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
opts.on("-q", "--quiet", "Disable progress output") do
|
|
185
|
+
@quiet = true
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
opts.on("--results-path=PATH", String, "Write the run result as JSON to PATH") do |v|
|
|
189
|
+
@results_path = v
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
opts.on("--atif-path=PATH", String, "Write the ATIF trajectory to PATH") do |v|
|
|
193
|
+
@atif_path = v
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
opts.on("--refresh-registry", "Refresh the model registry, persist it, and exit") do
|
|
197
|
+
@refresh_registry = true
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
opts.on("--no-refresh-registry", "Skip the model registry refresh on startup") do
|
|
201
|
+
@skip_registry_refresh = true
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
opts.on("-v", "--version", "Print version") do
|
|
205
|
+
$stdout.puts Miniswen::VERSION
|
|
206
|
+
exit 0
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
opts.on("-vv", "Print verbose logs") do
|
|
210
|
+
@verbose = true
|
|
211
|
+
ENV["MINISWEN_DEBUG"] = "1"
|
|
212
|
+
ENV["RUBYLLM_LOG_LEVEL"] = "debug"
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
parser.parse!
|
|
217
|
+
|
|
218
|
+
return if @refresh_registry
|
|
219
|
+
|
|
220
|
+
raise "Use -m to specify the model" unless @model
|
|
221
|
+
raise "Please, provide instructions via -p option" unless @instruction
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Miniswen
|
|
4
|
+
# Represents a current runtime environment for an agent (the one
|
|
5
|
+
# where instructions must be executed)
|
|
6
|
+
class Environment
|
|
7
|
+
ExecResult = Data.define(:exit_code, :output) do
|
|
8
|
+
def success? = exit_code.zero?
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Execute a shell command
|
|
12
|
+
def exec(cmd, timeout: nil, env: nil) = raise NotImplementedError
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
require "miniswen/environment"
|
|
6
|
+
|
|
7
|
+
module Miniswen
|
|
8
|
+
# Local execution environment (current machine)
|
|
9
|
+
class Local < Environment
|
|
10
|
+
TIMEOUT_EXIT_CODE = 124
|
|
11
|
+
|
|
12
|
+
def exec(command, timeout: nil, env: nil)
|
|
13
|
+
Open3.popen2e(env || {}, command, pgroup: true) do |stdin, io, wait_thr|
|
|
14
|
+
stdin.close
|
|
15
|
+
reader = Thread.new { io.read }
|
|
16
|
+
|
|
17
|
+
if timeout&.positive? && wait_thr.join(timeout).nil?
|
|
18
|
+
kill_group(wait_thr.pid)
|
|
19
|
+
wait_thr.join
|
|
20
|
+
output = "#{scrub(reader.value)}\n<command timed out after #{timeout} seconds>"
|
|
21
|
+
return ExecResult.new(exit_code: TIMEOUT_EXIT_CODE, output:)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
ExecResult.new(exit_code: exit_code(wait_thr.value), output: scrub(reader.value))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def kill_group(pid)
|
|
31
|
+
Process.kill(:KILL, -pid)
|
|
32
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def exit_code(status)
|
|
37
|
+
status.exitstatus || (status.termsig ? 128 + status.termsig : 1)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def scrub(output) = output.to_s.force_encoding(Encoding::UTF_8).scrub
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
# Logging configuration
|
|
6
|
+
RubyLLM.configure do |config|
|
|
7
|
+
config.log_level = ENV.fetch("RUBYLLM_LOG_LEVEL", "info").to_sym
|
|
8
|
+
config.logger = Logger.new(IO::NULL) unless ENV["MINISWEN_DEBUG"] == "1"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# ruby_llm reads no API keys from ENV on its own; the conventional variable is the provider's
|
|
12
|
+
# config option upcased.
|
|
13
|
+
RubyLLM.configure do |config|
|
|
14
|
+
RubyLLM::Provider.providers.each_value do |provider|
|
|
15
|
+
provider.configuration_requirements.each do |option|
|
|
16
|
+
value = ENV.fetch(option.to_s.upcase, nil)
|
|
17
|
+
config.public_send(:"#{option}=", value) if value
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module Miniswen
|
|
23
|
+
# OpenRouter requires reasoning_details replayed exactly as received; ruby_llm
|
|
24
|
+
# rebuilds them from its collapsed text+signature pair, and providers that
|
|
25
|
+
# sign each block separately reject that as a corrupted thought signature.
|
|
26
|
+
module VerbatimReasoningDetails
|
|
27
|
+
def format_thinking(msg)
|
|
28
|
+
details = msg.thinking.respond_to?(:details) ? msg.thinking.details : nil
|
|
29
|
+
details && !details.empty? ? { reasoning_details: details } : super
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A handshake reset never sent the request, so retrying is as safe as the
|
|
34
|
+
# ConnectionFailed retries ruby_llm already does; it only lists SSL errors
|
|
35
|
+
# as fatal.
|
|
36
|
+
module RetryTransientSSL
|
|
37
|
+
def retry_exceptions = super + [Faraday::SSLError]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
RubyLLM::Providers::OpenRouter.prepend(Miniswen::VerbatimReasoningDetails)
|
|
42
|
+
RubyLLM::Connection.prepend(Miniswen::RetryTransientSSL)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/test"
|
|
4
|
+
|
|
5
|
+
require "miniswen/agent"
|
|
6
|
+
require "miniswen/environment"
|
|
7
|
+
|
|
8
|
+
module Miniswen
|
|
9
|
+
# Test support for callers driving Miniswen::Agent without a real provider:
|
|
10
|
+
# a "test" model family resolving through ruby_llm's real path, an in-process
|
|
11
|
+
# FakeEnv shell, and stub_llm/llm_answer helpers on top of RubyLLM::Test's
|
|
12
|
+
# response queue. Include into a Minitest case; stubs reset before each test.
|
|
13
|
+
module Testing
|
|
14
|
+
# Registered under the :test slug; completions never reach it, RubyLLM::Test
|
|
15
|
+
# intercepts them at resolve time.
|
|
16
|
+
class Provider < RubyLLM::Provider
|
|
17
|
+
def self.slug = "test"
|
|
18
|
+
|
|
19
|
+
def api_base = "http://test.invalid"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# $20/M input (fresh and cached) and $400/M output price the default answer
|
|
23
|
+
# (95 fresh + 5 cached input tokens, 20 output tokens) at exactly $0.01.
|
|
24
|
+
PRICED_MODEL = RubyLLM::Model::Info.new(
|
|
25
|
+
id: "test", name: "Test", provider: "test",
|
|
26
|
+
capabilities: %w[function_calling],
|
|
27
|
+
modalities: { input: %w[text], output: %w[text] },
|
|
28
|
+
pricing: { text_tokens: { standard: {
|
|
29
|
+
input_per_million: 20.0, cached_input_per_million: 20.0, output_per_million: 400.0
|
|
30
|
+
} } }
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
UNPRICED_MODEL = RubyLLM::Model::Info.new(
|
|
34
|
+
id: "test-unpriced", name: "Test unpriced", provider: "test",
|
|
35
|
+
capabilities: %w[function_calling],
|
|
36
|
+
modalities: { input: %w[text], output: %w[text] }
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
MODELS = { PRICED_MODEL.id => PRICED_MODEL, UNPRICED_MODEL.id => UNPRICED_MODEL }.freeze
|
|
40
|
+
|
|
41
|
+
# Serves the test models from ruby_llm's registry lookups.
|
|
42
|
+
module FindTestModels
|
|
43
|
+
def find(model_id, provider = nil)
|
|
44
|
+
MODELS[model_id] || super
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
RawResponse = Data.define(:body)
|
|
49
|
+
|
|
50
|
+
SUBMIT = { content: "Done.", cmd: "echo #{Agent::SUBMIT_MARKER}" }.freeze
|
|
51
|
+
|
|
52
|
+
# A shell made of a Hash. `echo` behaves like echo, so the submit marker
|
|
53
|
+
# works the way it does in a real sandbox; anything else is looked up.
|
|
54
|
+
class FakeEnv
|
|
55
|
+
attr_reader :commands
|
|
56
|
+
|
|
57
|
+
def initialize(canned = {})
|
|
58
|
+
@canned = canned
|
|
59
|
+
@commands = []
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def on(command, output, exit_code: 0)
|
|
63
|
+
@canned[command] = [exit_code, output]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def exec(command, timeout: nil, env: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
67
|
+
@commands << command
|
|
68
|
+
return result(0, command.delete_prefix("echo ")) if command.start_with?("echo ")
|
|
69
|
+
|
|
70
|
+
exit_code, output = @canned.fetch(command, [0, ""])
|
|
71
|
+
result(exit_code, output)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def result(exit_code, output)
|
|
77
|
+
Environment::ExecResult.new(exit_code: exit_code, output: output)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def before_setup
|
|
82
|
+
super
|
|
83
|
+
RubyLLM::Test.reset
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Each answer is a command string, or a hash with :cmd (one command or many)
|
|
87
|
+
# and/or :content, plus optional overrides (:tool_calls, :finish_reason,
|
|
88
|
+
# :thinking, token counts). Overrides given here apply to every answer.
|
|
89
|
+
def stub_llm(*answers, **overrides)
|
|
90
|
+
RubyLLM::Test.stub_responses(*answers.map { llm_answer(_1, **overrides) })
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def llm_answer(answer, **overrides)
|
|
94
|
+
answer = { cmd: answer } if answer.is_a?(String)
|
|
95
|
+
answer = answer.merge(overrides)
|
|
96
|
+
raise ArgumentError, "an answer needs :cmd or :content" unless answer[:cmd] || answer[:content]
|
|
97
|
+
|
|
98
|
+
calls = tool_calls_for(answer)
|
|
99
|
+
RubyLLM::Message.new(
|
|
100
|
+
role: :assistant,
|
|
101
|
+
content: answer.fetch(:content, "Let me try."),
|
|
102
|
+
tool_calls: calls,
|
|
103
|
+
thinking: answer[:thinking] && RubyLLM::Thinking.new(text: answer[:thinking],
|
|
104
|
+
signature: answer[:thinking_signature]),
|
|
105
|
+
input_tokens: answer.fetch(:input_tokens, 95),
|
|
106
|
+
output_tokens: answer.fetch(:output_tokens, 20),
|
|
107
|
+
cached_tokens: answer.fetch(:cached_tokens, 5),
|
|
108
|
+
thinking_tokens: answer[:thinking] && 40,
|
|
109
|
+
raw: RawResponse.new(body: raw_body_for(answer, calls))
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def raw_body_for(answer, calls)
|
|
116
|
+
choice = { "finish_reason" => answer.fetch(:finish_reason) { calls.empty? ? "stop" : "tool_calls" } }
|
|
117
|
+
choice["message"] = { "reasoning_details" => answer[:reasoning_details] } if answer[:reasoning_details]
|
|
118
|
+
{ "choices" => [choice] }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def tool_calls_for(answer)
|
|
122
|
+
calls = answer[:tool_calls] || Array(answer[:cmd]).map { { name: "bash", arguments: { "command" => _1 } } }
|
|
123
|
+
calls.to_h do |call|
|
|
124
|
+
id = call[:id] || "call_#{@llm_answer_ids = @llm_answer_ids.to_i + 1}"
|
|
125
|
+
[id, RubyLLM::ToolCall.new(id: id, name: call[:name], arguments: call[:arguments],
|
|
126
|
+
thought_signature: call[:thought_signature])]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
RubyLLM::Provider.register(:test, Miniswen::Testing::Provider)
|
|
133
|
+
RubyLLM::Models.prepend(Miniswen::Testing::FindTestModels)
|
|
134
|
+
RubyLLM::Models.singleton_class.prepend(RubyLLM::Test::ResolveWithTestProvider)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Miniswen
|
|
4
|
+
# Serializes a finished Agent::Result into an ATIF-v1.7 document.
|
|
5
|
+
class Trajectory
|
|
6
|
+
SCHEMA_VERSION = "ATIF-v1.7"
|
|
7
|
+
|
|
8
|
+
# ATIF has no "tool" source: a tool result is not a step of its own but
|
|
9
|
+
# an observation on the step whose call produced it, so `tool` entries
|
|
10
|
+
# are folded rather than mapped. Everything left is a step.
|
|
11
|
+
SOURCE = { "system" => "system", "user" => "user", "assistant" => "agent" }.freeze
|
|
12
|
+
|
|
13
|
+
NOTES = "total_steps counts model turns; the steps array also carries the system and task " \
|
|
14
|
+
"messages, and each tool result rides as an observation on the step that called it"
|
|
15
|
+
|
|
16
|
+
def self.from(result, model:, session_id: nil, agent: {})
|
|
17
|
+
new(result: result, model: model, session_id: session_id, agent: agent)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(result:, model:, session_id: nil, agent: {})
|
|
21
|
+
@result = result
|
|
22
|
+
@model = model
|
|
23
|
+
@session_id = session_id
|
|
24
|
+
@agent = agent || {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_atif
|
|
28
|
+
{
|
|
29
|
+
schema_version: SCHEMA_VERSION,
|
|
30
|
+
session_id: session_id,
|
|
31
|
+
agent: agent_info,
|
|
32
|
+
steps: steps,
|
|
33
|
+
notes: NOTES,
|
|
34
|
+
final_metrics: final_metrics,
|
|
35
|
+
extra: { status: result.status, submission: result.submission, error: result.error }.compact
|
|
36
|
+
}.compact
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
attr_reader :result, :model, :session_id, :agent
|
|
42
|
+
|
|
43
|
+
def agent_info
|
|
44
|
+
{
|
|
45
|
+
name: agent.fetch(:name, "miniswen"),
|
|
46
|
+
version: agent.fetch(:version, VERSION),
|
|
47
|
+
model_name: model,
|
|
48
|
+
extra: agent[:extra]
|
|
49
|
+
}.compact
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def steps
|
|
53
|
+
result.messages.each_with_object([]) do |message, acc|
|
|
54
|
+
next acc << step_for(message, acc.size + 1) unless message[:role] == "tool"
|
|
55
|
+
next if acc.last.nil?
|
|
56
|
+
|
|
57
|
+
step = acc.last
|
|
58
|
+
(step[:observation] ||= { results: [] })[:results] << {
|
|
59
|
+
source_call_id: message[:tool_call_id],
|
|
60
|
+
content: message[:content],
|
|
61
|
+
extra: { exit_code: message.dig(:observation, :exit_code) }.compact
|
|
62
|
+
}.compact
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def step_for(message, step_id)
|
|
67
|
+
step = { step_id: step_id }
|
|
68
|
+
step[:timestamp] = message[:timestamp] if message[:timestamp]
|
|
69
|
+
step[:source] = SOURCE.fetch(message[:role])
|
|
70
|
+
step[:message] = message[:content]
|
|
71
|
+
step[:reasoning_content] = message[:thinking] if message[:thinking]
|
|
72
|
+
step[:tool_calls] = tool_calls_for(message[:tool_calls]) if message[:tool_calls]
|
|
73
|
+
if (metrics = message[:metrics])
|
|
74
|
+
step[:model_name] = model
|
|
75
|
+
step[:metrics] = metrics_for(metrics)
|
|
76
|
+
step[:llm_call_count] = 1
|
|
77
|
+
end
|
|
78
|
+
# ATIF has no field for how a turn ended
|
|
79
|
+
step[:extra] = { finish_reason: message[:finish_reason] } if message[:finish_reason]
|
|
80
|
+
step
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def tool_calls_for(calls)
|
|
84
|
+
calls.map { { tool_call_id: _1[:id], function_name: _1[:name], arguments: _1[:arguments] } }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def metrics_for(metrics)
|
|
88
|
+
thinking = metrics[:thinking_tokens].to_i
|
|
89
|
+
cached = metrics[:cached_tokens].to_i
|
|
90
|
+
named = metrics.except(:thinking_tokens).compact
|
|
91
|
+
extra = {}
|
|
92
|
+
extra[:completion_tokens_details] = { reasoning_tokens: thinking } if thinking.positive?
|
|
93
|
+
extra[:prompt_tokens_details] = { cached_tokens: cached } if cached.positive?
|
|
94
|
+
extra.empty? ? named : named.merge(extra: extra)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def final_metrics
|
|
98
|
+
totals = {
|
|
99
|
+
total_prompt_tokens: result.input_tokens,
|
|
100
|
+
total_completion_tokens: result.output_tokens,
|
|
101
|
+
total_cached_tokens: result.cached_tokens,
|
|
102
|
+
total_cost_usd: result.cost_usd,
|
|
103
|
+
total_steps: result.steps
|
|
104
|
+
}.compact
|
|
105
|
+
return totals unless result.thinking_tokens.positive?
|
|
106
|
+
|
|
107
|
+
totals.merge(extra: { total_reasoning_tokens: result.thinking_tokens })
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/lib/miniswen.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "miniswen/version"
|
|
4
|
+
require "miniswen/agent"
|
|
5
|
+
require "miniswen/trajectory"
|
|
6
|
+
|
|
7
|
+
module Miniswen # :nodoc:
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# A provider or environment failing in a way that is the harness's fault
|
|
11
|
+
# rather than the model's.
|
|
12
|
+
class InfrastructureError < Error; end
|
|
13
|
+
|
|
14
|
+
# Incomplete accounting: missing usage or cost data is invalid rather than
|
|
15
|
+
# silently under-reported.
|
|
16
|
+
class AccountingError < Error; end
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def refresh_registry!(persist: false)
|
|
20
|
+
return true if @ruby_llm_refreshed
|
|
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
|
|
27
|
+
@ruby_llm_refreshed = true
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
warn "Failed to refresh RubyLLM registry: #{e.message}"
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def registry_revision
|
|
34
|
+
@registry_revision ||= "ruby_llm #{RubyLLM::VERSION}#{registry_stamp}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# The registry file's mtime identifies the data revision: a persisted
|
|
40
|
+
# refresh moves it, while the gem's bundled file keeps its release date.
|
|
41
|
+
def registry_stamp
|
|
42
|
+
file = RubyLLM.config.model_registry_file
|
|
43
|
+
return "" unless File.exist?(file)
|
|
44
|
+
|
|
45
|
+
" (registry #{File.mtime(file).utc.strftime("%Y-%m-%dT%H:%M:%SZ")})"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|