brute 6.1.2 → 6.1.4
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/lib/brute/completion/open_router.rb +8 -2
- data/lib/brute/message_transport/open_router.rb +1 -27
- data/lib/brute/middleware/006_loop.rb +36 -1
- data/lib/brute/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f639a3b9b3ec1317f80ee945eb7d610af350d4c862206745aefad42a70afbb4d
|
|
4
|
+
data.tar.gz: 994bf0aaf3104bfad636338639db4c175de7bbcd468a9abd8f90114c4a8e233e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 29b29a00aad896433765e8bbff99ec42422af1c133b4b73231837f85ed7d0ffd4ea2101859fc37ca81c16678860f9a6de72d437d674faf3b9e12d3734f6c36e7
|
|
7
|
+
data.tar.gz: b43248273fc878a7c553e968b010552a7a670933b694a5a952bb3ecb5c197e08595e5134262475ee296c41b538e601347d34bfc7694cb6880b766e78d8231ea8
|
|
@@ -47,8 +47,10 @@ module Brute
|
|
|
47
47
|
env.emit(LLM_START_EVENT)
|
|
48
48
|
|
|
49
49
|
messages = Brute::MessageTransport::OpenRouter.dump_all(env[:messages], model: options(env).model)
|
|
50
|
+
raw = nil
|
|
50
51
|
|
|
51
52
|
::OpenRouter::Client.new(**@config).then do |client|
|
|
53
|
+
client.on(:on_error) { |error| raw = error }
|
|
52
54
|
response = nil
|
|
53
55
|
env.emit(LLM_DURATION_EVENT) { response = client.complete(messages, options(env)) }
|
|
54
56
|
|
|
@@ -76,13 +78,13 @@ module Brute
|
|
|
76
78
|
# kind of failure. The classes are looked up defensively — the provider
|
|
77
79
|
# gem is only a dependency of the app that uses this middleware.
|
|
78
80
|
rescue => error
|
|
79
|
-
env.emit(LLM_FAILURE_EVENT)
|
|
81
|
+
env.emit(LLM_FAILURE_EVENT, raw || error)
|
|
80
82
|
|
|
81
83
|
if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
|
|
82
84
|
env.emit(FARADAY_ERROR_EVENT, error)
|
|
83
85
|
|
|
84
86
|
elsif defined?(::OpenRouter::ServerError) && error.is_a?(::OpenRouter::ServerError)
|
|
85
|
-
env.emit(OPEN_ROUTER_SERVER_ERROR_EVENT, error)
|
|
87
|
+
env.emit(OPEN_ROUTER_SERVER_ERROR_EVENT, raw || error)
|
|
86
88
|
|
|
87
89
|
else
|
|
88
90
|
env.emit(STANDARD_ERROR_EVENT, error)
|
|
@@ -147,6 +149,7 @@ describe "brute/completion/open_router" do
|
|
|
147
149
|
# Run one turn against a stubbed OpenRouter::Client and hand back the env.
|
|
148
150
|
with_fake_client = lambda do |completion, response|
|
|
149
151
|
fake_client = Object.new
|
|
152
|
+
fake_client.define_singleton_method(:on) { |_event, &_block| self }
|
|
150
153
|
fake_client.define_singleton_method(:complete) { |_messages, _options| response }
|
|
151
154
|
original = OpenRouter::Client.method(:new)
|
|
152
155
|
OpenRouter::Client.define_singleton_method(:new) { |**_config| fake_client }
|
|
@@ -180,6 +183,7 @@ describe "brute/completion/open_router" do
|
|
|
180
183
|
it "advertises env[:tools] to the provider, and lets a tools: option win" do
|
|
181
184
|
captured = []
|
|
182
185
|
fake_client = Object.new
|
|
186
|
+
fake_client.define_singleton_method(:on) { |_event, &_block| self }
|
|
183
187
|
fake_client.define_singleton_method(:complete) { |_messages, options| captured << options; FakeUsageResponse.new(nil) }
|
|
184
188
|
original = OpenRouter::Client.method(:new)
|
|
185
189
|
OpenRouter::Client.define_singleton_method(:new) { |**_config| fake_client }
|
|
@@ -226,6 +230,7 @@ describe "brute/completion/open_router" do
|
|
|
226
230
|
it "takes the model from env[:model], and lets a model: option win" do
|
|
227
231
|
captured = []
|
|
228
232
|
fake_client = Object.new
|
|
233
|
+
fake_client.define_singleton_method(:on) { |_event, &_block| self }
|
|
229
234
|
fake_client.define_singleton_method(:complete) { |_messages, options| captured << options; FakeUsageResponse.new(nil) }
|
|
230
235
|
original = OpenRouter::Client.method(:new)
|
|
231
236
|
OpenRouter::Client.define_singleton_method(:new) { |**_config| fake_client }
|
|
@@ -260,6 +265,7 @@ describe "brute/completion/open_router" do
|
|
|
260
265
|
|
|
261
266
|
boom = RuntimeError.new("no route to host")
|
|
262
267
|
fake_client = Object.new
|
|
268
|
+
fake_client.define_singleton_method(:on) { |_event, &_block| self }
|
|
263
269
|
fake_client.define_singleton_method(:complete) { |_messages, _options| raise boom }
|
|
264
270
|
original = OpenRouter::Client.method(:new)
|
|
265
271
|
OpenRouter::Client.define_singleton_method(:new) { |**_config| fake_client }
|
|
@@ -13,7 +13,6 @@ module Brute
|
|
|
13
13
|
#
|
|
14
14
|
# require "open_router"
|
|
15
15
|
class OpenRouter < MessageTransport
|
|
16
|
-
EmptyCompletion = Class.new(StandardError)
|
|
17
16
|
|
|
18
17
|
# What the provider reported about this call — the transport knows its
|
|
19
18
|
# own library's shape, so it knows which detector to ask.
|
|
@@ -94,15 +93,6 @@ module Brute
|
|
|
94
93
|
|
|
95
94
|
case hash
|
|
96
95
|
in { role: (:system | :user | :assistant | :tool) }
|
|
97
|
-
# A completion that answered with nothing is not an answer. The
|
|
98
|
-
# provider was paid for it and something came back -- reasoning,
|
|
99
|
-
# a refusal, a field this does not know -- and appending it as an
|
|
100
|
-
# empty assistant message loses the turn quietly: the loop stops
|
|
101
|
-
# because it is not a tool result, and there is no reply in it.
|
|
102
|
-
if hash[:role] == :assistant && hash[:content].to_s.strip.empty? && Array(hash[:tool_calls]).empty? && reasoning(hash).nil?
|
|
103
|
-
raise EmptyCompletion, "the provider answered with no content and no tool calls: #{message.inspect}"
|
|
104
|
-
end
|
|
105
|
-
|
|
106
96
|
# Slice away provider extras (refusal, model, ...) that
|
|
107
97
|
# Brute::Message doesn't know. Reasoning is not one of them: it is
|
|
108
98
|
# the model's own thinking and it goes back up with the message.
|
|
@@ -193,7 +183,7 @@ describe "brute/message_transport/open_router" do
|
|
|
193
183
|
out.first.content.should == "hi there"
|
|
194
184
|
end
|
|
195
185
|
|
|
196
|
-
it "keeps the model's reasoning
|
|
186
|
+
it "keeps the model's reasoning" do
|
|
197
187
|
# Reasoning is the model's own thinking, and it goes back up with the
|
|
198
188
|
# message: a reasoning model that called a tool has to see it on the next
|
|
199
189
|
# pass. Dropping it left an empty assistant message and a lost turn.
|
|
@@ -218,22 +208,6 @@ describe "brute/message_transport/open_router" do
|
|
|
218
208
|
{ type: "reasoning.text", text: "step by step", signature: "sig-1" },
|
|
219
209
|
]
|
|
220
210
|
|
|
221
|
-
# A tool call is an answer, even with nothing said alongside it.
|
|
222
|
-
calling = Struct.new(:choices).new([
|
|
223
|
-
{ "message" => {
|
|
224
|
-
"role" => "assistant", "content" => nil,
|
|
225
|
-
"tool_calls" => [{ "id" => "tc1", "type" => "function", "function" => { "name" => "shell", "arguments" => "{}" } }],
|
|
226
|
-
} },
|
|
227
|
-
])
|
|
228
|
-
|
|
229
|
-
Brute::MessageTransport::OpenRouter.new(calling).wrap_each.to_a.first.tool_call?.should.be.true
|
|
230
|
-
|
|
231
|
-
# Nothing said, nothing thought, nothing called: the provider was paid for
|
|
232
|
-
# an answer and there is none, which is an error rather than a message.
|
|
233
|
-
empty = Struct.new(:choices).new([{ "message" => { "role" => "assistant", "content" => "", "refusal" => nil } }])
|
|
234
|
-
|
|
235
|
-
lambda { Brute::MessageTransport::OpenRouter.new(empty).wrap_each.to_a }
|
|
236
|
-
.should.raise(Brute::MessageTransport::OpenRouter::EmptyCompletion)
|
|
237
211
|
end
|
|
238
212
|
|
|
239
213
|
it "round-trips every detail type under its own payload key" do
|
|
@@ -50,6 +50,13 @@ module Brute
|
|
|
50
50
|
# stops when the LLM answers with text only or env[:should_exit] is set
|
|
51
51
|
# (e.g. by MaxIterations).
|
|
52
52
|
#
|
|
53
|
+
# It also loops on a completion that only thought: a reasoning model
|
|
54
|
+
# sometimes returns its thinking and stops — no text, no tool call —
|
|
55
|
+
# having decided what to do without doing it. That is not an answer, and
|
|
56
|
+
# the turn is not over; the thinking goes back with the conversation,
|
|
57
|
+
# which is what the provider asks for anyway, and the model does what it
|
|
58
|
+
# had already worked out.
|
|
59
|
+
#
|
|
53
60
|
# use Brute::Middleware::Loop::ToolResult
|
|
54
61
|
#
|
|
55
62
|
class ToolResult < Loop
|
|
@@ -58,7 +65,7 @@ module Brute
|
|
|
58
65
|
next false
|
|
59
66
|
end
|
|
60
67
|
|
|
61
|
-
unless env[:messages].last&.role == :tool
|
|
68
|
+
unless env[:messages].last&.role == :tool || ToolResult.thought_only?(env[:messages].last)
|
|
62
69
|
next false
|
|
63
70
|
end
|
|
64
71
|
|
|
@@ -67,6 +74,14 @@ module Brute
|
|
|
67
74
|
true
|
|
68
75
|
end
|
|
69
76
|
|
|
77
|
+
# Thinking, and nothing done with it.
|
|
78
|
+
def self.thought_only?(message)
|
|
79
|
+
message&.role == :assistant &&
|
|
80
|
+
message.reasoning &&
|
|
81
|
+
message.content.to_s.strip.empty? &&
|
|
82
|
+
!message.tool_call?
|
|
83
|
+
end
|
|
84
|
+
|
|
70
85
|
def initialize(app)
|
|
71
86
|
super(app, CONDITION)
|
|
72
87
|
end
|
|
@@ -136,6 +151,26 @@ describe "brute/middleware/006_loop" do
|
|
|
136
151
|
|
|
137
152
|
# --- Loop::BackgroundJobs ---
|
|
138
153
|
|
|
154
|
+
it "ToolResult reruns on a completion that only thought, so the turn is not lost" do
|
|
155
|
+
# A reasoning model sometimes returns the thinking and stops, having
|
|
156
|
+
# decided what to do without doing it. The turn is not over: the thinking
|
|
157
|
+
# goes back and the model does what it had worked out.
|
|
158
|
+
thought = Brute::Message.new(role: :assistant, reasoning: "I should list the events")
|
|
159
|
+
|
|
160
|
+
env = { messages: Brute.log.tap { |log| log << thought }, current_iteration: 1 }
|
|
161
|
+
Brute::Middleware::Loop::ToolResult::CONDITION.call(env).should.be.true
|
|
162
|
+
env[:current_iteration].should == 2
|
|
163
|
+
|
|
164
|
+
# An answer, or a tool call it did make, ends the turn as before.
|
|
165
|
+
answered = { messages: Brute.log.tap { |log| log.assistant("here they are") }, current_iteration: 1 }
|
|
166
|
+
Brute::Middleware::Loop::ToolResult::CONDITION.call(answered).should.be.false
|
|
167
|
+
|
|
168
|
+
calling = Brute::Message.new(role: :assistant, reasoning: "I should list them",
|
|
169
|
+
tool_calls: [{ id: "tc1", name: "list_events", arguments: {} }])
|
|
170
|
+
called = { messages: Brute.log.tap { |log| log << calling }, current_iteration: 1 }
|
|
171
|
+
Brute::Middleware::Loop::ToolResult::CONDITION.call(called).should.be.false
|
|
172
|
+
end
|
|
173
|
+
|
|
139
174
|
it "BackgroundJobs reruns while jobs are running" do
|
|
140
175
|
seen = []
|
|
141
176
|
inner = ->(env) { seen << env[:background_jobs]; env[:background_jobs] = seen.size < 3 }
|
data/lib/brute/version.rb
CHANGED