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
data/lib/brute/skill.rb
CHANGED
|
@@ -6,7 +6,7 @@ require "bundler/setup"
|
|
|
6
6
|
require "brute"
|
|
7
7
|
|
|
8
8
|
module Brute
|
|
9
|
-
#
|
|
9
|
+
# A single skill: metadata plus the address of its SKILL.md on disk.
|
|
10
10
|
#
|
|
11
11
|
# A skill is a directory containing a SKILL.md markdown file with YAML
|
|
12
12
|
# frontmatter:
|
|
@@ -18,71 +18,60 @@ module Brute
|
|
|
18
18
|
#
|
|
19
19
|
# When debugging, follow these steps...
|
|
20
20
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
21
|
+
# The object is a value object — it carries the parsed frontmatter, the
|
|
22
|
+
# body, and the file location, nothing else. Modeled on prime-agent's
|
|
23
|
+
# BaseSkill (packages/coding-agent/src/core/skills.ts).
|
|
24
24
|
#
|
|
25
|
-
#
|
|
26
|
-
# (
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
25
|
+
# Discovery is class-level and caller-side: Skill.all scans (in order)
|
|
26
|
+
# 1. <cwd>/.brute/skills/**/SKILL.md (project-local, :project)
|
|
27
|
+
# 2. ~/.config/brute/skills/**/SKILL.md (global, :user)
|
|
28
|
+
# 3. explicit paths: (dirs or .md files, :path)
|
|
29
|
+
#
|
|
30
|
+
# First found wins on name collisions (with a stderr warning naming winner
|
|
31
|
+
# and loser), and the same file reached twice via symlinks is skipped.
|
|
30
32
|
#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
# Parsing and validation mirror the Agent Skills specification
|
|
34
|
+
# (https://agentskills.io/specification). A skill whose frontmatter violates
|
|
35
|
+
# a rule is skipped with a stderr warning naming the rule — never raised.
|
|
36
|
+
class Skill
|
|
37
|
+
attr_reader :name, :description, :file_path, :base_dir, :content,
|
|
38
|
+
:source, :license, :compatibility, :metadata, :allowed_tools
|
|
37
39
|
|
|
38
40
|
FILENAME = "SKILL.md"
|
|
39
41
|
|
|
40
42
|
# Frontmatter keys permitted by the spec. Anything else is a violation.
|
|
41
|
-
ALLOWED_FIELDS = %w[name description license allowed-tools metadata compatibility].freeze
|
|
43
|
+
ALLOWED_FIELDS = %w[name description license allowed-tools metadata compatibility disable-model-invocation].freeze
|
|
42
44
|
|
|
43
45
|
MAX_NAME_LENGTH = 64
|
|
44
46
|
MAX_DESCRIPTION_LENGTH = 1024
|
|
45
47
|
MAX_COMPATIBILITY_LENGTH = 500
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
49
|
+
def initialize(name:, description:, file_path:, content: nil, source: :path,
|
|
50
|
+
license: nil, compatibility: nil, metadata: nil,
|
|
51
|
+
allowed_tools: nil, disable_model_invocation: false)
|
|
52
|
+
@name = name
|
|
53
|
+
@description = description
|
|
54
|
+
@file_path = file_path
|
|
55
|
+
@base_dir = File.dirname(file_path)
|
|
56
|
+
@content = content
|
|
57
|
+
@source = source
|
|
58
|
+
@license = license
|
|
59
|
+
@compatibility = compatibility
|
|
60
|
+
@metadata = metadata
|
|
61
|
+
@allowed_tools = allowed_tools
|
|
62
|
+
@disable_model_invocation = disable_model_invocation
|
|
61
63
|
end
|
|
62
64
|
|
|
63
|
-
#
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
end
|
|
65
|
+
# Hidden from the prompt listing (explicit invocation only), but still
|
|
66
|
+
# handed to the agent as an object.
|
|
67
|
+
def disable_model_invocation? = @disable_model_invocation
|
|
67
68
|
|
|
68
|
-
#
|
|
69
|
-
def
|
|
70
|
-
return nil if skills.empty?
|
|
69
|
+
# Back-compat alias (Tools::SkillLoad era).
|
|
70
|
+
def location = file_path
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
skills.each do |skill|
|
|
74
|
-
lines << " <skill>"
|
|
75
|
-
lines << " <name>#{skill.name}</name>"
|
|
76
|
-
lines << " <description>#{skill.description}</description>"
|
|
77
|
-
lines << " </skill>"
|
|
78
|
-
end
|
|
79
|
-
lines << "</available_skills>"
|
|
80
|
-
lines.join("\n")
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# Parse and validate a SKILL.md file into an Info struct.
|
|
72
|
+
# Parse and validate a SKILL.md file into a Skill.
|
|
84
73
|
# Returns nil (with a stderr warning) if the file is invalid.
|
|
85
|
-
def self.load(path)
|
|
74
|
+
def self.load(path, source: :path)
|
|
86
75
|
raw = File.read(path)
|
|
87
76
|
frontmatter, content = parse_frontmatter(path, raw)
|
|
88
77
|
return nil unless frontmatter
|
|
@@ -105,21 +94,86 @@ module Brute
|
|
|
105
94
|
return nil
|
|
106
95
|
end
|
|
107
96
|
|
|
108
|
-
|
|
97
|
+
new(
|
|
109
98
|
name: frontmatter["name"].to_s.strip,
|
|
110
99
|
description: frontmatter["description"].to_s.strip,
|
|
111
|
-
|
|
100
|
+
file_path: path,
|
|
112
101
|
content: content.to_s.strip,
|
|
102
|
+
source: source,
|
|
113
103
|
license: frontmatter["license"]&.to_s,
|
|
114
104
|
compatibility: frontmatter["compatibility"]&.to_s,
|
|
115
105
|
metadata: frontmatter["metadata"],
|
|
116
106
|
allowed_tools: parse_allowed_tools(frontmatter["allowed-tools"]),
|
|
107
|
+
disable_model_invocation: frontmatter["disable-model-invocation"] == true,
|
|
117
108
|
)
|
|
118
109
|
rescue => e
|
|
119
110
|
warn "Failed to load skill #{path}: #{e.message}"
|
|
120
111
|
nil
|
|
121
112
|
end
|
|
122
113
|
|
|
114
|
+
# Scan all skill directories and return an array of Skills, sorted by name.
|
|
115
|
+
#
|
|
116
|
+
# Precedence is first-found-wins: project-local overrides global overrides
|
|
117
|
+
# explicit paths. Name collisions warn to stderr naming winner and loser;
|
|
118
|
+
# the same file reached via different symlinks is loaded only once.
|
|
119
|
+
def self.all(cwd: Dir.pwd, paths: [])
|
|
120
|
+
skills = {}
|
|
121
|
+
seen_files = {}
|
|
122
|
+
|
|
123
|
+
add = lambda do |path, source|
|
|
124
|
+
skill = load(path, source: source)
|
|
125
|
+
return unless skill
|
|
126
|
+
|
|
127
|
+
real = realpath(path)
|
|
128
|
+
return if seen_files[real]
|
|
129
|
+
|
|
130
|
+
if (winner = skills[skill.name])
|
|
131
|
+
warn "Skill name collision: '#{skill.name}' at #{path} ignored; " \
|
|
132
|
+
"already loaded from #{winner.file_path}"
|
|
133
|
+
return
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
seen_files[real] = true
|
|
137
|
+
skills[skill.name] = skill
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
project = File.join(cwd, ".brute", "skills")
|
|
141
|
+
glob(project) { |path| add.call(path, :project) }
|
|
142
|
+
|
|
143
|
+
global = File.join(Dir.home, ".config", "brute", "skills")
|
|
144
|
+
glob(global) { |path| add.call(path, :user) }
|
|
145
|
+
|
|
146
|
+
paths.each do |raw|
|
|
147
|
+
path = File.expand_path(raw.to_s.sub(/\A~(?=\/|\z)/, Dir.home))
|
|
148
|
+
if File.directory?(path)
|
|
149
|
+
glob(path) { |p| add.call(p, :path) }
|
|
150
|
+
elsif File.file?(path) && path.end_with?(".md")
|
|
151
|
+
add.call(path, :path)
|
|
152
|
+
else
|
|
153
|
+
warn "Skill path #{path} is not a directory or markdown file (ignored)"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
skills.values.sort_by(&:name)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Get a single skill by name through the same scan as .all.
|
|
161
|
+
def self.get(name, cwd: Dir.pwd, paths: [])
|
|
162
|
+
all(cwd: cwd, paths: paths).detect { |s| s.name == name }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def self.glob(dir, &block)
|
|
166
|
+
return unless File.directory?(dir)
|
|
167
|
+
|
|
168
|
+
Dir.glob(File.join(dir, "**", FILENAME)).sort.each(&block)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def self.realpath(path)
|
|
172
|
+
File.realpath(path)
|
|
173
|
+
rescue SystemCallError
|
|
174
|
+
path
|
|
175
|
+
end
|
|
176
|
+
|
|
123
177
|
# Validate frontmatter against the spec. Returns an array of error strings
|
|
124
178
|
# (empty means valid), each naming the violated rule.
|
|
125
179
|
def self.validate(frontmatter, dir_name)
|
|
@@ -168,21 +222,6 @@ module Brute
|
|
|
168
222
|
value.to_s.split(/\s+/).reject(&:empty?)
|
|
169
223
|
end
|
|
170
224
|
|
|
171
|
-
# Directories to scan for skills, in priority order.
|
|
172
|
-
def self.scan_dirs(cwd)
|
|
173
|
-
dirs = []
|
|
174
|
-
|
|
175
|
-
# Project-local
|
|
176
|
-
project = File.join(cwd, ".brute", "skills")
|
|
177
|
-
dirs << project if File.directory?(project)
|
|
178
|
-
|
|
179
|
-
# Global
|
|
180
|
-
global = File.join(Dir.home, ".config", "brute", "skills")
|
|
181
|
-
dirs << global if File.directory?(global)
|
|
182
|
-
|
|
183
|
-
dirs
|
|
184
|
-
end
|
|
185
|
-
|
|
186
225
|
# Split YAML frontmatter from markdown body.
|
|
187
226
|
# Returns [hash, string] or [nil, nil].
|
|
188
227
|
def self.parse_frontmatter(path, raw)
|
|
@@ -200,8 +239,8 @@ module Brute
|
|
|
200
239
|
[nil, nil]
|
|
201
240
|
end
|
|
202
241
|
|
|
203
|
-
private_class_method :
|
|
204
|
-
:
|
|
242
|
+
private_class_method :glob, :realpath, :validate, :validate_name,
|
|
243
|
+
:validate_description, :parse_allowed_tools, :parse_frontmatter
|
|
205
244
|
end
|
|
206
245
|
end
|
|
207
246
|
|
|
@@ -222,9 +261,19 @@ describe "brute/skill" do
|
|
|
222
261
|
it "loads a valid skill" do
|
|
223
262
|
Dir.mktmpdir do |root|
|
|
224
263
|
path = make_skill_dir(root, "debugging", "name: debugging\ndescription: Debug things")
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
264
|
+
skill = Brute::Skill.load(path)
|
|
265
|
+
skill.name.should == "debugging"
|
|
266
|
+
skill.description.should == "Debug things"
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it "exposes file_path, base_dir, and a location alias" do
|
|
271
|
+
Dir.mktmpdir do |root|
|
|
272
|
+
path = make_skill_dir(root, "debugging", "name: debugging\ndescription: x")
|
|
273
|
+
skill = Brute::Skill.load(path)
|
|
274
|
+
skill.file_path.should == path
|
|
275
|
+
skill.base_dir.should == File.dirname(path)
|
|
276
|
+
skill.location.should == path
|
|
228
277
|
end
|
|
229
278
|
end
|
|
230
279
|
|
|
@@ -266,9 +315,9 @@ describe "brute/skill" do
|
|
|
266
315
|
it "loads a skill with an unexpected frontmatter field, dropping the extra" do
|
|
267
316
|
Dir.mktmpdir do |root|
|
|
268
317
|
path = make_skill_dir(root, "extra", "name: extra\ndescription: x\ntags: [a, b]")
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
318
|
+
skill = Brute::Skill.load(path)
|
|
319
|
+
skill.name.should == "extra"
|
|
320
|
+
skill.respond_to?(:tags).should.be.false
|
|
272
321
|
end
|
|
273
322
|
end
|
|
274
323
|
|
|
@@ -278,11 +327,25 @@ describe "brute/skill" do
|
|
|
278
327
|
root, "full",
|
|
279
328
|
"name: full\ndescription: x\nlicense: MIT\ncompatibility: claude\nallowed-tools: read shell\nmetadata:\n team: core",
|
|
280
329
|
)
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
330
|
+
skill = Brute::Skill.load(path)
|
|
331
|
+
skill.license.should == "MIT"
|
|
332
|
+
skill.compatibility.should == "claude"
|
|
333
|
+
skill.allowed_tools.should == %w[read shell]
|
|
334
|
+
skill.metadata.should == { "team" => "core" }
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
it "parses disable-model-invocation" do
|
|
339
|
+
Dir.mktmpdir do |root|
|
|
340
|
+
path = make_skill_dir(root, "hidden", "name: hidden\ndescription: x\ndisable-model-invocation: true")
|
|
341
|
+
Brute::Skill.load(path).disable_model_invocation?.should.be.true
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
it "defaults disable_model_invocation to false" do
|
|
346
|
+
Dir.mktmpdir do |root|
|
|
347
|
+
path = make_skill_dir(root, "shown", "name: shown\ndescription: x")
|
|
348
|
+
Brute::Skill.load(path).disable_model_invocation?.should.be.false
|
|
286
349
|
end
|
|
287
350
|
end
|
|
288
351
|
|
|
@@ -292,4 +355,72 @@ describe "brute/skill" do
|
|
|
292
355
|
Brute::Skill.load(path).should.be.nil
|
|
293
356
|
end
|
|
294
357
|
end
|
|
358
|
+
|
|
359
|
+
it "tags skills with their source" do
|
|
360
|
+
Dir.mktmpdir do |root|
|
|
361
|
+
make_skill_dir(root, "debugging", "name: debugging\ndescription: x")
|
|
362
|
+
Brute::Skill.all(cwd: root).first.source.should == :project
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def with_home(dir)
|
|
367
|
+
old = ENV["HOME"]
|
|
368
|
+
ENV["HOME"] = dir
|
|
369
|
+
yield
|
|
370
|
+
ensure
|
|
371
|
+
ENV["HOME"] = old
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
it "project-local skills override same-named global ones" do
|
|
375
|
+
Dir.mktmpdir do |project|
|
|
376
|
+
Dir.mktmpdir do |home|
|
|
377
|
+
make_skill_dir(project, "shared", "name: shared\ndescription: project variant")
|
|
378
|
+
global_dir = File.join(home, ".config", "brute", "skills", "shared")
|
|
379
|
+
FileUtils.mkdir_p(global_dir)
|
|
380
|
+
File.write(File.join(global_dir, "SKILL.md"), "---\nname: shared\ndescription: global variant\n---\n\nBody\n")
|
|
381
|
+
|
|
382
|
+
with_home(home) do
|
|
383
|
+
skills = Brute::Skill.all(cwd: project)
|
|
384
|
+
skills.size.should == 1
|
|
385
|
+
skills.first.description.should == "project variant"
|
|
386
|
+
skills.first.source.should == :project
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
it "loads skills from explicit paths" do
|
|
393
|
+
Dir.mktmpdir do |root|
|
|
394
|
+
dir = File.join(root, "elsewhere", "custom")
|
|
395
|
+
FileUtils.mkdir_p(dir)
|
|
396
|
+
File.write(File.join(dir, "SKILL.md"), "---\nname: custom\ndescription: explicit\n---\n\nBody\n")
|
|
397
|
+
|
|
398
|
+
skills = Brute::Skill.all(cwd: root, paths: [File.join(root, "elsewhere")])
|
|
399
|
+
skills.map(&:name).should == ["custom"]
|
|
400
|
+
skills.first.source.should == :path
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
it "expands ~ in explicit paths" do
|
|
405
|
+
Dir.mktmpdir do |home|
|
|
406
|
+
dir = File.join(home, "skills", "homey")
|
|
407
|
+
FileUtils.mkdir_p(dir)
|
|
408
|
+
File.write(File.join(dir, "SKILL.md"), "---\nname: homey\ndescription: x\n---\n\nBody\n")
|
|
409
|
+
|
|
410
|
+
with_home(home) do
|
|
411
|
+
skills = Brute::Skill.all(cwd: home, paths: ["~/skills"])
|
|
412
|
+
skills.map(&:name).should.include("homey")
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
it "loads the same file only once when reached via a symlink" do
|
|
418
|
+
Dir.mktmpdir do |root|
|
|
419
|
+
make_skill_dir(root, "debugging", "name: debugging\ndescription: x")
|
|
420
|
+
link = File.join(root, ".brute", "skills", "linked")
|
|
421
|
+
File.symlink(File.join(root, ".brute", "skills", "debugging"), link)
|
|
422
|
+
|
|
423
|
+
Brute::Skill.all(cwd: root).size.should == 1
|
|
424
|
+
end
|
|
425
|
+
end
|
|
295
426
|
end
|
|
@@ -45,10 +45,15 @@ module Brute
|
|
|
45
45
|
events: events,
|
|
46
46
|
metadata: {},
|
|
47
47
|
current_iteration: 1,
|
|
48
|
+
hooks: hooks,
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
+
hooks.emit(:turn_start, env)
|
|
51
|
+
begin
|
|
50
52
|
build.call(env)
|
|
53
|
+
ensure
|
|
54
|
+
hooks.emit(:turn_end, env)
|
|
51
55
|
end
|
|
56
|
+
env
|
|
52
57
|
end
|
|
53
58
|
|
|
54
59
|
private
|
|
@@ -119,6 +124,43 @@ describe "brute/turn/agent_pipeline" do
|
|
|
119
124
|
agent.start("hi")[:messages].last.content.should == "from ru"
|
|
120
125
|
end
|
|
121
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
|
+
|
|
143
|
+
it ".on chains off the builder and fires turn hooks around the turn" do
|
|
144
|
+
fired = []
|
|
145
|
+
agent = Brute.agent
|
|
146
|
+
.run(->(env) { env[:messages].assistant("done") })
|
|
147
|
+
.on(:turn_start) { |env| fired << [:start, env[:messages].last.content] }
|
|
148
|
+
.on(:turn_end) { |env| fired << [:end, env[:messages].last.content] }
|
|
149
|
+
|
|
150
|
+
env = agent.start("go")
|
|
151
|
+
env[:messages].last.content.should == "done"
|
|
152
|
+
fired.should == [[:start, "go"], [:end, "done"]]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it "fires turn_end on error (ensure)" do
|
|
156
|
+
fired = []
|
|
157
|
+
agent = Brute.agent
|
|
158
|
+
.run(->(_env) { raise "boom" })
|
|
159
|
+
.on(:turn_end) { |_env| fired << :end }
|
|
160
|
+
|
|
161
|
+
lambda { agent.start("go") }.should.raise(RuntimeError)
|
|
162
|
+
fired.should == [:end]
|
|
163
|
+
end
|
|
122
164
|
it "use in a ru string wraps the terminal" do
|
|
123
165
|
script = <<~RUBY
|
|
124
166
|
use AgentStubMW
|
data/lib/brute/turn/pipeline.rb
CHANGED
|
@@ -19,6 +19,16 @@ module Brute
|
|
|
19
19
|
#
|
|
20
20
|
def use(...) = tap { super }
|
|
21
21
|
def run(...) = tap { super }
|
|
22
|
+
|
|
23
|
+
# Subscribe a lifecycle hook (see Brute::Hooks):
|
|
24
|
+
#
|
|
25
|
+
# Brute.agent
|
|
26
|
+
# .use(MaxProfit)
|
|
27
|
+
# .run(->(env) { ... })
|
|
28
|
+
# .on(:before_llm) { |env| ... }
|
|
29
|
+
# .on(:approve_tool) { |call| call[:name] != "exec" }
|
|
30
|
+
#
|
|
31
|
+
def on(...) = tap { hooks.on(...) }
|
|
22
32
|
end
|
|
23
33
|
|
|
24
34
|
include Chainable
|
|
@@ -40,6 +50,11 @@ module Brute
|
|
|
40
50
|
# when `run` was never called.
|
|
41
51
|
alias_method :build, :to_app
|
|
42
52
|
|
|
53
|
+
# The lifecycle-hook registry for this pipeline (see Brute::Hooks).
|
|
54
|
+
def hooks
|
|
55
|
+
@hooks ||= Brute::Hooks.new
|
|
56
|
+
end
|
|
57
|
+
|
|
43
58
|
# Default null sink for env[:events] — swallows anything pushed to it.
|
|
44
59
|
class NullSink
|
|
45
60
|
def <<(_event); self; end
|
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
|
+
version: 4.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brute Contributors
|
|
@@ -37,6 +37,20 @@ dependencies:
|
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '1.5'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: file-tail
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.2'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.2'
|
|
40
54
|
- !ruby/object:Gem::Dependency
|
|
41
55
|
name: activesupport
|
|
42
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -163,6 +177,20 @@ dependencies:
|
|
|
163
177
|
- - "~>"
|
|
164
178
|
- !ruby/object:Gem::Version
|
|
165
179
|
version: '2.1'
|
|
180
|
+
- !ruby/object:Gem::Dependency
|
|
181
|
+
name: gem_kit-release
|
|
182
|
+
requirement: !ruby/object:Gem::Requirement
|
|
183
|
+
requirements:
|
|
184
|
+
- - "~>"
|
|
185
|
+
- !ruby/object:Gem::Version
|
|
186
|
+
version: '0.1'
|
|
187
|
+
type: :development
|
|
188
|
+
prerelease: false
|
|
189
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
190
|
+
requirements:
|
|
191
|
+
- - "~>"
|
|
192
|
+
- !ruby/object:Gem::Version
|
|
193
|
+
version: '0.1'
|
|
166
194
|
description: Production-grade coding agent with tool execution, middleware pipeline,
|
|
167
195
|
context compaction, session persistence, and multi-provider LLM support.
|
|
168
196
|
executables: []
|
|
@@ -170,9 +198,14 @@ extensions: []
|
|
|
170
198
|
extra_rdoc_files: []
|
|
171
199
|
files:
|
|
172
200
|
- lib/brute.rb
|
|
201
|
+
- lib/brute/changelog.rb
|
|
202
|
+
- lib/brute/completion/open_router.rb
|
|
203
|
+
- lib/brute/contrib/log_file.rb
|
|
204
|
+
- lib/brute/deprecate.rb
|
|
173
205
|
- lib/brute/events/handler.rb
|
|
174
206
|
- lib/brute/events/prefixed_terminal_output.rb
|
|
175
207
|
- lib/brute/events/terminal_output_handler.rb
|
|
208
|
+
- lib/brute/hooks.rb
|
|
176
209
|
- lib/brute/message_transport.rb
|
|
177
210
|
- lib/brute/message_transport/anthropic.rb
|
|
178
211
|
- lib/brute/message_transport/llm.rb
|
|
@@ -190,6 +223,7 @@ files:
|
|
|
190
223
|
- lib/brute/middleware/010_max_iterations.rb
|
|
191
224
|
- lib/brute/middleware/015_otel_token_usage.rb
|
|
192
225
|
- lib/brute/middleware/020_system_prompt.rb
|
|
226
|
+
- lib/brute/middleware/025_skills.rb
|
|
193
227
|
- lib/brute/middleware/040_compaction_check.rb
|
|
194
228
|
- lib/brute/middleware/060_questions.rb
|
|
195
229
|
- lib/brute/middleware/070_tool_pipeline.rb
|
|
@@ -198,6 +232,7 @@ files:
|
|
|
198
232
|
- lib/brute/middleware/event_handler.rb
|
|
199
233
|
- lib/brute/middleware/open_router.rb
|
|
200
234
|
- lib/brute/middleware/user_queue.rb
|
|
235
|
+
- lib/brute/prompt_template.rb
|
|
201
236
|
- lib/brute/prompts.rb
|
|
202
237
|
- lib/brute/prompts/autonomy.rb
|
|
203
238
|
- lib/brute/prompts/base.rb
|
|
@@ -231,6 +266,7 @@ files:
|
|
|
231
266
|
- lib/brute/prompts/text/identity/default.txt
|
|
232
267
|
- lib/brute/prompts/text/identity/google.txt
|
|
233
268
|
- lib/brute/prompts/text/identity/openai.txt
|
|
269
|
+
- lib/brute/prompts/text/skills/default.erb
|
|
234
270
|
- lib/brute/prompts/text/tone_and_style/anthropic.txt
|
|
235
271
|
- lib/brute/prompts/text/tone_and_style/default.txt
|
|
236
272
|
- lib/brute/prompts/text/tone_and_style/google.txt
|
|
@@ -268,6 +304,7 @@ files:
|
|
|
268
304
|
- lib/brute/turn/tool_pipeline.rb
|
|
269
305
|
- lib/brute/utils/diff.rb
|
|
270
306
|
- lib/brute/version.rb
|
|
307
|
+
- lib/brute/version.rb.erb
|
|
271
308
|
- lib/brute_cli/providers/shell.rb
|
|
272
309
|
- lib/brute_cli/providers/shell_response.rb
|
|
273
310
|
homepage: https://github.com/general-intelligence-systems/brute
|