brute 4.0.0 → 4.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 65f8fa383e8c2d5793cc4ec00023ee6d2117e75535a81676617232b94748f154
4
- data.tar.gz: 88f3794d39b79c3c7c03c96f54383fa8647850be1cfc77ae8877fb6ea42aa365
3
+ metadata.gz: 6016495cc66cb0d8c5b7b33aaef14eee694960355bb2d772c2d75b1aaacca844
4
+ data.tar.gz: 137446fd33a37736993e2d067d75169d8ca1ce83c109511054b73e768550fda5
5
5
  SHA512:
6
- metadata.gz: 57b849d89d406b624fe26dcbc93aa84af2f9b90b12160fb57608938ec53fd7109c87aa3d2c1357620c78f88b9702f797ff106432840a5e4ccaca343f49e7035a
7
- data.tar.gz: d73709348a3f205f034389e86950a5ce171d0fd0bf62a9c9e84ad587d3ac322aad211b94d059a93b9b19e16a0c1b6211baebd7f274b88435c1cf61a9a6373007
6
+ metadata.gz: 4098f01a58d7d6946fb4f362aab7d37db86cb1f6e77def51f96e08b86eddbaa8c5462e8a613d9e74ff84731baf5ed65076abd010c2ab854b5e838279b3801ee3
7
+ data.tar.gz: 25c4986d4beed8103eb6c0f527ad737b98de3f7c3d1e562c83f956afcc3b3267eb5c6cb669ac488cf9ffdf97c6a446888f9363a2d01bd6f3913701d7cc46c313
@@ -0,0 +1,322 @@
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
@@ -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,132 @@
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,46 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "gem_kit"
4
+
5
+ require_relative "../completion/open_router"
6
+
3
7
  module Brute
4
8
  module Middleware
5
9
  module OpenRouter
6
- class Completion
7
- # config: keyword arguments for OpenRouter::Client.new
8
- # (access_token:, request_timeout:, uri_base:, extra_headers:).
9
- # Defaults to OpenRouter.configuration's global settings.
10
- # options: keyword arguments for OpenRouter::CompletionOptions.new
11
- # (model:, temperature:, tools:, ...).
12
- def initialize(app, config: {}, **options)
13
- @app = app
14
- @config = config
15
- @options = ::OpenRouter::CompletionOptions.new(**options)
16
- end
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
10
+ # Deprecated. Completion middlewares now live under Brute::Completion,
11
+ # which names them for what they do (call one provider) rather than for
12
+ # where they happened to sit in the stack:
13
+ #
14
+ # Brute::Middleware::OpenRouter::Completion -> Brute::Completion::OpenRouter
15
+ #
16
+ # The old name stays a working subclass of the new one until the deadline
17
+ # below; see DEPRECATIONS.md and `gem kit deprecations`.
18
+ class Completion < Brute::Completion::OpenRouter
19
+ extend GemKit::Deprecate
20
+ superseded_by "Brute::Completion::OpenRouter", "5.0"
44
21
  end
45
22
  end
46
23
  end
@@ -49,59 +26,31 @@ end
49
26
  __END__
50
27
 
51
28
  describe "brute/middleware/open_router" do
52
- require "brute/messages"
29
+ require "brute/completion/open_router"
53
30
 
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
31
+ it "is the new Completion class under the old name" do
32
+ Brute::Middleware::OpenRouter::Completion.superclass.should == Brute::Completion::OpenRouter
63
33
  end
64
34
 
65
- FakeUsageResponse = Struct.new(:usage) do
66
- def choices
67
- [{ "message" => { "role" => "assistant", "content" => "hello" } }]
68
- end
69
- end
70
-
71
- it "records the provider usage into env metadata and appends the message" do
72
- response = FakeUsageResponse.new({ "prompt_tokens" => 10, "completion_tokens" => 5, "total_tokens" => 15 })
73
- fake_client = Object.new
74
- fake_client.define_singleton_method(:complete) { |_messages, _options| response }
75
- original = OpenRouter::Client.method(:new)
76
- OpenRouter::Client.define_singleton_method(:new) { |**_config| fake_client }
35
+ it "warns on use, naming the replacement and the removal version" do
36
+ captured = []
37
+ original = GemKit::Deprecate.method(:warn)
38
+ GemKit::Deprecate.define_singleton_method(:warn) { |message| captured << message }
77
39
  begin
78
- middleware = Brute::Middleware::OpenRouter::Completion.new(->(env) { env })
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
40
+ Brute::Middleware::OpenRouter::Completion.new(->(env) { env })
85
41
  ensure
86
- OpenRouter::Client.define_singleton_method(:new, original)
42
+ GemKit::Deprecate.define_singleton_method(:warn, original)
87
43
  end
88
- end
89
44
 
90
- it "leaves metadata alone when the response has no usage" do
91
- response = FakeUsageResponse.new(nil)
92
- fake_client = Object.new
93
- fake_client.define_singleton_method(:complete) { |_messages, _options| response }
94
- original = OpenRouter::Client.method(:new)
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)
45
+ captured.size.should == 1
46
+ captured.first.should.match(/Brute::Middleware::OpenRouter::Completion is deprecated/)
47
+ captured.first.should.match(/use Brute::Completion::OpenRouter instead/)
48
+ captured.first.should.match(/removed in 5\.0/)
49
+ end
101
50
 
102
- env.key?(:metadata).should.be.false
103
- ensure
104
- OpenRouter::Client.define_singleton_method(:new, original)
105
- end
51
+ it "is registered with its removal deadline" do
52
+ entry = GemKit::Deprecate.registry.find { |e| e.name == "Brute::Middleware::OpenRouter::Completion" }
53
+ entry.should.not.be.nil
54
+ entry.removed_in.should == Gem::Version.new("5.0")
106
55
  end
107
56
  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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brute
4
- VERSION = "4.0.0"
4
+ VERSION = "4.2.0"
5
5
  end
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.0.0
4
+ version: 4.2.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
@@ -121,6 +135,20 @@ dependencies:
121
135
  - - "~>"
122
136
  - !ruby/object:Gem::Version
123
137
  version: '4.34'
138
+ - !ruby/object:Gem::Dependency
139
+ name: gem_kit
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '0.2'
145
+ type: :runtime
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '0.2'
124
152
  - !ruby/object:Gem::Dependency
125
153
  name: rake
126
154
  requirement: !ruby/object:Gem::Requirement
@@ -163,6 +191,20 @@ dependencies:
163
191
  - - "~>"
164
192
  - !ruby/object:Gem::Version
165
193
  version: '2.1'
194
+ - !ruby/object:Gem::Dependency
195
+ name: gem_kit-release
196
+ requirement: !ruby/object:Gem::Requirement
197
+ requirements:
198
+ - - "~>"
199
+ - !ruby/object:Gem::Version
200
+ version: '0.2'
201
+ type: :development
202
+ prerelease: false
203
+ version_requirements: !ruby/object:Gem::Requirement
204
+ requirements:
205
+ - - "~>"
206
+ - !ruby/object:Gem::Version
207
+ version: '0.2'
166
208
  description: Production-grade coding agent with tool execution, middleware pipeline,
167
209
  context compaction, session persistence, and multi-provider LLM support.
168
210
  executables: []
@@ -170,6 +212,10 @@ extensions: []
170
212
  extra_rdoc_files: []
171
213
  files:
172
214
  - lib/brute.rb
215
+ - lib/brute/changelog.rb
216
+ - lib/brute/completion/open_router.rb
217
+ - lib/brute/contrib/log_file.rb
218
+ - lib/brute/deprecate.rb
173
219
  - lib/brute/events/handler.rb
174
220
  - lib/brute/events/prefixed_terminal_output.rb
175
221
  - lib/brute/events/terminal_output_handler.rb