brute 5.0.5 → 6.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/compaction/middleware/sliding_window.rb +170 -0
- data/lib/brute/compaction/middleware/strategy.rb +137 -0
- data/lib/brute/compaction/middleware/tool_results.rb +146 -0
- data/lib/brute/compaction/summarize.rb +349 -0
- data/lib/brute/compaction/transcript.rb +166 -0
- data/lib/brute/compaction.rb +57 -0
- data/lib/brute/completion/lang_chain.rb +34 -31
- data/lib/brute/completion/llmrb.rb +31 -29
- data/lib/brute/completion/open_router.rb +43 -40
- data/lib/brute/completion/ruby_llm.rb +28 -27
- data/lib/brute/contrib/otel.rb +83 -51
- data/lib/brute/env.rb +54 -0
- data/lib/brute/eval/case.rb +254 -0
- data/lib/brute/eval/suite.rb +181 -0
- data/lib/brute/eval/transcript.rb +147 -0
- data/lib/brute/eval/world.rb +106 -0
- data/lib/brute/eval.rb +48 -0
- data/lib/brute/events/handler.rb +3 -1
- data/lib/brute/events/prefixed_terminal_output.rb +3 -1
- data/lib/brute/events/terminal_output_handler.rb +1 -1
- data/lib/brute/hooks.rb +89 -102
- data/lib/brute/message_transport/anthropic.rb +13 -3
- data/lib/brute/message_transport/llm.rb +8 -4
- data/lib/brute/message_transport/open_router.rb +30 -16
- data/lib/brute/message_transport/openai.rb +14 -7
- data/lib/brute/message_transport/ruby_llm.rb +21 -17
- data/lib/brute/message_transport/ruby_open_ai.rb +59 -57
- data/lib/brute/messages.rb +15 -8
- data/lib/brute/middleware/000_base.rb +6 -6
- data/lib/brute/middleware/002_session_log.rb +12 -4
- data/lib/brute/middleware/008_checkpoint.rb +27 -19
- data/lib/brute/middleware/010_max_iterations.rb +1 -1
- data/lib/brute/middleware/020_system_prompt.rb +1 -1
- data/lib/brute/middleware/040_default_compaction_pipeline.rb +356 -0
- data/lib/brute/middleware/{070_tool_pipeline.rb → 070_default_tool_pipeline.rb} +49 -58
- data/lib/brute/prompt_template.rb +19 -15
- data/lib/brute/prompts/base.rb +19 -10
- data/lib/brute/prompts/environment.rb +3 -1
- data/lib/brute/prompts/instructions.rb +9 -7
- data/lib/brute/prompts/skills.rb +5 -3
- data/lib/brute/rack/adapter.rb +40 -23
- data/lib/brute/skill.rb +136 -89
- data/lib/brute/system_prompt.rb +9 -9
- data/lib/brute/token_counter/approximate.rb +54 -0
- data/lib/brute/token_counter/tiktoken.rb +80 -0
- data/lib/brute/token_counter.rb +150 -0
- data/lib/brute/tool.rb +10 -6
- data/lib/brute/tools/adapter.rb +57 -47
- data/lib/brute/tools/fs/snapshot_store.rb +5 -1
- data/lib/brute/tools/fs_patch.rb +16 -8
- data/lib/brute/tools/fs_read.rb +107 -80
- data/lib/brute/tools/fs_remove.rb +6 -2
- data/lib/brute/tools/fs_search.rb +14 -4
- data/lib/brute/tools/fs_undo.rb +6 -2
- data/lib/brute/tools/fs_write.rb +5 -1
- data/lib/brute/tools/net_fetch.rb +6 -2
- data/lib/brute/tools/question.rb +42 -39
- data/lib/brute/tools/shell.rb +20 -5
- data/lib/brute/tools/skill_load.rb +46 -41
- data/lib/brute/tools/sub_agent.rb +2 -2
- data/lib/brute/tools/todo_write.rb +19 -15
- data/lib/brute/truncation.rb +68 -43
- data/lib/brute/turn/agent_pipeline.rb +23 -13
- data/lib/brute/turn/compaction_pipeline.rb +123 -0
- data/lib/brute/turn/pipeline.rb +87 -96
- data/lib/brute/turn/tool_pipeline.rb +4 -3
- data/lib/brute/usage_detection/llmrb.rb +18 -14
- data/lib/brute/usage_detection/open_router.rb +20 -17
- data/lib/brute/usage_detection/ruby_llm.rb +18 -14
- data/lib/brute/usage_detection/usage.rb +10 -1
- data/lib/brute/utils/diff.rb +18 -10
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +25 -12
- data/lib/brute_cli/providers/shell.rb +32 -29
- data/lib/brute_cli/providers/shell_response.rb +20 -18
- metadata +48 -5
- data/lib/brute/middleware/040_compaction_check.rb +0 -157
- data/lib/brute/middleware/event_handler.rb +0 -27
data/lib/brute/prompts/base.rb
CHANGED
|
@@ -14,9 +14,14 @@ module Brute
|
|
|
14
14
|
def self.read(section, provider_name)
|
|
15
15
|
provider = provider_name.to_s
|
|
16
16
|
path = File.join(TEXT_DIR, section, "#{provider}.txt")
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
unless File.exist?(path)
|
|
18
|
+
path = File.join(TEXT_DIR, section, "default.txt")
|
|
19
|
+
end
|
|
20
|
+
if File.exist?(path)
|
|
21
|
+
File.read(path)
|
|
22
|
+
else
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
# Read a named agent prompt (e.g. "explore", "compaction").
|
|
@@ -33,9 +38,11 @@ module Brute
|
|
|
33
38
|
end
|
|
34
39
|
|
|
35
40
|
def method_missing(name, *args)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
if args.empty? && @ctx.key?(name)
|
|
42
|
+
@ctx[name]
|
|
43
|
+
else
|
|
44
|
+
super
|
|
45
|
+
end
|
|
39
46
|
end
|
|
40
47
|
|
|
41
48
|
def respond_to_missing?(name, include_private = false)
|
|
@@ -69,10 +76,12 @@ module Brute
|
|
|
69
76
|
path = [provider, "default"]
|
|
70
77
|
.map { |variant| File.join(TEXT_DIR, section, "#{variant}.erb") }
|
|
71
78
|
.find { |candidate| File.exist?(candidate) }
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
if path
|
|
80
|
+
erb = TEMPLATES[path] ||= ERB.new(File.read(path), trim_mode: "-")
|
|
81
|
+
erb.result(Context.new(ctx).get_binding)
|
|
82
|
+
else
|
|
83
|
+
read(section, provider)
|
|
84
|
+
end
|
|
76
85
|
end
|
|
77
86
|
end
|
|
78
87
|
end
|
|
@@ -12,7 +12,9 @@ module Brute
|
|
|
12
12
|
git = File.exist?(File.join(cwd, ".git"))
|
|
13
13
|
|
|
14
14
|
parts = []
|
|
15
|
-
|
|
15
|
+
unless model.empty?
|
|
16
|
+
parts << "You are powered by the model named #{model}."
|
|
17
|
+
end
|
|
16
18
|
parts << ""
|
|
17
19
|
parts << "Here is some useful information about the environment you are running in:"
|
|
18
20
|
parts << "<env>"
|
|
@@ -8,13 +8,15 @@ module Brute
|
|
|
8
8
|
module Instructions
|
|
9
9
|
def self.call(ctx)
|
|
10
10
|
rules = ctx[:custom_rules]
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
if rules.nil? || rules.strip.empty?
|
|
12
|
+
nil
|
|
13
|
+
else
|
|
14
|
+
<<~TXT
|
|
15
|
+
# Project-Specific Rules
|
|
16
|
+
|
|
17
|
+
#{rules}
|
|
18
|
+
TXT
|
|
19
|
+
end
|
|
18
20
|
end
|
|
19
21
|
end
|
|
20
22
|
end
|
data/lib/brute/prompts/skills.rb
CHANGED
|
@@ -16,9 +16,11 @@ module Brute
|
|
|
16
16
|
def self.call(ctx)
|
|
17
17
|
skills = ctx[:skills] || Brute::Skill.all(cwd: ctx[:cwd] || Dir.pwd)
|
|
18
18
|
visible = skills.reject(&:disable_model_invocation?)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
if visible.empty?
|
|
20
|
+
nil
|
|
21
|
+
else
|
|
22
|
+
Prompts.render("skills", ctx.merge(skills: visible))
|
|
23
|
+
end
|
|
22
24
|
end
|
|
23
25
|
end
|
|
24
26
|
end
|
data/lib/brute/rack/adapter.rb
CHANGED
|
@@ -38,7 +38,9 @@ module Brute
|
|
|
38
38
|
# @parameter agent [#start] Anything with `start(prompt) -> env` — an
|
|
39
39
|
# AgentPipeline, a SubAgent, or any turn-shaped callable.
|
|
40
40
|
def initialize(agent)
|
|
41
|
-
|
|
41
|
+
unless agent.respond_to?(:start)
|
|
42
|
+
raise ArgumentError, "agent must respond to #start"
|
|
43
|
+
end
|
|
42
44
|
|
|
43
45
|
@agent = agent
|
|
44
46
|
end
|
|
@@ -48,10 +50,12 @@ module Brute
|
|
|
48
50
|
# (client's fault); anything the turn raises is a 500.
|
|
49
51
|
def call(env)
|
|
50
52
|
prompt = prompt_from(env)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
if prompt.nil? || prompt.empty?
|
|
54
|
+
response_for(env, 400, "No prompt provided.")
|
|
55
|
+
else
|
|
56
|
+
turn = @agent.start(prompt)
|
|
57
|
+
response_for(env, 200, output_of(turn))
|
|
58
|
+
end
|
|
55
59
|
rescue => error
|
|
56
60
|
response_for(env, 500, error.message)
|
|
57
61
|
end
|
|
@@ -68,24 +72,29 @@ module Brute
|
|
|
68
72
|
request = ::Rack::Request.new(env)
|
|
69
73
|
|
|
70
74
|
if (query = request.GET["prompt"]) && !query.empty?
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
query
|
|
76
|
+
else
|
|
77
|
+
body = read_body(request)
|
|
78
|
+
if body.nil? || body.strip.empty?
|
|
79
|
+
nil
|
|
80
|
+
else
|
|
81
|
+
from_json = nil
|
|
82
|
+
if json?(request.media_type)
|
|
83
|
+
case data = parse_json(body)
|
|
84
|
+
when ::Hash then from_json = PROMPT_KEYS.filter_map { |key| data[key] }.first&.to_s || body
|
|
85
|
+
when ::String then from_json = data
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if from_json
|
|
90
|
+
from_json
|
|
91
|
+
elsif request.form_data? && (field = ::Rack::Utils.parse_nested_query(body)["prompt"])
|
|
92
|
+
field
|
|
93
|
+
else
|
|
94
|
+
body
|
|
95
|
+
end
|
|
81
96
|
end
|
|
82
97
|
end
|
|
83
|
-
|
|
84
|
-
if request.form_data? && (field = ::Rack::Utils.parse_nested_query(body)["prompt"])
|
|
85
|
-
return field
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
body
|
|
89
98
|
end
|
|
90
99
|
|
|
91
100
|
# output -> [status, headers, body]. Content-negotiated: JSON in (or an
|
|
@@ -96,7 +105,11 @@ module Brute
|
|
|
96
105
|
text = output.to_s
|
|
97
106
|
|
|
98
107
|
if wants_json?(env)
|
|
99
|
-
|
|
108
|
+
if status == 200
|
|
109
|
+
key = :response
|
|
110
|
+
else
|
|
111
|
+
key = :error
|
|
112
|
+
end
|
|
100
113
|
[status, {"content-type" => "application/json"}, [::JSON.generate(key => text)]]
|
|
101
114
|
else
|
|
102
115
|
[status, {"content-type" => "text/plain; charset=utf-8"}, [text]]
|
|
@@ -107,7 +120,11 @@ module Brute
|
|
|
107
120
|
|
|
108
121
|
# The agent's answer is the last message it appended to the log.
|
|
109
122
|
def output_of(turn)
|
|
110
|
-
|
|
123
|
+
if turn.is_a?(::Hash)
|
|
124
|
+
messages = turn[:messages]
|
|
125
|
+
else
|
|
126
|
+
messages = turn
|
|
127
|
+
end
|
|
111
128
|
messages&.last&.content.to_s
|
|
112
129
|
end
|
|
113
130
|
|
data/lib/brute/skill.rb
CHANGED
|
@@ -34,8 +34,16 @@ module Brute
|
|
|
34
34
|
# (https://agentskills.io/specification). A skill whose frontmatter violates
|
|
35
35
|
# a rule is skipped with a stderr warning naming the rule — never raised.
|
|
36
36
|
class Skill
|
|
37
|
-
attr_reader :name,
|
|
38
|
-
|
|
37
|
+
attr_reader :name,
|
|
38
|
+
:description,
|
|
39
|
+
:file_path,
|
|
40
|
+
:base_dir,
|
|
41
|
+
:content,
|
|
42
|
+
:source,
|
|
43
|
+
:license,
|
|
44
|
+
:compatibility,
|
|
45
|
+
:metadata,
|
|
46
|
+
:allowed_tools
|
|
39
47
|
|
|
40
48
|
FILENAME = "SKILL.md"
|
|
41
49
|
|
|
@@ -74,38 +82,42 @@ module Brute
|
|
|
74
82
|
def self.load(path, source: :path)
|
|
75
83
|
raw = File.read(path)
|
|
76
84
|
frontmatter, content = parse_frontmatter(path, raw)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
# Unknown fields are a soft violation: warn and drop them rather than
|
|
85
|
-
# reject the skill. The reference validator hard-fails here, but a runtime
|
|
86
|
-
# loader must tolerate vendor/forward extensions (e.g. `tags`), which real
|
|
87
|
-
# published skills carry, instead of silently dropping the whole skill.
|
|
88
|
-
extra = frontmatter.keys - ALLOWED_FIELDS
|
|
89
|
-
warn "Skill #{path} has unexpected frontmatter fields (ignored): #{extra.sort.join(', ')}" unless extra.empty?
|
|
90
|
-
|
|
91
|
-
errors = validate(frontmatter, dir_name)
|
|
92
|
-
unless errors.empty?
|
|
93
|
-
warn "Skipping invalid skill #{path}: #{errors.join('; ')}"
|
|
94
|
-
return nil
|
|
95
|
-
end
|
|
85
|
+
if frontmatter
|
|
86
|
+
dir_name = File.basename(File.dirname(path))
|
|
87
|
+
# Spec requires `name`; brute keeps the convenience of defaulting to the
|
|
88
|
+
# directory name when omitted (which trivially satisfies the dir-match rule).
|
|
89
|
+
unless frontmatter.key?("name")
|
|
90
|
+
frontmatter = { "name" => dir_name }.merge(frontmatter)
|
|
91
|
+
end
|
|
96
92
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
93
|
+
# Unknown fields are a soft violation: warn and drop them rather than
|
|
94
|
+
# reject the skill. The reference validator hard-fails here, but a runtime
|
|
95
|
+
# loader must tolerate vendor/forward extensions (e.g. `tags`), which real
|
|
96
|
+
# published skills carry, instead of silently dropping the whole skill.
|
|
97
|
+
extra = frontmatter.keys - ALLOWED_FIELDS
|
|
98
|
+
unless extra.empty?
|
|
99
|
+
warn "Skill #{path} has unexpected frontmatter fields (ignored): #{extra.sort.join(', ')}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
errors = validate(frontmatter, dir_name)
|
|
103
|
+
if errors.empty?
|
|
104
|
+
new(
|
|
105
|
+
name: frontmatter["name"].to_s.strip,
|
|
106
|
+
description: frontmatter["description"].to_s.strip,
|
|
107
|
+
file_path: path,
|
|
108
|
+
content: content.to_s.strip,
|
|
109
|
+
source: source,
|
|
110
|
+
license: frontmatter["license"]&.to_s,
|
|
111
|
+
compatibility: frontmatter["compatibility"]&.to_s,
|
|
112
|
+
metadata: frontmatter["metadata"],
|
|
113
|
+
allowed_tools: parse_allowed_tools(frontmatter["allowed-tools"]),
|
|
114
|
+
disable_model_invocation: frontmatter["disable-model-invocation"] == true,
|
|
115
|
+
)
|
|
116
|
+
else
|
|
117
|
+
warn "Skipping invalid skill #{path}: #{errors.join('; ')}"
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
109
121
|
rescue => e
|
|
110
122
|
warn "Failed to load skill #{path}: #{e.message}"
|
|
111
123
|
nil
|
|
@@ -122,25 +134,28 @@ module Brute
|
|
|
122
134
|
|
|
123
135
|
add = lambda do |path, source|
|
|
124
136
|
skill = load(path, source: source)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
137
|
+
real = skill && realpath(path)
|
|
138
|
+
|
|
139
|
+
if skill && !seen_files[real]
|
|
140
|
+
if (winner = skills[skill.name])
|
|
141
|
+
warn "Skill name collision: '#{skill.name}' at #{path} ignored; " \
|
|
142
|
+
"already loaded from #{winner.file_path}"
|
|
143
|
+
else
|
|
144
|
+
seen_files[real] = true
|
|
145
|
+
skills[skill.name] = skill
|
|
146
|
+
end
|
|
134
147
|
end
|
|
135
|
-
|
|
136
|
-
seen_files[real] = true
|
|
137
|
-
skills[skill.name] = skill
|
|
138
148
|
end
|
|
139
149
|
|
|
140
150
|
project = File.join(cwd, ".brute", "skills")
|
|
141
151
|
glob(project) { |path| add.call(path, :project) }
|
|
142
152
|
|
|
143
|
-
global = File.join(
|
|
153
|
+
global = File.join(
|
|
154
|
+
Dir.home,
|
|
155
|
+
".config",
|
|
156
|
+
"brute",
|
|
157
|
+
"skills",
|
|
158
|
+
)
|
|
144
159
|
glob(global) { |path| add.call(path, :user) }
|
|
145
160
|
|
|
146
161
|
paths.each do |raw|
|
|
@@ -163,9 +178,11 @@ module Brute
|
|
|
163
178
|
end
|
|
164
179
|
|
|
165
180
|
def self.glob(dir, &block)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
181
|
+
if File.directory?(dir)
|
|
182
|
+
Dir.glob(File.join(dir, "**", FILENAME)).sort.each(&block)
|
|
183
|
+
else
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
169
186
|
end
|
|
170
187
|
|
|
171
188
|
def self.realpath(path)
|
|
@@ -177,70 +194,100 @@ module Brute
|
|
|
177
194
|
# Validate frontmatter against the spec. Returns an array of error strings
|
|
178
195
|
# (empty means valid), each naming the violated rule.
|
|
179
196
|
def self.validate(frontmatter, dir_name)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
197
|
+
[].tap do |errors|
|
|
198
|
+
errors.concat(validate_name(frontmatter["name"], dir_name))
|
|
199
|
+
errors.concat(validate_description(frontmatter["description"]))
|
|
200
|
+
|
|
201
|
+
if frontmatter.key?("compatibility")
|
|
202
|
+
compatibility = frontmatter["compatibility"]
|
|
203
|
+
if !compatibility.is_a?(String)
|
|
204
|
+
errors << "'compatibility' must be a string"
|
|
205
|
+
elsif compatibility.length > MAX_COMPATIBILITY_LENGTH
|
|
206
|
+
errors << "'compatibility' exceeds #{MAX_COMPATIBILITY_LENGTH} characters"
|
|
207
|
+
end
|
|
191
208
|
end
|
|
192
209
|
end
|
|
193
|
-
|
|
194
|
-
errors
|
|
195
210
|
end
|
|
196
211
|
|
|
197
212
|
def self.validate_name(name, dir_name)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
213
|
+
if name.is_a?(String) && !name.strip.empty?
|
|
214
|
+
name = name.strip.unicode_normalize(:nfkc)
|
|
215
|
+
errors = []
|
|
216
|
+
if name.length > MAX_NAME_LENGTH
|
|
217
|
+
errors << "'name' exceeds #{MAX_NAME_LENGTH} characters"
|
|
218
|
+
end
|
|
219
|
+
if name != name.downcase
|
|
220
|
+
errors << "'name' must be lowercase"
|
|
221
|
+
end
|
|
222
|
+
if name.start_with?("-") || name.end_with?("-")
|
|
223
|
+
errors << "'name' cannot start or end with a hyphen"
|
|
224
|
+
end
|
|
225
|
+
if name.include?("--")
|
|
226
|
+
errors << "'name' cannot contain consecutive hyphens"
|
|
227
|
+
end
|
|
228
|
+
unless name.match?(/\A[\p{Alnum}\-]+\z/)
|
|
229
|
+
errors << "'name' may only contain letters, digits, and hyphens"
|
|
230
|
+
end
|
|
231
|
+
if dir_name.unicode_normalize(:nfkc) != name
|
|
232
|
+
errors << "directory name '#{dir_name}' must match skill name '#{name}'"
|
|
233
|
+
end
|
|
234
|
+
errors
|
|
235
|
+
else
|
|
236
|
+
["missing required field 'name'"]
|
|
237
|
+
end
|
|
209
238
|
end
|
|
210
239
|
|
|
211
240
|
def self.validate_description(description)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
241
|
+
if description.is_a?(String) && !description.strip.empty?
|
|
242
|
+
if description.length > MAX_DESCRIPTION_LENGTH
|
|
243
|
+
["'description' exceeds #{MAX_DESCRIPTION_LENGTH} characters"]
|
|
244
|
+
else
|
|
245
|
+
[]
|
|
246
|
+
end
|
|
247
|
+
else
|
|
248
|
+
["missing required field 'description'"]
|
|
249
|
+
end
|
|
216
250
|
end
|
|
217
251
|
|
|
218
252
|
# `allowed-tools` is an experimental, space-separated list of tool names.
|
|
219
253
|
def self.parse_allowed_tools(value)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
254
|
+
if value.nil?
|
|
255
|
+
nil
|
|
256
|
+
else
|
|
257
|
+
value.to_s.split(/\s+/).reject(&:empty?)
|
|
258
|
+
end
|
|
223
259
|
end
|
|
224
260
|
|
|
225
261
|
# Split YAML frontmatter from markdown body.
|
|
226
262
|
# Returns [hash, string] or [nil, nil].
|
|
227
263
|
def self.parse_frontmatter(path, raw)
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
264
|
+
parts = nil
|
|
265
|
+
if raw.start_with?("---")
|
|
266
|
+
parts = raw.split(/^---\s*$/, 3)
|
|
267
|
+
end
|
|
232
268
|
|
|
233
|
-
frontmatter =
|
|
234
|
-
|
|
269
|
+
frontmatter = nil
|
|
270
|
+
if parts && parts.length >= 3
|
|
271
|
+
frontmatter = YAML.safe_load(parts[1])
|
|
272
|
+
end
|
|
235
273
|
|
|
236
|
-
|
|
274
|
+
if frontmatter.is_a?(Hash)
|
|
275
|
+
[frontmatter, parts[2]]
|
|
276
|
+
else
|
|
277
|
+
[nil, nil]
|
|
278
|
+
end
|
|
237
279
|
rescue => e
|
|
238
280
|
warn "Failed to parse frontmatter in #{path}: #{e.message}"
|
|
239
281
|
[nil, nil]
|
|
240
282
|
end
|
|
241
283
|
|
|
242
|
-
private_class_method :glob,
|
|
243
|
-
|
|
284
|
+
private_class_method :glob,
|
|
285
|
+
:realpath,
|
|
286
|
+
:validate,
|
|
287
|
+
:validate_name,
|
|
288
|
+
:validate_description,
|
|
289
|
+
:parse_allowed_tools,
|
|
290
|
+
:parse_frontmatter
|
|
244
291
|
end
|
|
245
292
|
end
|
|
246
293
|
|
data/lib/brute/system_prompt.rb
CHANGED
|
@@ -34,11 +34,11 @@ module Brute
|
|
|
34
34
|
# upstream model family from the model name so we use the most
|
|
35
35
|
# appropriate prompt stack (e.g., anthropic stack for claude-*).
|
|
36
36
|
provider = ctx[:provider_name].to_s
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
if provider.start_with?("opencode")
|
|
38
|
+
stack_key = infer_stack_from_model(ctx[:model_name].to_s)
|
|
39
|
+
else
|
|
40
|
+
stack_key = provider
|
|
41
|
+
end
|
|
42
42
|
STACKS.fetch(stack_key, STACKS["default"]).each do |mod|
|
|
43
43
|
prompt << mod.call(ctx)
|
|
44
44
|
end
|
|
@@ -78,7 +78,7 @@ module Brute
|
|
|
78
78
|
],
|
|
79
79
|
|
|
80
80
|
# GPT-4 / o1 / o3 — pragmatic engineer persona, editing focus, autonomy
|
|
81
|
-
"openai"
|
|
81
|
+
"openai" => [
|
|
82
82
|
Prompts::Identity,
|
|
83
83
|
Prompts::EditingApproach,
|
|
84
84
|
Prompts::Autonomy,
|
|
@@ -94,7 +94,7 @@ module Brute
|
|
|
94
94
|
],
|
|
95
95
|
|
|
96
96
|
# Gemini — formal/structured, explicit workflows, security focus
|
|
97
|
-
"google"
|
|
97
|
+
"google" => [
|
|
98
98
|
Prompts::Identity,
|
|
99
99
|
Prompts::Conventions,
|
|
100
100
|
Prompts::DoingTasks,
|
|
@@ -109,7 +109,7 @@ module Brute
|
|
|
109
109
|
],
|
|
110
110
|
|
|
111
111
|
# Ollama — lean stack for local models with smaller context windows
|
|
112
|
-
"ollama"
|
|
112
|
+
"ollama" => [
|
|
113
113
|
Prompts::Identity,
|
|
114
114
|
Prompts::ToneAndStyle,
|
|
115
115
|
Prompts::Conventions,
|
|
@@ -121,7 +121,7 @@ module Brute
|
|
|
121
121
|
],
|
|
122
122
|
|
|
123
123
|
# Fallback — conservative, concise, fewer than 4 lines
|
|
124
|
-
"default"
|
|
124
|
+
"default" => [
|
|
125
125
|
Prompts::Identity,
|
|
126
126
|
Prompts::ToneAndStyle,
|
|
127
127
|
Prompts::Proactiveness,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module TokenCounter
|
|
8
|
+
# Tokens from text length, at a flat ratio of characters to tokens.
|
|
9
|
+
#
|
|
10
|
+
# Four characters to the token is the usual approximation. It needs no
|
|
11
|
+
# dependency and it is close enough to decide what to give up; put a
|
|
12
|
+
# Tiktoken counter on env[:token_counter] when the difference matters.
|
|
13
|
+
#
|
|
14
|
+
# `per_message` is what the rendering cannot see: every message is wrapped
|
|
15
|
+
# in the provider's own chat template on the way out, and that framing
|
|
16
|
+
# costs a few tokens each whatever the message says.
|
|
17
|
+
class Approximate
|
|
18
|
+
def initialize(chars_per_token: 4.0, per_message: 4)
|
|
19
|
+
unless chars_per_token.positive?
|
|
20
|
+
raise ArgumentError, "chars_per_token must be greater than 0, got #{chars_per_token}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@chars_per_token = chars_per_token
|
|
24
|
+
@per_message = per_message
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def count(messages, tools: nil)
|
|
28
|
+
text = Rendering.conversation(messages) + Rendering.tools(tools)
|
|
29
|
+
|
|
30
|
+
(text.length / @chars_per_token).to_i + (Array(messages).length * @per_message)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
__END__
|
|
37
|
+
|
|
38
|
+
describe "brute/token_counter/approximate" do
|
|
39
|
+
it "divides the rendered text by the ratio, and charges for the envelope each message rides in" do
|
|
40
|
+
log = Brute.log
|
|
41
|
+
log.user("a" * 400)
|
|
42
|
+
|
|
43
|
+
# "user: " + 400 characters, four to the token, plus the envelope.
|
|
44
|
+
Brute::TokenCounter::Approximate.new.count(log).should == 105
|
|
45
|
+
|
|
46
|
+
# The ratio and the envelope are both settings, not truths.
|
|
47
|
+
Brute::TokenCounter::Approximate.new(chars_per_token: 2.0).count(log).should == 207
|
|
48
|
+
Brute::TokenCounter::Approximate.new(per_message: 0).count(log).should == 101
|
|
49
|
+
|
|
50
|
+
Brute::TokenCounter::Approximate.new.count([]).should == 0
|
|
51
|
+
|
|
52
|
+
lambda { Brute::TokenCounter::Approximate.new(chars_per_token: 0) }.should.raise(ArgumentError)
|
|
53
|
+
end
|
|
54
|
+
end
|