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.
@@ -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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
48
+ .gsub('"', "&quot;").gsub("'", "&apos;")
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&lt;b&gt;&amp;&quot;c&apos;"
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
@@ -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
- cwd = ctx[:cwd] || Dir.pwd
11
- skills = Brute::Skill.all(cwd: cwd)
12
- return nil if skills.empty?
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
- listing = Brute::Skill.fmt(skills)
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&lt;b&gt;&amp;&quot;c&apos;")
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
- # Discovers, validates, and loads SKILL.md files from standard directories.
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
- # Skills are scanned from (in order):
22
- # 1. .brute/skills/**/SKILL.md (project-local)
23
- # 2. ~/.config/brute/skills/**/SKILL.md (global)
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
- # Parsing and validation mirror the Agent Skills specification
26
- # (https://agentskills.io/specification) and its reference validator
27
- # (https://github.com/agentskills/agentskills/tree/main/skills-ref). A skill
28
- # whose frontmatter violates a rule is skipped with a stderr warning naming
29
- # the violated rule, never raised.
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
- module Skill
32
- Info = Struct.new(
33
- :name, :description, :location, :content,
34
- :license, :compatibility, :metadata, :allowed_tools,
35
- keyword_init: true,
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
- # Scan all skill directories and return an array of Info structs.
48
- def self.all(cwd: Dir.pwd)
49
- skills = {}
50
-
51
- scan_dirs(cwd).each do |dir|
52
- Dir.glob(File.join(dir, "**", FILENAME)).sort.each do |path|
53
- info = load(path)
54
- next unless info
55
- # First found wins (project-local overrides global)
56
- skills[info.name] ||= info
57
- end
58
- end
59
-
60
- skills.values.sort_by(&:name)
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
- # Get a single skill by name.
64
- def self.get(name, cwd: Dir.pwd)
65
- all(cwd: cwd).detect { |s| s.name == name }
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
- # Format skills as XML for the system prompt.
69
- def self.fmt(skills)
70
- return nil if skills.empty?
69
+ # Back-compat alias (Tools::SkillLoad era).
70
+ def location = file_path
71
71
 
72
- lines = ["<available_skills>"]
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
- Info.new(
97
+ new(
109
98
  name: frontmatter["name"].to_s.strip,
110
99
  description: frontmatter["description"].to_s.strip,
111
- location: path,
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 :scan_dirs, :parse_frontmatter,
204
- :validate, :validate_name, :validate_description, :parse_allowed_tools
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
- info = Brute::Skill.load(path)
226
- info.name.should == "debugging"
227
- info.description.should == "Debug things"
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
- info = Brute::Skill.load(path)
270
- info.name.should == "extra"
271
- info.respond_to?(:tags).should.be.false
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
- info = Brute::Skill.load(path)
282
- info.license.should == "MIT"
283
- info.compatibility.should == "claude"
284
- info.allowed_tools.should == %w[read shell]
285
- info.metadata.should == { "team" => "core" }
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
- env.tap do
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brute
4
- VERSION = "3.2.1"
4
+ VERSION = "4.0.0"
5
5
  end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brute
4
+ VERSION = "<%= version %>"
5
+ end