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
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "miniswen/version"
|
|
5
|
+
require "miniswen/ruby_llm"
|
|
6
|
+
|
|
7
|
+
module Miniswen
|
|
8
|
+
# A Ruby port of mini-swe-agent's loop (mini.yaml at commit a83fcae): ask the
|
|
9
|
+
# model for bash tool calls, run them, repeat until it submits or a limit trips.
|
|
10
|
+
class Agent
|
|
11
|
+
SUBMIT_MARKER = "COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT"
|
|
12
|
+
MAX_OBSERVATION_CHARS = 10_000
|
|
13
|
+
MAX_CONSECUTIVE_FORMAT_ERRORS = 3
|
|
14
|
+
|
|
15
|
+
# Both finish_reason dialects accepted raw: OpenAI-shaped providers say
|
|
16
|
+
# "length"/"tool_calls", Anthropic says "max_tokens"/"tool_use".
|
|
17
|
+
TRUNCATION_FINISH_REASONS = %w[length max_tokens].freeze
|
|
18
|
+
CLAIMED_TOOL_FINISH_REASONS = %w[tool_calls tool_use].freeze
|
|
19
|
+
# A safety stop, which arrives looking exactly like a model that forgot
|
|
20
|
+
# to call the tool: no content, no tool call, and — since the provider
|
|
21
|
+
# bills nothing for a turn it refused — no tokens either. Only the
|
|
22
|
+
# finish reason tells the two apart, so the run is labelled by it. The
|
|
23
|
+
# retry is unchanged: the nudge still goes back, because matching
|
|
24
|
+
# mini-swe-agent turn for turn is what makes runs comparable.
|
|
25
|
+
REFUSAL_FINISH_REASONS = %w[content_filter refusal safety].freeze
|
|
26
|
+
|
|
27
|
+
# The breakpoint marker Anthropic reads, shaped the way OpenRouter forwards it.
|
|
28
|
+
CACHE_CONTROL = { type: "ephemeral" }.freeze
|
|
29
|
+
|
|
30
|
+
EXEC_ENV = {
|
|
31
|
+
"PAGER" => "cat",
|
|
32
|
+
"MANPAGER" => "cat",
|
|
33
|
+
"LESS" => "-R",
|
|
34
|
+
"PIP_PROGRESS_BAR" => "off",
|
|
35
|
+
"TQDM_DISABLE" => "1"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Providers that serve local inference (and cost zero)
|
|
39
|
+
LOCAL_PROVIDERS = %i[ollama gpustack].freeze
|
|
40
|
+
|
|
41
|
+
SYSTEM_TEMPLATE = <<~PROMPT
|
|
42
|
+
You are a helpful assistant that can interact with a computer.
|
|
43
|
+
PROMPT
|
|
44
|
+
|
|
45
|
+
INSTANCE_TEMPLATE = <<~PROMPT.freeze
|
|
46
|
+
Please solve this issue: %<instruction>s
|
|
47
|
+
|
|
48
|
+
You can execute bash commands and edit files to implement the necessary changes.
|
|
49
|
+
|
|
50
|
+
## Recommended Workflow
|
|
51
|
+
|
|
52
|
+
This workflow should be done step-by-step so that you can iterate on your changes and any possible problems.
|
|
53
|
+
|
|
54
|
+
1. Analyze the codebase by finding and reading relevant files
|
|
55
|
+
2. Create a script to reproduce the issue
|
|
56
|
+
3. Edit the source code to resolve the issue
|
|
57
|
+
4. Verify your fix works by running your script again
|
|
58
|
+
5. Test edge cases to ensure your fix is robust
|
|
59
|
+
6. Submit your changes and finish your work by issuing the following command: `echo #{SUBMIT_MARKER}`.
|
|
60
|
+
Do not combine it with any other command. <important>After this command, you cannot continue working on this task.</important>
|
|
61
|
+
|
|
62
|
+
## Command Execution Rules
|
|
63
|
+
|
|
64
|
+
You are operating in an environment where
|
|
65
|
+
|
|
66
|
+
1. You issue at least one command
|
|
67
|
+
2. The system executes the command(s) in a subshell
|
|
68
|
+
3. You see the result(s)
|
|
69
|
+
4. You write your next command(s)
|
|
70
|
+
|
|
71
|
+
Each response should include:
|
|
72
|
+
|
|
73
|
+
1. **Reasoning text** where you explain your analysis and plan
|
|
74
|
+
2. At least one tool call with your command
|
|
75
|
+
|
|
76
|
+
**CRITICAL REQUIREMENTS:**
|
|
77
|
+
|
|
78
|
+
- Your response SHOULD include reasoning text explaining what you're doing
|
|
79
|
+
- Your response MUST include AT LEAST ONE bash tool call
|
|
80
|
+
- Directory or environment variable changes are not persistent. Every action is executed in a new subshell.
|
|
81
|
+
- However, you can prefix any action with `MY_ENV_VAR=MY_VALUE cd /path/to/working/dir && ...` or write/load environment variables from files
|
|
82
|
+
- Submit your changes and finish your work by issuing the following command: `echo #{SUBMIT_MARKER}`.
|
|
83
|
+
Do not combine it with any other command. <important>After this command, you cannot continue working on this task.</important>
|
|
84
|
+
|
|
85
|
+
Example of a CORRECT response:
|
|
86
|
+
<example_response>
|
|
87
|
+
I need to understand the structure of the repository first. Let me check what files are in the current directory to get a better understanding of the codebase.
|
|
88
|
+
|
|
89
|
+
[Makes bash tool call with {"command": "ls -la"} as arguments]
|
|
90
|
+
</example_response>
|
|
91
|
+
|
|
92
|
+
<system_information>
|
|
93
|
+
%<system_information>s
|
|
94
|
+
</system_information>
|
|
95
|
+
|
|
96
|
+
## Useful command examples
|
|
97
|
+
|
|
98
|
+
### Create a new file:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
cat <<'EOF' > newfile.py
|
|
102
|
+
import numpy as np
|
|
103
|
+
hello = "ciao"
|
|
104
|
+
print(hello)
|
|
105
|
+
EOF
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Edit files with sed:
|
|
109
|
+
%<macos_sed_note>s
|
|
110
|
+
```bash
|
|
111
|
+
# Replace all occurrences
|
|
112
|
+
sed -i 's/old_string/new_string/g' filename.py
|
|
113
|
+
|
|
114
|
+
# Replace only first occurrence
|
|
115
|
+
sed -i 's/old_string/new_string/' filename.py
|
|
116
|
+
|
|
117
|
+
# Replace first occurrence on line 1
|
|
118
|
+
sed -i '1s/old_string/new_string/' filename.py
|
|
119
|
+
|
|
120
|
+
# Replace all occurrences in lines 1-10
|
|
121
|
+
sed -i '1,10s/old_string/new_string/g' filename.py
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### View file content:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# View specific lines with numbers
|
|
128
|
+
nl -ba filename.py | sed -n '10,20p'
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Any other command you want to run
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
anything
|
|
135
|
+
```
|
|
136
|
+
PROMPT
|
|
137
|
+
|
|
138
|
+
MACOS_SED_NOTE = <<~NOTE
|
|
139
|
+
<important>
|
|
140
|
+
You are on MacOS. For all the below examples, you need to use `sed -i ''` instead of `sed -i`.
|
|
141
|
+
</important>
|
|
142
|
+
NOTE
|
|
143
|
+
|
|
144
|
+
NO_TOOL_CALLS_ERROR = "No tool calls found in the response. Every response MUST include at least one tool call."
|
|
145
|
+
|
|
146
|
+
TRUNCATION_ERROR_MESSAGE = <<~MESSAGE
|
|
147
|
+
Your previous response reached the output token limit (finish_reason=%<finish_reason>s) before you produced a tool call, so it was cut off. Respond more concisely and finish with exactly one bash tool call. If you need to think more, do so briefly.
|
|
148
|
+
MESSAGE
|
|
149
|
+
|
|
150
|
+
TOOL_CALL_ERROR_MESSAGE = <<~MESSAGE.freeze
|
|
151
|
+
Tool call error:
|
|
152
|
+
|
|
153
|
+
<error>
|
|
154
|
+
%<error>s
|
|
155
|
+
</error>
|
|
156
|
+
|
|
157
|
+
Here is general guidance on how to submit correct toolcalls:
|
|
158
|
+
|
|
159
|
+
Every response needs to use the 'bash' tool at least once to execute commands.
|
|
160
|
+
|
|
161
|
+
Call the bash tool with your command as the argument:
|
|
162
|
+
- Tool: bash
|
|
163
|
+
- Arguments: {"command": "your_command_here"}
|
|
164
|
+
|
|
165
|
+
If you want to end the task, please issue the following command: `echo #{SUBMIT_MARKER}`
|
|
166
|
+
without any other command.
|
|
167
|
+
MESSAGE
|
|
168
|
+
|
|
169
|
+
# Thinking that also carries the provider's reasoning_details for verbatim replay.
|
|
170
|
+
class VerbatimThinking < RubyLLM::Thinking
|
|
171
|
+
attr_reader :details
|
|
172
|
+
|
|
173
|
+
def initialize(text: nil, signature: nil, details: nil)
|
|
174
|
+
super(text: text, signature: signature)
|
|
175
|
+
@details = details
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Only ever rendered into the request — the completion executes nothing — so no execute body.
|
|
180
|
+
class BashTool < RubyLLM::Tool
|
|
181
|
+
description "Execute a bash command"
|
|
182
|
+
param :command, desc: "The bash command to execute"
|
|
183
|
+
|
|
184
|
+
# ruby_llm would otherwise derive "lemans--miniswen--bash" from the class path.
|
|
185
|
+
def name = "bash"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
Result = Data.define(:status, :submission, :messages, :steps, :cost_source,
|
|
189
|
+
:input_tokens, :output_tokens, :cached_tokens, :thinking_tokens, :cost_usd,
|
|
190
|
+
:error) do
|
|
191
|
+
def success? = status == :submitted
|
|
192
|
+
|
|
193
|
+
def to_h = super.merge(cost_source: cost_source&.to_h, version: Miniswen::VERSION)
|
|
194
|
+
|
|
195
|
+
def self.from_h(payload)
|
|
196
|
+
data = deep_symbolize(payload)
|
|
197
|
+
source = data[:cost_source]
|
|
198
|
+
new(
|
|
199
|
+
status: data[:status]&.to_sym,
|
|
200
|
+
submission: data[:submission],
|
|
201
|
+
messages: data[:messages] || [],
|
|
202
|
+
steps: data[:steps],
|
|
203
|
+
cost_source: source && CostSource.new(name: source[:name]&.to_sym, model: source[:model],
|
|
204
|
+
priced_as: source[:priced_as], registry: source[:registry]),
|
|
205
|
+
input_tokens: data[:input_tokens], output_tokens: data[:output_tokens],
|
|
206
|
+
cached_tokens: data[:cached_tokens], thinking_tokens: data[:thinking_tokens],
|
|
207
|
+
cost_usd: data[:cost_usd],
|
|
208
|
+
error: data[:error]
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def self.deep_symbolize(value)
|
|
213
|
+
case value
|
|
214
|
+
when Hash
|
|
215
|
+
value.to_h do |key, item|
|
|
216
|
+
key = key.to_sym
|
|
217
|
+
# Tool-call arguments and reasoning details keep their provider-style string keys.
|
|
218
|
+
[key, %i[arguments reasoning_details].include?(key) ? item : deep_symbolize(item)]
|
|
219
|
+
end
|
|
220
|
+
when Array then value.map { deep_symbolize(_1) }
|
|
221
|
+
else value
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
CostSource = Data.define(:name, :model, :priced_as, :registry) do
|
|
227
|
+
def to_h = { name: name, model: model, priced_as: priced_as, registry: registry }.compact
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
attr_reader :messages, :environment
|
|
231
|
+
|
|
232
|
+
private attr_reader :max_steps, :max_time, :max_cost, :exec_timeout,
|
|
233
|
+
:clock, :reporter
|
|
234
|
+
|
|
235
|
+
# `model` is a litellm-style name ("openrouter/z-ai/glm-5.2"). Limits of 0 or nil are disabled.
|
|
236
|
+
def initialize(model:, environment:, max_steps: 0, max_time: 0, max_cost: nil,
|
|
237
|
+
exec_timeout: 30, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
238
|
+
reporter: nil)
|
|
239
|
+
@provider, @id = model.split("/", 2)
|
|
240
|
+
unless @id
|
|
241
|
+
@id = @provider
|
|
242
|
+
@provider = nil
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
@model = model
|
|
246
|
+
@environment = environment
|
|
247
|
+
|
|
248
|
+
@bash_tool = BashTool.new
|
|
249
|
+
|
|
250
|
+
@max_steps = max_steps.to_i
|
|
251
|
+
@max_time = max_time.to_f
|
|
252
|
+
@max_cost = max_cost
|
|
253
|
+
@exec_timeout = exec_timeout
|
|
254
|
+
|
|
255
|
+
@clock = clock
|
|
256
|
+
@reporter = reporter
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def run(instruction)
|
|
260
|
+
uname = execute("uname -srvm").output.to_s.strip
|
|
261
|
+
@messages = [
|
|
262
|
+
{ role: "system", content: SYSTEM_TEMPLATE },
|
|
263
|
+
{ role: "user", content: format(INSTANCE_TEMPLATE,
|
|
264
|
+
instruction: instruction,
|
|
265
|
+
system_information: uname,
|
|
266
|
+
macos_sed_note: uname.start_with?("Darwin") ? "\n#{MACOS_SED_NOTE}" : "") }
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
@steps = 0
|
|
270
|
+
@cost = 0.0
|
|
271
|
+
|
|
272
|
+
@totals = { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 }
|
|
273
|
+
|
|
274
|
+
@cost_known = true
|
|
275
|
+
@consecutive_format_errors = 0
|
|
276
|
+
@refused_turns = 0
|
|
277
|
+
@started_at = @clock.call
|
|
278
|
+
|
|
279
|
+
loop do
|
|
280
|
+
(status = limit_reached) and return finish(status)
|
|
281
|
+
|
|
282
|
+
actions = next_actions
|
|
283
|
+
if actions.nil?
|
|
284
|
+
if @consecutive_format_errors >= MAX_CONSECUTIVE_FORMAT_ERRORS
|
|
285
|
+
return finish(@refused_turns.positive? ? :content_filter : :format_error)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
next
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
actions.each do |action|
|
|
292
|
+
reporter&.on_tool_call(action)
|
|
293
|
+
result = execute(action.fetch(:arguments).fetch("command"))
|
|
294
|
+
# The submit command's output is observed too, so the final tool
|
|
295
|
+
# call has a linked result in the trajectory.
|
|
296
|
+
observe(action, result)
|
|
297
|
+
return finish(:submitted, submission: submission_from(result)) if submitted?(result)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def partial_result(error)
|
|
303
|
+
Result.new(
|
|
304
|
+
status: :error, submission: nil, messages: @messages || [], steps: @steps.to_i,
|
|
305
|
+
cost_source: cost_source, cost_usd: @cost_known == false ? nil : @cost.to_f,
|
|
306
|
+
error: error,
|
|
307
|
+
**(@totals || { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 })
|
|
308
|
+
)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# The env a remote miniswen needs to drive this model: the resolved
|
|
312
|
+
# provider's required config options, named the way ruby_llm.rb reads
|
|
313
|
+
# them back from ENV on boot (the option upcased).
|
|
314
|
+
def provider_env
|
|
315
|
+
_, provider = resolved
|
|
316
|
+
env = provider.configuration_requirements.to_h { [_1.to_s.upcase, RubyLLM.config.public_send(_1)] }.compact
|
|
317
|
+
|
|
318
|
+
order = ENV["LEMANS_PROVIDER_ORDER"]
|
|
319
|
+
env["LEMANS_PROVIDER_ORDER"] = order if order
|
|
320
|
+
env
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
private
|
|
324
|
+
|
|
325
|
+
def execute(command)
|
|
326
|
+
environment.exec(command, timeout: exec_timeout, env: EXEC_ENV)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Checked before the model is asked, so the tripping step is never paid for.
|
|
330
|
+
def limit_reached
|
|
331
|
+
return :step_limit if max_steps.positive? && @steps >= max_steps
|
|
332
|
+
return :time_limit if max_time.positive? && (@clock.call - @started_at) >= max_time
|
|
333
|
+
return :cost_limit if max_cost && @cost_known && @cost >= max_cost
|
|
334
|
+
|
|
335
|
+
nil
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# One model turn. Returns the actions to run, or nil after appending a
|
|
339
|
+
# format-error message the model gets to react to on its next turn.
|
|
340
|
+
def next_actions
|
|
341
|
+
response = complete(@messages)
|
|
342
|
+
@steps += 1
|
|
343
|
+
@totals.each_key { @totals[_1] += response[_1].to_i }
|
|
344
|
+
track_cost(response)
|
|
345
|
+
|
|
346
|
+
entry = { role: "assistant", timestamp: Time.now.utc.iso8601, content: response[:content].to_s,
|
|
347
|
+
metrics: metrics_from(response) }
|
|
348
|
+
# a refused turn: a safety stop hands back no
|
|
349
|
+
# content, no tool call and no billed tokens.
|
|
350
|
+
entry[:finish_reason] = response[:finish_reason] if response[:finish_reason]
|
|
351
|
+
entry[:thinking] = response[:thinking] if response[:thinking]
|
|
352
|
+
# The provider's opaque handles on this turn's reasoning: replayed back
|
|
353
|
+
# to the provider only, never into the trajectory.
|
|
354
|
+
entry[:thinking_signature] = response[:thinking_signature] if response[:thinking_signature]
|
|
355
|
+
entry[:reasoning_details] = response[:reasoning_details] if response[:reasoning_details]
|
|
356
|
+
|
|
357
|
+
observe_message entry
|
|
358
|
+
|
|
359
|
+
tool_calls = Array(response[:tool_calls])
|
|
360
|
+
if (error = actions_error(tool_calls))
|
|
361
|
+
# The bad calls are kept off the entry the llm replays: an assistant
|
|
362
|
+
# message with unanswered tool calls is a request providers reject.
|
|
363
|
+
entry[:invalid_tool_calls] = tool_calls if tool_calls.any?
|
|
364
|
+
@consecutive_format_errors += 1
|
|
365
|
+
@refused_turns += 1 if refused?(response)
|
|
366
|
+
|
|
367
|
+
observe_message({ role: "user", content: format_error_message(error, response, tool_calls) })
|
|
368
|
+
|
|
369
|
+
return nil
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
@consecutive_format_errors = 0
|
|
373
|
+
@refused_turns = 0
|
|
374
|
+
entry[:tool_calls] = tool_calls
|
|
375
|
+
tool_calls
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def refused?(response) = REFUSAL_FINISH_REASONS.include?(response[:finish_reason].to_s)
|
|
379
|
+
|
|
380
|
+
# An unpriced completion under a cost ceiling is fatal on the spot: only failing fast stops
|
|
381
|
+
# the spend. Without a ceiling, unknown cost is just a fact to report.
|
|
382
|
+
def track_cost(response)
|
|
383
|
+
cost = response[:cost_usd]
|
|
384
|
+
if cost.nil?
|
|
385
|
+
if @max_cost
|
|
386
|
+
raise AccountingError,
|
|
387
|
+
"#{@model} returned an unpriced completion; cost_limit cannot be enforced"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
@cost_known = false
|
|
391
|
+
else
|
|
392
|
+
@cost += cost
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def actions_error(tool_calls)
|
|
397
|
+
return NO_TOOL_CALLS_ERROR if tool_calls.empty?
|
|
398
|
+
|
|
399
|
+
tool_calls.each do |call|
|
|
400
|
+
error = +""
|
|
401
|
+
error << "Unknown tool '#{call[:name]}'." if call[:name] != "bash"
|
|
402
|
+
arguments = call[:arguments]
|
|
403
|
+
error << "Missing 'command' argument in bash tool call." unless arguments.is_a?(Hash) && arguments["command"]
|
|
404
|
+
return error unless error.empty?
|
|
405
|
+
end
|
|
406
|
+
nil
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def format_error_message(error, response, tool_calls)
|
|
410
|
+
finish_reason = response[:finish_reason].to_s
|
|
411
|
+
if TRUNCATION_FINISH_REASONS.include?(finish_reason) ||
|
|
412
|
+
(CLAIMED_TOOL_FINISH_REASONS.include?(finish_reason) && tool_calls.empty?)
|
|
413
|
+
format(TRUNCATION_ERROR_MESSAGE, finish_reason: finish_reason)
|
|
414
|
+
else
|
|
415
|
+
format(TOOL_CALL_ERROR_MESSAGE, error: error)
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def metrics_from(response)
|
|
420
|
+
{
|
|
421
|
+
prompt_tokens: response[:input_tokens].to_i,
|
|
422
|
+
completion_tokens: response[:output_tokens].to_i,
|
|
423
|
+
cached_tokens: response[:cached_tokens].to_i,
|
|
424
|
+
thinking_tokens: response[:thinking_tokens].to_i,
|
|
425
|
+
cost_usd: response[:cost_usd]
|
|
426
|
+
}
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def submitted?(result)
|
|
430
|
+
result.exit_code.zero? && result.output.to_s.lstrip.lines.first&.strip == SUBMIT_MARKER
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def submission_from(result)
|
|
434
|
+
result.output.to_s.lstrip.lines.drop(1).join
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def observe(action, result)
|
|
438
|
+
output = result.output.to_s
|
|
439
|
+
observe_message({
|
|
440
|
+
role: "tool",
|
|
441
|
+
tool_call_id: action[:id],
|
|
442
|
+
content: observation_content(result.exit_code, output),
|
|
443
|
+
observation: { exit_code: result.exit_code, output: truncate(output) }
|
|
444
|
+
})
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def observe_message(msg)
|
|
448
|
+
@messages << msg
|
|
449
|
+
reporter&.on_message(msg)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def observation_content(exit_code, output)
|
|
453
|
+
if output.length < MAX_OBSERVATION_CHARS
|
|
454
|
+
<<~OBSERVATION.strip
|
|
455
|
+
{
|
|
456
|
+
"returncode": #{exit_code},
|
|
457
|
+
"output": #{output.to_json}
|
|
458
|
+
}
|
|
459
|
+
OBSERVATION
|
|
460
|
+
else
|
|
461
|
+
half = MAX_OBSERVATION_CHARS / 2
|
|
462
|
+
<<~OBSERVATION.strip
|
|
463
|
+
{
|
|
464
|
+
"returncode": #{exit_code},
|
|
465
|
+
"output_head": #{output[0, half].to_json},
|
|
466
|
+
"output_tail": #{output[-half, half].to_json},
|
|
467
|
+
"elided_chars": #{output.length - MAX_OBSERVATION_CHARS},
|
|
468
|
+
"warning": "Output too long."
|
|
469
|
+
}
|
|
470
|
+
OBSERVATION
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def truncate(output)
|
|
475
|
+
return output if output.length <= MAX_OBSERVATION_CHARS
|
|
476
|
+
|
|
477
|
+
half = MAX_OBSERVATION_CHARS / 2
|
|
478
|
+
"#{output[0, half]}\n...[#{output.length - MAX_OBSERVATION_CHARS} characters omitted]...\n#{output[-half, half]}"
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def finish(status, submission: nil)
|
|
482
|
+
Result.new(
|
|
483
|
+
status: status, submission: submission, messages: @messages, steps: @steps,
|
|
484
|
+
cost_source: cost_source, cost_usd: @cost_known ? @cost : nil, error: nil, **@totals
|
|
485
|
+
)
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Provider#complete, not Chat: Chat runs its own loop, and the loop lives above.
|
|
489
|
+
def complete(messages)
|
|
490
|
+
model_info, provider = resolved
|
|
491
|
+
response = provider.complete(
|
|
492
|
+
with_cache_breakpoints(messages.map { as_ruby_llm(_1) }),
|
|
493
|
+
tools: { bash: @bash_tool },
|
|
494
|
+
temperature: nil,
|
|
495
|
+
model: model_info,
|
|
496
|
+
params: routing_params
|
|
497
|
+
)
|
|
498
|
+
payload(response)
|
|
499
|
+
rescue RubyLLM::Error => e
|
|
500
|
+
raise InfrastructureError, "miniswen: the model call failed: #{e.message}"
|
|
501
|
+
rescue Faraday::SSLError, Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
|
502
|
+
raise InfrastructureError, "miniswen: the model call failed: #{e.class}: #{e.message}"
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Anthropic bills every token fresh unless the request marks explicit cache
|
|
506
|
+
# breakpoints
|
|
507
|
+
def explicit_cache? = @provider == "openrouter" && @id.to_s.start_with?("anthropic/")
|
|
508
|
+
|
|
509
|
+
def with_cache_breakpoints(messages)
|
|
510
|
+
return messages unless explicit_cache?
|
|
511
|
+
|
|
512
|
+
system = messages.find { _1.role == :system }
|
|
513
|
+
[system, messages.last].compact.uniq.each do |message|
|
|
514
|
+
text = message.content
|
|
515
|
+
next unless text.is_a?(String) && !text.empty?
|
|
516
|
+
|
|
517
|
+
message.content = RubyLLM::Content::Raw.new(
|
|
518
|
+
[{ type: "text", text: text, cache_control: CACHE_CONTROL }]
|
|
519
|
+
)
|
|
520
|
+
end
|
|
521
|
+
messages
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def routing_params
|
|
525
|
+
order = ENV["LEMANS_PROVIDER_ORDER"]
|
|
526
|
+
return {} unless order && @provider == "openrouter"
|
|
527
|
+
|
|
528
|
+
{ provider: { order: order.split(",").map(&:strip), allow_fallbacks: false } }
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def cost_source
|
|
532
|
+
if local?
|
|
533
|
+
return CostSource.new(name: :local_provider, model: @model,
|
|
534
|
+
priced_as: "#{@provider || info&.provider}/#{@id} ($0.00, local)",
|
|
535
|
+
registry: nil)
|
|
536
|
+
end
|
|
537
|
+
return nil unless info
|
|
538
|
+
|
|
539
|
+
CostSource.new(name: :model_registry, model: @model,
|
|
540
|
+
priced_as: "#{info.provider}/#{info.id}",
|
|
541
|
+
registry: Miniswen.registry_revision)
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def resolved
|
|
545
|
+
@resolved ||= RubyLLM::Models.resolve(@id, provider: @provider, assume_exists: !@provider.nil?)
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def as_ruby_llm(entry)
|
|
549
|
+
case entry[:role]
|
|
550
|
+
when "assistant"
|
|
551
|
+
RubyLLM::Message.new(role: :assistant, content: entry[:content],
|
|
552
|
+
thinking: as_ruby_llm_thinking(entry),
|
|
553
|
+
tool_calls: as_ruby_llm_tool_calls(entry[:tool_calls]))
|
|
554
|
+
when "tool"
|
|
555
|
+
RubyLLM::Message.new(role: :tool, content: entry[:content], tool_call_id: entry[:tool_call_id])
|
|
556
|
+
else
|
|
557
|
+
RubyLLM::Message.new(role: entry[:role].to_sym, content: entry[:content])
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def as_ruby_llm_thinking(entry)
|
|
562
|
+
details = entry[:reasoning_details]
|
|
563
|
+
if details && !details.empty?
|
|
564
|
+
VerbatimThinking.new(text: entry[:thinking], signature: entry[:thinking_signature], details: details)
|
|
565
|
+
else
|
|
566
|
+
RubyLLM::Thinking.build(text: entry[:thinking], signature: entry[:thinking_signature])
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def as_ruby_llm_tool_calls(tool_calls)
|
|
571
|
+
return nil if tool_calls.nil? || tool_calls.empty?
|
|
572
|
+
|
|
573
|
+
tool_calls.to_h do |call|
|
|
574
|
+
[call[:id], RubyLLM::ToolCall.new(id: call[:id], name: call[:name], arguments: call[:arguments],
|
|
575
|
+
thought_signature: call[:thought_signature])]
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def payload(response)
|
|
580
|
+
# Providers under load occasionally answer with no completion at all.
|
|
581
|
+
raise InfrastructureError, "miniswen: #{@model} returned an empty completion" if response.nil?
|
|
582
|
+
|
|
583
|
+
tokens = response.tokens
|
|
584
|
+
{
|
|
585
|
+
content: response.content.to_s,
|
|
586
|
+
thinking: response.thinking&.text,
|
|
587
|
+
thinking_signature: response.thinking&.signature,
|
|
588
|
+
reasoning_details: reasoning_details_from(response),
|
|
589
|
+
tool_calls: tool_calls_from(response),
|
|
590
|
+
finish_reason: finish_reason_from(response),
|
|
591
|
+
# ruby_llm's input_tokens is the cache-miss remainder only; the
|
|
592
|
+
# convention counts the whole prompt, cached and cache-write included.
|
|
593
|
+
input_tokens: response.input_tokens.to_i + tokens&.cached.to_i + tokens&.cache_creation.to_i,
|
|
594
|
+
output_tokens: response.output_tokens.to_i,
|
|
595
|
+
cached_tokens: tokens&.cached.to_i,
|
|
596
|
+
thinking_tokens: tokens&.thinking.to_i,
|
|
597
|
+
cost_usd: price(response)
|
|
598
|
+
}
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def tool_calls_from(response)
|
|
602
|
+
Array(response.tool_calls&.values).map do |call|
|
|
603
|
+
entry = { id: call.id, name: call.name, arguments: normalize_arguments(call.arguments) }
|
|
604
|
+
entry[:thought_signature] = call.thought_signature if call.thought_signature
|
|
605
|
+
entry
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Providers hand arguments back parsed; a provider that didn't gets one
|
|
610
|
+
# parse attempt, anything else bounces as a format error.
|
|
611
|
+
def normalize_arguments(arguments)
|
|
612
|
+
arguments = JSON.parse(arguments) if arguments.is_a?(String)
|
|
613
|
+
arguments.is_a?(Hash) ? arguments.transform_keys(&:to_s) : arguments
|
|
614
|
+
rescue JSON::ParserError
|
|
615
|
+
arguments
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def finish_reason_from(response)
|
|
619
|
+
body = raw_body(response)
|
|
620
|
+
body && (body.dig("choices", 0, "finish_reason") || body["stop_reason"])
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def reasoning_details_from(response)
|
|
624
|
+
details = raw_body(response)&.dig("choices", 0, "message", "reasoning_details")
|
|
625
|
+
details.is_a?(Array) && !details.empty? ? details : nil
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def raw_body(response)
|
|
629
|
+
body = response.raw&.body
|
|
630
|
+
body = JSON.parse(body) if body.is_a?(String)
|
|
631
|
+
body.is_a?(Hash) ? body : nil
|
|
632
|
+
rescue JSON::ParserError
|
|
633
|
+
nil
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def local? = LOCAL_PROVIDERS.include?((@provider || info&.provider)&.to_sym)
|
|
637
|
+
|
|
638
|
+
def info
|
|
639
|
+
return @info if defined?(@info)
|
|
640
|
+
|
|
641
|
+
@info = find_model
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def find_model
|
|
645
|
+
@provider ? RubyLLM.models.find(@id, @provider) : RubyLLM.models.find(@id)
|
|
646
|
+
rescue RubyLLM::ModelNotFoundError
|
|
647
|
+
nil
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def price(response)
|
|
651
|
+
return 0.0 if local?
|
|
652
|
+
|
|
653
|
+
input = info&.input_price_per_million
|
|
654
|
+
output = info&.output_price_per_million
|
|
655
|
+
return nil unless input.is_a?(Numeric) && output.is_a?(Numeric)
|
|
656
|
+
|
|
657
|
+
tokens = response.tokens
|
|
658
|
+
cache_read = info.cache_read_input_price_per_million || input
|
|
659
|
+
cache_write = info.cache_write_input_price_per_million || input
|
|
660
|
+
# Providers usually fold thinking into output_tokens; max() bills the
|
|
661
|
+
# larger count once and can never double-bill.
|
|
662
|
+
generated = [response.output_tokens.to_i, tokens&.thinking.to_i].max
|
|
663
|
+
((response.input_tokens.to_i * input) +
|
|
664
|
+
(tokens&.cached.to_i * cache_read) +
|
|
665
|
+
(tokens&.cache_creation.to_i * cache_write) +
|
|
666
|
+
(generated * output)) / 1_000_000.0
|
|
667
|
+
end
|
|
668
|
+
end
|
|
669
|
+
end
|