brute 3.2.2 → 4.1.0

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.
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems/deprecate"
4
+
5
+ # gem_kit-release is a *development* dependency: the release tooling reads the
6
+ # registry below, but the library itself must not depend on release tooling to
7
+ # load. So mirror into its registry when it happens to be there, and carry on
8
+ # when it is not.
9
+ begin
10
+ require "gem_kit/release/deprecate"
11
+ rescue LoadError
12
+ # Not installed — this is a normal production install of Brute.
13
+ end
14
+
15
+ module Brute
16
+ # Brute's deprecation policy, built on Gem::Deprecate.
17
+ #
18
+ # Rubygems' own convention is the one worth copying: a deprecation names its
19
+ # replacement and the *version it will be removed in*, so "deprecated" is a
20
+ # dated promise rather than an open-ended apology. Brute adds one thing on
21
+ # top — a registry. Every declaration records itself, which turns the set of
22
+ # outstanding deprecations into data the tooling can act on:
23
+ #
24
+ # bin/deprecations # list everything still outstanding
25
+ # bin/increment-version major # refuses to bump past a removal deadline
26
+ #
27
+ # Deprecate a method:
28
+ #
29
+ # class Session
30
+ # extend Brute::Deprecate
31
+ #
32
+ # def old_reset; new_reset; end
33
+ # brute_deprecate :old_reset, "Session#new_reset", "5.0"
34
+ # end
35
+ #
36
+ # Deprecate a whole class — a renamed or moved constant. Leave the old name
37
+ # in place as a subclass of the new one, then declare it:
38
+ #
39
+ # class Completion < Brute::Completion::OpenRouter
40
+ # extend Brute::Deprecate
41
+ # brute_deprecate_constant "Brute::Completion::OpenRouter", "5.0"
42
+ # end
43
+ #
44
+ # Both warn on use, naming the caller. Gem::Deprecate.skip_during { ... }
45
+ # silences them, so a test suite can exercise the old path in quiet.
46
+ module Deprecate
47
+ extend Gem::Deprecate
48
+
49
+ # One outstanding deprecation. `removed_in` is the version the old name
50
+ # stops existing in — the deadline both CLI commands read.
51
+ Entry = Struct.new(:name, :replacement, :removed_in, :declared_at, keyword_init: true) do
52
+ def to_s
53
+ "#{name} -> #{replacement == :none ? "(no replacement)" : replacement}"
54
+ end
55
+ end
56
+
57
+ class << self
58
+ # Every deprecation declared in the loaded library, in declaration order.
59
+ def registry
60
+ @registry ||= []
61
+ end
62
+
63
+ # Record a deprecation. Returns the Entry.
64
+ def register(name:, replacement:, removed_in:, declared_at: nil)
65
+ entry = Entry.new(
66
+ name: name.to_s,
67
+ replacement: replacement,
68
+ removed_in: Gem::Version.new(removed_in.to_s),
69
+ declared_at: declared_at || caller_locations(1, 1)&.first&.then { |l| "#{l.path}:#{l.lineno}" },
70
+ )
71
+ registry << entry
72
+
73
+ # Same shape (name, replacement, removed_in, declared_at), so
74
+ # `gem kit deprecations` can read Brute's entries directly.
75
+ if defined?(::GemKit::Release::Deprecate)
76
+ ::GemKit::Release::Deprecate.registry << entry
77
+ end
78
+
79
+ entry
80
+ end
81
+
82
+ # The deprecations that come due at `version` — everything whose removal
83
+ # deadline has arrived or passed. This is the gate: releasing `version`
84
+ # with any of these still present breaks the promise the warning made.
85
+ def pending(version)
86
+ target = Gem::Version.new(version.to_s)
87
+ registry.select { |entry| entry.removed_in <= target }
88
+ end
89
+
90
+ # Deprecations still in their grace period at `version`.
91
+ def upcoming(version)
92
+ target = Gem::Version.new(version.to_s)
93
+ registry.reject { |entry| entry.removed_in <= target }
94
+ end
95
+
96
+ # The default deadline: the next major version after the current one.
97
+ def next_major_version
98
+ Gem::Version.new(Brute::VERSION.split(".").first).bump.to_s
99
+ end
100
+
101
+ # Single funnel for every warning, so Gem::Deprecate.skip_during works
102
+ # across all of them and specs have one place to listen.
103
+ def warn(message)
104
+ Kernel.warn(message) unless Gem::Deprecate.skip
105
+ end
106
+
107
+ # Build the Gem::Deprecate-shaped message body. `origin` must be
108
+ # computed at the call site — one frame deeper and it names this file
109
+ # rather than the code that needs changing.
110
+ def message(target, replacement, removed_in, origin)
111
+ [
112
+ "NOTE: #{target} is deprecated",
113
+ replacement == :none ? " with no replacement" : "; use #{replacement} instead",
114
+ ". It will be removed in Brute #{removed_in}",
115
+ "\n#{target} called from #{origin}.",
116
+ ].join
117
+ end
118
+ end
119
+
120
+ # Deprecate one method. Mirrors Gem::Deprecate#rubygems_deprecate, but the
121
+ # deadline is explicit rather than "the next major" — a deprecation added
122
+ # late in a cycle usually wants the major after next.
123
+ def brute_deprecate(name, replacement = :none, removed_in = Brute::Deprecate.next_major_version)
124
+ label = singleton_class? ? "#{attached_object}.#{name}" : "#{self}##{name}"
125
+ Brute::Deprecate.register(
126
+ name: label,
127
+ replacement: replacement,
128
+ removed_in: removed_in,
129
+ declared_at: caller_locations(1, 1)&.first&.then { |l| "#{l.path}:#{l.lineno}" },
130
+ )
131
+
132
+ class_eval do
133
+ old = "_deprecated_#{name}"
134
+ alias_method old, name
135
+ define_method name do |*args, &block|
136
+ target = is_a?(Module) ? "#{self}.#{name}" : "#{self.class}##{name}"
137
+ origin = Gem.location_of_caller.join(":")
138
+ Brute::Deprecate.warn(Brute::Deprecate.message(target, replacement, removed_in, origin))
139
+ send(old, *args, &block)
140
+ end
141
+ ruby2_keywords name if respond_to?(:ruby2_keywords, true)
142
+ end
143
+ end
144
+
145
+ # Deprecate a whole constant — the renamed-or-moved case. Call it in the
146
+ # body of the old name (kept as a subclass of the new one); it registers
147
+ # the rename and warns whenever the old name is instantiated.
148
+ def brute_deprecate_constant(replacement, removed_in = Brute::Deprecate.next_major_version)
149
+ Brute::Deprecate.register(
150
+ name: name || to_s,
151
+ replacement: replacement,
152
+ removed_in: removed_in,
153
+ declared_at: caller_locations(1, 1)&.first&.then { |l| "#{l.path}:#{l.lineno}" },
154
+ )
155
+
156
+ return unless respond_to?(:new)
157
+
158
+ define_singleton_method(:new) do |*args, **options, &block|
159
+ origin = Gem.location_of_caller.join(":")
160
+ Brute::Deprecate.warn(Brute::Deprecate.message(name || to_s, replacement, removed_in, origin))
161
+ super(*args, **options, &block)
162
+ end
163
+ end
164
+ end
165
+ end
166
+
167
+ __END__
168
+
169
+ describe "brute/deprecate" do
170
+ # Capture what Brute::Deprecate.warn emits, and keep the shared registry
171
+ # clean — these specs declare throwaway deprecations.
172
+ captured = []
173
+ around_each = lambda do |&block|
174
+ saved = Brute::Deprecate.registry.dup
175
+ original = Brute::Deprecate.method(:warn)
176
+ captured.clear
177
+ Brute::Deprecate.define_singleton_method(:warn) { |message| captured << message }
178
+ begin
179
+ block.call
180
+ ensure
181
+ Brute::Deprecate.define_singleton_method(:warn, original)
182
+ Brute::Deprecate.registry.replace(saved)
183
+ end
184
+ end
185
+
186
+ it "warns on a deprecated method, naming the replacement, version and caller" do
187
+ around_each.call do
188
+ klass = Class.new do
189
+ extend Brute::Deprecate
190
+ def new_name = :result
191
+ def old_name = new_name
192
+ brute_deprecate :old_name, "Thing#new_name", "9.0"
193
+ end
194
+
195
+ klass.new.old_name.should == :result # still works
196
+ captured.size.should == 1
197
+ captured.first.should.match(/is deprecated/)
198
+ captured.first.should.match(/use Thing#new_name instead/)
199
+ captured.first.should.match(/removed in Brute 9\.0/)
200
+ captured.first.should.match(/called from /)
201
+ end
202
+ end
203
+
204
+ it "warns on a deprecated constant but keeps it working" do
205
+ around_each.call do
206
+ modern = Class.new { def initialize(x); @x = x; end; attr_reader :x }
207
+ legacy = Class.new(modern) do
208
+ extend Brute::Deprecate
209
+ def self.name = "Old::Name"
210
+ brute_deprecate_constant "New::Name", "9.0"
211
+ end
212
+
213
+ legacy.new(42).x.should == 42 # still works
214
+ captured.size.should == 1
215
+ captured.first.should.match(/Old::Name is deprecated; use New::Name instead/)
216
+ end
217
+ end
218
+
219
+ it "names the caller, not the deprecation machinery" do
220
+ around_each.call do
221
+ klass = Class.new do
222
+ extend Brute::Deprecate
223
+ def old_name = :result
224
+ brute_deprecate :old_name, "Thing#new_name", "9.0"
225
+ end
226
+
227
+ # These specs live in this file's __END__, so "the caller" is a line in
228
+ # deprecate.rb either way — pin the exact line to tell them apart.
229
+ klass.new.old_name; call_line = __LINE__
230
+ captured.first.should.match(/called from .*deprecate\.rb:#{call_line}\./)
231
+ end
232
+ end
233
+
234
+ it "labels a class-method deprecation by the class, not its singleton" do
235
+ around_each.call do
236
+ Class.new do
237
+ def self.to_s = "Demo"
238
+ def self.old_thing = :ok
239
+ class << self
240
+ extend Brute::Deprecate
241
+ brute_deprecate :old_thing, "Other.new_thing", "9.0"
242
+ end
243
+ end
244
+
245
+ Brute::Deprecate.registry.last.name.should == "Demo.old_thing"
246
+ end
247
+ end
248
+
249
+ it "registers each declaration with its deadline and source" do
250
+ around_each.call do
251
+ Class.new do
252
+ extend Brute::Deprecate
253
+ def gone = nil
254
+ brute_deprecate :gone, "Other#kept", "9.0"
255
+ end
256
+
257
+ entry = Brute::Deprecate.registry.last
258
+ entry.replacement.should == "Other#kept"
259
+ entry.removed_in.should == Gem::Version.new("9.0")
260
+ entry.declared_at.should.match(/deprecate\.rb:\d+/)
261
+ end
262
+ end
263
+
264
+ it "mirrors registrations into gem_kit-release's registry when it is present" do
265
+ # Conditional by design: gem_kit-release is a development dependency, so
266
+ # in a production install of Brute there is nothing to mirror into. (Also
267
+ # true inside a pre-commit hook, whose flake snapshot is HEAD's.)
268
+ next unless defined?(::GemKit::Release::Deprecate)
269
+
270
+ around_each.call do
271
+ saved = GemKit::Release::Deprecate.registry.dup
272
+ begin
273
+ Brute::Deprecate.register(name: "Mirrored", replacement: "New", removed_in: "9.0")
274
+
275
+ GemKit::Release::Deprecate.registry.last.name.should == "Mirrored"
276
+ GemKit::Release::Deprecate.registry.last.removed_in.should == Gem::Version.new("9.0")
277
+ ensure
278
+ GemKit::Release::Deprecate.registry.replace(saved)
279
+ end
280
+ end
281
+ end
282
+
283
+ it "splits the registry into pending (due) and upcoming at a version" do
284
+ around_each.call do
285
+ Brute::Deprecate.registry.clear
286
+ Brute::Deprecate.register(name: "A", replacement: "A2", removed_in: "5.0")
287
+ Brute::Deprecate.register(name: "B", replacement: "B2", removed_in: "6.0")
288
+
289
+ Brute::Deprecate.pending("5.0.0").map(&:name).should == ["A"]
290
+ Brute::Deprecate.upcoming("5.0.0").map(&:name).should == ["B"]
291
+ Brute::Deprecate.pending("4.9.0").should.be.empty
292
+ Brute::Deprecate.pending("6.1.0").map(&:name).should == ["A", "B"]
293
+ end
294
+ end
295
+
296
+ it "defaults the deadline to the next major version" do
297
+ around_each.call do
298
+ Brute::Deprecate.next_major_version.should == Gem::Version.new(Brute::VERSION.split(".").first).bump.to_s
299
+ end
300
+ end
301
+
302
+ it "stays quiet inside Gem::Deprecate.skip_during" do
303
+ saved = Brute::Deprecate.registry.dup
304
+ begin
305
+ klass = Class.new do
306
+ extend Brute::Deprecate
307
+ def quiet = :ok
308
+ brute_deprecate :quiet, "Other#loud", "9.0"
309
+ end
310
+
311
+ warned = []
312
+ original = Kernel.method(:warn)
313
+ Kernel.define_singleton_method(:warn) { |*args| warned << args.join }
314
+ begin
315
+ Gem::Deprecate.skip_during { klass.new.quiet.should == :ok }
316
+ ensure
317
+ Kernel.define_singleton_method(:warn, original)
318
+ end
319
+
320
+ warned.should.be.empty
321
+ ensure
322
+ Brute::Deprecate.registry.replace(saved)
323
+ end
324
+ end
325
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ # Pub/sub registry for agent lifecycle hooks, subscribed on the builder:
8
+ #
9
+ # Brute.agent
10
+ # .use(Brute::Middleware::MaxIterations)
11
+ # .run(->(env) { env[:messages].assistant("done") })
12
+ # .on(:before_llm) { |env| ... }
13
+ # .on(:approve_tool) { |call| call[:name] != "exec" }
14
+ #
15
+ # Emission points and payloads:
16
+ #
17
+ # :turn_start, :turn_end → the turn env (AgentPipeline#start; turn_end
18
+ # fires from an ensure, so it also fires on error)
19
+ # :before_llm, :after_llm → the turn env, around every LLM call
20
+ # :before_tool → call env {name:, arguments:, result:, events:,
21
+ # metadata:, turn_env:} — mutate :arguments to
22
+ # rewrite the call, or set :result (or return a
23
+ # value) to skip execution entirely ("respond")
24
+ # :approve_tool → call env — a false return denies the call; a
25
+ # String return denies it with that message
26
+ # :after_tool → call env — mutate :result
27
+ #
28
+ # Subscribers run inline (tool events may fire from parallel threads).
29
+ # Exceptions propagate to the caller — layers that want fail-open semantics
30
+ # rescue in their own subscriber.
31
+ class Hooks
32
+ EVENTS = %i[turn_start turn_end before_llm after_llm before_tool approve_tool after_tool].freeze
33
+
34
+ def initialize
35
+ @subscribers = Hash.new { |hash, key| hash[key] = [] }
36
+ end
37
+
38
+ def on(event, &block)
39
+ @subscribers[event.to_sym] << block
40
+ self
41
+ end
42
+
43
+ # Fire an event; returns every subscriber's raw result (nils and false
44
+ # included — the deny contract distinguishes them).
45
+ def emit(event, payload)
46
+ @subscribers[event.to_sym].map { |subscriber| subscriber.call(payload) }
47
+ end
48
+
49
+ def any?(event) = @subscribers[event.to_sym].any?
50
+ end
51
+ end
52
+
53
+ __END__
54
+
55
+ describe "brute/hooks" do
56
+ it "emits to subscribers in registration order" do
57
+ hooks = Brute::Hooks.new
58
+ seen = []
59
+ hooks.on(:before_llm) { |p| seen << "a#{p}" }
60
+ hooks.on(:before_llm) { |p| seen << "b#{p}" }
61
+ hooks.emit(:before_llm, 1)
62
+ seen.should == ["a1", "b1"]
63
+ end
64
+
65
+ it "returns raw results, false included (deny contract)" do
66
+ hooks = Brute::Hooks.new
67
+ hooks.on(:approve_tool) { |_call| false }
68
+ hooks.emit(:approve_tool, {}).should == [false]
69
+ end
70
+
71
+ it "answers any? and stays chainable" do
72
+ hooks = Brute::Hooks.new
73
+ hooks.any?(:turn_start).should.be.false
74
+ hooks.on(:turn_start) { nil }.should.equal?(hooks)
75
+ hooks.any?(:turn_start).should.be.true
76
+ end
77
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module Middleware
8
+ # Loads skill objects into the agent context.
9
+ #
10
+ # Skills are handed in as objects — discovery is the caller's job:
11
+ #
12
+ # skills = Brute::Skill.all(cwd: Dir.pwd)
13
+ # agent
14
+ # .use(Brute::Middleware::Skills, skills: skills)
15
+ # .use(Brute::Middleware::SystemPrompt)
16
+ #
17
+ # Per turn:
18
+ # 1. env[:skills] = the objects, for downstream middleware, tools, and
19
+ # the terminal app (prime-agent's resourceLoader.getSkills() analogue)
20
+ # 2. env[:metadata][:skills] = the same objects, so
21
+ # Middleware::SystemPrompt merges them into the prompt ctx and
22
+ # Brute::Prompts::Skills renders the <available_skills> section
23
+ #
24
+ # Place it before Middleware::SystemPrompt in the stack. It never touches
25
+ # env[:messages] itself.
26
+ class Skills
27
+ def initialize(app, skills: [])
28
+ @app = app
29
+ @skills = skills
30
+ end
31
+
32
+ def call(env)
33
+ env[:skills] = @skills
34
+ env[:metadata] ||= {}
35
+ env[:metadata][:skills] ||= @skills
36
+
37
+ @app.call(env)
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ __END__
44
+
45
+ describe "brute/middleware/025_skills" do
46
+ def skill(name)
47
+ Brute::Skill.new(name: name, description: "x", file_path: "/x/#{name}/SKILL.md")
48
+ end
49
+
50
+ def build_middleware(skills: [], &inner)
51
+ Brute::Middleware::Skills.new(inner || ->(env) { env }, skills: skills)
52
+ end
53
+
54
+ it "stashes skill objects in env[:skills]" do
55
+ skills = [skill("debugging")]
56
+ env = { messages: Brute.log, metadata: {} }
57
+
58
+ build_middleware(skills: skills).call(env)
59
+
60
+ env[:skills].should == skills
61
+ end
62
+
63
+ it "mirrors skills into env[:metadata] for the prompt layer" do
64
+ skills = [skill("debugging")]
65
+ env = { messages: Brute.log }
66
+
67
+ build_middleware(skills: skills).call(env)
68
+
69
+ env[:metadata][:skills].should == skills
70
+ end
71
+
72
+ it "does not clobber an explicit metadata[:skills]" do
73
+ explicit = [skill("explicit")]
74
+ env = { messages: Brute.log, metadata: { skills: explicit } }
75
+
76
+ build_middleware(skills: [skill("other")]).call(env)
77
+
78
+ env[:metadata][:skills].should == explicit
79
+ env[:skills].map(&:name).should == ["other"]
80
+ end
81
+
82
+ it "defaults to an empty list and passes control down the chain" do
83
+ called = false
84
+ env = { messages: Brute.log }
85
+
86
+ build_middleware { |e| called = true }.call(env)
87
+
88
+ env[:skills].should == []
89
+ called.should.be.true
90
+ end
91
+ end
@@ -48,23 +48,56 @@ module Brute
48
48
  name = tool_call.name.to_sym
49
49
  args = tool_call.arguments
50
50
 
51
- available_tools[name].call(args).then do |result|
52
-
53
- # Coerce to String so Hash results (e.g. Shell's
54
- # {stdout:, stderr:, exit_code:}) serialize predictably.
55
- if result.is_a?(String)
56
- content = result
57
- else
58
- content = result.to_s
51
+ # Lifecycle hooks (Brute::Hooks): before_tool may rewrite
52
+ # :arguments or short-circuit with a :result; approve_tool
53
+ # denies on a false (or String) return; after_tool may
54
+ # rewrite :result.
55
+ call_env = {
56
+ name: name.to_s,
57
+ arguments: args,
58
+ result: nil,
59
+ events: env[:events],
60
+ metadata: {},
61
+ turn_env: env,
62
+ }
63
+ if (hooks = env[:hooks])
64
+ responses = hooks.emit(:before_tool, call_env).compact
65
+ call_env[:result] = responses.last if call_env[:result].nil? && !responses.empty?
66
+
67
+ if call_env[:result].nil?
68
+ denial = hooks.emit(:approve_tool, call_env).find { |r| r == false || r.is_a?(String) }
69
+ unless denial.nil?
70
+ call_env[:result] = denial.is_a?(String) ? denial : %(Tool call to "#{name}" was denied.)
71
+ end
59
72
  end
73
+ end
60
74
 
61
- # Universal truncation safety net — skip if already truncated
62
- unless Brute::Truncation.already_truncated?(content)
63
- content = Brute::Truncation.truncate(content)
64
- end
75
+ result = if call_env[:result].nil?
76
+ available_tools[name].call(call_env[:arguments])
77
+ else
78
+ call_env[:result]
79
+ end
80
+
81
+ if (hooks = env[:hooks])
82
+ call_env[:result] = result
83
+ hooks.emit(:after_tool, call_env)
84
+ result = call_env[:result]
85
+ end
86
+
87
+ # Coerce to String so Hash results (e.g. Shell's
88
+ # {stdout:, stderr:, exit_code:}) serialize predictably.
89
+ if result.is_a?(String)
90
+ content = result
91
+ else
92
+ content = result.to_s
93
+ end
65
94
 
66
- results << [tool_call, content]
95
+ # Universal truncation safety net — skip if already truncated
96
+ unless Brute::Truncation.already_truncated?(content)
97
+ content = Brute::Truncation.truncate(content)
67
98
  end
99
+
100
+ results << [tool_call, content]
68
101
  rescue => e
69
102
  # Capture the error as a tool result so the LLM can see it
70
103
  # and reason about the failure, rather than crashing the
@@ -141,6 +174,70 @@ describe "brute/middleware/070_tool_pipeline" do
141
174
  seen.should == [tool]
142
175
  end
143
176
 
177
+ # --- lifecycle hooks (Brute::Hooks) ---
178
+
179
+ def hook_env(hooks)
180
+ { messages: Brute.log, events: [], hooks: hooks }
181
+ end
182
+
183
+ it "before_tool may rewrite arguments and short-circuit with a result" do
184
+ tool = { name: "echo", description: "", execute: ->(text:) { "ran:#{text}" } }
185
+ inner = ->(env) do
186
+ env[:messages] << Brute::Message.new(role: :assistant, content: "",
187
+ tool_calls: [{ id: "tc1", name: "echo", arguments: { "text" => "orig" } }])
188
+ end
189
+ hooks = Brute::Hooks.new
190
+ hooks.on(:before_tool) { |call| call[:arguments] = { text: "rewritten" }; nil }
191
+ mw = Brute::Middleware::ToolPipeline.new(inner, tools: [tool])
192
+ env = hook_env(hooks)
193
+ env[:messages].user("hi")
194
+ mw.call(env)
195
+ env[:messages].last.content.should == "ran:rewritten"
196
+
197
+ hooks2 = Brute::Hooks.new
198
+ hooks2.on(:before_tool) { |_call| "canned" }
199
+ mw2 = Brute::Middleware::ToolPipeline.new(inner, tools: [tool])
200
+ env2 = hook_env(hooks2)
201
+ env2[:messages].user("hi")
202
+ mw2.call(env2)
203
+ env2[:messages].last.content.should == "canned" # never executed
204
+ end
205
+
206
+ it "approve_tool denies on false (generic message) or String (custom)" do
207
+ tool = { name: "exec", description: "", execute: ->(**) { "ran" } }
208
+ inner = ->(env) do
209
+ env[:messages] << Brute::Message.new(role: :assistant, content: "",
210
+ tool_calls: [{ id: "tc1", name: "exec", arguments: {} }])
211
+ end
212
+ hooks = Brute::Hooks.new
213
+ hooks.on(:approve_tool) { |_call| false }
214
+ env = hook_env(hooks)
215
+ env[:messages].user("hi")
216
+ Brute::Middleware::ToolPipeline.new(inner, tools: [tool]).call(env)
217
+ env[:messages].last.content.should == %(Tool call to "exec" was denied.)
218
+
219
+ hooks2 = Brute::Hooks.new
220
+ hooks2.on(:approve_tool) { |_call| "denied by policy" }
221
+ env2 = hook_env(hooks2)
222
+ env2[:messages].user("hi")
223
+ Brute::Middleware::ToolPipeline.new(inner, tools: [tool]).call(env2)
224
+ env2[:messages].last.content.should == "denied by policy"
225
+ end
226
+
227
+ it "after_tool may rewrite the result" do
228
+ tool = { name: "echo", description: "", execute: ->(**) { "raw" } }
229
+ inner = ->(env) do
230
+ env[:messages] << Brute::Message.new(role: :assistant, content: "",
231
+ tool_calls: [{ id: "tc1", name: "echo", arguments: {} }])
232
+ end
233
+ hooks = Brute::Hooks.new
234
+ hooks.on(:after_tool) { |call| call[:result] = "rewrote(#{call[:result]})" }
235
+ env = hook_env(hooks)
236
+ env[:messages].user("hi")
237
+ Brute::Middleware::ToolPipeline.new(inner, tools: [tool]).call(env)
238
+ env[:messages].last.content.should == "rewrote(raw)"
239
+ end
240
+
144
241
  # --- Universal output truncation ---
145
242
 
146
243
  it "truncates large tool results via Truncation" do