brute 4.3.2 → 5.0.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/completion/async_faraday.rb +38 -0
- data/lib/brute/completion/lang_chain.rb +185 -0
- data/lib/brute/completion/llmrb.rb +182 -0
- data/lib/brute/completion/open_router.rb +54 -42
- data/lib/brute/completion/ruby_llm.rb +198 -0
- data/lib/brute/contrib/otel.rb +208 -0
- data/lib/brute/hooks.rb +149 -31
- data/lib/brute/message_transport/lang_chain.rb +36 -0
- data/lib/brute/message_transport/llm.rb +6 -0
- data/lib/brute/message_transport/open_router.rb +6 -0
- data/lib/brute/message_transport/ruby_llm.rb +6 -0
- data/lib/brute/message_transport.rb +8 -0
- data/lib/brute/middleware/000_base.rb +61 -0
- data/lib/brute/middleware/002_session_log.rb +1 -1
- data/lib/brute/middleware/004_summarize.rb +1 -1
- data/lib/brute/middleware/005_tracing.rb +1 -1
- data/lib/brute/middleware/006_loop.rb +1 -1
- data/lib/brute/middleware/008_checkpoint.rb +1 -1
- data/lib/brute/middleware/010_max_iterations.rb +1 -1
- data/lib/brute/middleware/020_system_prompt.rb +1 -1
- data/lib/brute/middleware/025_skills.rb +1 -1
- data/lib/brute/middleware/040_compaction_check.rb +1 -1
- data/lib/brute/middleware/060_questions.rb +1 -1
- data/lib/brute/middleware/070_tool_pipeline.rb +60 -47
- data/lib/brute/middleware/event_handler.rb +1 -1
- data/lib/brute/middleware/user_queue.rb +1 -1
- data/lib/brute/turn/agent_pipeline.rb +3 -4
- data/lib/brute/turn/pipeline.rb +151 -2
- data/lib/brute/usage_detection/lang_chain.rb +44 -0
- data/lib/brute/usage_detection/llmrb.rb +53 -0
- data/lib/brute/usage_detection/open_router.rb +62 -0
- data/lib/brute/usage_detection/ruby_llm.rb +55 -0
- data/lib/brute/usage_detection/usage.rb +51 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +95 -3
- metadata +83 -8
- data/lib/brute/changelog.rb +0 -322
- data/lib/brute/deprecate.rb +0 -132
- data/lib/brute/middleware/001_otel_span.rb +0 -79
- data/lib/brute/middleware/015_otel_token_usage.rb +0 -44
- data/lib/brute/middleware/073_otel_tool_call.rb +0 -51
- data/lib/brute/middleware/075_otel_tool_results.rb +0 -48
- data/lib/brute/middleware/open_router.rb +0 -56
data/lib/brute/changelog.rb
DELETED
|
@@ -1,322 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "date"
|
|
4
|
-
|
|
5
|
-
require "gem_kit"
|
|
6
|
-
|
|
7
|
-
module Brute
|
|
8
|
-
# A parser and linter for CHANGELOG.md, which follows
|
|
9
|
-
# [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
10
|
-
#
|
|
11
|
-
# The changelog is the one release artefact nothing else can regenerate, so
|
|
12
|
-
# it is the one most easily forgotten. Making it machine-checkable turns
|
|
13
|
-
# "did anyone write the changelog?" into a gate: `bin/lint-changelog`
|
|
14
|
-
# validates the format, and `bin/release-gem` refuses to push a version that
|
|
15
|
-
# has no section of its own.
|
|
16
|
-
#
|
|
17
|
-
# changelog = Brute::Changelog.load
|
|
18
|
-
# changelog.problems # => [] when the format is clean
|
|
19
|
-
# changelog.release_problems("4.1.0")
|
|
20
|
-
#
|
|
21
|
-
# The shape it expects:
|
|
22
|
-
#
|
|
23
|
-
# # Changelog
|
|
24
|
-
#
|
|
25
|
-
# ## [Unreleased]
|
|
26
|
-
#
|
|
27
|
-
# ## [4.1.0] - 2026-08-20
|
|
28
|
-
#
|
|
29
|
-
# ### Added
|
|
30
|
-
#
|
|
31
|
-
# - Something that happened.
|
|
32
|
-
#
|
|
33
|
-
class Changelog
|
|
34
|
-
# Deprecated. This moved out to the gem_kit-release gem, where the rest of
|
|
35
|
-
# the release toolchain now lives — `gem kit changelog` is the CLI over it.
|
|
36
|
-
# The implementation stays here, working, until the deadline.
|
|
37
|
-
extend GemKit::Deprecate
|
|
38
|
-
superseded_by "GemKit::Release::Changelog", "5.0"
|
|
39
|
-
|
|
40
|
-
# The six change types Keep a Changelog defines. Anything else under a
|
|
41
|
-
# version is a typo or an invention, and both are worth catching.
|
|
42
|
-
SECTIONS = %w[Added Changed Deprecated Removed Fixed Security].freeze
|
|
43
|
-
|
|
44
|
-
UNRELEASED = "Unreleased"
|
|
45
|
-
HEADING = /\A##\s+\[([^\]]+)\](?:\s+-\s+(.*))?\s*\z/
|
|
46
|
-
SUBHEADING = /\A###\s+(.*?)\s*\z/
|
|
47
|
-
BULLET = /\A[-*]\s+\S/
|
|
48
|
-
DATE = /\A\d{4}-\d{2}-\d{2}\z/
|
|
49
|
-
|
|
50
|
-
# One `## [...]` section of the file.
|
|
51
|
-
Release = Struct.new(:version, :date, :line, :subsections, keyword_init: true) do
|
|
52
|
-
def unreleased? = version == UNRELEASED
|
|
53
|
-
def empty? = subsections.empty? || subsections.values.all?(&:empty?)
|
|
54
|
-
def to_s = unreleased? ? "[#{version}]" : "[#{version}] - #{date}"
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# Load the changelog sitting beside the gem (../../CHANGELOG.md).
|
|
58
|
-
def self.load(path = File.expand_path("../../CHANGELOG.md", __dir__))
|
|
59
|
-
new(File.exist?(path) ? File.read(path) : nil, path: path)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
attr_reader :path, :releases
|
|
63
|
-
|
|
64
|
-
# @parameter text [String, nil] the file's contents; nil means "no file".
|
|
65
|
-
def initialize(text, path: "CHANGELOG.md")
|
|
66
|
-
@path = path
|
|
67
|
-
@text = text
|
|
68
|
-
@releases = text ? parse(text) : []
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def missing? = @text.nil?
|
|
72
|
-
|
|
73
|
-
def unreleased = releases.find(&:unreleased?)
|
|
74
|
-
|
|
75
|
-
def released = releases.reject(&:unreleased?)
|
|
76
|
-
|
|
77
|
-
def find(version)
|
|
78
|
-
target = Gem::Version.new(version.to_s)
|
|
79
|
-
released.find { |release| Gem::Version.new(release.version) == target rescue false }
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# Everything wrong with the file's *format*, as a list of human-readable
|
|
83
|
-
# problems. Empty means it lints clean.
|
|
84
|
-
def problems
|
|
85
|
-
return ["#{path} does not exist"] if missing?
|
|
86
|
-
|
|
87
|
-
[*header_problems, *heading_problems, *ordering_problems, *content_problems]
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
# Everything standing between this changelog and releasing `version`.
|
|
91
|
-
# Format problems count: a file nobody can parse is not documentation.
|
|
92
|
-
def release_problems(version)
|
|
93
|
-
return problems unless problems.empty?
|
|
94
|
-
|
|
95
|
-
release = find(version)
|
|
96
|
-
return ["#{path} has no section for #{version} — run bin/update-changelog"] if release.nil?
|
|
97
|
-
return ["#{path} section for #{version} is empty"] if release.empty?
|
|
98
|
-
|
|
99
|
-
newest = released.first
|
|
100
|
-
if newest && newest.version != release.version
|
|
101
|
-
return ["#{path} lists #{newest.version} above #{version}; the release being cut must come first"]
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
[]
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
private
|
|
108
|
-
|
|
109
|
-
# Split the file into `## [...]` sections, recording each one's
|
|
110
|
-
# `### Type` subsections and their top-level bullets.
|
|
111
|
-
def parse(text)
|
|
112
|
-
found = []
|
|
113
|
-
current = nil
|
|
114
|
-
heading = nil
|
|
115
|
-
|
|
116
|
-
text.each_line.with_index(1) do |line, number|
|
|
117
|
-
case line
|
|
118
|
-
when HEADING
|
|
119
|
-
heading = nil
|
|
120
|
-
current = Release.new(version: $1, date: $2&.strip, line: number, subsections: {})
|
|
121
|
-
found << current
|
|
122
|
-
when SUBHEADING
|
|
123
|
-
next unless current
|
|
124
|
-
|
|
125
|
-
heading = $1
|
|
126
|
-
(current.subsections[heading] ||= [])
|
|
127
|
-
when BULLET
|
|
128
|
-
current.subsections[heading] << line.strip if current && heading
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
found
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def header_problems
|
|
136
|
-
first = @text.each_line.find { |line| !line.strip.empty? }
|
|
137
|
-
return [] if first&.strip == "# Changelog"
|
|
138
|
-
|
|
139
|
-
["#{path}:1 must start with the title `# Changelog`"]
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def heading_problems
|
|
143
|
-
problems = []
|
|
144
|
-
|
|
145
|
-
@text.each_line.with_index(1) do |line, number|
|
|
146
|
-
next unless line.start_with?("## ") && !line.start_with?("###")
|
|
147
|
-
|
|
148
|
-
unless line =~ HEADING
|
|
149
|
-
problems << "#{path}:#{number} malformed version heading: #{line.strip.inspect} " \
|
|
150
|
-
"(expected `## [Unreleased]` or `## [1.2.3] - YYYY-MM-DD`)"
|
|
151
|
-
next
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
version, date = $1, $2&.strip
|
|
155
|
-
next if version == UNRELEASED
|
|
156
|
-
|
|
157
|
-
problems << "#{path}:#{number} #{version} has no date (expected `## [#{version}] - YYYY-MM-DD`)" if date.nil? || date.empty?
|
|
158
|
-
problems << "#{path}:#{number} #{version} has a malformed date: #{date.inspect}" if date && !date.empty? && date !~ DATE
|
|
159
|
-
problems << "#{path}:#{number} #{version} is not a valid version number" unless valid_version?(version)
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
problems + subheading_problems
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def subheading_problems
|
|
166
|
-
@text.each_line.with_index(1).filter_map do |line, number|
|
|
167
|
-
next unless line.start_with?("### ")
|
|
168
|
-
next if line =~ SUBHEADING && SECTIONS.include?($1)
|
|
169
|
-
|
|
170
|
-
"#{path}:#{number} unknown section #{line.sub("###", "").strip.inspect} " \
|
|
171
|
-
"(expected one of: #{SECTIONS.join(", ")})"
|
|
172
|
-
end
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def ordering_problems
|
|
176
|
-
problems = []
|
|
177
|
-
|
|
178
|
-
if unreleased && releases.first&.unreleased? == false
|
|
179
|
-
problems << "#{path}:#{unreleased.line} [Unreleased] must be the first section"
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
seen = {}
|
|
183
|
-
released.each do |release|
|
|
184
|
-
if (first = seen[release.version])
|
|
185
|
-
problems << "#{path}:#{release.line} duplicate section for #{release.version} (also at line #{first})"
|
|
186
|
-
end
|
|
187
|
-
seen[release.version] ||= release.line
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
versions = released.select { |release| valid_version?(release.version) }
|
|
191
|
-
versions.each_cons(2) do |newer, older|
|
|
192
|
-
next if Gem::Version.new(newer.version) > Gem::Version.new(older.version)
|
|
193
|
-
|
|
194
|
-
problems << "#{path}:#{older.line} #{older.version} is listed below #{newer.version}; " \
|
|
195
|
-
"releases must run newest to oldest"
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
problems
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
def content_problems
|
|
202
|
-
released.flat_map do |release|
|
|
203
|
-
if release.subsections.empty?
|
|
204
|
-
["#{path}:#{release.line} #{release} has no #{SECTIONS.join("/")} section"]
|
|
205
|
-
else
|
|
206
|
-
release.subsections.filter_map do |heading, bullets|
|
|
207
|
-
"#{path}:#{release.line} #{release} has an empty `### #{heading}` section" if bullets.empty?
|
|
208
|
-
end
|
|
209
|
-
end
|
|
210
|
-
end
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def valid_version?(version)
|
|
214
|
-
Gem::Version.correct?(version)
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
__END__
|
|
220
|
-
|
|
221
|
-
describe "brute/changelog" do
|
|
222
|
-
# These specs exercise a deprecated class on purpose; skip_during keeps the
|
|
223
|
-
# warning out of the suite's output (see DEPRECATIONS.md).
|
|
224
|
-
changelog = lambda do |body|
|
|
225
|
-
Gem::Deprecate.skip_during { Brute::Changelog.new("# Changelog\n\n#{body}") }
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
good = <<~MD
|
|
229
|
-
## [Unreleased]
|
|
230
|
-
|
|
231
|
-
## [4.1.0] - 2026-08-20
|
|
232
|
-
|
|
233
|
-
### Added
|
|
234
|
-
|
|
235
|
-
- A thing.
|
|
236
|
-
|
|
237
|
-
## [4.0.0] - 2026-08-01
|
|
238
|
-
|
|
239
|
-
### Removed
|
|
240
|
-
|
|
241
|
-
- An older thing.
|
|
242
|
-
MD
|
|
243
|
-
|
|
244
|
-
it "parses sections, dates and bullets" do
|
|
245
|
-
log = changelog.call(good)
|
|
246
|
-
|
|
247
|
-
log.releases.map(&:version).should == ["Unreleased", "4.1.0", "4.0.0"]
|
|
248
|
-
log.unreleased.should.be.kind_of?(Brute::Changelog::Release)
|
|
249
|
-
log.released.first.date.should == "2026-08-20"
|
|
250
|
-
log.released.first.subsections["Added"].should == ["- A thing."]
|
|
251
|
-
log.find("4.0.0").subsections["Removed"].size.should == 1
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
it "lints a well-formed file clean and clears it for release" do
|
|
255
|
-
log = changelog.call(good)
|
|
256
|
-
|
|
257
|
-
log.problems.should == []
|
|
258
|
-
log.release_problems("4.1.0").should == []
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
it "requires the `# Changelog` title" do
|
|
262
|
-
Gem::Deprecate.skip_during do
|
|
263
|
-
Brute::Changelog.new("## [1.0.0] - 2026-01-01\n\n### Added\n\n- x\n")
|
|
264
|
-
.problems.first.should.match(/must start with the title/)
|
|
265
|
-
end
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
it "reports a missing file" do
|
|
269
|
-
log = Gem::Deprecate.skip_during { Brute::Changelog.new(nil, path: "nope.md") }
|
|
270
|
-
log.missing?.should.be.true
|
|
271
|
-
log.problems.first.should.match(/does not exist/)
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
it "rejects a malformed version heading" do
|
|
275
|
-
changelog.call("## 1.0.0\n").problems.first.should.match(/malformed version heading/)
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
it "rejects an undated or badly dated release" do
|
|
279
|
-
changelog.call("## [1.0.0]\n\n### Added\n\n- x\n").problems.first.should.match(/has no date/)
|
|
280
|
-
changelog.call("## [1.0.0] - 20260101\n\n### Added\n\n- x\n").problems.first.should.match(/malformed date/)
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
it "rejects an unknown section type" do
|
|
284
|
-
changelog.call("## [1.0.0] - 2026-01-01\n\n### Improved\n\n- x\n")
|
|
285
|
-
.problems.first.should.match(/unknown section "Improved"/)
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
it "rejects an empty release and an empty subsection" do
|
|
289
|
-
changelog.call("## [1.0.0] - 2026-01-01\n").problems.first.should.match(/has no Added/)
|
|
290
|
-
changelog.call("## [1.0.0] - 2026-01-01\n\n### Added\n\n## [0.9.0] - 2026-01-01\n\n### Added\n\n- x\n")
|
|
291
|
-
.problems.first.should.match(/empty `### Added` section/)
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
it "rejects duplicates and out-of-order releases" do
|
|
295
|
-
body = "## [1.0.0] - 2026-01-02\n\n### Added\n\n- x\n\n## [1.0.0] - 2026-01-01\n\n### Added\n\n- y\n"
|
|
296
|
-
changelog.call(body).problems.first.should.match(/duplicate section for 1\.0\.0/)
|
|
297
|
-
|
|
298
|
-
body = "## [1.0.0] - 2026-01-01\n\n### Added\n\n- x\n\n## [2.0.0] - 2026-01-02\n\n### Added\n\n- y\n"
|
|
299
|
-
changelog.call(body).problems.first.should.match(/must run newest to oldest/)
|
|
300
|
-
end
|
|
301
|
-
|
|
302
|
-
it "requires [Unreleased] to come first" do
|
|
303
|
-
body = "## [1.0.0] - 2026-01-01\n\n### Added\n\n- x\n\n## [Unreleased]\n"
|
|
304
|
-
changelog.call(body).problems.first.should.match(/\[Unreleased\] must be the first section/)
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
it "blocks a release with no section of its own" do
|
|
308
|
-
changelog.call(good).release_problems("9.9.9").first.should.match(/no section for 9\.9\.9/)
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
it "blocks a release that is not the newest section" do
|
|
312
|
-
changelog.call(good).release_problems("4.0.0").first.should.match(/lists 4\.1\.0 above 4\.0\.0/)
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
it "reports format problems ahead of release problems" do
|
|
316
|
-
changelog.call("## nonsense\n").release_problems("4.1.0").first.should.match(/malformed version heading/)
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
it "loads the repo's own CHANGELOG.md" do
|
|
320
|
-
Gem::Deprecate.skip_during { Brute::Changelog.load.missing?.should.be.false }
|
|
321
|
-
end
|
|
322
|
-
end
|
data/lib/brute/deprecate.rb
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "gem_kit"
|
|
4
|
-
|
|
5
|
-
module Brute
|
|
6
|
-
# Deprecated. This is [`GemKit::Deprecate`](https://rubygems.org/gems/gem_kit)
|
|
7
|
-
# now — the same code, extracted so that other gems could use it, and so that
|
|
8
|
-
# `gem kit deprecations` could read one registry rather than two.
|
|
9
|
-
#
|
|
10
|
-
# extend Brute::Deprecate -> extend GemKit::Deprecate
|
|
11
|
-
# brute_deprecate -> deprecate
|
|
12
|
-
# brute_deprecate_constant -> superseded_by
|
|
13
|
-
#
|
|
14
|
-
# Extending this module still works and still registers: it warns, then
|
|
15
|
-
# extends GemKit::Deprecate for you and aliases the old method names onto the
|
|
16
|
-
# new ones. It will stop working in 5.0.
|
|
17
|
-
module Deprecate
|
|
18
|
-
REPLACEMENT = "GemKit::Deprecate"
|
|
19
|
-
REMOVED_IN = "5.0"
|
|
20
|
-
|
|
21
|
-
# The names Brute used before the extraction. `deprecate` collides with
|
|
22
|
-
# Gem::Deprecate's own, which is why Brute's carried a prefix; GemKit's
|
|
23
|
-
# namespace does that job instead.
|
|
24
|
-
ALIASES = { brute_deprecate: :deprecate, brute_deprecate_constant: :superseded_by }.freeze
|
|
25
|
-
|
|
26
|
-
def self.extended(base)
|
|
27
|
-
origin = Gem.location_of_caller.join(":")
|
|
28
|
-
GemKit::Deprecate.warn(
|
|
29
|
-
GemKit::Deprecate.message("Brute::Deprecate", REPLACEMENT, REMOVED_IN, origin),
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
base.extend(GemKit::Deprecate)
|
|
33
|
-
ALIASES.each { |old, new| base.singleton_class.alias_method(old, new) }
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# The registry moved with the DSL. Delegated rather than mirrored: two
|
|
37
|
-
# registries meant two answers to "what is still outstanding".
|
|
38
|
-
class << self
|
|
39
|
-
def registry = GemKit::Deprecate.registry
|
|
40
|
-
def pending(version) = GemKit::Deprecate.pending(version)
|
|
41
|
-
def upcoming(version) = GemKit::Deprecate.upcoming(version)
|
|
42
|
-
def register(...) = GemKit::Deprecate.register(...)
|
|
43
|
-
def warn(message) = GemKit::Deprecate.warn(message)
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# The module itself is deprecated, and a module cannot announce that the way a
|
|
49
|
-
# class can — there is no `new` to wrap. Registering it by hand is what puts it
|
|
50
|
-
# in `gem kit deprecations` alongside everything else.
|
|
51
|
-
GemKit::Deprecate.register(
|
|
52
|
-
name: "Brute::Deprecate",
|
|
53
|
-
replacement: Brute::Deprecate::REPLACEMENT,
|
|
54
|
-
removed_in: Brute::Deprecate::REMOVED_IN,
|
|
55
|
-
declared_at: "#{__FILE__}:#{__LINE__ - 5}",
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
__END__
|
|
59
|
-
|
|
60
|
-
describe "brute/deprecate" do
|
|
61
|
-
captured = []
|
|
62
|
-
# Capture what the shim emits, and keep the shared registry clean.
|
|
63
|
-
isolated = lambda do |&block|
|
|
64
|
-
saved = GemKit::Deprecate.registry.dup
|
|
65
|
-
original = GemKit::Deprecate.method(:warn)
|
|
66
|
-
captured.clear
|
|
67
|
-
GemKit::Deprecate.define_singleton_method(:warn) { |message| captured << message }
|
|
68
|
-
begin
|
|
69
|
-
block.call
|
|
70
|
-
ensure
|
|
71
|
-
GemKit::Deprecate.define_singleton_method(:warn, original)
|
|
72
|
-
GemKit::Deprecate.registry.replace(saved)
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
it "warns when extended, naming GemKit::Deprecate" do
|
|
77
|
-
isolated.call do
|
|
78
|
-
Class.new { extend Brute::Deprecate }
|
|
79
|
-
|
|
80
|
-
captured.size.should == 1
|
|
81
|
-
captured.first.should.match(/Brute::Deprecate is deprecated/)
|
|
82
|
-
captured.first.should.match(/use GemKit::Deprecate instead/)
|
|
83
|
-
captured.first.should.match(/removed in 5\.0/)
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
it "still declares a method deprecation under the old name" do
|
|
88
|
-
isolated.call do
|
|
89
|
-
klass = Class.new do
|
|
90
|
-
extend Brute::Deprecate
|
|
91
|
-
def new_name = :result
|
|
92
|
-
def old_name = new_name
|
|
93
|
-
brute_deprecate :old_name, "Thing#new_name", "9.0"
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
klass.new.old_name.should == :result
|
|
97
|
-
GemKit::Deprecate.registry.last.replacement.should == "Thing#new_name"
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
it "still declares a constant deprecation under the old name" do
|
|
102
|
-
isolated.call do
|
|
103
|
-
modern = Class.new { def initialize(x); @x = x; end; attr_reader :x }
|
|
104
|
-
legacy = Class.new(modern) do
|
|
105
|
-
extend Brute::Deprecate
|
|
106
|
-
def self.name = "Old::Name"
|
|
107
|
-
brute_deprecate_constant "New::Name", "9.0"
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
legacy.new(42).x.should == 42
|
|
111
|
-
GemKit::Deprecate.registry.last.name.should == "Old::Name"
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
it "delegates the registry rather than keeping one of its own" do
|
|
116
|
-
isolated.call do
|
|
117
|
-
Brute::Deprecate.registry.should.equal?(GemKit::Deprecate.registry)
|
|
118
|
-
|
|
119
|
-
GemKit::Deprecate.register(name: "A", replacement: "A2", removed_in: "5.0")
|
|
120
|
-
Brute::Deprecate.pending("5.0.0").map(&:name).should.include?("A")
|
|
121
|
-
Brute::Deprecate.upcoming("4.0.0").map(&:name).should.include?("A")
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
it "registers itself, so `gem kit deprecations` lists it" do
|
|
126
|
-
entry = GemKit::Deprecate.registry.find { |e| e.name == "Brute::Deprecate" }
|
|
127
|
-
|
|
128
|
-
entry.should.not.be.nil
|
|
129
|
-
entry.replacement.should == "GemKit::Deprecate"
|
|
130
|
-
entry.removed_in.should == Gem::Version.new("5.0")
|
|
131
|
-
end
|
|
132
|
-
end
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "brute"
|
|
5
|
-
|
|
6
|
-
module Brute
|
|
7
|
-
module Middleware
|
|
8
|
-
# Outermost OTel middleware. Creates a span per LLM stack call
|
|
9
|
-
# and passes it through env[:span] for inner OTel middlewares to
|
|
10
|
-
# decorate with events and attributes.
|
|
11
|
-
#
|
|
12
|
-
# When opentelemetry-sdk is not loaded, this is a pure pass-through.
|
|
13
|
-
#
|
|
14
|
-
# Stack position: outermost (wraps everything including retries).
|
|
15
|
-
#
|
|
16
|
-
# use Brute::Middleware::OTel::Span
|
|
17
|
-
# use Brute::Middleware::OTel::ToolResultLoop
|
|
18
|
-
# use Brute::Middleware::OTel::ToolCalls
|
|
19
|
-
# use Brute::Middleware::OTel::TokenUsage
|
|
20
|
-
# # ... existing middleware ...
|
|
21
|
-
# run ->(env) { ... } # inline LLM call proc (see Brute.agent)
|
|
22
|
-
#
|
|
23
|
-
class OtelSpan
|
|
24
|
-
def initialize(app)
|
|
25
|
-
@app = app
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def call(env)
|
|
29
|
-
#return @app.call(env) unless defined?(::OpenTelemetry::SDK)
|
|
30
|
-
|
|
31
|
-
#provider_name = provider_type(env[:provider])
|
|
32
|
-
#model = env[:model] || (env[:provider].default_model rescue nil)
|
|
33
|
-
#span_name = model ? "llm.call #{model}" : "llm.call"
|
|
34
|
-
|
|
35
|
-
#attributes = {
|
|
36
|
-
# "brute.provider" => provider_name,
|
|
37
|
-
# "brute.streaming" => !!env[:streaming],
|
|
38
|
-
# "brute.context_messages" => env[:messages].size,
|
|
39
|
-
#}
|
|
40
|
-
#attributes["brute.model"] = model.to_s if model
|
|
41
|
-
#attributes["brute.session_id"] = env[:metadata][:session_id].to_s if env.dig(:metadata, :session_id)
|
|
42
|
-
|
|
43
|
-
#tracer.in_span(span_name, attributes: attributes, kind: :internal) do |span|
|
|
44
|
-
# env[:span] = span
|
|
45
|
-
# response = @app.call(env)
|
|
46
|
-
|
|
47
|
-
# # Record response model if it differs from request model
|
|
48
|
-
# resp_model = begin; response.model; rescue; nil; end
|
|
49
|
-
# span.set_attribute("brute.response_model", resp_model.to_s) if resp_model && resp_model != model
|
|
50
|
-
|
|
51
|
-
# response
|
|
52
|
-
#rescue ::StandardError => e
|
|
53
|
-
# span.record_exception(e)
|
|
54
|
-
# span.status = ::OpenTelemetry::Trace::Status.error(e.message)
|
|
55
|
-
# raise
|
|
56
|
-
#ensure
|
|
57
|
-
# env.delete(:span)
|
|
58
|
-
#end
|
|
59
|
-
@app.all(env)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
private
|
|
63
|
-
|
|
64
|
-
def tracer
|
|
65
|
-
@tracer ||= ::OpenTelemetry.tracer_provider.tracer("brute", Brute::VERSION)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def provider_type(provider)
|
|
69
|
-
provider.name.to_s
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
__END__
|
|
76
|
-
|
|
77
|
-
describe "brute/middleware/001_otel_span" do
|
|
78
|
-
# not implemented
|
|
79
|
-
end
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "brute"
|
|
5
|
-
|
|
6
|
-
module Brute
|
|
7
|
-
module Middleware
|
|
8
|
-
module OTel
|
|
9
|
-
# Records token usage from the LLM response as span attributes.
|
|
10
|
-
#
|
|
11
|
-
# Runs POST-call: reads token counts from the response usage object
|
|
12
|
-
# and sets them as attributes on the span.
|
|
13
|
-
#
|
|
14
|
-
class TokenUsage
|
|
15
|
-
def initialize(app)
|
|
16
|
-
@app = app
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def call(env)
|
|
20
|
-
#response = @app.call(env)
|
|
21
|
-
|
|
22
|
-
#span = env[:span]
|
|
23
|
-
#if span && response.respond_to?(:usage) && (usage = response.usage)
|
|
24
|
-
# span.set_attribute("gen_ai.usage.input_tokens", usage.input_tokens.to_i)
|
|
25
|
-
# span.set_attribute("gen_ai.usage.output_tokens", usage.output_tokens.to_i)
|
|
26
|
-
# span.set_attribute("gen_ai.usage.total_tokens", usage.total_tokens.to_i)
|
|
27
|
-
|
|
28
|
-
# reasoning = usage.reasoning_tokens.to_i
|
|
29
|
-
# span.set_attribute("gen_ai.usage.reasoning_tokens", reasoning) if reasoning > 0
|
|
30
|
-
#end
|
|
31
|
-
|
|
32
|
-
#response
|
|
33
|
-
@app.call(env)
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
__END__
|
|
41
|
-
|
|
42
|
-
describe "brute/middleware/015_otel_token_usage" do
|
|
43
|
-
# not implemented
|
|
44
|
-
end
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "brute"
|
|
5
|
-
|
|
6
|
-
module Brute
|
|
7
|
-
module Middleware
|
|
8
|
-
# Records tool calls the LLM requested as span events.
|
|
9
|
-
#
|
|
10
|
-
# Runs POST-call: after the LLM responds, inspects ctx.functions
|
|
11
|
-
# for any tool calls the model wants to make, and adds a span event
|
|
12
|
-
# for each one with the tool name, call ID, and arguments.
|
|
13
|
-
#
|
|
14
|
-
class OtelToolCalls
|
|
15
|
-
def initialize(app)
|
|
16
|
-
@app = app
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def call(env)
|
|
20
|
-
#response = @app.call(env)
|
|
21
|
-
|
|
22
|
-
#span = env[:span]
|
|
23
|
-
#if span
|
|
24
|
-
# functions = env[:pending_functions]
|
|
25
|
-
# if functions && !functions.empty?
|
|
26
|
-
# span.set_attribute("brute.tool_calls.count", functions.size)
|
|
27
|
-
|
|
28
|
-
# functions.each do |fn|
|
|
29
|
-
# attrs = {
|
|
30
|
-
# "tool.name" => fn.name.to_s,
|
|
31
|
-
# "tool.id" => fn.id.to_s,
|
|
32
|
-
# }
|
|
33
|
-
# args = fn.arguments
|
|
34
|
-
# attrs["tool.arguments"] = args.to_json if args
|
|
35
|
-
# span.add_event("tool_call", attributes: attrs)
|
|
36
|
-
# end
|
|
37
|
-
# end
|
|
38
|
-
#end
|
|
39
|
-
|
|
40
|
-
#response
|
|
41
|
-
@app.call(env)
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
__END__
|
|
48
|
-
|
|
49
|
-
describe "brute/middleware/073_otel_tool_call" do
|
|
50
|
-
# not implemented
|
|
51
|
-
end
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "brute"
|
|
5
|
-
|
|
6
|
-
module Brute
|
|
7
|
-
module Middleware
|
|
8
|
-
# Records tool results as span events.
|
|
9
|
-
#
|
|
10
|
-
# Tool results are now appended directly to env[:messages] as :tool
|
|
11
|
-
# role messages. This middleware can inspect the last messages to
|
|
12
|
-
# record them as span events.
|
|
13
|
-
#
|
|
14
|
-
class OtelToolResults
|
|
15
|
-
def initialize(app)
|
|
16
|
-
@app = app
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def call(env)
|
|
20
|
-
#span = env[:span]
|
|
21
|
-
|
|
22
|
-
#if span && (results = env[:tool_results])
|
|
23
|
-
# span.set_attribute("brute.tool_results.count", results.size)
|
|
24
|
-
|
|
25
|
-
# results.each do |name, value|
|
|
26
|
-
# error = value.is_a?(Hash) && value[:error]
|
|
27
|
-
# attrs = { "tool.name" => name.to_s }
|
|
28
|
-
# if error
|
|
29
|
-
# attrs["tool.status"] = "error"
|
|
30
|
-
# attrs["tool.error"] = value[:error].to_s
|
|
31
|
-
# else
|
|
32
|
-
# attrs["tool.status"] = "ok"
|
|
33
|
-
# end
|
|
34
|
-
# span.add_event("tool_result", attributes: attrs)
|
|
35
|
-
# end
|
|
36
|
-
#end
|
|
37
|
-
|
|
38
|
-
@app.call(env)
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
__END__
|
|
45
|
-
|
|
46
|
-
describe "brute/middleware/075_otel_tool_results" do
|
|
47
|
-
# not implemented
|
|
48
|
-
end
|