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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e08acbbd71a23c8da28df07f18d191b8df282b81e43faa49934d123274258090
4
- data.tar.gz: 418f3ced0cba3f915859741f43f6e64150651c3f00f05ec05825db38376e9033
3
+ metadata.gz: f639a3b9b3ec1317f80ee945eb7d610af350d4c862206745aefad42a70afbb4d
4
+ data.tar.gz: 994bf0aaf3104bfad636338639db4c175de7bbcd468a9abd8f90114c4a8e233e
5
5
  SHA512:
6
- metadata.gz: 117fccb5ea1751db05c53ff3a4d1ceee66de13b4c3384877ed198cd1c384eaad0eb1344280a04486c013fdb0f956a2aab1038e1c7f156bda144de88e52fd200f
7
- data.tar.gz: 9a9deb24a12e26280dd77402489140907ff8760a8232616b8288c1586e345c844c090858435d3662a3258089734fb2a16c1fedca75a271abb4aa9b01ca93c60a
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, and refuses a completion that answered with nothing at all" do
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brute
4
- VERSION = "6.1.2"
4
+ VERSION = "6.1.4"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brute
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.1.2
4
+ version: 6.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brute Contributors