brute 4.0.0 → 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/middleware/open_router.rb +32 -84
- data/lib/brute/turn/agent_pipeline.rb +16 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +16 -0
- metadata +33 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 428a37a7f0f2e0930d1b0c901fc75606686c25e07046b54c6dc4964fbbb88433
|
|
4
|
+
data.tar.gz: 33f0b4a2956004b7e4d26790f981718fea46d2ae7cf1f54deaea250a7951edaf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7c382ae2d9229bdd7524cbdb87614f94b8c54e25ae2f76ee68892198bcf8d901d35398b3555f712b07253f17c3dddd1d8d66907c44ff9eaa28dd5adca3441582
|
|
7
|
+
data.tar.gz: 1c3816fff0d6664a25a9bfcb0c44df62c6833c2ba5066b3cbec0772fd24ea1ab4e078b7e1faca3f577297842fdd92c3aca59368f376993ba88956aaf3c02d500
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
require_relative "deprecate"
|
|
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 Brute::Deprecate
|
|
38
|
+
brute_deprecate_constant "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
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brute
|
|
4
|
+
# Completion middlewares — the terminal step of an agent turn. Each one is a
|
|
5
|
+
# ready-made replacement for the hand-written `run` proc: it takes
|
|
6
|
+
# env[:messages], calls one provider, and appends the reply back onto the log.
|
|
7
|
+
#
|
|
8
|
+
# Brute.agent
|
|
9
|
+
# .use(Brute::Middleware::SystemPrompt)
|
|
10
|
+
# .run(Brute::Completion::OpenRouter.new(model: "anthropic/claude-sonnet-4"))
|
|
11
|
+
#
|
|
12
|
+
# Brute still owns no LLM library: each class here requires only the gem for
|
|
13
|
+
# its own provider, and only when you use it.
|
|
14
|
+
module Completion
|
|
15
|
+
class OpenRouter
|
|
16
|
+
# config: keyword arguments for OpenRouter::Client.new
|
|
17
|
+
# (access_token:, request_timeout:, uri_base:, extra_headers:).
|
|
18
|
+
# Defaults to OpenRouter.configuration's global settings.
|
|
19
|
+
# options: keyword arguments for OpenRouter::CompletionOptions.new
|
|
20
|
+
# (model:, temperature:, tools:, ...).
|
|
21
|
+
def initialize(app, config: {}, **options)
|
|
22
|
+
@app = app
|
|
23
|
+
@config = config
|
|
24
|
+
@options = ::OpenRouter::CompletionOptions.new(**options)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(env)
|
|
28
|
+
env[:hooks]&.emit(:before_llm, env)
|
|
29
|
+
|
|
30
|
+
messages = Brute::MessageTransport::OpenRouter.dump_all(env[:messages])
|
|
31
|
+
|
|
32
|
+
::OpenRouter::Client.new(**@config).then do |client|
|
|
33
|
+
client.complete(messages, @options).then do |response|
|
|
34
|
+
|
|
35
|
+
# Expose the provider's usage for downstream accounting
|
|
36
|
+
# middleware (goal budgets, autonomous limits, compaction
|
|
37
|
+
# thresholds, usage attribution) — additive metadata only.
|
|
38
|
+
if response.respond_to?(:usage) && response.usage
|
|
39
|
+
(env[:metadata] ||= {})[:last_llm_usage] = response.usage
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# OpenRouter in fact only returns a single message...
|
|
43
|
+
# https://github.com/estiens/open_router_enhanced/blob/main/lib/open_router/response.rb
|
|
44
|
+
Brute::MessageTransport::OpenRouter.wrap_each(response) do |message|
|
|
45
|
+
env[:messages] << message
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
env[:hooks]&.emit(:after_llm, env)
|
|
51
|
+
env
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
__END__
|
|
58
|
+
|
|
59
|
+
describe "brute/completion/open_router" do
|
|
60
|
+
require "brute/messages"
|
|
61
|
+
|
|
62
|
+
# The repo suite has no open_router gem; stub the two constants the
|
|
63
|
+
# middleware touches (the transport wraps duck-typed responses fine).
|
|
64
|
+
begin
|
|
65
|
+
require "open_router"
|
|
66
|
+
rescue LoadError
|
|
67
|
+
module OpenRouter
|
|
68
|
+
CompletionOptions = Class.new { def initialize(**_opts); end }
|
|
69
|
+
Client = Class.new
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
FakeUsageResponse = Struct.new(:usage) do
|
|
74
|
+
def choices
|
|
75
|
+
[{ "message" => { "role" => "assistant", "content" => "hello" } }]
|
|
76
|
+
end
|
|
77
|
+
end unless defined?(FakeUsageResponse)
|
|
78
|
+
|
|
79
|
+
# Run one turn against a stubbed OpenRouter::Client and hand back the env.
|
|
80
|
+
with_fake_client = lambda do |middleware, response|
|
|
81
|
+
fake_client = Object.new
|
|
82
|
+
fake_client.define_singleton_method(:complete) { |_messages, _options| response }
|
|
83
|
+
original = OpenRouter::Client.method(:new)
|
|
84
|
+
OpenRouter::Client.define_singleton_method(:new) { |**_config| fake_client }
|
|
85
|
+
begin
|
|
86
|
+
env = { messages: Brute.log }
|
|
87
|
+
env[:messages].user("hi")
|
|
88
|
+
middleware.call(env)
|
|
89
|
+
env
|
|
90
|
+
ensure
|
|
91
|
+
OpenRouter::Client.define_singleton_method(:new, original)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "records the provider usage into env metadata and appends the message" do
|
|
96
|
+
response = FakeUsageResponse.new({ "prompt_tokens" => 10, "completion_tokens" => 5, "total_tokens" => 15 })
|
|
97
|
+
env = with_fake_client.call(Brute::Completion::OpenRouter.new(->(e) { e }), response)
|
|
98
|
+
|
|
99
|
+
env[:messages].last.role.should == :assistant
|
|
100
|
+
env[:metadata][:last_llm_usage]["total_tokens"].should == 15
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "leaves metadata alone when the response has no usage" do
|
|
104
|
+
env = with_fake_client.call(Brute::Completion::OpenRouter.new(->(e) { e }), FakeUsageResponse.new(nil))
|
|
105
|
+
|
|
106
|
+
env.key?(:metadata).should.be.false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "still answers to the deprecated Middleware::OpenRouter::Completion name" do
|
|
110
|
+
deprecated = Brute::Middleware::OpenRouter::Completion.new(->(e) { e })
|
|
111
|
+
deprecated.should.be.kind_of?(Brute::Completion::OpenRouter)
|
|
112
|
+
|
|
113
|
+
env = with_fake_client.call(deprecated, FakeUsageResponse.new(nil))
|
|
114
|
+
env[:messages].last.content.should == "hello"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "file/tail"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Contrib
|
|
8
|
+
# A line-oriented, append-only log file that doubles as a work queue.
|
|
9
|
+
#
|
|
10
|
+
# Every entry is exactly one line — newlines in the input are folded to
|
|
11
|
+
# spaces on the way in — so a line is the unit of both storage and
|
|
12
|
+
# retrieval. Reads are destructive: `pop` takes the newest line off the
|
|
13
|
+
# end, `drain` yields every line oldest-first and empties the file.
|
|
14
|
+
#
|
|
15
|
+
# log = Brute::Contrib::LogFile.new("tmp/queue.log")
|
|
16
|
+
# log.append("something happened")
|
|
17
|
+
# log.pop # => "something happened"
|
|
18
|
+
# log.drain { |line| handle(line) }
|
|
19
|
+
#
|
|
20
|
+
# Safe across both threads (a mutex) and processes (an exclusive flock),
|
|
21
|
+
# so several agents can share one file without losing lines.
|
|
22
|
+
class LogFile < File
|
|
23
|
+
include File::Tail
|
|
24
|
+
|
|
25
|
+
def initialize(path)
|
|
26
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
27
|
+
super(path, File::RDWR | File::CREAT | File::APPEND)
|
|
28
|
+
@mutex = Mutex.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Append one line. Blank (or whitespace-only) input is a no-op and
|
|
32
|
+
# returns nil; otherwise returns the stripped line that was written.
|
|
33
|
+
def append(line)
|
|
34
|
+
strip_whitespace(line).then do |stripped_text|
|
|
35
|
+
unless stripped_text.empty?
|
|
36
|
+
locked do
|
|
37
|
+
puts(stripped_text)
|
|
38
|
+
flush
|
|
39
|
+
stripped_text
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Remove and return the newest line, or nil when the file is empty.
|
|
46
|
+
def pop
|
|
47
|
+
locked do
|
|
48
|
+
backward(1)
|
|
49
|
+
offset = tell
|
|
50
|
+
gets&.chomp.tap { truncate(offset) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Yield every line oldest-first, then empty the file. Requires a block —
|
|
55
|
+
# without one there is nowhere for the lines to go, so it raises rather
|
|
56
|
+
# than discarding them.
|
|
57
|
+
def drain
|
|
58
|
+
locked do
|
|
59
|
+
if block_given?
|
|
60
|
+
backward(line_count)
|
|
61
|
+
each_line { |x| yield x.chomp }
|
|
62
|
+
truncate(0)
|
|
63
|
+
else
|
|
64
|
+
raise "No block given..."
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def line_count
|
|
72
|
+
rewind
|
|
73
|
+
each_line.count
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def strip_whitespace(line)
|
|
77
|
+
line.to_s.gsub(/\r?\n/, " ").strip
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def locked
|
|
81
|
+
@mutex.synchronize do
|
|
82
|
+
flock(File::LOCK_EX)
|
|
83
|
+
yield
|
|
84
|
+
ensure
|
|
85
|
+
flock(File::LOCK_UN)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
__END__
|
|
93
|
+
|
|
94
|
+
require "tmpdir"
|
|
95
|
+
|
|
96
|
+
describe "brute/contrib/log_file" do
|
|
97
|
+
it "pops the last line, then nothing" do
|
|
98
|
+
Dir.mktmpdir do |dir|
|
|
99
|
+
log = Brute::Contrib::LogFile.new(File.join(dir, "log"))
|
|
100
|
+
log.append("first")
|
|
101
|
+
log.append("second")
|
|
102
|
+
|
|
103
|
+
log.pop.should == "second"
|
|
104
|
+
log.pop.should == "first"
|
|
105
|
+
log.pop.should.be.nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "keeps one entry on one line" do
|
|
110
|
+
Dir.mktmpdir do |dir|
|
|
111
|
+
log = Brute::Contrib::LogFile.new(File.join(dir, "log"))
|
|
112
|
+
log.append("two\nlines")
|
|
113
|
+
log.append(" ")
|
|
114
|
+
|
|
115
|
+
log.pop.should == "two lines"
|
|
116
|
+
log.pop.should.be.nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "loses no line to concurrent threads" do
|
|
121
|
+
Dir.mktmpdir do |dir|
|
|
122
|
+
log = Brute::Contrib::LogFile.new(File.join(dir, "log"))
|
|
123
|
+
4.times.map { |i| Thread.new { 50.times { |j| log.append("p#{i}-#{j}") } } }.each(&:join)
|
|
124
|
+
|
|
125
|
+
popped = []
|
|
126
|
+
4.times.map { Thread.new { while (line = log.pop) do popped << line end } }.each(&:join)
|
|
127
|
+
|
|
128
|
+
popped.uniq.size.should == 200
|
|
129
|
+
File.size(log.path).should == 0
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "drains every line, oldest first, and empties the file" do
|
|
134
|
+
Dir.mktmpdir do |dir|
|
|
135
|
+
log = Brute::Contrib::LogFile.new(File.join(dir, "log"))
|
|
136
|
+
log.append("first")
|
|
137
|
+
log.append("second")
|
|
138
|
+
log.append("third")
|
|
139
|
+
|
|
140
|
+
drained = []
|
|
141
|
+
log.drain { |line| drained << line }
|
|
142
|
+
|
|
143
|
+
drained.should == ["first", "second", "third"]
|
|
144
|
+
File.size(log.path).should == 0
|
|
145
|
+
log.pop.should.be.nil
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "refuses to drain without a block, leaving the lines alone" do
|
|
150
|
+
Dir.mktmpdir do |dir|
|
|
151
|
+
log = Brute::Contrib::LogFile.new(File.join(dir, "log"))
|
|
152
|
+
log.append("first")
|
|
153
|
+
log.append("second")
|
|
154
|
+
|
|
155
|
+
lambda { log.drain }.should.raise(RuntimeError)
|
|
156
|
+
|
|
157
|
+
File.read(log.path).should == "first\nsecond\n"
|
|
158
|
+
log.pop.should == "second"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "keeps appending after a drain" do
|
|
163
|
+
Dir.mktmpdir do |dir|
|
|
164
|
+
log = Brute::Contrib::LogFile.new(File.join(dir, "log"))
|
|
165
|
+
log.append("before")
|
|
166
|
+
log.drain { |line| line }
|
|
167
|
+
log.append("after")
|
|
168
|
+
|
|
169
|
+
log.pop.should == "after"
|
|
170
|
+
log.pop.should.be.nil
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -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
|
|
@@ -1,46 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../deprecate"
|
|
4
|
+
require_relative "../completion/open_router"
|
|
5
|
+
|
|
3
6
|
module Brute
|
|
4
7
|
module Middleware
|
|
5
8
|
module OpenRouter
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def call(env)
|
|
19
|
-
env[:hooks]&.emit(:before_llm, env)
|
|
20
|
-
|
|
21
|
-
messages = Brute::MessageTransport::OpenRouter.dump_all(env[:messages])
|
|
22
|
-
|
|
23
|
-
::OpenRouter::Client.new(**@config).then do |client|
|
|
24
|
-
client.complete(messages, @options).then do |response|
|
|
25
|
-
|
|
26
|
-
# Expose the provider's usage for downstream accounting
|
|
27
|
-
# middleware (goal budgets, autonomous limits, compaction
|
|
28
|
-
# thresholds, usage attribution) — additive metadata only.
|
|
29
|
-
if response.respond_to?(:usage) && response.usage
|
|
30
|
-
(env[:metadata] ||= {})[:last_llm_usage] = response.usage
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# OpenRouter in fact only returns a single message...
|
|
34
|
-
# https://github.com/estiens/open_router_enhanced/blob/main/lib/open_router/response.rb
|
|
35
|
-
Brute::MessageTransport::OpenRouter.wrap_each(response) do |message|
|
|
36
|
-
env[:messages] << message
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
env[:hooks]&.emit(:after_llm, env)
|
|
42
|
-
env
|
|
43
|
-
end
|
|
9
|
+
# Deprecated. Completion middlewares now live under Brute::Completion,
|
|
10
|
+
# which names them for what they do (call one provider) rather than for
|
|
11
|
+
# where they happened to sit in the stack:
|
|
12
|
+
#
|
|
13
|
+
# Brute::Middleware::OpenRouter::Completion -> Brute::Completion::OpenRouter
|
|
14
|
+
#
|
|
15
|
+
# The old name stays a working subclass of the new one until the deadline
|
|
16
|
+
# below; see Brute::Deprecate and `bin/deprecations`.
|
|
17
|
+
class Completion < Brute::Completion::OpenRouter
|
|
18
|
+
extend Brute::Deprecate
|
|
19
|
+
brute_deprecate_constant "Brute::Completion::OpenRouter", "5.0"
|
|
44
20
|
end
|
|
45
21
|
end
|
|
46
22
|
end
|
|
@@ -49,59 +25,31 @@ end
|
|
|
49
25
|
__END__
|
|
50
26
|
|
|
51
27
|
describe "brute/middleware/open_router" do
|
|
52
|
-
require "brute/
|
|
53
|
-
|
|
54
|
-
# The repo suite has no open_router gem; stub the two constants the
|
|
55
|
-
# middleware touches (the transport wraps duck-typed responses fine).
|
|
56
|
-
begin
|
|
57
|
-
require "open_router"
|
|
58
|
-
rescue LoadError
|
|
59
|
-
module OpenRouter
|
|
60
|
-
CompletionOptions = Class.new { def initialize(**_opts); end }
|
|
61
|
-
Client = Class.new
|
|
62
|
-
end
|
|
63
|
-
end
|
|
28
|
+
require "brute/completion/open_router"
|
|
64
29
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
[{ "message" => { "role" => "assistant", "content" => "hello" } }]
|
|
68
|
-
end
|
|
30
|
+
it "is the new Completion class under the old name" do
|
|
31
|
+
Brute::Middleware::OpenRouter::Completion.superclass.should == Brute::Completion::OpenRouter
|
|
69
32
|
end
|
|
70
33
|
|
|
71
|
-
it "
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
original = OpenRouter::Client.method(:new)
|
|
76
|
-
OpenRouter::Client.define_singleton_method(:new) { |**_config| fake_client }
|
|
34
|
+
it "warns on use, naming the replacement and the removal version" do
|
|
35
|
+
captured = []
|
|
36
|
+
original = Brute::Deprecate.method(:warn)
|
|
37
|
+
Brute::Deprecate.define_singleton_method(:warn) { |message| captured << message }
|
|
77
38
|
begin
|
|
78
|
-
|
|
79
|
-
env = { messages: Brute.log }
|
|
80
|
-
env[:messages].user("hi")
|
|
81
|
-
middleware.call(env)
|
|
82
|
-
|
|
83
|
-
env[:messages].last.role.should == :assistant
|
|
84
|
-
env[:metadata][:last_llm_usage]["total_tokens"].should == 15
|
|
39
|
+
Brute::Middleware::OpenRouter::Completion.new(->(env) { env })
|
|
85
40
|
ensure
|
|
86
|
-
|
|
41
|
+
Brute::Deprecate.define_singleton_method(:warn, original)
|
|
87
42
|
end
|
|
88
|
-
end
|
|
89
43
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
OpenRouter::Client.define_singleton_method(:new) { |**_config| fake_client }
|
|
96
|
-
begin
|
|
97
|
-
middleware = Brute::Middleware::OpenRouter::Completion.new(->(env) { env })
|
|
98
|
-
env = { messages: Brute.log }
|
|
99
|
-
env[:messages].user("hi")
|
|
100
|
-
middleware.call(env)
|
|
44
|
+
captured.size.should == 1
|
|
45
|
+
captured.first.should.match(/Brute::Middleware::OpenRouter::Completion is deprecated/)
|
|
46
|
+
captured.first.should.match(/use Brute::Completion::OpenRouter instead/)
|
|
47
|
+
captured.first.should.match(/removed in Brute 5\.0/)
|
|
48
|
+
end
|
|
101
49
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
50
|
+
it "is registered with its removal deadline" do
|
|
51
|
+
entry = Brute::Deprecate.registry.find { |e| e.name == "Brute::Middleware::OpenRouter::Completion" }
|
|
52
|
+
entry.should.not.be.nil
|
|
53
|
+
entry.removed_in.should == Gem::Version.new("5.0")
|
|
106
54
|
end
|
|
107
55
|
end
|
|
@@ -124,6 +124,22 @@ describe "brute/turn/agent_pipeline" do
|
|
|
124
124
|
agent.start("hi")[:messages].last.content.should == "from ru"
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
+
it "Brute.load_agent loads an agent from a .ru file and starts it" do
|
|
128
|
+
require "tmpdir"
|
|
129
|
+
Dir.mktmpdir do |dir|
|
|
130
|
+
path = File.join(dir, "agent.ru")
|
|
131
|
+
File.write(path, 'run ->(env) { env[:messages].assistant("from file") }')
|
|
132
|
+
|
|
133
|
+
agent = Brute.load_agent(path)
|
|
134
|
+
agent.should.be.kind_of?(Brute::Turn::AgentPipeline)
|
|
135
|
+
agent.start("hi")[:messages].last.content.should == "from file"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "Brute.load_agent raises for a missing file" do
|
|
140
|
+
lambda { Brute.load_agent("definitely/not/here.ru") }.should.raise(ArgumentError)
|
|
141
|
+
end
|
|
142
|
+
|
|
127
143
|
it ".on chains off the builder and fires turn hooks around the turn" do
|
|
128
144
|
fired = []
|
|
129
145
|
agent = Brute.agent
|
data/lib/brute/version.rb
CHANGED
data/lib/brute.rb
CHANGED
|
@@ -57,6 +57,22 @@ module Brute
|
|
|
57
57
|
Brute::Turn::AgentPipeline.new(&block)
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# Load an agent from a brute.ru file — the Brute analogue of `rackup`.
|
|
61
|
+
# The file is a rackup-style script using the same `use` / `run` / `map`
|
|
62
|
+
# DSL as `Brute.agent`, and what comes back is the AgentPipeline itself,
|
|
63
|
+
# so it can be started, further `.use`d, or served through
|
|
64
|
+
# Brute::Rack::Adapter:
|
|
65
|
+
#
|
|
66
|
+
# Brute.load_agent.start("what changed?") # ./agent.ru
|
|
67
|
+
# Brute.load_agent("examples/agents/brute.ru").start("hi")
|
|
68
|
+
#
|
|
69
|
+
def self.load_agent(path = "agent.ru")
|
|
70
|
+
path = File.expand_path(path)
|
|
71
|
+
raise ArgumentError, "no such agent file: #{path}" unless File.file?(path)
|
|
72
|
+
|
|
73
|
+
Brute::Turn::AgentPipeline.parse_file(path)
|
|
74
|
+
end
|
|
75
|
+
|
|
60
76
|
# Adapt any Brute tools (hashes, Brute::Tool, Brute::Turn::ToolPipeline,
|
|
61
77
|
# SubAgent …) into a { name_sym => Brute::Tools::Adapter } hash. Each
|
|
62
78
|
# adapter exposes #to_h — a neutral JSON-Schema-ish definition the inline
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: brute
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.
|
|
4
|
+
version: 4.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brute Contributors
|
|
@@ -37,6 +37,20 @@ dependencies:
|
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '1.5'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: file-tail
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.2'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.2'
|
|
40
54
|
- !ruby/object:Gem::Dependency
|
|
41
55
|
name: activesupport
|
|
42
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -163,6 +177,20 @@ dependencies:
|
|
|
163
177
|
- - "~>"
|
|
164
178
|
- !ruby/object:Gem::Version
|
|
165
179
|
version: '2.1'
|
|
180
|
+
- !ruby/object:Gem::Dependency
|
|
181
|
+
name: gem_kit-release
|
|
182
|
+
requirement: !ruby/object:Gem::Requirement
|
|
183
|
+
requirements:
|
|
184
|
+
- - "~>"
|
|
185
|
+
- !ruby/object:Gem::Version
|
|
186
|
+
version: '0.1'
|
|
187
|
+
type: :development
|
|
188
|
+
prerelease: false
|
|
189
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
190
|
+
requirements:
|
|
191
|
+
- - "~>"
|
|
192
|
+
- !ruby/object:Gem::Version
|
|
193
|
+
version: '0.1'
|
|
166
194
|
description: Production-grade coding agent with tool execution, middleware pipeline,
|
|
167
195
|
context compaction, session persistence, and multi-provider LLM support.
|
|
168
196
|
executables: []
|
|
@@ -170,6 +198,10 @@ extensions: []
|
|
|
170
198
|
extra_rdoc_files: []
|
|
171
199
|
files:
|
|
172
200
|
- lib/brute.rb
|
|
201
|
+
- lib/brute/changelog.rb
|
|
202
|
+
- lib/brute/completion/open_router.rb
|
|
203
|
+
- lib/brute/contrib/log_file.rb
|
|
204
|
+
- lib/brute/deprecate.rb
|
|
173
205
|
- lib/brute/events/handler.rb
|
|
174
206
|
- lib/brute/events/prefixed_terminal_output.rb
|
|
175
207
|
- lib/brute/events/terminal_output_handler.rb
|