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.
@@ -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
- class Completion
7
- # config: keyword arguments for OpenRouter::Client.new
8
- # (access_token:, request_timeout:, uri_base:, extra_headers:).
9
- # Defaults to OpenRouter.configuration's global settings.
10
- # options: keyword arguments for OpenRouter::CompletionOptions.new
11
- # (model:, temperature:, tools:, ...).
12
- def initialize(app, config: {}, **options)
13
- @app = app
14
- @config = config
15
- @options = ::OpenRouter::CompletionOptions.new(**options)
16
- end
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
@@ -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>