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 +4 -4
- data/lib/brute/hooks.rb +77 -0
- data/lib/brute/middleware/025_skills.rb +91 -0
- data/lib/brute/middleware/070_tool_pipeline.rb +110 -13
- data/lib/brute/middleware/open_router.rb +70 -0
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 65f8fa383e8c2d5793cc4ec00023ee6d2117e75535a81676617232b94748f154
|
|
4
|
+
data.tar.gz: 88f3794d39b79c3c7c03c96f54383fa8647850be1cfc77ae8877fb6ea42aa365
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 57b849d89d406b624fe26dcbc93aa84af2f9b90b12160fb57608938ec53fd7109c87aa3d2c1357620c78f88b9702f797ff106432840a5e4ccaca343f49e7035a
|
|
7
|
+
data.tar.gz: d73709348a3f205f034389e86950a5ce171d0fd0bf62a9c9e84ad587d3ac322aad211b94d059a93b9b19e16a0c1b6211baebd7f274b88435c1cf61a9a6373007
|
data/lib/brute/hooks.rb
ADDED
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
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
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: brute
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 4.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
|