brute 4.2.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: 6016495cc66cb0d8c5b7b33aaef14eee694960355bb2d772c2d75b1aaacca844
4
- data.tar.gz: 137446fd33a37736993e2d067d75169d8ca1ce83c109511054b73e768550fda5
3
+ metadata.gz: f8264f0e2d23e001a3c980d3edd0f88ac3ceb8a3f167359c56391e3fe9702e59
4
+ data.tar.gz: 7439466f57b0f3e18aae290a723f8cb4077970b200f1a8e0f7970fd7d863aee6
5
5
  SHA512:
6
- metadata.gz: 4098f01a58d7d6946fb4f362aab7d37db86cb1f6e77def51f96e08b86eddbaa8c5462e8a613d9e74ff84731baf5ed65076abd010c2ab854b5e838279b3801ee3
7
- data.tar.gz: 25c4986d4beed8103eb6c0f527ad737b98de3f7c3d1e562c83f956afcc3b3267eb5c6cb669ac488cf9ffdf97c6a446888f9363a2d01bd6f3913701d7cc46c313
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
@@ -71,6 +71,24 @@ module Brute
71
71
  super(app, CONDITION)
72
72
  end
73
73
  end
74
+
75
+ # Keeps the turn alive while background jobs are running. Sit it
76
+ # OUTSIDE the tool loop: the inner loop ends when the model answers
77
+ # with text, and this one sends control back in for another pass while
78
+ # jobs are still running — which is how a finished job gets reported.
79
+ # Whatever spawns the jobs (say, a middleware running a subagent in
80
+ # the background) keeps env[:background_jobs] current.
81
+ #
82
+ # use Brute::Middleware::Loop::BackgroundJobs
83
+ # use Brute::Middleware::Loop::ToolResult
84
+ #
85
+ class BackgroundJobs < Loop
86
+ CONDITION = ->(env) { env[:background_jobs] }
87
+
88
+ def initialize(app)
89
+ super(app, CONDITION)
90
+ end
91
+ end
74
92
  end
75
93
  end
76
94
  end
@@ -80,90 +98,54 @@ __END__
80
98
  describe "brute/middleware/006_loop" do
81
99
  require "brute/messages"
82
100
 
83
- it "runs the inner app once when the condition is immediately false" do
84
- calls = 0
85
- mw = Brute::Middleware::Loop.new(->(_env) { calls += 1 }, ->(_env) { false })
86
- mw.call({})
87
- calls.should == 1
88
- end
89
-
90
- it "loops while the condition is truthy" do
91
- calls = 0
92
- mw = Brute::Middleware::Loop.new(->(_env) { calls += 1 }, ->(_env) { calls < 3 })
93
- mw.call({})
94
- calls.should == 3
95
- end
96
-
97
- it "accepts a block condition and passes env to it" do
98
- seen = []
99
- 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 = []
100
103
  env = { n: 0 }
101
- mw.call(env)
102
- env[:n].should == 2
103
- seen.should == [1, 2]
104
- end
104
+ returned = Brute::Middleware::Loop.new(->(e) { e[:n] += 1; passes << e[:n]; e }) { |e| e[:n] < 3 }.call(env)
105
105
 
106
- it "returns env" do
107
- env = { a: 1 }
108
- Brute::Middleware::Loop.new(->(_e) {}, ->(_e) { false }).call(env).should == env
109
- end
106
+ passes.should == [1, 2, 3]
107
+ returned.should.be.identical_to env
110
108
 
111
- it "raises without a proc or block" do
112
109
  lambda { Brute::Middleware::Loop.new(->(_e) {}) }.should.raise(ArgumentError)
113
110
  end
114
111
 
115
112
  # --- Loop::ToolResult (reimplements the old ToolResultLoop) ---
116
113
 
117
- it "ToolResult loops until the last message is not a tool result" do
118
- call_count = 0
114
+ it "ToolResult reruns while the last message is a tool result, unless should_exit" do
115
+ calls = []
119
116
  inner = ->(env) do
120
- call_count += 1
121
- if call_count == 1
122
- env[:messages] << Brute::Message.new(role: :tool, content: "result", tool_call_id: "tc1")
123
- else
124
- env[:messages] << Brute::Message.new(role: :assistant, content: "done")
125
- 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
126
120
  end
127
121
 
128
- mw = Brute::Middleware::Loop::ToolResult.new(inner)
129
- env = { messages: Brute.log, current_iteration: 1 }
130
- env[:messages].user("hi")
131
- mw.call(env)
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]
132
125
 
133
- call_count.should == 2
134
- env[:current_iteration].should == 2
135
- env[:messages].last.role.should == :assistant
136
- end
137
-
138
- it "ToolResult stops when should_exit is set" do
139
- call_count = 0
140
- inner = ->(env) do
141
- call_count += 1
142
- env[:messages] << Brute::Message.new(role: :tool, content: "result", tool_call_id: "tc#{call_count}")
143
- 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")
144
130
  end
145
-
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
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]
134
+ env[:current_iteration].should == 1
152
135
  end
153
136
 
154
- it "ToolResult does not loop when last message is assistant" do
155
- call_count = 0
156
- inner = ->(env) do
157
- call_count += 1
158
- env[:messages] << Brute::Message.new(role: :assistant, content: "hello")
159
- end
137
+ # --- Loop::BackgroundJobs ---
160
138
 
161
- mw = Brute::Middleware::Loop::ToolResult.new(inner)
162
- env = { messages: Brute.log, current_iteration: 1 }
163
- env[:messages].user("hi")
164
- mw.call(env)
139
+ it "BackgroundJobs reruns while jobs are running" do
140
+ seen = []
141
+ inner = ->(env) { seen << env[:background_jobs]; env[:background_jobs] = seen.size < 3 }
165
142
 
166
- call_count.should == 1
167
- env[:current_iteration].should == 1
143
+ Brute::Middleware::Loop::BackgroundJobs.new(inner).call({})
144
+ seen.should == [nil, true, true]
145
+
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]
168
150
  end
169
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.2.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.2.0
4
+ version: 4.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brute Contributors
@@ -197,14 +197,14 @@ dependencies:
197
197
  requirements:
198
198
  - - "~>"
199
199
  - !ruby/object:Gem::Version
200
- version: '0.2'
200
+ version: '0.3'
201
201
  type: :development
202
202
  prerelease: false
203
203
  version_requirements: !ruby/object:Gem::Requirement
204
204
  requirements:
205
205
  - - "~>"
206
206
  - !ruby/object:Gem::Version
207
- version: '0.2'
207
+ version: '0.3'
208
208
  description: Production-grade coding agent with tool execution, middleware pipeline,
209
209
  context compaction, session persistence, and multi-provider LLM support.
210
210
  executables: []