brute 3.2.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd242366b30cf384cb0326b8fa006f43883f1cf117d410b830288164a4d13582
4
- data.tar.gz: 3d4973d9a6fa69e6fd04d464343d8d2d85fb8455236e9d03f32df17888182e1f
3
+ metadata.gz: 65f8fa383e8c2d5793cc4ec00023ee6d2117e75535a81676617232b94748f154
4
+ data.tar.gz: 88f3794d39b79c3c7c03c96f54383fa8647850be1cfc77ae8877fb6ea42aa365
5
5
  SHA512:
6
- metadata.gz: f0659b341410c0cb87db3cefe95e3c8c99bbf5ea9c88ba974d6b8f1adc5835919e256a577435748d652e1e229b30fb6cbaac321d297688fc9fac0812a028e369
7
- data.tar.gz: 5f693c1c49b4bd74ce901e78bcd459f04c6316fe044bb871a04142b09154cb04767a1166dd64d02d2332fb9fac63572a6d13b28fbef40520c18737b1a11f98f4
6
+ metadata.gz: 57b849d89d406b624fe26dcbc93aa84af2f9b90b12160fb57608938ec53fd7109c87aa3d2c1357620c78f88b9702f797ff106432840a5e4ccaca343f49e7035a
7
+ data.tar.gz: d73709348a3f205f034389e86950a5ce171d0fd0bf62a9c9e84ad587d3ac322aad211b94d059a93b9b19e16a0c1b6211baebd7f274b88435c1cf61a9a6373007
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ # Pub/sub registry for agent lifecycle hooks, subscribed on the builder:
8
+ #
9
+ # Brute.agent
10
+ # .use(Brute::Middleware::MaxIterations)
11
+ # .run(->(env) { env[:messages].assistant("done") })
12
+ # .on(:before_llm) { |env| ... }
13
+ # .on(:approve_tool) { |call| call[:name] != "exec" }
14
+ #
15
+ # Emission points and payloads:
16
+ #
17
+ # :turn_start, :turn_end → the turn env (AgentPipeline#start; turn_end
18
+ # fires from an ensure, so it also fires on error)
19
+ # :before_llm, :after_llm → the turn env, around every LLM call
20
+ # :before_tool → call env {name:, arguments:, result:, events:,
21
+ # metadata:, turn_env:} — mutate :arguments to
22
+ # rewrite the call, or set :result (or return a
23
+ # value) to skip execution entirely ("respond")
24
+ # :approve_tool → call env — a false return denies the call; a
25
+ # String return denies it with that message
26
+ # :after_tool → call env — mutate :result
27
+ #
28
+ # Subscribers run inline (tool events may fire from parallel threads).
29
+ # Exceptions propagate to the caller — layers that want fail-open semantics
30
+ # rescue in their own subscriber.
31
+ class Hooks
32
+ EVENTS = %i[turn_start turn_end before_llm after_llm before_tool approve_tool after_tool].freeze
33
+
34
+ def initialize
35
+ @subscribers = Hash.new { |hash, key| hash[key] = [] }
36
+ end
37
+
38
+ def on(event, &block)
39
+ @subscribers[event.to_sym] << block
40
+ self
41
+ end
42
+
43
+ # Fire an event; returns every subscriber's raw result (nils and false
44
+ # included — the deny contract distinguishes them).
45
+ def emit(event, payload)
46
+ @subscribers[event.to_sym].map { |subscriber| subscriber.call(payload) }
47
+ end
48
+
49
+ def any?(event) = @subscribers[event.to_sym].any?
50
+ end
51
+ end
52
+
53
+ __END__
54
+
55
+ describe "brute/hooks" do
56
+ it "emits to subscribers in registration order" do
57
+ hooks = Brute::Hooks.new
58
+ seen = []
59
+ hooks.on(:before_llm) { |p| seen << "a#{p}" }
60
+ hooks.on(:before_llm) { |p| seen << "b#{p}" }
61
+ hooks.emit(:before_llm, 1)
62
+ seen.should == ["a1", "b1"]
63
+ end
64
+
65
+ it "returns raw results, false included (deny contract)" do
66
+ hooks = Brute::Hooks.new
67
+ hooks.on(:approve_tool) { |_call| false }
68
+ hooks.emit(:approve_tool, {}).should == [false]
69
+ end
70
+
71
+ it "answers any? and stays chainable" do
72
+ hooks = Brute::Hooks.new
73
+ hooks.any?(:turn_start).should.be.false
74
+ hooks.on(:turn_start) { nil }.should.equal?(hooks)
75
+ hooks.any?(:turn_start).should.be.true
76
+ end
77
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module Middleware
8
+ # Loads skill objects into the agent context.
9
+ #
10
+ # Skills are handed in as objects — discovery is the caller's job:
11
+ #
12
+ # skills = Brute::Skill.all(cwd: Dir.pwd)
13
+ # agent
14
+ # .use(Brute::Middleware::Skills, skills: skills)
15
+ # .use(Brute::Middleware::SystemPrompt)
16
+ #
17
+ # Per turn:
18
+ # 1. env[:skills] = the objects, for downstream middleware, tools, and
19
+ # the terminal app (prime-agent's resourceLoader.getSkills() analogue)
20
+ # 2. env[:metadata][:skills] = the same objects, so
21
+ # Middleware::SystemPrompt merges them into the prompt ctx and
22
+ # Brute::Prompts::Skills renders the <available_skills> section
23
+ #
24
+ # Place it before Middleware::SystemPrompt in the stack. It never touches
25
+ # env[:messages] itself.
26
+ class Skills
27
+ def initialize(app, skills: [])
28
+ @app = app
29
+ @skills = skills
30
+ end
31
+
32
+ def call(env)
33
+ env[:skills] = @skills
34
+ env[:metadata] ||= {}
35
+ env[:metadata][:skills] ||= @skills
36
+
37
+ @app.call(env)
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ __END__
44
+
45
+ describe "brute/middleware/025_skills" do
46
+ def skill(name)
47
+ Brute::Skill.new(name: name, description: "x", file_path: "/x/#{name}/SKILL.md")
48
+ end
49
+
50
+ def build_middleware(skills: [], &inner)
51
+ Brute::Middleware::Skills.new(inner || ->(env) { env }, skills: skills)
52
+ end
53
+
54
+ it "stashes skill objects in env[:skills]" do
55
+ skills = [skill("debugging")]
56
+ env = { messages: Brute.log, metadata: {} }
57
+
58
+ build_middleware(skills: skills).call(env)
59
+
60
+ env[:skills].should == skills
61
+ end
62
+
63
+ it "mirrors skills into env[:metadata] for the prompt layer" do
64
+ skills = [skill("debugging")]
65
+ env = { messages: Brute.log }
66
+
67
+ build_middleware(skills: skills).call(env)
68
+
69
+ env[:metadata][:skills].should == skills
70
+ end
71
+
72
+ it "does not clobber an explicit metadata[:skills]" do
73
+ explicit = [skill("explicit")]
74
+ env = { messages: Brute.log, metadata: { skills: explicit } }
75
+
76
+ build_middleware(skills: [skill("other")]).call(env)
77
+
78
+ env[:metadata][:skills].should == explicit
79
+ env[:skills].map(&:name).should == ["other"]
80
+ end
81
+
82
+ it "defaults to an empty list and passes control down the chain" do
83
+ called = false
84
+ env = { messages: Brute.log }
85
+
86
+ build_middleware { |e| called = true }.call(env)
87
+
88
+ env[:skills].should == []
89
+ called.should.be.true
90
+ end
91
+ end
@@ -48,23 +48,56 @@ module Brute
48
48
  name = tool_call.name.to_sym
49
49
  args = tool_call.arguments
50
50
 
51
- available_tools[name].call(args).then do |result|
52
-
53
- # Coerce to String so Hash results (e.g. Shell's
54
- # {stdout:, stderr:, exit_code:}) serialize predictably.
55
- if result.is_a?(String)
56
- content = result
57
- else
58
- content = result.to_s
51
+ # Lifecycle hooks (Brute::Hooks): before_tool may rewrite
52
+ # :arguments or short-circuit with a :result; approve_tool
53
+ # denies on a false (or String) return; after_tool may
54
+ # rewrite :result.
55
+ call_env = {
56
+ name: name.to_s,
57
+ arguments: args,
58
+ result: nil,
59
+ events: env[:events],
60
+ metadata: {},
61
+ turn_env: env,
62
+ }
63
+ if (hooks = env[:hooks])
64
+ responses = hooks.emit(:before_tool, call_env).compact
65
+ call_env[:result] = responses.last if call_env[:result].nil? && !responses.empty?
66
+
67
+ if call_env[:result].nil?
68
+ denial = hooks.emit(:approve_tool, call_env).find { |r| r == false || r.is_a?(String) }
69
+ unless denial.nil?
70
+ call_env[:result] = denial.is_a?(String) ? denial : %(Tool call to "#{name}" was denied.)
71
+ end
59
72
  end
73
+ end
60
74
 
61
- # Universal truncation safety net — skip if already truncated
62
- unless Brute::Truncation.already_truncated?(content)
63
- content = Brute::Truncation.truncate(content)
64
- end
75
+ result = if call_env[:result].nil?
76
+ available_tools[name].call(call_env[:arguments])
77
+ else
78
+ call_env[:result]
79
+ end
80
+
81
+ if (hooks = env[:hooks])
82
+ call_env[:result] = result
83
+ hooks.emit(:after_tool, call_env)
84
+ result = call_env[:result]
85
+ end
86
+
87
+ # Coerce to String so Hash results (e.g. Shell's
88
+ # {stdout:, stderr:, exit_code:}) serialize predictably.
89
+ if result.is_a?(String)
90
+ content = result
91
+ else
92
+ content = result.to_s
93
+ end
65
94
 
66
- results << [tool_call, content]
95
+ # Universal truncation safety net — skip if already truncated
96
+ unless Brute::Truncation.already_truncated?(content)
97
+ content = Brute::Truncation.truncate(content)
67
98
  end
99
+
100
+ results << [tool_call, content]
68
101
  rescue => e
69
102
  # Capture the error as a tool result so the LLM can see it
70
103
  # and reason about the failure, rather than crashing the
@@ -141,6 +174,70 @@ describe "brute/middleware/070_tool_pipeline" do
141
174
  seen.should == [tool]
142
175
  end
143
176
 
177
+ # --- lifecycle hooks (Brute::Hooks) ---
178
+
179
+ def hook_env(hooks)
180
+ { messages: Brute.log, events: [], hooks: hooks }
181
+ end
182
+
183
+ it "before_tool may rewrite arguments and short-circuit with a result" do
184
+ tool = { name: "echo", description: "", execute: ->(text:) { "ran:#{text}" } }
185
+ inner = ->(env) do
186
+ env[:messages] << Brute::Message.new(role: :assistant, content: "",
187
+ tool_calls: [{ id: "tc1", name: "echo", arguments: { "text" => "orig" } }])
188
+ end
189
+ hooks = Brute::Hooks.new
190
+ hooks.on(:before_tool) { |call| call[:arguments] = { text: "rewritten" }; nil }
191
+ mw = Brute::Middleware::ToolPipeline.new(inner, tools: [tool])
192
+ env = hook_env(hooks)
193
+ env[:messages].user("hi")
194
+ mw.call(env)
195
+ env[:messages].last.content.should == "ran:rewritten"
196
+
197
+ hooks2 = Brute::Hooks.new
198
+ hooks2.on(:before_tool) { |_call| "canned" }
199
+ mw2 = Brute::Middleware::ToolPipeline.new(inner, tools: [tool])
200
+ env2 = hook_env(hooks2)
201
+ env2[:messages].user("hi")
202
+ mw2.call(env2)
203
+ env2[:messages].last.content.should == "canned" # never executed
204
+ end
205
+
206
+ it "approve_tool denies on false (generic message) or String (custom)" do
207
+ tool = { name: "exec", description: "", execute: ->(**) { "ran" } }
208
+ inner = ->(env) do
209
+ env[:messages] << Brute::Message.new(role: :assistant, content: "",
210
+ tool_calls: [{ id: "tc1", name: "exec", arguments: {} }])
211
+ end
212
+ hooks = Brute::Hooks.new
213
+ hooks.on(:approve_tool) { |_call| false }
214
+ env = hook_env(hooks)
215
+ env[:messages].user("hi")
216
+ Brute::Middleware::ToolPipeline.new(inner, tools: [tool]).call(env)
217
+ env[:messages].last.content.should == %(Tool call to "exec" was denied.)
218
+
219
+ hooks2 = Brute::Hooks.new
220
+ hooks2.on(:approve_tool) { |_call| "denied by policy" }
221
+ env2 = hook_env(hooks2)
222
+ env2[:messages].user("hi")
223
+ Brute::Middleware::ToolPipeline.new(inner, tools: [tool]).call(env2)
224
+ env2[:messages].last.content.should == "denied by policy"
225
+ end
226
+
227
+ it "after_tool may rewrite the result" do
228
+ tool = { name: "echo", description: "", execute: ->(**) { "raw" } }
229
+ inner = ->(env) do
230
+ env[:messages] << Brute::Message.new(role: :assistant, content: "",
231
+ tool_calls: [{ id: "tc1", name: "echo", arguments: {} }])
232
+ end
233
+ hooks = Brute::Hooks.new
234
+ hooks.on(:after_tool) { |call| call[:result] = "rewrote(#{call[:result]})" }
235
+ env = hook_env(hooks)
236
+ env[:messages].user("hi")
237
+ Brute::Middleware::ToolPipeline.new(inner, tools: [tool]).call(env)
238
+ env[:messages].last.content.should == "rewrote(raw)"
239
+ end
240
+
144
241
  # --- Universal output truncation ---
145
242
 
146
243
  it "truncates large tool results via Truncation" do
@@ -16,11 +16,20 @@ module Brute
16
16
  end
17
17
 
18
18
  def call(env)
19
+ env[:hooks]&.emit(:before_llm, env)
20
+
19
21
  messages = Brute::MessageTransport::OpenRouter.dump_all(env[:messages])
20
22
 
21
23
  ::OpenRouter::Client.new(**@config).then do |client|
22
24
  client.complete(messages, @options).then do |response|
23
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
+
24
33
  # OpenRouter in fact only returns a single message...
25
34
  # https://github.com/estiens/open_router_enhanced/blob/main/lib/open_router/response.rb
26
35
  Brute::MessageTransport::OpenRouter.wrap_each(response) do |message|
@@ -29,9 +38,70 @@ module Brute
29
38
  end
30
39
  end
31
40
 
41
+ env[:hooks]&.emit(:after_llm, env)
32
42
  env
33
43
  end
34
44
  end
35
45
  end
36
46
  end
37
47
  end
48
+
49
+ __END__
50
+
51
+ describe "brute/middleware/open_router" do
52
+ require "brute/messages"
53
+
54
+ # The repo suite has no open_router gem; stub the two constants the
55
+ # middleware touches (the transport wraps duck-typed responses fine).
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
63
+ end
64
+
65
+ FakeUsageResponse = Struct.new(:usage) do
66
+ def choices
67
+ [{ "message" => { "role" => "assistant", "content" => "hello" } }]
68
+ end
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 }
77
+ begin
78
+ middleware = Brute::Middleware::OpenRouter::Completion.new(->(env) { env })
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
85
+ ensure
86
+ OpenRouter::Client.define_singleton_method(:new, original)
87
+ end
88
+ end
89
+
90
+ it "leaves metadata alone when the response has no usage" do
91
+ response = FakeUsageResponse.new(nil)
92
+ fake_client = Object.new
93
+ fake_client.define_singleton_method(:complete) { |_messages, _options| response }
94
+ original = OpenRouter::Client.method(:new)
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)
101
+
102
+ env.key?(:metadata).should.be.false
103
+ ensure
104
+ OpenRouter::Client.define_singleton_method(:new, original)
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "erb"
6
+
7
+ module Brute
8
+ # An ERB-backed system-prompt object for Middleware::SystemPrompt — the
9
+ # open alternative to Brute::SystemPrompt's built-in section stacks. You
10
+ # bring a template and named values; every keyword becomes an attr_accessor
11
+ # and an ERB local of the same name:
12
+ #
13
+ # prompt = Brute::PromptTemplate.new(
14
+ # "prompt.erb",
15
+ # identity: "You are Pico.",
16
+ # memory: -> { File.read("memory/MEMORY.md") }, # zero-arity proc
17
+ # env: ->(ctx) { Brute::Prompts::Environment.call(ctx) },
18
+ # )
19
+ # prompt.identity = "You are Brute." # attr_accessor per section
20
+ #
21
+ # Brute.agent.use(Brute::Middleware::SystemPrompt, system_prompt: prompt)
22
+ #
23
+ # Proc values are re-evaluated on every prepare (zero-arity procs are
24
+ # called with no arguments, others receive the turn ctx), and a template
25
+ # path is re-read from disk each time — so file-backed sections hot-reload
26
+ # between turns. The prepare(ctx) -> Result(#empty?, #to_s) contract is
27
+ # what Middleware::SystemPrompt expects.
28
+ class PromptTemplate
29
+ Result = Struct.new(:text) do
30
+ def to_s = text.to_s
31
+ def empty? = text.to_s.strip.empty?
32
+ end
33
+
34
+ def initialize(template, **sections)
35
+ @template = template
36
+ @section_keys = []
37
+ sections.each { |key, value| self[key] = value }
38
+ end
39
+
40
+ def [](key)
41
+ public_send(key)
42
+ end
43
+
44
+ def []=(key, value)
45
+ unless respond_to?(key)
46
+ singleton_class.class_eval { attr_accessor key }
47
+ @section_keys << key
48
+ end
49
+ public_send("#{key}=", value)
50
+ end
51
+
52
+ # Called once per turn by Middleware::SystemPrompt.
53
+ def prepare(ctx = {})
54
+ Result.new(render(locals(ctx)))
55
+ end
56
+
57
+ private
58
+
59
+ def locals(ctx)
60
+ @section_keys.to_h do |key|
61
+ value = self[key]
62
+ resolved = value.is_a?(Proc) ? (value.arity.zero? ? value.call : value.call(ctx)) : value
63
+ [key, resolved]
64
+ end.merge(ctx: ctx)
65
+ end
66
+
67
+ def render(values)
68
+ context = binding
69
+ values.each { |key, value| context.local_variable_set(key, value) }
70
+ ERB.new(template_source, trim_mode: "-").result(context)
71
+ end
72
+
73
+ # A path that exists is re-read every time; anything else is treated as
74
+ # an inline ERB source string.
75
+ def template_source
76
+ File.exist?(@template.to_s) ? File.read(@template) : @template.to_s
77
+ end
78
+ end
79
+ end
80
+
81
+ __END__
82
+
83
+ describe "brute/prompt_template" do
84
+ require "tmpdir"
85
+
86
+ it "renders keyword sections as ERB locals" do
87
+ prompt = Brute::PromptTemplate.new("Hello <%= name %>, <%= mood %> today.", name: "Pico", mood: "happy")
88
+ prompt.prepare.to_s.should == "Hello Pico, happy today."
89
+ end
90
+
91
+ it "exposes an attr_accessor per section" do
92
+ prompt = Brute::PromptTemplate.new("<%= name %>", name: "Pico")
93
+ prompt.name.should == "Pico"
94
+ prompt.name = "Brute"
95
+ prompt.prepare.to_s.should == "Brute"
96
+ end
97
+
98
+ it "re-evaluates zero-arity procs on every prepare" do
99
+ count = 0
100
+ prompt = Brute::PromptTemplate.new("<%= tick %>", tick: -> { count += 1 })
101
+ prompt.prepare.to_s.should == "1"
102
+ prompt.prepare.to_s.should == "2"
103
+ end
104
+
105
+ it "passes ctx to procs that take an argument" do
106
+ prompt = Brute::PromptTemplate.new("<%= who %>", who: ->(ctx) { ctx[:agent] })
107
+ prompt.prepare(agent: "pico").to_s.should == "pico"
108
+ end
109
+
110
+ it "re-reads a template file on every prepare" do
111
+ Dir.mktmpdir do |dir|
112
+ path = File.join(dir, "prompt.erb")
113
+ File.write(path, "v1 <%= x %>")
114
+ prompt = Brute::PromptTemplate.new(path, x: "a")
115
+ prompt.prepare.to_s.should == "v1 a"
116
+ File.write(path, "v2 <%= x %>")
117
+ prompt.prepare.to_s.should == "v2 a"
118
+ end
119
+ end
120
+
121
+ it "honours the Result contract (empty?/to_s)" do
122
+ Brute::PromptTemplate.new("").prepare.empty?.should.be.true
123
+ Brute::PromptTemplate.new("hi").prepare.empty?.should.be.false
124
+ end
125
+ end
@@ -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.2"
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
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: 3.2.2
4
+ version: 4.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brute Contributors
@@ -173,6 +173,7 @@ files:
173
173
  - lib/brute/events/handler.rb
174
174
  - lib/brute/events/prefixed_terminal_output.rb
175
175
  - lib/brute/events/terminal_output_handler.rb
176
+ - lib/brute/hooks.rb
176
177
  - lib/brute/message_transport.rb
177
178
  - lib/brute/message_transport/anthropic.rb
178
179
  - lib/brute/message_transport/llm.rb
@@ -190,6 +191,7 @@ files:
190
191
  - lib/brute/middleware/010_max_iterations.rb
191
192
  - lib/brute/middleware/015_otel_token_usage.rb
192
193
  - lib/brute/middleware/020_system_prompt.rb
194
+ - lib/brute/middleware/025_skills.rb
193
195
  - lib/brute/middleware/040_compaction_check.rb
194
196
  - lib/brute/middleware/060_questions.rb
195
197
  - lib/brute/middleware/070_tool_pipeline.rb
@@ -198,6 +200,7 @@ files:
198
200
  - lib/brute/middleware/event_handler.rb
199
201
  - lib/brute/middleware/open_router.rb
200
202
  - lib/brute/middleware/user_queue.rb
203
+ - lib/brute/prompt_template.rb
201
204
  - lib/brute/prompts.rb
202
205
  - lib/brute/prompts/autonomy.rb
203
206
  - lib/brute/prompts/base.rb
@@ -231,6 +234,7 @@ files:
231
234
  - lib/brute/prompts/text/identity/default.txt
232
235
  - lib/brute/prompts/text/identity/google.txt
233
236
  - lib/brute/prompts/text/identity/openai.txt
237
+ - lib/brute/prompts/text/skills/default.erb
234
238
  - lib/brute/prompts/text/tone_and_style/anthropic.txt
235
239
  - lib/brute/prompts/text/tone_and_style/default.txt
236
240
  - lib/brute/prompts/text/tone_and_style/google.txt
@@ -268,6 +272,7 @@ files:
268
272
  - lib/brute/turn/tool_pipeline.rb
269
273
  - lib/brute/utils/diff.rb
270
274
  - lib/brute/version.rb
275
+ - lib/brute/version.rb.erb
271
276
  - lib/brute_cli/providers/shell.rb
272
277
  - lib/brute_cli/providers/shell_response.rb
273
278
  homepage: https://github.com/general-intelligence-systems/brute