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.
- checksums.yaml +4 -4
- data/lib/brute/changelog.rb +322 -0
- data/lib/brute/completion/open_router.rb +116 -0
- data/lib/brute/contrib/log_file.rb +173 -0
- data/lib/brute/deprecate.rb +325 -0
- data/lib/brute/hooks.rb +77 -0
- data/lib/brute/middleware/025_skills.rb +91 -0
- data/lib/brute/middleware/070_tool_pipeline.rb +110 -13
- data/lib/brute/middleware/open_router.rb +46 -28
- data/lib/brute/prompt_template.rb +125 -0
- data/lib/brute/prompts/base.rb +105 -0
- data/lib/brute/prompts/skills.rb +51 -13
- data/lib/brute/prompts/text/skills/default.erb +14 -0
- data/lib/brute/skill.rb +211 -80
- data/lib/brute/turn/agent_pipeline.rb +43 -1
- data/lib/brute/turn/pipeline.rb +15 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute/version.rb.erb +5 -0
- data/lib/brute.rb +16 -0
- metadata +38 -1
|
@@ -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
|
data/lib/brute/hooks.rb
ADDED
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|