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.
- checksums.yaml +4 -4
- data/lib/brute/compaction/middleware/sliding_window.rb +170 -0
- data/lib/brute/compaction/middleware/strategy.rb +139 -0
- data/lib/brute/compaction/middleware/tool_results.rb +146 -0
- data/lib/brute/compaction/summarize.rb +340 -0
- data/lib/brute/compaction/transcript.rb +166 -0
- data/lib/brute/compaction.rb +57 -0
- data/lib/brute/contrib/otel.rb +2 -2
- data/lib/brute/env.rb +54 -0
- data/lib/brute/eval/case.rb +249 -0
- data/lib/brute/eval/suite.rb +176 -0
- data/lib/brute/eval/transcript.rb +147 -0
- data/lib/brute/eval/world.rb +106 -0
- data/lib/brute/eval.rb +48 -0
- data/lib/brute/hooks.rb +11 -0
- data/lib/brute/middleware/000_base.rb +1 -1
- data/lib/brute/middleware/008_checkpoint.rb +1 -1
- data/lib/brute/middleware/040_compaction_check.rb +51 -135
- data/lib/brute/middleware/040_default_compaction_pipeline.rb +352 -0
- data/lib/brute/middleware/070_default_tool_pipeline.rb +327 -0
- data/lib/brute/middleware/070_tool_pipeline.rb +39 -305
- data/lib/brute/token_counter/approximate.rb +54 -0
- data/lib/brute/token_counter/tiktoken.rb +80 -0
- data/lib/brute/token_counter.rb +148 -0
- data/lib/brute/tools/adapter.rb +4 -4
- data/lib/brute/tools/skill_load.rb +2 -2
- data/lib/brute/tools/sub_agent.rb +2 -2
- data/lib/brute/turn/compaction_pipeline.rb +122 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +15 -1
- metadata +19 -1
|
@@ -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 "
|
|
6
|
-
require "async"
|
|
7
|
-
require "async/barrier"
|
|
5
|
+
require "gem_kit"
|
|
8
6
|
|
|
9
7
|
module Brute
|
|
10
8
|
module Middleware
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|