brute 3.2.1 → 4.0.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/hooks.rb +77 -0
- data/lib/brute/message_transport/open_router.rb +77 -3
- 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 +79 -4
- 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 +27 -1
- data/lib/brute/turn/pipeline.rb +15 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute/version.rb.erb +5 -0
- metadata +6 -1
data/lib/brute/prompts/base.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
3
5
|
require "bundler/setup"
|
|
4
6
|
require "brute"
|
|
5
7
|
|
|
@@ -22,5 +24,108 @@ module Brute
|
|
|
22
24
|
path = File.join(TEXT_DIR, "agents", "#{name}.txt")
|
|
23
25
|
File.exist?(path) ? File.read(path) : nil
|
|
24
26
|
end
|
|
27
|
+
|
|
28
|
+
# Template context handed to ERB templates. Context-hash keys become
|
|
29
|
+
# methods (<%= skills %>, <%= cwd %>), plus view helpers like +h+.
|
|
30
|
+
class Context
|
|
31
|
+
def initialize(ctx)
|
|
32
|
+
@ctx = ctx
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def method_missing(name, *args)
|
|
36
|
+
return @ctx[name] if args.empty? && @ctx.key?(name)
|
|
37
|
+
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def respond_to_missing?(name, include_private = false)
|
|
42
|
+
@ctx.key?(name) || super
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# XML-escape a value for prompt markup (& < > " ').
|
|
46
|
+
def escape_xml(value)
|
|
47
|
+
value.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
48
|
+
.gsub('"', """).gsub("'", "'")
|
|
49
|
+
end
|
|
50
|
+
alias h escape_xml
|
|
51
|
+
|
|
52
|
+
def get_binding
|
|
53
|
+
binding
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Compiled templates, keyed by absolute path. Compilation is idempotent,
|
|
58
|
+
# so a racy double-assign under Async is harmless.
|
|
59
|
+
TEMPLATES = {}
|
|
60
|
+
|
|
61
|
+
# Resolve and render text/<section>/<provider>.erb, falling back to
|
|
62
|
+
# default.erb, then to the legacy plain .txt files. Returns nil when the
|
|
63
|
+
# section has no template or text file at all.
|
|
64
|
+
#
|
|
65
|
+
# Templates are ERB: arbitrary Ruby. Only ship templates with the gem or
|
|
66
|
+
# load them from paths you trust.
|
|
67
|
+
def self.render(section, ctx)
|
|
68
|
+
provider = ctx[:provider_name].to_s
|
|
69
|
+
path = [provider, "default"]
|
|
70
|
+
.map { |variant| File.join(TEXT_DIR, section, "#{variant}.erb") }
|
|
71
|
+
.find { |candidate| File.exist?(candidate) }
|
|
72
|
+
return read(section, provider) unless path
|
|
73
|
+
|
|
74
|
+
erb = TEMPLATES[path] ||= ERB.new(File.read(path), trim_mode: "-")
|
|
75
|
+
erb.result(Context.new(ctx).get_binding)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
__END__
|
|
81
|
+
|
|
82
|
+
describe "brute/prompts/base" do
|
|
83
|
+
require "tmpdir"
|
|
84
|
+
require "fileutils"
|
|
85
|
+
|
|
86
|
+
it "interpolates ctx keys as methods" do
|
|
87
|
+
ctx = Brute::Prompts::Context.new(name: "debugging")
|
|
88
|
+
template = ERB.new("<%= name %>")
|
|
89
|
+
template.result(ctx.get_binding).should == "debugging"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "escapes XML via h" do
|
|
93
|
+
ctx = Brute::Prompts::Context.new({})
|
|
94
|
+
ctx.h(%q{a<b>&"c'}).should == "a<b>&"c'"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "renders a section template with provider fallback" do
|
|
98
|
+
section = "base_test_render"
|
|
99
|
+
dir = File.join(Brute::Prompts::TEXT_DIR, section)
|
|
100
|
+
FileUtils.mkdir_p(dir)
|
|
101
|
+
File.write(File.join(dir, "default.erb"), "hello <%= thing %>")
|
|
102
|
+
begin
|
|
103
|
+
Brute::Prompts.render(section, provider_name: "nope", thing: "world").should == "hello world"
|
|
104
|
+
ensure
|
|
105
|
+
FileUtils.remove_entry(dir)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "prefers a provider-specific template over default" do
|
|
110
|
+
section = "base_test_provider"
|
|
111
|
+
dir = File.join(Brute::Prompts::TEXT_DIR, section)
|
|
112
|
+
FileUtils.mkdir_p(dir)
|
|
113
|
+
File.write(File.join(dir, "default.erb"), "default")
|
|
114
|
+
File.write(File.join(dir, "anthropic.erb"), "anthropic")
|
|
115
|
+
begin
|
|
116
|
+
Brute::Prompts.render(section, provider_name: "anthropic").should == "anthropic"
|
|
117
|
+
Brute::Prompts.render(section, provider_name: "openai").should == "default"
|
|
118
|
+
ensure
|
|
119
|
+
FileUtils.remove_entry(dir)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "falls back to a legacy plain text file when no template exists" do
|
|
124
|
+
Brute::Prompts.render("identity", provider_name: "anthropic").should ==
|
|
125
|
+
Brute::Prompts.read("identity", "anthropic")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "returns nil for a section with no template or text file" do
|
|
129
|
+
Brute::Prompts.render("no_such_section_anywhere", provider_name: "x").should.be.nil
|
|
25
130
|
end
|
|
26
131
|
end
|
data/lib/brute/prompts/skills.rb
CHANGED
|
@@ -5,22 +5,20 @@ require "brute"
|
|
|
5
5
|
|
|
6
6
|
module Brute
|
|
7
7
|
module Prompts
|
|
8
|
+
# The <available_skills> system-prompt section.
|
|
9
|
+
#
|
|
10
|
+
# Uses skill objects from ctx[:skills] when present (handed in via
|
|
11
|
+
# Brute::Middleware::Skills -> env[:metadata][:skills]); falls back to
|
|
12
|
+
# scanning from ctx[:cwd] so the default stacks keep working unwired.
|
|
13
|
+
# Skills with disable_model_invocation? are loaded but hidden here.
|
|
14
|
+
# Returns nil when no skills are visible, dropping the section entirely.
|
|
8
15
|
module Skills
|
|
9
16
|
def self.call(ctx)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return nil if
|
|
17
|
+
skills = ctx[:skills] || Brute::Skill.all(cwd: ctx[:cwd] || Dir.pwd)
|
|
18
|
+
visible = skills.reject(&:disable_model_invocation?)
|
|
19
|
+
return nil if visible.empty?
|
|
13
20
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
<<~TXT
|
|
17
|
-
Skills provide specialized instructions and workflows for specific tasks.
|
|
18
|
-
Use the skill tool to load a skill when a task matches its description. The tool
|
|
19
|
-
returns the skill's full instructions plus a base directory whose bundled files
|
|
20
|
-
(scripts, references, assets) you can read or run by relative path.
|
|
21
|
-
|
|
22
|
-
#{listing}
|
|
23
|
-
TXT
|
|
21
|
+
Prompts.render("skills", ctx.merge(skills: visible))
|
|
24
22
|
end
|
|
25
23
|
end
|
|
26
24
|
end
|
|
@@ -31,9 +29,49 @@ __END__
|
|
|
31
29
|
describe "brute/prompts/skills" do
|
|
32
30
|
require "tmpdir"
|
|
33
31
|
|
|
32
|
+
def skill(name, description: "Does things", file_path: "/x/#{name}/SKILL.md", hidden: false)
|
|
33
|
+
Brute::Skill.new(
|
|
34
|
+
name: name, description: description, file_path: file_path,
|
|
35
|
+
disable_model_invocation: hidden,
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
34
39
|
it "returns nil when no skills are found" do
|
|
35
40
|
Dir.mktmpdir do |dir|
|
|
36
41
|
Brute::Prompts::Skills.call(cwd: dir).should.be.nil
|
|
37
42
|
end
|
|
38
43
|
end
|
|
44
|
+
|
|
45
|
+
it "renders skill objects passed through ctx" do
|
|
46
|
+
out = Brute::Prompts::Skills.call(skills: [skill("debugging", description: "Debug things")])
|
|
47
|
+
out.should.include("<name>debugging</name>")
|
|
48
|
+
out.should.include("<description>Debug things</description>")
|
|
49
|
+
out.should.include("<location>/x/debugging/SKILL.md</location>")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "prefers ctx[:skills] over scanning, even when empty" do
|
|
53
|
+
Dir.mktmpdir do |dir|
|
|
54
|
+
skill_dir = File.join(dir, ".brute", "skills", "debugging")
|
|
55
|
+
FileUtils.mkdir_p(skill_dir)
|
|
56
|
+
File.write(File.join(skill_dir, "SKILL.md"), "---\nname: debugging\ndescription: x\n---\n\nBody\n")
|
|
57
|
+
|
|
58
|
+
Brute::Prompts::Skills.call(cwd: dir, skills: []).should.be.nil
|
|
59
|
+
Brute::Prompts::Skills.call(cwd: dir).should.include("debugging")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "hides disable-model-invocation skills from the listing" do
|
|
64
|
+
out = Brute::Prompts::Skills.call(skills: [skill("shown"), skill("hidden", hidden: true)])
|
|
65
|
+
out.should.include("shown")
|
|
66
|
+
out.should.not.include("hidden")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "returns nil when every skill is hidden" do
|
|
70
|
+
Brute::Prompts::Skills.call(skills: [skill("hidden", hidden: true)]).should.be.nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "xml-escapes skill fields" do
|
|
74
|
+
out = Brute::Prompts::Skills.call(skills: [skill("debugging", description: %q{a<b>&"c'})])
|
|
75
|
+
out.should.include("a<b>&"c'")
|
|
76
|
+
end
|
|
39
77
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Skills provide specialized instructions and workflows for specific tasks.
|
|
2
|
+
When a task matches a skill's description, read the file at its location for the
|
|
3
|
+
full instructions. Relative paths in a skill resolve against its directory (the
|
|
4
|
+
parent of its SKILL.md).
|
|
5
|
+
|
|
6
|
+
<available_skills>
|
|
7
|
+
<% skills.each do |skill| -%>
|
|
8
|
+
<skill>
|
|
9
|
+
<name><%= h skill.name %></name>
|
|
10
|
+
<description><%= h skill.description %></description>
|
|
11
|
+
<location><%= h skill.file_path %></location>
|
|
12
|
+
</skill>
|
|
13
|
+
<% end -%>
|
|
14
|
+
</available_skills>
|
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,27 @@ describe "brute/turn/agent_pipeline" do
|
|
|
119
124
|
agent.start("hi")[:messages].last.content.should == "from ru"
|
|
120
125
|
end
|
|
121
126
|
|
|
127
|
+
it ".on chains off the builder and fires turn hooks around the turn" do
|
|
128
|
+
fired = []
|
|
129
|
+
agent = Brute.agent
|
|
130
|
+
.run(->(env) { env[:messages].assistant("done") })
|
|
131
|
+
.on(:turn_start) { |env| fired << [:start, env[:messages].last.content] }
|
|
132
|
+
.on(:turn_end) { |env| fired << [:end, env[:messages].last.content] }
|
|
133
|
+
|
|
134
|
+
env = agent.start("go")
|
|
135
|
+
env[:messages].last.content.should == "done"
|
|
136
|
+
fired.should == [[:start, "go"], [:end, "done"]]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "fires turn_end on error (ensure)" do
|
|
140
|
+
fired = []
|
|
141
|
+
agent = Brute.agent
|
|
142
|
+
.run(->(_env) { raise "boom" })
|
|
143
|
+
.on(:turn_end) { |_env| fired << :end }
|
|
144
|
+
|
|
145
|
+
lambda { agent.start("go") }.should.raise(RuntimeError)
|
|
146
|
+
fired.should == [:end]
|
|
147
|
+
end
|
|
122
148
|
it "use in a ru string wraps the terminal" do
|
|
123
149
|
script = <<~RUBY
|
|
124
150
|
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