brute 5.0.5 → 5.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.
@@ -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
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module TokenCounter
8
+ # Tokens from OpenAI's byte-pair encoder, through the tiktoken_ruby gem.
9
+ #
10
+ # gem "tiktoken_ruby"
11
+ # use Brute::Middleware::DefaultCompactionPipeline,
12
+ # window: 200_000,
13
+ # token_counter: Brute::TokenCounter::Tiktoken.new
14
+ #
15
+ # Brute depends on no LLM library, and this is no exception: the gem is
16
+ # required the first time the counter is asked for a number, so an agent
17
+ # that never installs it never pays for it and never hears about it.
18
+ #
19
+ # It is exact about the text and still approximate about the request --
20
+ # `per_message` stands in for the chat-template framing, which is the
21
+ # provider's and not in any encoder.
22
+ class Tiktoken
23
+ ENCODING = "o200k_base"
24
+
25
+ def initialize(encoding: ENCODING, per_message: 4, encoder: nil)
26
+ @encoding = encoding
27
+ @per_message = per_message
28
+ @encoder = encoder
29
+ end
30
+
31
+ # Load the encoder, downloading its vocabulary if it is not cached yet.
32
+ def warm_up
33
+ @encoder ||= load_encoder
34
+ end
35
+
36
+ def count(messages, tools: nil)
37
+ warm_up
38
+ text = Rendering.conversation(messages) + Rendering.tools(tools)
39
+
40
+ @encoder.encode(text).length + (Array(messages).length * @per_message)
41
+ end
42
+
43
+ private
44
+
45
+ def load_encoder
46
+ begin
47
+ require "tiktoken_ruby"
48
+ rescue LoadError
49
+ raise LoadError, "#{self.class} needs the tiktoken_ruby gem: add `gem \"tiktoken_ruby\"` to your Gemfile"
50
+ end
51
+
52
+ ::Tiktoken.get_encoding(@encoding)
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ __END__
59
+
60
+ describe "brute/token_counter/tiktoken" do
61
+ it "encodes the same rendering the approximate counter measures, and says what to install when it cannot" do
62
+ encoded = []
63
+ encoder = Object.new
64
+ encoder.define_singleton_method(:encode) { |text| encoded << text; text.split(/\b/) }
65
+
66
+ log = Brute.log
67
+ log.user("what changed?")
68
+
69
+ counter = Brute::TokenCounter::Tiktoken.new(encoder: encoder, per_message: 4)
70
+ counter.count(log).should == encoder.encode("user: what changed?").length + 4
71
+ encoded.last.should == "user: what changed?"
72
+
73
+ # The vocabulary is loaded once, on the first count rather than at boot.
74
+ counter.warm_up.should.equal? encoder
75
+
76
+ missing = Brute::TokenCounter::Tiktoken.new(encoding: "nothing-real")
77
+ missing.define_singleton_method(:require) { |_name| raise LoadError }
78
+ lambda { missing.count(log) }.should.raise(LoadError).message.should.include "tiktoken_ruby"
79
+ end
80
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "json"
6
+
7
+ module Brute
8
+ # How big a conversation is, in tokens.
9
+ #
10
+ # A counter is anything answering `count(messages, tools: nil)`. The tools
11
+ # are part of the question because their schemas ride in every request
12
+ # alongside the messages -- an agent carrying a dozen of them is spending
13
+ # context on JSON Schema before anyone has said a word.
14
+ #
15
+ # counter = Brute::TokenCounter::Approximate.new
16
+ # counter.count(env[:messages], tools: env[:tools])
17
+ #
18
+ # What a turn is *currently* costing is a different question, and
19
+ # `estimate` answers it: the provider counted the conversation exactly when
20
+ # it answered, so that number is trusted and only what has been appended
21
+ # since is counted locally.
22
+ #
23
+ # Brute::TokenCounter.estimate(env)
24
+ #
25
+ module TokenCounter
26
+ def self.default = Approximate.new
27
+
28
+ # What the whole turn costs right now.
29
+ #
30
+ # :counter defaults to the one the turn already decided on
31
+ # (env[:token_counter]), :tools to the ones the pipeline advertises
32
+ # (env[:tools]).
33
+ #
34
+ # The reported total already covers the system prompt, the tool schemas
35
+ # and the provider's own chat-template overhead, so the warm path adds
36
+ # only the messages that landed after the reply it describes -- and never
37
+ # the schemas, which are already inside it. Anything else double counts.
38
+ def self.estimate(env, counter: nil, tools: nil)
39
+ counter ||= env[:token_counter] || default
40
+ tools = env[:tools] if tools.nil?
41
+ messages = env[:messages] || []
42
+ reported = env.dig(:metadata, :last_llm_usage)&.total.to_i
43
+ answered = messages.rindex { |message| message.role == :assistant }
44
+
45
+ # Nothing sent yet, or a conversation the reported number no longer
46
+ # describes -- a compaction that left no reply behind, say. Count it all.
47
+ if reported.zero? || answered.nil?
48
+ counter.count(messages, tools: tools)
49
+ else
50
+ reported + counter.count(messages[(answered + 1)..])
51
+ end
52
+ end
53
+
54
+ # The text a counter measures.
55
+ #
56
+ # One rendering, so two counters cannot disagree about what the
57
+ # conversation even is -- and it is the same text a summariser reads,
58
+ # which is why it says who spoke and what was called rather than being
59
+ # the shortest thing that could be measured.
60
+ module Rendering
61
+ def self.conversation(messages) = Array(messages).map { |message| message(message) }.join("\n")
62
+
63
+ def self.message(message)
64
+ parts = ["#{message.role}:", message.content.to_s]
65
+
66
+ message.tool_calls&.each do |call|
67
+ parts << "#{call.name}(#{JSON.generate(call.arguments)}) -> #{call.id}"
68
+ end
69
+
70
+ unless message.tool_call_id.nil?
71
+ parts << "(answering #{message.tool_call_id})"
72
+ end
73
+
74
+ parts.reject(&:empty?).join(" ")
75
+ end
76
+
77
+ # The schemas as the provider is given them, which is what they cost.
78
+ def self.tools(tools)
79
+ wrapped = Brute::Tools::Adapter.wrap_all(tools || [])
80
+
81
+ if wrapped.empty?
82
+ ""
83
+ else
84
+ JSON.generate(wrapped.values.map(&:to_h))
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ __END__
92
+
93
+ describe "brute/token_counter" do
94
+ def usage(total) = Brute::UsageDetection::Usage.new(total: total)
95
+
96
+ it "measures a conversation and its schemas, and trusts the provider for what it already counted" do
97
+ log = Brute.log
98
+ log.system("you are a helpful agent")
99
+ log.user("what changed?")
100
+
101
+ counter = Brute::TokenCounter.default
102
+ counter.should.be.kind_of Brute::TokenCounter::Approximate
103
+
104
+ text = Brute::TokenCounter::Rendering.conversation(log)
105
+ text.should == "system: you are a helpful agent\nuser: what changed?"
106
+
107
+ # A tool call and the result answering it are rendered too -- both cost
108
+ # tokens, and neither is in the message's content.
109
+ log << Brute::Message.new(
110
+ role: :assistant,
111
+ content: "",
112
+ tool_calls: [{ id: "tc1", name: "search", arguments: { "query" => "fed" } }]
113
+ )
114
+ log.tool("found nothing", tool_call_id: "tc1")
115
+ Brute::TokenCounter::Rendering.conversation(log).should.include 'search({"query":"fed"}) -> tc1'
116
+ Brute::TokenCounter::Rendering.conversation(log).should.include "(answering tc1)"
117
+
118
+ # Schemas ride in every request, so they are part of the question.
119
+ tool = Brute::Turn::ToolPipeline.new(name: "search", description: "search the web") do
120
+ run ->(env) { env[:result] = "" }
121
+ end
122
+ Brute::TokenCounter::Rendering.tools(nil).should == ""
123
+ Brute::TokenCounter::Rendering.tools([tool]).should.include "search the web"
124
+ counter.count(log, tools: [tool]).should > counter.count(log)
125
+
126
+ # Nothing reported yet: everything is counted, schemas included.
127
+ cold = { messages: log, tools: [tool], metadata: {} }
128
+ Brute::TokenCounter.estimate(cold).should == counter.count(log, tools: [tool])
129
+
130
+ # Reported: the provider's number, plus only what landed after the reply
131
+ # it describes. The schemas are already inside it.
132
+ log.assistant("nothing changed")
133
+ log.user("are you sure?")
134
+ warm = { messages: log, tools: [tool], metadata: { last_llm_usage: usage(9_000) } }
135
+ Brute::TokenCounter.estimate(warm).should == 9_000 + counter.count([log.last])
136
+
137
+ # A conversation the reported number no longer describes -- a compaction
138
+ # that left no reply behind -- is counted from scratch rather than added
139
+ # to a total for a conversation that is gone.
140
+ stale = { messages: Brute.log.tap { |l| l.user("only me") }, metadata: { last_llm_usage: usage(9_000) } }
141
+ Brute::TokenCounter.estimate(stale).should == counter.count(stale[:messages])
142
+
143
+ # The counter the turn already decided on is the one that gets used.
144
+ given = { messages: log, metadata: {}, token_counter: Object.new }
145
+ given[:token_counter].define_singleton_method(:count) { |_messages, tools: nil| 7 }
146
+ Brute::TokenCounter.estimate(given).should == 7
147
+ end
148
+ end
@@ -118,6 +118,10 @@ module Brute
118
118
  )
119
119
  end
120
120
 
121
+ # The tool object this adapter wraps (Brute::Tool, Brute::Turn::ToolPipeline,
122
+ # SubAgent, Hash definition, ...).
123
+ attr_reader :original
124
+
121
125
  def initialize(name:, description:, params:, handler:, schema: nil, original: nil)
122
126
  @name = name
123
127
  @description = description
@@ -127,10 +131,6 @@ module Brute
127
131
  @original = original
128
132
  end
129
133
 
130
- # The tool object this adapter wraps (Brute::Tool, Brute::Turn::ToolPipeline,
131
- # SubAgent, Hash definition, ...).
132
- attr_reader :original
133
-
134
134
  # Execute the tool. Accepts string- or symbol-keyed argument hashes,
135
135
  # as delivered by LLM providers.
136
136
  def call(arguments = {})
@@ -40,13 +40,13 @@ module Brute
40
40
 
41
41
  FILE_LIMIT = 10
42
42
 
43
- def name; "skill"; end
44
-
45
43
  def initialize(cwd: Dir.pwd)
46
44
  super()
47
45
  @cwd = cwd
48
46
  end
49
47
 
48
+ def name; "skill"; end
49
+
50
50
  def execute(name:)
51
51
  skill = Brute::Skill.get(name, cwd: @cwd)
52
52
  return unknown_skill(name) unless skill
@@ -21,7 +21,7 @@ module Brute
21
21
  # use Brute::Middleware::SystemPrompt
22
22
  # use Brute::Middleware::Loop::ToolResult
23
23
  # use Brute::Middleware::MaxIterations, max_iterations: 10
24
- # use Brute::Middleware::ToolPipeline, tools: [Brute::Tools::FSRead, Brute::Tools::FSSearch]
24
+ # use Brute::Middleware::DefaultToolPipeline, tools: [Brute::Tools::FSRead, Brute::Tools::FSSearch]
25
25
  # run ->(env) do
26
26
  # # The LLM call, written with your library of choice. Convert
27
27
  # # env[:messages] to its format, call it, and append the response
@@ -32,7 +32,7 @@ module Brute
32
32
  #
33
33
  # # The SubAgent IS a tool — hand it to a parent agent's ToolPipeline:
34
34
  # main_agent = Brute.agent do
35
- # use Brute::Middleware::ToolPipeline, tools: [Brute::Tools::FSRead, researcher]
35
+ # use Brute::Middleware::DefaultToolPipeline, tools: [Brute::Tools::FSRead, researcher]
36
36
  # run ->(env) { ... }
37
37
  # end
38
38
  # main_agent.start("delegate some research")
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/turn/pipeline"
6
+
7
+ module Brute
8
+ module Turn
9
+ # A compactor built out of middleware.
10
+ #
11
+ # Like ToolPipeline it *composes* a Pipeline rather than inheriting: the
12
+ # definition block is instance_eval'd into the internal Pipeline, so `use`
13
+ # / `run` inside it are the builder's methods.
14
+ #
15
+ # The stack order is the policy. A layer that got the conversation under
16
+ # target does not call the next one, so the strategies that cost nothing
17
+ # sit at the top and the terminal app -- the summariser, the only thing
18
+ # here that spends money -- is reached only when they could not get there.
19
+ # That is `run` meaning what it means everywhere else in Brute: the model
20
+ # call, at the bottom, with the layers deciding what reaches it.
21
+ #
22
+ # Brute::Turn::CompactionPipeline.new do
23
+ # use Brute::Compaction::Middleware::ToolResults, keep_steps: 1
24
+ # use Brute::Compaction::Middleware::SlidingWindow, keep_steps: 2
25
+ #
26
+ # run Brute::Compaction::Summarize.new(
27
+ # Brute::Completion::OpenRouter.new(config: { access_token: key }),
28
+ # )
29
+ # end
30
+ #
31
+ # A pipeline that must never spend a call is the first two layers and
32
+ # `run ->(env) { env }` -- a policy said out loud rather than configured.
33
+ #
34
+ # The conversation being compacted rides on `env[:conversation]`, leaving
35
+ # `env[:messages]` to mean what it means to every other terminal app in
36
+ # Brute: the prompt going down, the reply coming back.
37
+ #
38
+ # It answers #compact, so what comes out drops into
39
+ # Brute::Middleware::DefaultCompactionPipeline as that turn's compactor.
40
+ class CompactionPipeline
41
+ def initialize(&block)
42
+ @pipeline = Pipeline.new
43
+ @pipeline.instance_eval(&block) if block
44
+ end
45
+
46
+ # Answer a smaller conversation, or nil when nothing was given up.
47
+ def compact(messages, target:, events: Pipeline::NullSink.new, token_counter: nil)
48
+ env = {
49
+ conversation: messages,
50
+ target: target,
51
+ token_counter: token_counter,
52
+ applied: [],
53
+ messages: Brute.log,
54
+ events: events,
55
+ metadata: {},
56
+ }
57
+ @pipeline.call(env)
58
+
59
+ unless env[:applied].empty?
60
+ env[:conversation]
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ __END__
68
+
69
+ describe "brute/turn/compaction_pipeline" do
70
+ def said(content) = Brute::Message.new(role: :user, content: content)
71
+
72
+ def layer(name, &block)
73
+ Class.new(Brute::Compaction::Middleware::Strategy) do
74
+ define_method(:strategy) { name }
75
+ define_method(:rewrite) { |conversation, target:| block.call(conversation, target) }
76
+ end
77
+ end
78
+
79
+ it "composes strategies into one compactor, and only reaches run when they could not" do
80
+ reached = []
81
+ halves = layer("halves") { |c, _t| reached << "halves"; c.first(c.length / 2) }
82
+ never = layer("never") { |_c, _t| reached << "never"; nil }
83
+ # A terminal belongs to one pipeline: the builder binds an emit onto it
84
+ # and refuses to bind a second, so each pipeline gets its own.
85
+ terminal = lambda do
86
+ Object.new.tap { |it| it.define_singleton_method(:call) { |env| reached << "run"; env } }
87
+ end
88
+
89
+ four = [said("a" * 4_000), said("b" * 4_000), said("c" * 4_000), said("d" * 4_000)]
90
+
91
+ # Halving is enough, so the terminal -- the one that costs money -- is
92
+ # never reached. The stack order is the policy.
93
+ pipeline = Brute::Turn::CompactionPipeline.new do
94
+ use halves
95
+ run terminal.call
96
+ end
97
+ pipeline.compact(four, target: 3_000).map { |m| m.content[0, 1] }.should == ["a", "b"]
98
+ reached.should == ["halves"]
99
+
100
+ # A strategy that declines lets the turn fall through to it.
101
+ reached.clear
102
+ Brute::Turn::CompactionPipeline.new do
103
+ use never
104
+ run terminal.call
105
+ end.compact(four, target: 3_000).should.be.nil
106
+ reached.should == ["never", "run"]
107
+
108
+ # Nothing given up at all is nil, not the conversation it was handed --
109
+ # the caller applies whatever else comes back.
110
+ reached.clear
111
+ pipeline.compact(four, target: 500_000).should.be.nil
112
+ reached.should == []
113
+
114
+ # The conversation rides on :conversation, leaving :messages to mean what
115
+ # it means to every other terminal app -- the prompt down, the reply back.
116
+ seen = nil
117
+ Brute::Turn::CompactionPipeline.new do
118
+ run ->(env) { seen = [env[:conversation].length, env[:messages], env[:target]] }
119
+ end.compact(four, target: 10)
120
+ seen.should == [4, [], 10]
121
+ end
122
+ end
data/lib/brute/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brute
4
- VERSION = "5.0.5"
4
+ VERSION = "5.1.0"
5
5
  end
data/lib/brute.rb CHANGED
@@ -42,7 +42,7 @@ module Brute
42
42
  #
43
43
  # agent = Brute.agent
44
44
  # .use(Brute::Middleware::SystemPrompt)
45
- # .use(Brute::Middleware::ToolPipeline, tools: Brute::Tools::ALL)
45
+ # .use(Brute::Middleware::DefaultToolPipeline, tools: Brute::Tools::ALL)
46
46
  # .run ->(env) { ... } # the LLM-call proc (provider/model/creds here)
47
47
  #
48
48
  # agent.start("what changed?")
@@ -93,7 +93,14 @@ end
93
93
  require_relative "brute/version"
94
94
  require_relative "brute/hooks"
95
95
  require_relative "brute/messages"
96
+ require_relative "brute/env"
96
97
  require_relative "brute/middleware/000_base"
98
+ require_relative "brute/compaction"
99
+ require_relative "brute/compaction/transcript"
100
+ require_relative "brute/compaction/middleware/strategy"
101
+ require_relative "brute/compaction/middleware/tool_results"
102
+ require_relative "brute/compaction/middleware/sliding_window"
103
+ require_relative "brute/compaction/summarize"
97
104
  require_relative "brute/completion/open_router"
98
105
  require_relative "brute/completion/lang_chain"
99
106
  require_relative "brute/completion/llmrb"
@@ -124,8 +131,10 @@ require_relative "brute/middleware/008_checkpoint"
124
131
  require_relative "brute/middleware/010_max_iterations"
125
132
  require_relative "brute/middleware/020_system_prompt"
126
133
  require_relative "brute/middleware/025_skills"
134
+ require_relative "brute/middleware/040_default_compaction_pipeline"
127
135
  require_relative "brute/middleware/040_compaction_check"
128
136
  require_relative "brute/middleware/060_questions"
137
+ require_relative "brute/middleware/070_default_tool_pipeline"
129
138
  require_relative "brute/middleware/070_tool_pipeline"
130
139
  require_relative "brute/middleware/event_handler"
131
140
  require_relative "brute/middleware/user_queue"
@@ -157,6 +166,9 @@ require_relative "brute/prompts/tool_usage"
157
166
  require_relative "brute/rack/adapter"
158
167
  require_relative "brute/skill"
159
168
  require_relative "brute/system_prompt"
169
+ require_relative "brute/token_counter"
170
+ require_relative "brute/token_counter/approximate"
171
+ require_relative "brute/token_counter/tiktoken"
160
172
  require_relative "brute/tool"
161
173
  require_relative "brute/tools"
162
174
  require_relative "brute/tools/adapter"
@@ -179,7 +191,9 @@ require_relative "brute/tools/todo_write"
179
191
  require_relative "brute/truncation"
180
192
  require_relative "brute/turn/agent_pipeline"
181
193
  require_relative "brute/turn/pipeline"
194
+ require_relative "brute/turn/compaction_pipeline"
182
195
  require_relative "brute/turn/tool_pipeline"
196
+ require_relative "brute/eval"
183
197
  require_relative "brute/utils/diff"
184
198
 
185
199
  # gangsta g-dogg bruh...
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: 5.0.5
4
+ version: 5.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brute Contributors
@@ -282,12 +282,24 @@ extensions: []
282
282
  extra_rdoc_files: []
283
283
  files:
284
284
  - lib/brute.rb
285
+ - lib/brute/compaction.rb
286
+ - lib/brute/compaction/middleware/sliding_window.rb
287
+ - lib/brute/compaction/middleware/strategy.rb
288
+ - lib/brute/compaction/middleware/tool_results.rb
289
+ - lib/brute/compaction/summarize.rb
290
+ - lib/brute/compaction/transcript.rb
285
291
  - lib/brute/completion/lang_chain.rb
286
292
  - lib/brute/completion/llmrb.rb
287
293
  - lib/brute/completion/open_router.rb
288
294
  - lib/brute/completion/ruby_llm.rb
289
295
  - lib/brute/contrib/log_file.rb
290
296
  - lib/brute/contrib/otel.rb
297
+ - lib/brute/env.rb
298
+ - lib/brute/eval.rb
299
+ - lib/brute/eval/case.rb
300
+ - lib/brute/eval/suite.rb
301
+ - lib/brute/eval/transcript.rb
302
+ - lib/brute/eval/world.rb
291
303
  - lib/brute/events/handler.rb
292
304
  - lib/brute/events/prefixed_terminal_output.rb
293
305
  - lib/brute/events/terminal_output_handler.rb
@@ -311,7 +323,9 @@ files:
311
323
  - lib/brute/middleware/020_system_prompt.rb
312
324
  - lib/brute/middleware/025_skills.rb
313
325
  - lib/brute/middleware/040_compaction_check.rb
326
+ - lib/brute/middleware/040_default_compaction_pipeline.rb
314
327
  - lib/brute/middleware/060_questions.rb
328
+ - lib/brute/middleware/070_default_tool_pipeline.rb
315
329
  - lib/brute/middleware/070_tool_pipeline.rb
316
330
  - lib/brute/middleware/event_handler.rb
317
331
  - lib/brute/middleware/user_queue.rb
@@ -362,6 +376,9 @@ files:
362
376
  - lib/brute/rack/adapter.rb
363
377
  - lib/brute/skill.rb
364
378
  - lib/brute/system_prompt.rb
379
+ - lib/brute/token_counter.rb
380
+ - lib/brute/token_counter/approximate.rb
381
+ - lib/brute/token_counter/tiktoken.rb
365
382
  - lib/brute/tool.rb
366
383
  - lib/brute/tools.rb
367
384
  - lib/brute/tools/adapter.rb
@@ -383,6 +400,7 @@ files:
383
400
  - lib/brute/tools/todo_write.rb
384
401
  - lib/brute/truncation.rb
385
402
  - lib/brute/turn/agent_pipeline.rb
403
+ - lib/brute/turn/compaction_pipeline.rb
386
404
  - lib/brute/turn/pipeline.rb
387
405
  - lib/brute/turn/tool_pipeline.rb
388
406
  - lib/brute/usage_detection/lang_chain.rb