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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/compaction/middleware/sliding_window.rb +170 -0
  3. data/lib/brute/compaction/middleware/strategy.rb +137 -0
  4. data/lib/brute/compaction/middleware/tool_results.rb +146 -0
  5. data/lib/brute/compaction/summarize.rb +349 -0
  6. data/lib/brute/compaction/transcript.rb +166 -0
  7. data/lib/brute/compaction.rb +57 -0
  8. data/lib/brute/completion/lang_chain.rb +34 -31
  9. data/lib/brute/completion/llmrb.rb +31 -29
  10. data/lib/brute/completion/open_router.rb +43 -40
  11. data/lib/brute/completion/ruby_llm.rb +28 -27
  12. data/lib/brute/contrib/otel.rb +83 -51
  13. data/lib/brute/env.rb +54 -0
  14. data/lib/brute/eval/case.rb +254 -0
  15. data/lib/brute/eval/suite.rb +181 -0
  16. data/lib/brute/eval/transcript.rb +147 -0
  17. data/lib/brute/eval/world.rb +106 -0
  18. data/lib/brute/eval.rb +48 -0
  19. data/lib/brute/events/handler.rb +3 -1
  20. data/lib/brute/events/prefixed_terminal_output.rb +3 -1
  21. data/lib/brute/events/terminal_output_handler.rb +1 -1
  22. data/lib/brute/hooks.rb +89 -102
  23. data/lib/brute/message_transport/anthropic.rb +13 -3
  24. data/lib/brute/message_transport/llm.rb +8 -4
  25. data/lib/brute/message_transport/open_router.rb +30 -16
  26. data/lib/brute/message_transport/openai.rb +14 -7
  27. data/lib/brute/message_transport/ruby_llm.rb +21 -17
  28. data/lib/brute/message_transport/ruby_open_ai.rb +59 -57
  29. data/lib/brute/messages.rb +15 -8
  30. data/lib/brute/middleware/000_base.rb +6 -6
  31. data/lib/brute/middleware/002_session_log.rb +12 -4
  32. data/lib/brute/middleware/008_checkpoint.rb +27 -19
  33. data/lib/brute/middleware/010_max_iterations.rb +1 -1
  34. data/lib/brute/middleware/020_system_prompt.rb +1 -1
  35. data/lib/brute/middleware/040_default_compaction_pipeline.rb +356 -0
  36. data/lib/brute/middleware/{070_tool_pipeline.rb → 070_default_tool_pipeline.rb} +49 -58
  37. data/lib/brute/prompt_template.rb +19 -15
  38. data/lib/brute/prompts/base.rb +19 -10
  39. data/lib/brute/prompts/environment.rb +3 -1
  40. data/lib/brute/prompts/instructions.rb +9 -7
  41. data/lib/brute/prompts/skills.rb +5 -3
  42. data/lib/brute/rack/adapter.rb +40 -23
  43. data/lib/brute/skill.rb +136 -89
  44. data/lib/brute/system_prompt.rb +9 -9
  45. data/lib/brute/token_counter/approximate.rb +54 -0
  46. data/lib/brute/token_counter/tiktoken.rb +80 -0
  47. data/lib/brute/token_counter.rb +150 -0
  48. data/lib/brute/tool.rb +10 -6
  49. data/lib/brute/tools/adapter.rb +57 -47
  50. data/lib/brute/tools/fs/snapshot_store.rb +5 -1
  51. data/lib/brute/tools/fs_patch.rb +16 -8
  52. data/lib/brute/tools/fs_read.rb +107 -80
  53. data/lib/brute/tools/fs_remove.rb +6 -2
  54. data/lib/brute/tools/fs_search.rb +14 -4
  55. data/lib/brute/tools/fs_undo.rb +6 -2
  56. data/lib/brute/tools/fs_write.rb +5 -1
  57. data/lib/brute/tools/net_fetch.rb +6 -2
  58. data/lib/brute/tools/question.rb +42 -39
  59. data/lib/brute/tools/shell.rb +20 -5
  60. data/lib/brute/tools/skill_load.rb +46 -41
  61. data/lib/brute/tools/sub_agent.rb +2 -2
  62. data/lib/brute/tools/todo_write.rb +19 -15
  63. data/lib/brute/truncation.rb +68 -43
  64. data/lib/brute/turn/agent_pipeline.rb +23 -13
  65. data/lib/brute/turn/compaction_pipeline.rb +123 -0
  66. data/lib/brute/turn/pipeline.rb +87 -96
  67. data/lib/brute/turn/tool_pipeline.rb +4 -3
  68. data/lib/brute/usage_detection/llmrb.rb +18 -14
  69. data/lib/brute/usage_detection/open_router.rb +20 -17
  70. data/lib/brute/usage_detection/ruby_llm.rb +18 -14
  71. data/lib/brute/usage_detection/usage.rb +10 -1
  72. data/lib/brute/utils/diff.rb +18 -10
  73. data/lib/brute/version.rb +1 -1
  74. data/lib/brute.rb +25 -12
  75. data/lib/brute_cli/providers/shell.rb +32 -29
  76. data/lib/brute_cli/providers/shell_response.rb +20 -18
  77. metadata +48 -5
  78. data/lib/brute/middleware/040_compaction_check.rb +0 -157
  79. data/lib/brute/middleware/event_handler.rb +0 -27
@@ -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
- path = File.join(TEXT_DIR, section, "default.txt") unless File.exist?(path)
18
- return nil unless File.exist?(path)
19
- File.read(path)
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
- return @ctx[name] if args.empty? && @ctx.key?(name)
37
-
38
- super
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
- 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)
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
- parts << "You are powered by the model named #{model}." unless model.empty?
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
- return nil if rules.nil? || rules.strip.empty?
12
-
13
- <<~TXT
14
- # Project-Specific Rules
15
-
16
- #{rules}
17
- TXT
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
@@ -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
- return nil if visible.empty?
20
-
21
- Prompts.render("skills", ctx.merge(skills: visible))
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
@@ -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
- raise ArgumentError, "agent must respond to #start" unless agent.respond_to?(:start)
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
- return response_for(env, 400, "No prompt provided.") if prompt.nil? || prompt.empty?
52
-
53
- turn = @agent.start(prompt)
54
- response_for(env, 200, output_of(turn))
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
- return query
72
- end
73
-
74
- body = read_body(request)
75
- return nil if body.nil? || body.strip.empty?
76
-
77
- if json?(request.media_type)
78
- case data = parse_json(body)
79
- when ::Hash then return PROMPT_KEYS.filter_map { |key| data[key] }.first&.to_s || body
80
- when ::String then return data
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
- key = status == 200 ? :response : :error
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
- messages = turn.is_a?(::Hash) ? turn[:messages] : turn
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, :description, :file_path, :base_dir, :content,
38
- :source, :license, :compatibility, :metadata, :allowed_tools
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
- return nil unless frontmatter
78
-
79
- dir_name = File.basename(File.dirname(path))
80
- # Spec requires `name`; brute keeps the convenience of defaulting to the
81
- # directory name when omitted (which trivially satisfies the dir-match rule).
82
- frontmatter = { "name" => dir_name }.merge(frontmatter) unless frontmatter.key?("name")
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
- new(
98
- name: frontmatter["name"].to_s.strip,
99
- description: frontmatter["description"].to_s.strip,
100
- file_path: path,
101
- content: content.to_s.strip,
102
- source: source,
103
- license: frontmatter["license"]&.to_s,
104
- compatibility: frontmatter["compatibility"]&.to_s,
105
- metadata: frontmatter["metadata"],
106
- allowed_tools: parse_allowed_tools(frontmatter["allowed-tools"]),
107
- disable_model_invocation: frontmatter["disable-model-invocation"] == true,
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
- 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
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(Dir.home, ".config", "brute", "skills")
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
- return unless File.directory?(dir)
167
-
168
- Dir.glob(File.join(dir, "**", FILENAME)).sort.each(&block)
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
- errors = []
181
-
182
- errors.concat(validate_name(frontmatter["name"], dir_name))
183
- errors.concat(validate_description(frontmatter["description"]))
184
-
185
- if frontmatter.key?("compatibility")
186
- compatibility = frontmatter["compatibility"]
187
- if !compatibility.is_a?(String)
188
- errors << "'compatibility' must be a string"
189
- elsif compatibility.length > MAX_COMPATIBILITY_LENGTH
190
- errors << "'compatibility' exceeds #{MAX_COMPATIBILITY_LENGTH} characters"
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
- return ["missing required field 'name'"] unless name.is_a?(String) && !name.strip.empty?
199
-
200
- name = name.strip.unicode_normalize(:nfkc)
201
- errors = []
202
- errors << "'name' exceeds #{MAX_NAME_LENGTH} characters" if name.length > MAX_NAME_LENGTH
203
- errors << "'name' must be lowercase" if name != name.downcase
204
- errors << "'name' cannot start or end with a hyphen" if name.start_with?("-") || name.end_with?("-")
205
- errors << "'name' cannot contain consecutive hyphens" if name.include?("--")
206
- errors << "'name' may only contain letters, digits, and hyphens" unless name.match?(/\A[\p{Alnum}\-]+\z/)
207
- errors << "directory name '#{dir_name}' must match skill name '#{name}'" if dir_name.unicode_normalize(:nfkc) != name
208
- errors
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
- return ["missing required field 'description'"] unless description.is_a?(String) && !description.strip.empty?
213
- return ["'description' exceeds #{MAX_DESCRIPTION_LENGTH} characters"] if description.length > MAX_DESCRIPTION_LENGTH
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
- return nil if value.nil?
221
-
222
- value.to_s.split(/\s+/).reject(&:empty?)
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
- return [nil, nil] unless raw.start_with?("---")
229
-
230
- parts = raw.split(/^---\s*$/, 3)
231
- return [nil, nil] if parts.length < 3
264
+ parts = nil
265
+ if raw.start_with?("---")
266
+ parts = raw.split(/^---\s*$/, 3)
267
+ end
232
268
 
233
- frontmatter = YAML.safe_load(parts[1])
234
- return [nil, nil] unless frontmatter.is_a?(Hash)
269
+ frontmatter = nil
270
+ if parts && parts.length >= 3
271
+ frontmatter = YAML.safe_load(parts[1])
272
+ end
235
273
 
236
- [frontmatter, parts[2]]
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, :realpath, :validate, :validate_name,
243
- :validate_description, :parse_allowed_tools, :parse_frontmatter
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
 
@@ -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
- stack_key = if provider.start_with?("opencode")
38
- infer_stack_from_model(ctx[:model_name].to_s)
39
- else
40
- provider
41
- end
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