brute 3.2.2 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/brute/changelog.rb +322 -0
- data/lib/brute/completion/open_router.rb +116 -0
- data/lib/brute/contrib/log_file.rb +173 -0
- data/lib/brute/deprecate.rb +325 -0
- data/lib/brute/hooks.rb +77 -0
- data/lib/brute/middleware/025_skills.rb +91 -0
- data/lib/brute/middleware/070_tool_pipeline.rb +110 -13
- data/lib/brute/middleware/open_router.rb +46 -28
- data/lib/brute/prompt_template.rb +125 -0
- data/lib/brute/prompts/base.rb +105 -0
- data/lib/brute/prompts/skills.rb +51 -13
- data/lib/brute/prompts/text/skills/default.erb +14 -0
- data/lib/brute/skill.rb +211 -80
- data/lib/brute/turn/agent_pipeline.rb +43 -1
- data/lib/brute/turn/pipeline.rb +15 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute/version.rb.erb +5 -0
- data/lib/brute.rb +16 -0
- metadata +38 -1
|
@@ -1,37 +1,55 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../deprecate"
|
|
4
|
+
require_relative "../completion/open_router"
|
|
5
|
+
|
|
3
6
|
module Brute
|
|
4
7
|
module Middleware
|
|
5
8
|
module OpenRouter
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def call(env)
|
|
19
|
-
messages = Brute::MessageTransport::OpenRouter.dump_all(env[:messages])
|
|
20
|
-
|
|
21
|
-
::OpenRouter::Client.new(**@config).then do |client|
|
|
22
|
-
client.complete(messages, @options).then do |response|
|
|
23
|
-
|
|
24
|
-
# OpenRouter in fact only returns a single message...
|
|
25
|
-
# https://github.com/estiens/open_router_enhanced/blob/main/lib/open_router/response.rb
|
|
26
|
-
Brute::MessageTransport::OpenRouter.wrap_each(response) do |message|
|
|
27
|
-
env[:messages] << message
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
env
|
|
33
|
-
end
|
|
9
|
+
# Deprecated. Completion middlewares now live under Brute::Completion,
|
|
10
|
+
# which names them for what they do (call one provider) rather than for
|
|
11
|
+
# where they happened to sit in the stack:
|
|
12
|
+
#
|
|
13
|
+
# Brute::Middleware::OpenRouter::Completion -> Brute::Completion::OpenRouter
|
|
14
|
+
#
|
|
15
|
+
# The old name stays a working subclass of the new one until the deadline
|
|
16
|
+
# below; see Brute::Deprecate and `bin/deprecations`.
|
|
17
|
+
class Completion < Brute::Completion::OpenRouter
|
|
18
|
+
extend Brute::Deprecate
|
|
19
|
+
brute_deprecate_constant "Brute::Completion::OpenRouter", "5.0"
|
|
34
20
|
end
|
|
35
21
|
end
|
|
36
22
|
end
|
|
37
23
|
end
|
|
24
|
+
|
|
25
|
+
__END__
|
|
26
|
+
|
|
27
|
+
describe "brute/middleware/open_router" do
|
|
28
|
+
require "brute/completion/open_router"
|
|
29
|
+
|
|
30
|
+
it "is the new Completion class under the old name" do
|
|
31
|
+
Brute::Middleware::OpenRouter::Completion.superclass.should == Brute::Completion::OpenRouter
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "warns on use, naming the replacement and the removal version" do
|
|
35
|
+
captured = []
|
|
36
|
+
original = Brute::Deprecate.method(:warn)
|
|
37
|
+
Brute::Deprecate.define_singleton_method(:warn) { |message| captured << message }
|
|
38
|
+
begin
|
|
39
|
+
Brute::Middleware::OpenRouter::Completion.new(->(env) { env })
|
|
40
|
+
ensure
|
|
41
|
+
Brute::Deprecate.define_singleton_method(:warn, original)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
captured.size.should == 1
|
|
45
|
+
captured.first.should.match(/Brute::Middleware::OpenRouter::Completion is deprecated/)
|
|
46
|
+
captured.first.should.match(/use Brute::Completion::OpenRouter instead/)
|
|
47
|
+
captured.first.should.match(/removed in Brute 5\.0/)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "is registered with its removal deadline" do
|
|
51
|
+
entry = Brute::Deprecate.registry.find { |e| e.name == "Brute::Middleware::OpenRouter::Completion" }
|
|
52
|
+
entry.should.not.be.nil
|
|
53
|
+
entry.removed_in.should == Gem::Version.new("5.0")
|
|
54
|
+
end
|
|
55
|
+
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>
|