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 +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 +132 -0
- data/lib/brute/middleware/open_router.rb +33 -84
- data/lib/brute/turn/agent_pipeline.rb +16 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +16 -0
- metadata +47 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6016495cc66cb0d8c5b7b33aaef14eee694960355bb2d772c2d75b1aaacca844
|
|
4
|
+
data.tar.gz: 137446fd33a37736993e2d067d75169d8ca1ce83c109511054b73e768550fda5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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/
|
|
29
|
+
require "brute/completion/open_router"
|
|
53
30
|
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
+
GemKit::Deprecate.define_singleton_method(:warn, original)
|
|
87
43
|
end
|
|
88
|
-
end
|
|
89
44
|
|
|
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)
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
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.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
|