brute 3.2.2 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/brute/changelog.rb +322 -0
- data/lib/brute/completion/open_router.rb +116 -0
- data/lib/brute/contrib/log_file.rb +173 -0
- data/lib/brute/deprecate.rb +325 -0
- data/lib/brute/hooks.rb +77 -0
- data/lib/brute/middleware/025_skills.rb +91 -0
- data/lib/brute/middleware/070_tool_pipeline.rb +110 -13
- data/lib/brute/middleware/open_router.rb +46 -28
- data/lib/brute/prompt_template.rb +125 -0
- data/lib/brute/prompts/base.rb +105 -0
- data/lib/brute/prompts/skills.rb +51 -13
- data/lib/brute/prompts/text/skills/default.erb +14 -0
- data/lib/brute/skill.rb +211 -80
- data/lib/brute/turn/agent_pipeline.rb +43 -1
- data/lib/brute/turn/pipeline.rb +15 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute/version.rb.erb +5 -0
- data/lib/brute.rb +16 -0
- metadata +38 -1
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
|