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 +4 -4
- data/lib/brute/completion/open_router.rb +46 -0
- data/lib/brute/hooks.rb +7 -1
- data/lib/brute/middleware/002_session_log.rb +7 -13
- data/lib/brute/middleware/004_summarize.rb +15 -81
- data/lib/brute/middleware/006_loop.rb +28 -91
- data/lib/brute/middleware/010_max_iterations.rb +7 -22
- 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: f8264f0e2d23e001a3c980d3edd0f88ac3ceb8a3f167359c56391e3fe9702e59
|
|
4
|
+
data.tar.gz: 7439466f57b0f3e18aae290a723f8cb4077970b200f1a8e0f7970fd7d863aee6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
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, "
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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 "
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
env
|
|
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
|
|
136
|
-
|
|
114
|
+
it "ToolResult reruns while the last message is a tool result, unless should_exit" do
|
|
115
|
+
calls = []
|
|
137
116
|
inner = ->(env) do
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 "
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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