brute 5.0.5 → 5.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,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/truncation"
6
+ require "async"
7
+ require "async/barrier"
8
+
9
+ module Brute
10
+ module Middleware
11
+ class DefaultToolPipeline < Brute::Middleware::Base
12
+ def initialize(app, tools: [])
13
+ @app = app
14
+ @tools = tools
15
+ end
16
+
17
+ def call(env)
18
+ env[:tools] = @tools
19
+ @app.call(env)
20
+
21
+ response = env[:messages].last
22
+
23
+ if response.respond_to?(:tool_calls) && response.tool_calls.present?
24
+ tools_to_run = response.tool_calls
25
+
26
+ # tool_to_run may be an Array (Brute::ToolCall) or an id-keyed Hash (some libraries' native shape)
27
+ if tools_to_run.respond_to?(:values)
28
+ tools_to_run = tools_to_run.values
29
+ end
30
+
31
+ # No idea why we use Array() here... probably in case it's a hash or something...
32
+ # because reject would work on the hash...
33
+ tools_to_run = Array(tools_to_run).reject { |tc| tc.name == "question" }
34
+
35
+ available_tools = Brute::Tools::Adapter.wrap_all(env[:tools])
36
+ env[:events] << on_tool_call_start_event(tools_to_run)
37
+
38
+ results = []
39
+
40
+ # Async::Barrier blocks until all tasks are complete.
41
+ # Tasks run in parrallel.
42
+ #
43
+ Sync do
44
+ barrier = Async::Barrier.new
45
+
46
+ tools_to_run.each do |tool_call|
47
+ barrier.async do
48
+ name = tool_call.name.to_sym
49
+ args = tool_call.arguments
50
+
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
+ denied: nil,
60
+ events: env[:events],
61
+ metadata: {},
62
+ turn_env: env,
63
+ }
64
+ # A subscriber takes part by mutating the call env: set
65
+ # :result to answer without executing, set :denied to refuse.
66
+ emit(BEFORE_TOOL_EVENT, env, call_env)
67
+
68
+ if call_env[:result].nil?
69
+ emit(APPROVE_TOOL_EVENT, env, call_env)
70
+
71
+ if (denial = call_env[:denied])
72
+ call_env[:result] = denial.is_a?(String) ? denial : %(Tool call to "#{name}" was denied.)
73
+ end
74
+ end
75
+
76
+ # Only the tool's own execution is timed: a call that
77
+ # before_tool answered, or approve_tool denied, never ran.
78
+ result = call_env[:result]
79
+
80
+ if result.nil?
81
+ emit(TOOL_DURATION_EVENT, env, call_env) do
82
+ result = available_tools[name].call(call_env[:arguments])
83
+ end
84
+ end
85
+
86
+ call_env[:result] = result
87
+ emit(AFTER_TOOL_EVENT, env, call_env)
88
+ result = call_env[:result]
89
+
90
+ # Coerce to String so Hash results (e.g. Shell's
91
+ # {stdout:, stderr:, exit_code:}) serialize predictably.
92
+ if result.is_a?(String)
93
+ content = result
94
+ else
95
+ content = result.to_s
96
+ end
97
+
98
+ # Universal truncation safety net — skip if already truncated
99
+ unless Brute::Truncation.already_truncated?(content)
100
+ content = Brute::Truncation.truncate(content)
101
+ end
102
+
103
+ results << [tool_call, content]
104
+ rescue => e
105
+ # Capture the error as a tool result so the LLM can see it
106
+ # and reason about the failure, rather than crashing the
107
+ # entire middleware chain.
108
+ env[:events] << { type: :error, data: { error: e, message: e.message } }
109
+ results << [tool_call, "Error: #{e.class}: #{e.message}"]
110
+ end
111
+ end
112
+
113
+ barrier.wait
114
+ ensure
115
+ barrier&.cancel
116
+ end
117
+
118
+ # Append events and messages in the original tool_call order so the
119
+ # LLM sees a deterministic sequence regardless of completion order.
120
+ results.sort_by! { |tool_call, _| tools_to_run.index(tool_call) }
121
+
122
+ results.each do |tool_call, content|
123
+ env[:events] << { type: :tool_result, data: { name: tool_call.name, content: content } }
124
+ env[:messages] << Brute::Message.new(role: :tool, content: content, tool_call_id: tool_call.id)
125
+ end
126
+ end
127
+
128
+ env
129
+ end
130
+
131
+ private
132
+
133
+ def on_tool_call_start_event(pending_tools)
134
+ {
135
+ type: :tool_call_start,
136
+ data: pending_tools.map { |tc|
137
+ {
138
+ name: tc.name,
139
+ call_id: tc.id,
140
+ arguments: tc.arguments
141
+ }
142
+ }
143
+ }
144
+ end
145
+ end
146
+ end
147
+ end
148
+
149
+ __END__
150
+
151
+ describe "brute/middleware/070_default_tool_pipeline" do
152
+ require "brute/messages"
153
+ require "brute/truncation"
154
+
155
+ it "passes through when no tool calls pending" do
156
+ inner = ->(env) {
157
+ env[:messages] << Brute::Message.new(role: :assistant, content: "hi")
158
+ }
159
+ mw = Brute::Middleware::DefaultToolPipeline.new(inner, tools: [])
160
+ env = {
161
+ messages: Brute.log,
162
+ events: [],
163
+ }
164
+ env[:messages].user("hello")
165
+ mw.call(env)
166
+ env[:messages].last.content.should == "hi"
167
+ end
168
+
169
+ it "advertises its tools on env[:tools] on the way in" do
170
+ seen = nil
171
+ inner = ->(env) { seen = env[:tools] }
172
+ tool = { name: "echo", description: "", execute: ->(**) { "ok" } }
173
+ mw = Brute::Middleware::DefaultToolPipeline.new(inner, tools: [tool])
174
+ env = { messages: Brute.log, events: [] }
175
+ env[:messages].user("hi")
176
+ mw.call(env)
177
+ seen.should == [tool]
178
+ end
179
+
180
+ # --- lifecycle hooks (Brute::Hooks) ---
181
+
182
+ # A layer only gets its emit from the builder that made it, so a hook spec
183
+ # builds a real pipeline rather than instantiating the middleware alone.
184
+ def hooked(inner, tools:, &subscribe)
185
+ pipeline = Brute::Turn::Pipeline.new
186
+ pipeline.use Brute::Middleware::DefaultToolPipeline, tools: tools
187
+ pipeline.run(Object.new.tap { |o| o.define_singleton_method(:call, &inner) })
188
+ subscribe.call(pipeline)
189
+ pipeline
190
+ end
191
+
192
+ def hook_env
193
+ { messages: Brute.log, events: [] }
194
+ end
195
+
196
+ it "before_tool may rewrite arguments and short-circuit with a result" do
197
+ tool = { name: "echo", description: "", execute: ->(text:) { "ran:#{text}" } }
198
+ inner = ->(env) do
199
+ env[:messages] << Brute::Message.new(role: :assistant, content: "",
200
+ tool_calls: [{ id: "tc1", name: "echo", arguments: { "text" => "orig" } }])
201
+ end
202
+
203
+ pipeline = hooked(inner, tools: [tool]) do |p|
204
+ p.on(:before_tool) { |_env, call| call[:arguments] = { text: "rewritten" } }
205
+ end
206
+ env = hook_env
207
+ env[:messages].user("hi")
208
+ pipeline.call(env)
209
+ env[:messages].last.content.should == "ran:rewritten"
210
+
211
+ canned = hooked(inner, tools: [tool]) { |p| p.on(:before_tool) { |_env, call| call[:result] = "canned" } }
212
+ env2 = hook_env
213
+ env2[:messages].user("hi")
214
+ canned.call(env2)
215
+ env2[:messages].last.content.should == "canned" # never executed
216
+ end
217
+
218
+ it "approve_tool denies on false (generic message) or String (custom)" do
219
+ tool = { name: "exec", description: "", execute: ->(**) { "ran" } }
220
+ inner = ->(env) do
221
+ env[:messages] << Brute::Message.new(role: :assistant, content: "",
222
+ tool_calls: [{ id: "tc1", name: "exec", arguments: {} }])
223
+ end
224
+
225
+ denied = hooked(inner, tools: [tool]) { |p| p.on(:approve_tool) { |_env, call| call[:denied] = true } }
226
+ env = hook_env
227
+ env[:messages].user("hi")
228
+ denied.call(env)
229
+ env[:messages].last.content.should == %(Tool call to "exec" was denied.)
230
+
231
+ by_policy = hooked(inner, tools: [tool]) { |p| p.on(:approve_tool) { |_env, call| call[:denied] = "denied by policy" } }
232
+ env2 = hook_env
233
+ env2[:messages].user("hi")
234
+ by_policy.call(env2)
235
+ env2[:messages].last.content.should == "denied by policy"
236
+ end
237
+
238
+ it "after_tool may rewrite the result" do
239
+ tool = { name: "echo", description: "", execute: ->(**) { "raw" } }
240
+ inner = ->(env) do
241
+ env[:messages] << Brute::Message.new(role: :assistant, content: "",
242
+ tool_calls: [{ id: "tc1", name: "echo", arguments: {} }])
243
+ end
244
+
245
+ pipeline = hooked(inner, tools: [tool]) do |p|
246
+ p.on(:after_tool) { |_env, call| call[:result] = "rewrote(#{call[:result]})" }
247
+ end
248
+ env = hook_env
249
+ env[:messages].user("hi")
250
+ pipeline.call(env)
251
+ env[:messages].last.content.should == "rewrote(raw)"
252
+ end
253
+
254
+ # --- Universal output truncation ---
255
+
256
+ it "truncates large tool results via Truncation" do
257
+ # A fake tool that returns a huge string
258
+ big_tool = Class.new(Brute::Tool) do
259
+ description "test tool"
260
+ param :input, type: "string", desc: "input"
261
+ def name; "big_tool"; end
262
+ def execute(input:)
263
+ "line\n" * 3000
264
+ end
265
+ end
266
+
267
+ tool_calls = [
268
+ Brute::ToolCall.new(
269
+ id: "tc_1",
270
+ name: "big_tool",
271
+ arguments: { "input" => "go" },
272
+ )
273
+ ]
274
+
275
+ inner = ->(env) {
276
+ env[:messages] << Brute::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
277
+ }
278
+ pipeline = hooked(inner, tools: [big_tool]) { |_p| nil }
279
+ env = {
280
+ messages: Brute.log,
281
+ events: [],
282
+ }
283
+ env[:messages].user("hello")
284
+ pipeline.call(env)
285
+
286
+ tool_msg = env[:messages].select { |m| m.role == :tool }.last
287
+ tool_msg.content.lines.size.should.be < 2100
288
+ tool_msg.content.should =~ /truncated/i
289
+ end
290
+
291
+ # --- Skip double-truncation ---
292
+
293
+ it "does not double-truncate already-truncated output" do
294
+ # A fake tool that returns output already containing the truncation marker
295
+ pre_truncated_tool = Class.new(Brute::Tool) do
296
+ description "test tool"
297
+ param :input, type: "string", desc: "input"
298
+ def name; "pre_truncated_tool"; end
299
+ def execute(input:)
300
+ "some result\n[Output truncated: showing 100 of 5000 lines]"
301
+ end
302
+ end
303
+
304
+ tool_calls = [
305
+ Brute::ToolCall.new(
306
+ id: "tc_2",
307
+ name: "pre_truncated_tool",
308
+ arguments: { "input" => "go" },
309
+ )
310
+ ]
311
+
312
+ inner = ->(env) {
313
+ env[:messages] << Brute::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
314
+ }
315
+ pipeline = hooked(inner, tools: [pre_truncated_tool]) { |_p| nil }
316
+ env = {
317
+ messages: Brute.log,
318
+ events: [],
319
+ }
320
+ env[:messages].user("hello")
321
+ pipeline.call(env)
322
+
323
+ tool_msg = env[:messages].select { |m| m.role == :tool }.last
324
+ # Should contain exactly one truncation marker, not two
325
+ tool_msg.content.scan(/Output truncated/).size.should == 1
326
+ end
327
+ end
@@ -2,146 +2,20 @@
2
2
 
3
3
  require "bundler/setup"
4
4
  require "brute"
5
- require "brute/truncation"
6
- require "async"
7
- require "async/barrier"
5
+ require "gem_kit"
8
6
 
9
7
  module Brute
10
8
  module Middleware
11
- class ToolPipeline < Brute::Middleware::Base
12
- def initialize(app, tools: [])
13
- @app = app
14
- @tools = tools
15
- end
16
-
17
- def call(env)
18
- env[:tools] = @tools
19
- @app.call(env)
20
-
21
- response = env[:messages].last
22
-
23
- if response.respond_to?(:tool_calls) && response.tool_calls.present?
24
- tools_to_run = response.tool_calls
25
-
26
- # tool_to_run may be an Array (Brute::ToolCall) or an id-keyed Hash (some libraries' native shape)
27
- if tools_to_run.respond_to?(:values)
28
- tools_to_run = tools_to_run.values
29
- end
30
-
31
- # No idea why we use Array() here... probably in case it's a hash or something...
32
- # because reject would work on the hash...
33
- tools_to_run = Array(tools_to_run).reject { |tc| tc.name == "question" }
34
-
35
- available_tools = Brute::Tools::Adapter.wrap_all(env[:tools])
36
- env[:events] << on_tool_call_start_event(tools_to_run)
37
-
38
- results = []
39
-
40
- # Async::Barrier blocks until all tasks are complete.
41
- # Tasks run in parrallel.
42
- #
43
- Sync do
44
- barrier = Async::Barrier.new
45
-
46
- tools_to_run.each do |tool_call|
47
- barrier.async do
48
- name = tool_call.name.to_sym
49
- args = tool_call.arguments
50
-
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
- denied: nil,
60
- events: env[:events],
61
- metadata: {},
62
- turn_env: env,
63
- }
64
- # A subscriber takes part by mutating the call env: set
65
- # :result to answer without executing, set :denied to refuse.
66
- emit(BEFORE_TOOL_EVENT, env, call_env)
67
-
68
- if call_env[:result].nil?
69
- emit(APPROVE_TOOL_EVENT, env, call_env)
70
-
71
- if (denial = call_env[:denied])
72
- call_env[:result] = denial.is_a?(String) ? denial : %(Tool call to "#{name}" was denied.)
73
- end
74
- end
75
-
76
- # Only the tool's own execution is timed: a call that
77
- # before_tool answered, or approve_tool denied, never ran.
78
- result = call_env[:result]
79
-
80
- if result.nil?
81
- emit(TOOL_DURATION_EVENT, env, call_env) do
82
- result = available_tools[name].call(call_env[:arguments])
83
- end
84
- end
85
-
86
- call_env[:result] = result
87
- emit(AFTER_TOOL_EVENT, env, call_env)
88
- result = call_env[:result]
89
-
90
- # Coerce to String so Hash results (e.g. Shell's
91
- # {stdout:, stderr:, exit_code:}) serialize predictably.
92
- if result.is_a?(String)
93
- content = result
94
- else
95
- content = result.to_s
96
- end
97
-
98
- # Universal truncation safety net — skip if already truncated
99
- unless Brute::Truncation.already_truncated?(content)
100
- content = Brute::Truncation.truncate(content)
101
- end
102
-
103
- results << [tool_call, content]
104
- rescue => e
105
- # Capture the error as a tool result so the LLM can see it
106
- # and reason about the failure, rather than crashing the
107
- # entire middleware chain.
108
- env[:events] << { type: :error, data: { error: e, message: e.message } }
109
- results << [tool_call, "Error: #{e.class}: #{e.message}"]
110
- end
111
- end
112
-
113
- barrier.wait
114
- ensure
115
- barrier&.cancel
116
- end
117
-
118
- # Append events and messages in the original tool_call order so the
119
- # LLM sees a deterministic sequence regardless of completion order.
120
- results.sort_by! { |tool_call, _| tools_to_run.index(tool_call) }
121
-
122
- results.each do |tool_call, content|
123
- env[:events] << { type: :tool_result, data: { name: tool_call.name, content: content } }
124
- env[:messages] << Brute::Message.new(role: :tool, content: content, tool_call_id: tool_call.id)
125
- end
126
- end
127
-
128
- env
129
- end
130
-
131
- private
132
-
133
- def on_tool_call_start_event(pending_tools)
134
- {
135
- type: :tool_call_start,
136
- data: pending_tools.map { |tc|
137
- {
138
- name: tc.name,
139
- call_id: tc.id,
140
- arguments: tc.arguments
141
- }
142
- }
143
- }
144
- end
9
+ # The old name for DefaultToolPipeline, kept working while it is
10
+ # deprecated. The middleware is one particular wiring of tool dispatch and
11
+ # the name now says so, leaving Brute::Turn::ToolPipeline as the mechanism
12
+ # to compose when that wiring is not what you want.
13
+ #
14
+ # use Brute::Middleware::DefaultToolPipeline, tools: tools
15
+ #
16
+ class ToolPipeline < DefaultToolPipeline
17
+ extend GemKit::Deprecate
18
+ superseded_by "Brute::Middleware::DefaultToolPipeline", "6.0"
145
19
  end
146
20
  end
147
21
  end
@@ -149,179 +23,39 @@ end
149
23
  __END__
150
24
 
151
25
  describe "brute/middleware/070_tool_pipeline" do
152
- require "brute/messages"
153
- require "brute/truncation"
154
-
155
- it "passes through when no tool calls pending" do
156
- inner = ->(env) {
157
- env[:messages] << Brute::Message.new(role: :assistant, content: "hi")
158
- }
159
- mw = Brute::Middleware::ToolPipeline.new(inner, tools: [])
160
- env = {
161
- messages: Brute.log,
162
- events: [],
163
- }
164
- env[:messages].user("hello")
165
- mw.call(env)
166
- env[:messages].last.content.should == "hi"
167
- end
168
-
169
- it "advertises its tools on env[:tools] on the way in" do
170
- seen = nil
171
- inner = ->(env) { seen = env[:tools] }
172
- tool = { name: "echo", description: "", execute: ->(**) { "ok" } }
173
- mw = Brute::Middleware::ToolPipeline.new(inner, tools: [tool])
174
- env = { messages: Brute.log, events: [] }
175
- env[:messages].user("hi")
176
- mw.call(env)
177
- seen.should == [tool]
178
- end
179
-
180
- # --- lifecycle hooks (Brute::Hooks) ---
181
-
182
- # A layer only gets its emit from the builder that made it, so a hook spec
183
- # builds a real pipeline rather than instantiating the middleware alone.
184
- def hooked(inner, tools:, &subscribe)
185
- pipeline = Brute::Turn::Pipeline.new
186
- pipeline.use Brute::Middleware::ToolPipeline, tools: tools
187
- pipeline.run(Object.new.tap { |o| o.define_singleton_method(:call, &inner) })
188
- subscribe.call(pipeline)
189
- pipeline
190
- end
191
-
192
- def hook_env
193
- { messages: Brute.log, events: [] }
194
- end
195
-
196
- it "before_tool may rewrite arguments and short-circuit with a result" do
197
- tool = { name: "echo", description: "", execute: ->(text:) { "ran:#{text}" } }
198
- inner = ->(env) do
199
- env[:messages] << Brute::Message.new(role: :assistant, content: "",
200
- tool_calls: [{ id: "tc1", name: "echo", arguments: { "text" => "orig" } }])
201
- end
202
-
203
- pipeline = hooked(inner, tools: [tool]) do |p|
204
- p.on(:before_tool) { |_env, call| call[:arguments] = { text: "rewritten" } }
205
- end
206
- env = hook_env
207
- env[:messages].user("hi")
208
- pipeline.call(env)
209
- env[:messages].last.content.should == "ran:rewritten"
210
-
211
- canned = hooked(inner, tools: [tool]) { |p| p.on(:before_tool) { |_env, call| call[:result] = "canned" } }
212
- env2 = hook_env
213
- env2[:messages].user("hi")
214
- canned.call(env2)
215
- env2[:messages].last.content.should == "canned" # never executed
216
- end
217
-
218
- it "approve_tool denies on false (generic message) or String (custom)" do
219
- tool = { name: "exec", description: "", execute: ->(**) { "ran" } }
220
- inner = ->(env) do
221
- env[:messages] << Brute::Message.new(role: :assistant, content: "",
222
- tool_calls: [{ id: "tc1", name: "exec", arguments: {} }])
223
- end
224
-
225
- denied = hooked(inner, tools: [tool]) { |p| p.on(:approve_tool) { |_env, call| call[:denied] = true } }
226
- env = hook_env
227
- env[:messages].user("hi")
228
- denied.call(env)
229
- env[:messages].last.content.should == %(Tool call to "exec" was denied.)
230
-
231
- by_policy = hooked(inner, tools: [tool]) { |p| p.on(:approve_tool) { |_env, call| call[:denied] = "denied by policy" } }
232
- env2 = hook_env
233
- env2[:messages].user("hi")
234
- by_policy.call(env2)
235
- env2[:messages].last.content.should == "denied by policy"
236
- end
237
-
238
- it "after_tool may rewrite the result" do
239
- tool = { name: "echo", description: "", execute: ->(**) { "raw" } }
240
- inner = ->(env) do
241
- env[:messages] << Brute::Message.new(role: :assistant, content: "",
242
- tool_calls: [{ id: "tc1", name: "echo", arguments: {} }])
243
- end
244
-
245
- pipeline = hooked(inner, tools: [tool]) do |p|
246
- p.on(:after_tool) { |_env, call| call[:result] = "rewrote(#{call[:result]})" }
247
- end
248
- env = hook_env
249
- env[:messages].user("hi")
250
- pipeline.call(env)
251
- env[:messages].last.content.should == "rewrote(raw)"
252
- end
253
-
254
- # --- Universal output truncation ---
255
-
256
- it "truncates large tool results via Truncation" do
257
- # A fake tool that returns a huge string
258
- big_tool = Class.new(Brute::Tool) do
259
- description "test tool"
260
- param :input, type: "string", desc: "input"
261
- def name; "big_tool"; end
262
- def execute(input:)
263
- "line\n" * 3000
26
+ it "still dispatches tools under the old name, and says what to use instead" do
27
+ warned = []
28
+ original = GemKit::Deprecate.method(:warn)
29
+ GemKit::Deprecate.define_singleton_method(:warn) { |message| warned << message }
30
+
31
+ begin
32
+ tool = Brute::Turn::ToolPipeline.new(name: "echo", description: "echo") do
33
+ run ->(env) { env[:result] = "echoed" }
264
34
  end
265
- end
266
35
 
267
- tool_calls = [
268
- Brute::ToolCall.new(
269
- id: "tc_1",
270
- name: "big_tool",
271
- arguments: { "input" => "go" },
272
- )
273
- ]
274
-
275
- inner = ->(env) {
276
- env[:messages] << Brute::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
277
- }
278
- pipeline = hooked(inner, tools: [big_tool]) { |_p| nil }
279
- env = {
280
- messages: Brute.log,
281
- events: [],
282
- }
283
- env[:messages].user("hello")
284
- pipeline.call(env)
285
-
286
- tool_msg = env[:messages].select { |m| m.role == :tool }.last
287
- tool_msg.content.lines.size.should.be < 2100
288
- tool_msg.content.should =~ /truncated/i
289
- end
290
-
291
- # --- Skip double-truncation ---
36
+ agent = Brute.agent
37
+ .use(Brute::Middleware::ToolPipeline, tools: [tool])
38
+ .run(
39
+ ->(env) {
40
+ env[:messages] << Brute::Message.new(
41
+ role: :assistant,
42
+ content: "",
43
+ tool_calls: [{ id: "1", name: "echo", arguments: {} }]
44
+ )
45
+ }
46
+ )
292
47
 
293
- it "does not double-truncate already-truncated output" do
294
- # A fake tool that returns output already containing the truncation marker
295
- pre_truncated_tool = Class.new(Brute::Tool) do
296
- description "test tool"
297
- param :input, type: "string", desc: "input"
298
- def name; "pre_truncated_tool"; end
299
- def execute(input:)
300
- "some result\n[Output truncated: showing 100 of 5000 lines]"
301
- end
48
+ agent.start("go")[:messages].last.content.should == "echoed"
49
+ ensure
50
+ GemKit::Deprecate.define_singleton_method(:warn, original)
302
51
  end
303
52
 
304
- tool_calls = [
305
- Brute::ToolCall.new(
306
- id: "tc_2",
307
- name: "pre_truncated_tool",
308
- arguments: { "input" => "go" },
309
- )
310
- ]
311
-
312
- inner = ->(env) {
313
- env[:messages] << Brute::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
314
- }
315
- pipeline = hooked(inner, tools: [pre_truncated_tool]) { |_p| nil }
316
- env = {
317
- messages: Brute.log,
318
- events: [],
319
- }
320
- env[:messages].user("hello")
321
- pipeline.call(env)
53
+ Brute::Middleware::ToolPipeline.ancestors.should.include Brute::Middleware::DefaultToolPipeline
54
+ warned.first.should.include "Brute::Middleware::DefaultToolPipeline"
55
+ warned.first.should.include "6.0"
322
56
 
323
- tool_msg = env[:messages].select { |m| m.role == :tool }.last
324
- # Should contain exactly one truncation marker, not two
325
- tool_msg.content.scan(/Output truncated/).size.should == 1
57
+ declared = GemKit::Deprecate.registry.find { |entry| entry.name == "Brute::Middleware::ToolPipeline" }
58
+ declared.replacement.should == "Brute::Middleware::DefaultToolPipeline"
59
+ declared.removed_in.should == Gem::Version.new("6.0")
326
60
  end
327
61
  end