brute 4.3.0 → 4.3.1

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: f3ccdff7358171494bf69f8a71b61dc12e6c256c8fa34501d9eab9852173ea49
4
- data.tar.gz: 81882a81b997e09d91de4e121a194bca501d1504d829d4ed711bdd3a7937e6a8
3
+ metadata.gz: f8264f0e2d23e001a3c980d3edd0f88ac3ceb8a3f167359c56391e3fe9702e59
4
+ data.tar.gz: 7439466f57b0f3e18aae290a723f8cb4077970b200f1a8e0f7970fd7d863aee6
5
5
  SHA512:
6
- metadata.gz: 42711f6b308d9954bf6d92f762ac2b871a31c5629c6f5d83e47dca8f3fd17c8eca40b5b2668cec1ebd147310e3d4349b3ff77b3394be768482ba81d918ce249b
7
- data.tar.gz: eac8529b0158efcd494e8fce53c185a0cdc88177065397695b6dc3fe766b7a46f081b053d8c56f463ebf865a4f7845f06747d5d67721883df6f010f2dd7dc26d
6
+ metadata.gz: 5fa71cbfe00b6069e2bdb90872cff9fe2f88eb2f48c7ff614feb59ead8f03890739c4772c863b09edf27fd7c2c122ac486fc5c3d84f582f477527f5f69d2dee4
7
+ data.tar.gz: 866794dc4f87f5433508b1e883db27788b00bb57157211ea2942fe64ccaa46969c3cfdd36fe917b9717fe7bdc385f4fd9881b1a5ce47c9763e859d7e00fbf356
@@ -48,6 +48,25 @@ module Brute
48
48
  end
49
49
 
50
50
  env[:hooks]&.emit(:after_llm, env)
51
+ env
52
+ # A provider call that raises is reported through the hooks rather than
53
+ # up the stack: :llm_failure with the turn env, then one hook naming the
54
+ # kind of failure. The classes are looked up defensively — the provider
55
+ # gem is only a dependency of the app that uses this middleware.
56
+ rescue => error
57
+ env[:hooks]&.emit(:llm_failure, env)
58
+
59
+ if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
60
+ env[:hooks]&.emit(:faraday_error, error)
61
+
62
+ elsif defined?(::OpenRouter::ServerError) && error.is_a?(::OpenRouter::ServerError)
63
+ env[:hooks]&.emit(:open_router_server_error, error)
64
+
65
+ else
66
+ env[:hooks]&.emit(:standard_error, error)
67
+
68
+ end
69
+
51
70
  env
52
71
  end
53
72
  end
@@ -106,6 +125,33 @@ describe "brute/completion/open_router" do
106
125
  env.key?(:metadata).should.be.false
107
126
  end
108
127
 
128
+ it "reports a failed provider call through the hooks instead of raising" do
129
+ seen = []
130
+ hooks = Brute::Hooks.new
131
+ %i[llm_failure standard_error after_llm].each do |event|
132
+ hooks.on(event) { |payload| seen << [event, payload] }
133
+ end
134
+
135
+ boom = RuntimeError.new("no route to host")
136
+ fake_client = Object.new
137
+ fake_client.define_singleton_method(:complete) { |_messages, _options| raise boom }
138
+ original = OpenRouter::Client.method(:new)
139
+ OpenRouter::Client.define_singleton_method(:new) { |**_config| fake_client }
140
+
141
+ begin
142
+ env = { messages: Brute.log, hooks: hooks }
143
+ env[:messages].user("hi")
144
+ returned = Brute::Completion::OpenRouter.new(->(e) { e }).call(env)
145
+
146
+ returned.should.be.identical_to env
147
+ seen.map(&:first).should == [:llm_failure, :standard_error]
148
+ seen.first.last.should.be.identical_to env
149
+ seen.last.last.should.be.identical_to boom
150
+ ensure
151
+ OpenRouter::Client.define_singleton_method(:new, original)
152
+ end
153
+ end
154
+
109
155
  it "still answers to the deprecated Middleware::OpenRouter::Completion name" do
110
156
  deprecated = Brute::Middleware::OpenRouter::Completion.new(->(e) { e })
111
157
  deprecated.should.be.kind_of?(Brute::Completion::OpenRouter)
data/lib/brute/hooks.rb CHANGED
@@ -17,6 +17,10 @@ module Brute
17
17
  # :turn_start, :turn_end → the turn env (AgentPipeline#start; turn_end
18
18
  # fires from an ensure, so it also fires on error)
19
19
  # :before_llm, :after_llm → the turn env, around every LLM call
20
+ # :llm_failure → the turn env, when the LLM call raises; the
21
+ # completion middleware then emits one of
22
+ # :faraday_error, :open_router_server_error or
23
+ # :standard_error with the exception itself
20
24
  # :before_tool → call env {name:, arguments:, result:, events:,
21
25
  # metadata:, turn_env:} — mutate :arguments to
22
26
  # rewrite the call, or set :result (or return a
@@ -29,7 +33,9 @@ module Brute
29
33
  # Exceptions propagate to the caller — layers that want fail-open semantics
30
34
  # rescue in their own subscriber.
31
35
  class Hooks
32
- EVENTS = %i[turn_start turn_end before_llm after_llm before_tool approve_tool after_tool].freeze
36
+ EVENTS = %i[turn_start turn_end before_llm after_llm llm_failure faraday_error
37
+ open_router_server_error standard_error before_tool approve_tool
38
+ after_tool].freeze
33
39
 
34
40
  def initialize
35
41
  @subscribers = Hash.new { |hash, key| hash[key] = [] }
@@ -71,30 +71,24 @@ describe "brute/middleware/002_session_log" do
71
71
  Dir.mktmpdir do |dir|
72
72
  path = File.join(dir, "session.jsonl")
73
73
 
74
- # Turn 1: no file yet; terminal appends an assistant reply.
75
- turn = ->(env) { env[:messages].assistant("first reply") }
76
- Brute::Middleware::SessionLog.new(turn, path: path).call(
77
- { messages: Brute.log.tap { |l| l.user("hi") } }
78
- )
74
+ Brute::Middleware::SessionLog.new(->(e) { e[:messages].assistant("first reply") }, path: path)
75
+ .call({ messages: Brute.log.tap { |l| l.user("hi") } })
79
76
 
80
- File.exist?(path).should.be.true
81
-
82
- # Turn 2: fresh log with a new user message; history is prepended.
83
77
  env = { messages: Brute.log.tap { |l| l.user("again") } }
84
78
  Brute::Middleware::SessionLog.new(->(e) { }, path: path).call(env)
85
79
 
80
+ File.exist?(path).should.be.true
86
81
  env[:messages].map(&:content).should == ["hi", "first reply", "again"]
87
82
  end
88
83
  end
89
84
 
90
85
  it "does not persist the system message" do
91
86
  Dir.mktmpdir do |dir|
92
- path = File.join(dir, "s.jsonl")
93
- env = { messages: Brute.log.tap { |l| l.system("secret prompt"); l.user("hi") } }
94
- Brute::Middleware::SessionLog.new(->(e) { }, path: path).call(env)
87
+ path = File.join(dir, "session.jsonl")
88
+ Brute::Middleware::SessionLog.new(->(e) { }, path: path)
89
+ .call({ messages: Brute.log.tap { |l| l.system("secret prompt"); l.user("hi") } })
95
90
 
96
- reloaded = File.readlines(path).map(&:strip).reject(&:empty?)
97
- reloaded.any? { |line| line.include?("secret prompt") }.should.be.false
91
+ File.read(path).should.not.match(/secret prompt/)
98
92
  end
99
93
  end
100
94
  end
@@ -51,91 +51,25 @@ __END__
51
51
  describe "brute/middleware/004_summarize" do
52
52
  require "brute/messages"
53
53
 
54
- it "produces a final assistant message after tool loop" do
55
- call_count = 0
56
-
57
- # Fake inner app: first call simulates a tool loop ending with a tool message,
58
- # second call (summary) produces an assistant message.
54
+ it "reruns the stack tool-free with a summary prompt, then restores the tools" do
55
+ seen = []
59
56
  inner = ->(env) do
60
- call_count += 1
61
- if call_count == 1
62
- env[:messages] << Brute::Message.new(role: :tool, content: "some result", tool_call_id: "tc1")
63
- else
64
- env[:messages] << Brute::Message.new(role: :assistant, content: "Here is my complete summary.")
65
- end
66
- end
67
-
68
- mw = Brute::Middleware::Summarize.new(inner)
69
- session = Brute.log
70
- session.user("explore the codebase")
71
- env = { messages: session, tools: [:some_tool], current_iteration: 5 }
72
- mw.call(env)
73
-
74
- env[:messages].last.role.should == :assistant
75
- env[:messages].last.content.should =~ /summary/i
76
- end
77
-
78
- it "restores tools after summary call" do
79
- inner = ->(env) {
80
- env[:messages] << Brute::Message.new(role: :assistant, content: "done")
81
- }
82
-
83
- mw = Brute::Middleware::Summarize.new(inner)
84
- tools = [:read, :search]
85
- env = { messages: Brute.log, tools: tools.dup, current_iteration: 1 }
86
- env[:messages].user("hi")
87
- mw.call(env)
88
-
89
- env[:tools].should == tools
90
- end
91
-
92
- it "resets current_iteration for the summary call" do
93
- captured_iteration = nil
94
- inner = ->(env) {
95
- captured_iteration = env[:current_iteration]
96
- env[:messages] << Brute::Message.new(role: :assistant, content: "done")
97
- }
98
-
99
- mw = Brute::Middleware::Summarize.new(inner)
100
- env = { messages: Brute.log, tools: [], current_iteration: 99 }
101
- env[:messages].user("hi")
102
- mw.call(env)
103
-
104
- # The second call (summary) should have iteration reset to 1
105
- captured_iteration.should == 1
106
- end
107
-
108
- it "injects a summary prompt message" do
109
- messages_at_second_call = nil
110
- call_count = 0
111
- inner = ->(env) {
112
- call_count += 1
113
- messages_at_second_call = env[:messages].map(&:content) if call_count == 2
57
+ seen << [env[:tools], env[:current_iteration], env[:messages].last.content]
114
58
  env[:messages] << Brute::Message.new(role: :assistant, content: "done")
115
- }
116
-
117
- mw = Brute::Middleware::Summarize.new(inner)
118
- env = { messages: Brute.log, tools: [], current_iteration: 1 }
119
- env[:messages].user("hi")
120
- mw.call(env)
121
-
122
- messages_at_second_call.last.should =~ /findings/i
123
- end
59
+ end
124
60
 
125
- it "accepts a custom prompt" do
126
- messages_at_second_call = nil
127
- call_count = 0
128
- inner = ->(env) {
129
- call_count += 1
130
- messages_at_second_call = env[:messages].map(&:content) if call_count == 2
131
- env[:messages] << Brute::Message.new(role: :assistant, content: "done")
132
- }
61
+ env = { messages: Brute.log.tap { |l| l.user("explore the codebase") },
62
+ tools: [:read, :search], current_iteration: 5 }
63
+ Brute::Middleware::Summarize.new(inner).call(env)
133
64
 
134
- mw = Brute::Middleware::Summarize.new(inner, prompt: "Give me the TL;DR.")
135
- env = { messages: Brute.log, tools: [], current_iteration: 1 }
136
- env[:messages].user("hi")
137
- mw.call(env)
65
+ seen.should == [
66
+ [[:read, :search], 5, "explore the codebase"],
67
+ [[], 1, Brute::Middleware::Summarize::DEFAULT_PROMPT],
68
+ ]
69
+ env[:tools].should == [:read, :search]
138
70
 
139
- messages_at_second_call.last.should == "Give me the TL;DR."
71
+ seen.clear
72
+ Brute::Middleware::Summarize.new(inner, prompt: "Give me the TL;DR.").call(env)
73
+ seen.last.last.should == "Give me the TL;DR."
140
74
  end
141
75
  end
@@ -98,117 +98,54 @@ __END__
98
98
  describe "brute/middleware/006_loop" do
99
99
  require "brute/messages"
100
100
 
101
- it "runs the inner app once when the condition is immediately false" do
102
- calls = 0
103
- mw = Brute::Middleware::Loop.new(->(_env) { calls += 1 }, ->(_env) { false })
104
- mw.call({})
105
- calls.should == 1
106
- end
107
-
108
- it "loops while the condition is truthy" do
109
- calls = 0
110
- mw = Brute::Middleware::Loop.new(->(_env) { calls += 1 }, ->(_env) { calls < 3 })
111
- mw.call({})
112
- calls.should == 3
113
- end
114
-
115
- it "accepts a block condition and passes env to it" do
116
- seen = []
117
- mw = Brute::Middleware::Loop.new(->(env) { env[:n] += 1 }) { |env| seen << env[:n]; env[:n] < 2 }
101
+ it "runs the app until the condition turns false, passing it env" do
102
+ passes = []
118
103
  env = { n: 0 }
119
- mw.call(env)
120
- env[:n].should == 2
121
- seen.should == [1, 2]
122
- end
104
+ returned = Brute::Middleware::Loop.new(->(e) { e[:n] += 1; passes << e[:n]; e }) { |e| e[:n] < 3 }.call(env)
123
105
 
124
- it "returns env" do
125
- env = { a: 1 }
126
- Brute::Middleware::Loop.new(->(_e) {}, ->(_e) { false }).call(env).should == env
127
- end
106
+ passes.should == [1, 2, 3]
107
+ returned.should.be.identical_to env
128
108
 
129
- it "raises without a proc or block" do
130
109
  lambda { Brute::Middleware::Loop.new(->(_e) {}) }.should.raise(ArgumentError)
131
110
  end
132
111
 
133
112
  # --- Loop::ToolResult (reimplements the old ToolResultLoop) ---
134
113
 
135
- it "ToolResult loops until the last message is not a tool result" do
136
- call_count = 0
114
+ it "ToolResult reruns while the last message is a tool result, unless should_exit" do
115
+ calls = []
137
116
  inner = ->(env) do
138
- call_count += 1
139
- if call_count == 1
140
- env[:messages] << Brute::Message.new(role: :tool, content: "result", tool_call_id: "tc1")
141
- else
142
- env[:messages] << Brute::Message.new(role: :assistant, content: "done")
143
- end
117
+ calls << env[:current_iteration]
118
+ env[:messages] << Brute::Message.new(role: :tool, content: "result", tool_call_id: "tc#{calls.size}")
119
+ env[:should_exit] = { reason: "max" } if calls.size >= 2
144
120
  end
145
121
 
146
- mw = Brute::Middleware::Loop::ToolResult.new(inner)
147
- env = { messages: Brute.log, current_iteration: 1 }
148
- env[:messages].user("hi")
149
- mw.call(env)
150
-
151
- call_count.should == 2
152
- env[:current_iteration].should == 2
153
- env[:messages].last.role.should == :assistant
154
- end
122
+ env = { messages: Brute.log.tap { |l| l.user("hi") }, current_iteration: 1 }
123
+ Brute::Middleware::Loop::ToolResult.new(inner).call(env)
124
+ calls.should == [1, 2]
155
125
 
156
- it "ToolResult stops when should_exit is set" do
157
- call_count = 0
158
- inner = ->(env) do
159
- call_count += 1
160
- env[:messages] << Brute::Message.new(role: :tool, content: "result", tool_call_id: "tc#{call_count}")
161
- env[:should_exit] = { reason: "max" } if call_count >= 2
126
+ calls.clear
127
+ text_only = ->(env) do
128
+ calls << env[:current_iteration]
129
+ env[:messages] << Brute::Message.new(role: :assistant, content: "done")
162
130
  end
163
-
164
- mw = Brute::Middleware::Loop::ToolResult.new(inner)
165
- env = { messages: Brute.log, current_iteration: 1 }
166
- env[:messages].user("hi")
167
- mw.call(env)
168
-
169
- call_count.should == 2
170
- end
171
-
172
- it "ToolResult does not loop when last message is assistant" do
173
- call_count = 0
174
- inner = ->(env) do
175
- call_count += 1
176
- env[:messages] << Brute::Message.new(role: :assistant, content: "hello")
177
- end
178
-
179
- mw = Brute::Middleware::Loop::ToolResult.new(inner)
180
- env = { messages: Brute.log, current_iteration: 1 }
181
- env[:messages].user("hi")
182
- mw.call(env)
183
-
184
- call_count.should == 1
131
+ env = { messages: Brute.log.tap { |l| l.user("hi") }, current_iteration: 1 }
132
+ Brute::Middleware::Loop::ToolResult.new(text_only).call(env)
133
+ calls.should == [1]
185
134
  env[:current_iteration].should == 1
186
135
  end
187
136
 
188
137
  # --- Loop::BackgroundJobs ---
189
138
 
190
- it "BackgroundJobs runs the inner stack once when nothing is running" do
191
- calls = 0
192
- Brute::Middleware::Loop::BackgroundJobs.new(->(_env) { calls += 1 }).call({})
193
-
194
- calls.should == 1
195
- end
139
+ it "BackgroundJobs reruns while jobs are running" do
140
+ seen = []
141
+ inner = ->(env) { seen << env[:background_jobs]; env[:background_jobs] = seen.size < 3 }
196
142
 
197
- it "BackgroundJobs loops while jobs are running" do
198
- calls = 0
199
- inner = ->(env) do
200
- calls += 1
201
- env[:background_jobs] = calls < 3
202
- end
203
143
  Brute::Middleware::Loop::BackgroundJobs.new(inner).call({})
144
+ seen.should == [nil, true, true]
204
145
 
205
- calls.should == 3
206
- end
207
-
208
- it "BackgroundJobs stops once the jobs are done" do
209
- env = { background_jobs: true }
210
- Brute::Middleware::Loop::BackgroundJobs.new(->(e) { e[:background_jobs] = false }).call(env)
211
-
212
- env[:background_jobs].should.be.false
146
+ seen.clear
147
+ Brute::Middleware::Loop::BackgroundJobs.new(->(e) { seen << e[:background_jobs]; e[:background_jobs] = false })
148
+ .call({ background_jobs: true })
149
+ seen.should == [true]
213
150
  end
214
151
  end
@@ -45,30 +45,15 @@ __END__
45
45
  describe "brute/middleware/010_max_iterations" do
46
46
  require "brute/messages"
47
47
 
48
- it "can be added to a stack" do
49
- called = false
50
- inner = ->(env) { called = true }
51
- mw = Brute::Middleware::MaxIterations.new(inner)
52
- mw.call({ current_iteration: 1, messages: Brute.log })
53
- called.should.be.true
54
- end
48
+ it "lets the turn run under the max, then blocks the stack and injects a notice" do
49
+ ran = []
50
+ env = { messages: Brute.log.tap { |l| l.user("hi") }, current_iteration: 1 }
55
51
 
56
- it "prevents execution after given max" do
57
- called = false
58
- inner = ->(env) { called = true }
59
- mw = Brute::Middleware::MaxIterations.new(inner, max_iterations: 0)
60
- env = { current_iteration: 1, messages: Brute.log }
61
- mw.call(env)
62
- called.should.be.false
63
- end
52
+ Brute::Middleware::MaxIterations.new(->(e) { ran << e[:current_iteration] }).call(env)
53
+ ran.should == [1]
64
54
 
65
- it "injects a user message when max is hit" do
66
- inner = ->(env) { }
67
- mw = Brute::Middleware::MaxIterations.new(inner, max_iterations: 0)
68
- session = Brute.log
69
- session.user("hi")
70
- env = { current_iteration: 1, messages: session }
71
- mw.call(env)
55
+ Brute::Middleware::MaxIterations.new(->(e) { ran << e[:current_iteration] }, max_iterations: 0).call(env)
56
+ ran.should == [1]
72
57
  env[:messages].last.role.should == :user
73
58
  env[:messages].last.content.should =~ /Maximum iterations reached/
74
59
  end
data/lib/brute/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brute
4
- VERSION = "4.3.0"
4
+ VERSION = "4.3.1"
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: 4.3.0
4
+ version: 4.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brute Contributors