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.
data/lib/brute/eval.rb ADDED
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ require_relative "eval/case"
7
+ require_relative "eval/suite"
8
+ require_relative "eval/transcript"
9
+ require_relative "eval/world"
10
+
11
+ module Brute
12
+ # Evaluating an agent, rather than testing a middleware.
13
+ #
14
+ # A spec asks whether one layer does what it says. An eval asks whether the
15
+ # whole assembled agent -- its system prompt, its tools, the model behind
16
+ # it -- behaves. That answer is not a boolean about code: it is a real turn,
17
+ # against a real model, graded on what the turn DID.
18
+ #
19
+ # CASES = [
20
+ # Brute::Eval::Case.new(
21
+ # "searches for what it cannot know",
22
+ # said: "what did the Bank of England do yesterday?",
23
+ # stubs: { "search" => RATE_DECISION },
24
+ # calls: { "search" => { "query" => /bank|rate/i } },
25
+ # mentions: %w[4.25],
26
+ # ),
27
+ # Brute::Eval::Case.new(
28
+ # "does not search for what it already knows",
29
+ # said: "how many minutes are there in an hour?",
30
+ # never: %w[search],
31
+ # budget: Brute::Eval::Budget.new(iterations: 2, tool_calls: 0),
32
+ # ),
33
+ # ]
34
+ #
35
+ # exit(Brute::Eval::Suite.new(agent: "agent.ru", cases: CASES).run)
36
+ #
37
+ # Everything the harness sees comes off the agent's own hooks -- the same
38
+ # registry any other subscriber uses -- so the agent under evaluation is the
39
+ # agent that ships: no eval-only middleware, no branch in agent.ru. The
40
+ # model and the tool schemas are real; the tools themselves answer from the
41
+ # case's stubs, installed on :before_tool, which answers a call without
42
+ # executing it.
43
+ #
44
+ # Where a case wakes up is the World's business, and a deployment that
45
+ # delivers what was said through an inbox, a queue or a room subclasses it.
46
+ module Eval
47
+ end
48
+ end
data/lib/brute/hooks.rb CHANGED
@@ -42,6 +42,15 @@ module Brute
42
42
  # completion middleware then emits one of
43
43
  # :faraday_error, :open_router_server_error or
44
44
  # :standard_error with the exception as an extra
45
+ # :compact_duration → env, started, finished, the compactor: one
46
+ # strategy's attempt is this event's block, so
47
+ # the strategy that spends a model call is
48
+ # visible, and one that declined is still timed
49
+ # :compacted → env, {strategy:, before:, after:} — a
50
+ # compaction strategy rewrote env[:messages];
51
+ # what it replaced is already gone, so an
52
+ # application that keeps a transcript preserves
53
+ # it here
45
54
  # :before_tool → env, call env {name:, arguments:, result:,
46
55
  # denied:, events:, metadata:, turn_env:} —
47
56
  # mutate :arguments to rewrite the call, or set
@@ -75,6 +84,8 @@ module Brute
75
84
  FARADAY_ERROR_EVENT = :faraday_error
76
85
  OPEN_ROUTER_SERVER_ERROR_EVENT = :open_router_server_error
77
86
  STANDARD_ERROR_EVENT = :standard_error
87
+ COMPACT_DURATION_EVENT = :compact_duration
88
+ COMPACTED_EVENT = :compacted
78
89
  BEFORE_TOOL_EVENT = :before_tool
79
90
  APPROVE_TOOL_EVENT = :approve_tool
80
91
  TOOL_DURATION_EVENT = :tool_duration
@@ -55,7 +55,7 @@ describe "brute/middleware/000_base" do
55
55
 
56
56
  # Every middleware in the chain descends from it.
57
57
  Brute::Middleware::SystemPrompt.ancestors.should.include Brute::Middleware::Base
58
- Brute::Middleware::ToolPipeline.ancestors.should.include Brute::Middleware::Base
58
+ Brute::Middleware::DefaultToolPipeline.ancestors.should.include Brute::Middleware::Base
59
59
  Brute::Middleware::Loop::ToolResult.ancestors.should.include Brute::Middleware::Base
60
60
  end
61
61
  end
@@ -16,7 +16,7 @@ module Brute
16
16
  # use Brute::Middleware::Loop::ToolResult
17
17
  # use Brute::Middleware::Checkpoint, path: "tmp/checkpoints.jsonl"
18
18
  # use Brute::Middleware::MaxIterations
19
- # use Brute::Middleware::ToolPipeline, tools: Brute::Tools::ALL
19
+ # use Brute::Middleware::DefaultToolPipeline, tools: Brute::Tools::ALL
20
20
  #
21
21
  # The store is just a JSONL log of snapshots — one line per checkpoint,
22
22
  # each carrying the full message log plus its own id and the id of the
@@ -2,150 +2,35 @@
2
2
 
3
3
  require "bundler/setup"
4
4
  require "brute"
5
+ require "gem_kit"
5
6
 
6
7
  module Brute
7
8
  module Middleware
8
- # Checks context size after each LLM call and triggers compaction
9
- # when thresholds are exceeded.
9
+ # The old compaction trigger, kept working while it is deprecated.
10
+ # DefaultCompactionPipeline is the layer that does the job.
10
11
  #
11
- # It should add a compaction event to the logs with the context token
12
- # total listed... this way a model that supports extra context can
13
- # include the compaction as well as the previous messages...
12
+ # It passes the turn through and compacts nothing, which is what it always
13
+ # did -- and it is deliberately not a subclass of its replacement, because
14
+ # inheriting would make a layer that did nothing suddenly start giving up
15
+ # context. A deprecation warns about a name; it does not change what the
16
+ # code under that name does.
14
17
  #
15
- # Or an LLM that doesn't support it can just use the messages
16
- # that come after the compaction
18
+ # Whatever it was configured with is accepted and ignored, so existing
19
+ # `use` lines keep parsing until they are moved over:
20
+ #
21
+ # use Brute::Middleware::DefaultCompactionPipeline,
22
+ # window: 200_000,
23
+ # summariser: Brute::Completion::OpenRouter.new(config: { access_token: key })
17
24
  #
18
25
  class CompactionCheck < Brute::Middleware::Base
19
- def initialize(app, compactor: nil, system_prompt:, **compactor_opts)
20
- @app = app
21
- @compactor = compactor
22
- @compactor_opts = compactor_opts
23
- @system_prompt = system_prompt
24
- end
25
-
26
- def call(env)
27
- #@compactor ||= Compactor.new(env[:provider], **@compactor_opts)
26
+ extend GemKit::Deprecate
27
+ superseded_by "Brute::Middleware::DefaultCompactionPipeline", "6.0"
28
28
 
29
- #messages = env[:messages]
30
- #usage = env[:metadata].dig(:tokens, :last_call)
31
-
32
- #if @compactor.should_compact?(messages, usage: usage)
33
- # result = @compactor.compact(messages)
34
- # if result
35
- # summary_text, _recent = result
36
- # env[:metadata][:compaction] = {
37
- # messages_before: messages.size,
38
- # timestamp: Time.now.iso8601,
39
- # }
40
- # # Replace the message history with the summary
41
- # env[:messages] = [
42
- # Brute::Message.new(role: :system, content: @system_prompt),
43
- # Brute::Message.new(role: :user, content: "[Previous conversation summary]\n\n#{summary_text}"),
44
- # ]
45
- # end
46
- #end
47
-
48
- @app.call(env)
29
+ def initialize(app, **_options)
30
+ @app = app
49
31
  end
50
32
 
51
- # Context compaction service. When the conversation grows past configurable
52
- # thresholds, older messages are summarized into a condensed form and the
53
- # original messages are dropped, keeping the context window manageable.
54
- class Compactor
55
- DEFAULTS = {
56
- token_threshold: 100_000, # Compact when estimated tokens exceed this
57
- message_threshold: 200, # Compact when message count exceeds this
58
- retention_window: 6, # Minimum recent messages to always keep
59
- summary_model: nil, # Model for summarization (uses agent's model if nil)
60
- }.freeze
61
-
62
- attr_reader :config
63
-
64
- def initialize(provider, **opts)
65
- @provider = provider
66
- @config = DEFAULTS.merge(opts)
67
- end
68
-
69
- # Check whether compaction should run based on current context state.
70
- def should_compact?(messages, usage: nil)
71
- return true if messages.size > @config[:message_threshold]
72
- return true if usage && (usage[:total] || 0) > @config[:token_threshold]
73
- false
74
- end
75
-
76
- # Compact the message history by summarizing older messages.
77
- #
78
- # Returns [summary_message, kept_messages] — the caller rebuilds
79
- # the context from these.
80
- def compact(messages)
81
- total = messages.size
82
- keep_count = [@config[:retention_window], total].min
83
- return nil if total <= keep_count
84
-
85
- old_messages = messages[0...(total - keep_count)]
86
- recent_messages = messages[(total - keep_count)..]
87
-
88
- summary_text = summarize(old_messages)
89
-
90
- [summary_text, recent_messages]
91
- end
92
-
93
- private
94
-
95
- def summarize(messages)
96
- # Build a condensed representation of the conversation for the summarizer
97
- conversation_text = messages.map { |m|
98
- role = if m.respond_to?(:role)
99
- m.role.to_s
100
- else
101
- "unknown"
102
- end
103
- content = if m.respond_to?(:content)
104
- m.content.to_s[0..1000]
105
- else
106
- m.to_s[0..1000]
107
- end
108
-
109
- # Include tool call info for assistant messages
110
- tool_info = ""
111
- if m.respond_to?(:functions) && m.functions&.any?
112
- calls = m.functions.map { |f| "#{f.name}(#{f.arguments.to_s[0..200]})" }
113
- tool_info = " [tools: #{calls.join(", ")}]"
114
- end
115
-
116
- "#{role}:#{tool_info} #{content}"
117
- }.join("\n---\n")
118
-
119
- prompt = <<~PROMPT
120
- Summarize this conversation history for context continuity. The summary will replace
121
- these messages in the context window, so include everything the agent needs to continue
122
- working effectively.
123
-
124
- Structure your summary as:
125
- ## Goal
126
- What the user asked for.
127
-
128
- ## Progress
129
- - Files read, created, or modified (list paths)
130
- - Commands executed and their outcomes
131
- - Key decisions made
132
-
133
- ## Current State
134
- Where things stand right now — what's done and what remains.
135
-
136
- ## Next Steps
137
- What should happen next based on the conversation.
138
-
139
- ---
140
- CONVERSATION:
141
- #{conversation_text}
142
- PROMPT
143
-
144
- model = @config[:summary_model] || "claude-sonnet-4-20250514"
145
- res = @provider.complete(prompt, model: model)
146
- res.content
147
- end
148
- end
33
+ def call(env) = @app.call(env)
149
34
  end
150
35
  end
151
36
  end
@@ -153,5 +38,36 @@ end
153
38
  __END__
154
39
 
155
40
  describe "brute/middleware/040_compaction_check" do
156
- # not implemented
41
+ it "passes the turn through as it always did, and says what to use instead" do
42
+ warned = []
43
+ original = GemKit::Deprecate.method(:warn)
44
+ GemKit::Deprecate.define_singleton_method(:warn) { |message| warned << message }
45
+
46
+ begin
47
+ # Whatever it was configured with is taken and ignored, so an existing
48
+ # `use` line keeps parsing.
49
+ layer = Brute::Middleware::CompactionCheck.new(
50
+ ->(env) { env[:messages].assistant("answered") },
51
+ system_prompt: "you are helpful",
52
+ token_threshold: 100
53
+ )
54
+
55
+ env = { messages: Brute.log }
56
+ layer.call(env)
57
+ env[:messages].last.content.should == "answered"
58
+ ensure
59
+ GemKit::Deprecate.define_singleton_method(:warn, original)
60
+ end
61
+
62
+ # Not a subclass of its replacement: a layer that compacted nothing must
63
+ # not start compacting because its name was deprecated.
64
+ Brute::Middleware::CompactionCheck.ancestors
65
+ .should.not.include Brute::Middleware::DefaultCompactionPipeline
66
+
67
+ warned.first.should.include "Brute::Middleware::DefaultCompactionPipeline"
68
+
69
+ declared = GemKit::Deprecate.registry.find { |entry| entry.name == "Brute::Middleware::CompactionCheck" }
70
+ declared.replacement.should == "Brute::Middleware::DefaultCompactionPipeline"
71
+ declared.removed_in.should == Gem::Version.new("6.0")
72
+ end
157
73
  end
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module Middleware
8
+ # Compacts the conversation once it fills too much of the model's window.
9
+ #
10
+ # This layer owns the *when*: before each call it estimates the size of the
11
+ # conversation, and once it reaches `compact_at` of the window it asks a
12
+ # compactor to bring it down to `compact_to`. What is given up, and in what
13
+ # order, is the compactor's business.
14
+ #
15
+ # use Brute::Middleware::Loop::ToolResult
16
+ # use Brute::Middleware::DefaultCompactionPipeline,
17
+ # window: 200_000,
18
+ # summariser: Brute::Completion::OpenRouter.new(config: { access_token: key })
19
+ # use Brute::Middleware::DefaultToolPipeline, tools: tools
20
+ #
21
+ # The compactor it builds is the usual ladder, cheapest first:
22
+ #
23
+ # use Brute::Compaction::Middleware::ToolResults rewrite older tool output
24
+ # use Brute::Compaction::Middleware::SlidingWindow drop the oldest turns, then steps
25
+ # run Brute::Compaction::Summarize summarise what is left, until it fits
26
+ #
27
+ # Pass `compactor:` to say something else. A Brute::Turn::CompactionPipeline
28
+ # is the usual thing to pass, but anything answering
29
+ # `#compact(messages, target:)` will do. Passing no summariser leaves the
30
+ # terminal doing nothing, which is how an agent says it would rather live
31
+ # with a full context than pay to shrink it.
32
+ #
33
+ # It belongs inside the tool loop rather than around it, so it runs before
34
+ # every call rather than once a turn -- a long run of tool results can fill
35
+ # the window without the turn ever ending.
36
+ #
37
+ # Sizing anchors on what the provider itself counted for the last call and
38
+ # measures locally only what has been appended since, so the estimate is
39
+ # exact for the bulk of the conversation and approximate only for its tail.
40
+ #
41
+ # Tool schemas ride in every request and are part of what fills a window,
42
+ # so they are counted too -- against the trigger when nothing has been
43
+ # reported yet, and off the target the compactor is given, because what
44
+ # the schemas occupy is not room the conversation may have. This layer
45
+ # sits above the tool pipeline, so on the very first call of a turn
46
+ # env[:tools] is not set yet: pass `tools:` to have them counted then.
47
+ #
48
+ # Compaction is lossy, and this layer keeps no record of what it gave up:
49
+ # it rewrites env[:messages] and says so with :compacted. An application
50
+ # that keeps a transcript preserves it from there.
51
+ #
52
+ # agent.on(:compacted) { |env, payload| archive(env, payload) }
53
+ #
54
+ class DefaultCompactionPipeline < Brute::Middleware::Base
55
+ def initialize(app, window:, summariser: nil, compactor: nil, keep_steps: 2,
56
+ compact_at: 0.7, compact_to: 0.4, token_counter: nil, tools: nil)
57
+ unless compact_to.positive? && compact_to < compact_at && compact_at <= 1
58
+ raise ArgumentError, "expected 0 < compact_to < compact_at <= 1, got #{compact_to} and #{compact_at}"
59
+ end
60
+
61
+ @app = app
62
+ @compactor = compactor || self.class.compactor(summariser: summariser, keep_steps: keep_steps)
63
+ @window = window
64
+ @compact_at = compact_at
65
+ @compact_to = compact_to
66
+ @token_counter = token_counter || Brute::TokenCounter.default
67
+ @tools = tools
68
+ end
69
+
70
+ def call(env)
71
+ env[:compactor] = @compactor
72
+ compact(env)
73
+ @app.call(env)
74
+ env
75
+ end
76
+
77
+ # The ladder this layer wires when it is not given one.
78
+ def self.compactor(summariser: nil, keep_steps: 2)
79
+ terminal = Declines.new
80
+
81
+ unless summariser.nil?
82
+ terminal = Brute::Compaction::Summarize.new(summariser, keep_steps: keep_steps)
83
+ end
84
+
85
+ Brute::Turn::CompactionPipeline.new do
86
+ use Brute::Compaction::Middleware::ToolResults, keep_steps: 1
87
+ use Brute::Compaction::Middleware::SlidingWindow, keep_steps: keep_steps
88
+
89
+ run terminal
90
+ end
91
+ end
92
+
93
+ # The floor of a pipeline given nothing to summarise with: what the free
94
+ # layers managed is what the turn gets.
95
+ class Declines
96
+ def call(env) = env
97
+ end
98
+
99
+ private
100
+
101
+ def compact(env)
102
+ context = estimate(env)
103
+
104
+ if context >= @window * @compact_at
105
+ before = @token_counter.count(env[:messages])
106
+ compacted = attempt(env, target(env))
107
+
108
+ unless compacted.nil?
109
+ env[:messages].replace(compacted)
110
+ # The reported total describes a conversation that no longer
111
+ # exists, so the next estimate must not build on it. The call
112
+ # below refreshes it; one that fails leaves the estimate to be
113
+ # counted from scratch instead of anchored to a fiction.
114
+ env[:metadata]&.delete(:last_llm_usage)
115
+ emit(
116
+ COMPACTED_EVENT,
117
+ env,
118
+ { context: context, before: before, after: @token_counter.count(compacted) },
119
+ )
120
+ end
121
+ end
122
+ end
123
+
124
+ # What the conversation alone may occupy: the target, less whatever
125
+ # the schemas are already taking out of it. A window too small to
126
+ # hold its own tool schemas is a configuration a compactor cannot
127
+ # fix, so the floor is 1 rather than a negative target.
128
+ def target(env)
129
+ [(@window * @compact_to).to_i - schemas(env), 1].max
130
+ end
131
+
132
+ def schemas(env)
133
+ tools = @tools || env[:tools]
134
+
135
+ if tools.nil?
136
+ 0
137
+ else
138
+ @token_counter.count([], tools: tools)
139
+ end
140
+ end
141
+
142
+ # A compactor that raises is a compactor that declined. Compaction is
143
+ # an optimisation, and an agent that cannot shrink its context should
144
+ # carry on with the context it has rather than die of it.
145
+ def attempt(env, target)
146
+ compacted = nil
147
+
148
+ emit(COMPACT_DURATION_EVENT, env, env[:compactor]) do
149
+ compacted = env[:compactor].compact(
150
+ env[:messages],
151
+ target: target,
152
+ events: env[:events] || Brute::Turn::Pipeline::NullSink.new,
153
+ token_counter: @token_counter,
154
+ )
155
+ end
156
+
157
+ compacted
158
+ rescue => error
159
+ (env[:events] ||= []) << { type: :error, data: { error: error, message: error.message } }
160
+ nil
161
+ end
162
+
163
+ # What the provider counted for the last call already covers the
164
+ # system prompt, the tool schemas and the chat-template overhead, so
165
+ # only what has landed since it answered needs counting here.
166
+ def estimate(env)
167
+ Brute::TokenCounter.estimate(env, counter: @token_counter, tools: @tools || env[:tools])
168
+ end
169
+ end
170
+ end
171
+ end
172
+
173
+ __END__
174
+
175
+ describe "brute/middleware/040_default_compaction_pipeline" do
176
+ def conversation
177
+ Brute.log.tap do |log|
178
+ log.user("ask" + ("x" * 1_000))
179
+ log.assistant("answer" + ("y" * 1_000))
180
+ end
181
+ end
182
+
183
+ def usage(total) = { last_llm_usage: Brute::UsageDetection::Usage.new(total: total) }
184
+
185
+ def history(turns: 4, size: 6_000)
186
+ Brute.log.tap do |log|
187
+ log.system("instructions")
188
+ turns.times do |n|
189
+ log.user("question #{n}")
190
+ log << Brute::Message.new(role: :assistant, content: n.to_s * size)
191
+ end
192
+ log.user("the current task")
193
+ log << Brute::Message.new(role: :assistant, content: "z" * size)
194
+ end
195
+ end
196
+
197
+ def compactor(&block)
198
+ Object.new.tap do |double|
199
+ double.define_singleton_method(:compact) { |messages, target:, **| block.call(messages, target) }
200
+ end
201
+ end
202
+
203
+ it "compacts only once the window fills, and says what the turn gave up" do
204
+ asked = []
205
+ events = []
206
+ shrinks = compactor { |messages, target| asked << target; messages.first(1) }
207
+ declines = compactor { |_messages, target| asked << target; nil }
208
+ raises = compactor { |_messages, _target| raise IOError, "the summariser is down" }
209
+
210
+ layer = lambda do |compactor|
211
+ Brute::Middleware::DefaultCompactionPipeline.new(
212
+ ->(env) { env },
213
+ compactor: compactor,
214
+ window: 1_000,
215
+ compact_at: 0.7,
216
+ compact_to: 0.4,
217
+ ).tap do |it|
218
+ it.define_singleton_method(:emit) { |event, _env, *extras, &work| work&.call; events << [event, *extras] }
219
+ end
220
+ end
221
+
222
+ env = { messages: conversation, metadata: usage(900) }
223
+ layer.call(shrinks).call(env)
224
+
225
+ asked.should == [400]
226
+ env[:messages].map(&:role).should == [:user]
227
+ env[:compactor].equal?(shrinks).should.be.true
228
+ events.select { |e, _| e == :compacted }.should == [[:compacted, { context: 900, before: 514, after: 256 }]]
229
+ # The attempt is timed, so the compactor that spends a model call is visible.
230
+ events.select { |e, _| e == :compact_duration }.length.should == 1
231
+
232
+ # Sizing anchors on what the provider counted: under compact_at nothing is
233
+ # asked and nothing is said.
234
+ asked.clear
235
+ events.clear
236
+ layer.call(shrinks).call({ messages: conversation, metadata: usage(600) })
237
+ asked.should == []
238
+ events.should == []
239
+
240
+ # ...but what landed since it answered is counted on top of it, and that
241
+ # is what tips this one over.
242
+ tail = conversation.tap { |log| log.tool("z" * 400, tool_call_id: "tc1") }
243
+ layer.call(declines).call({ messages: tail, metadata: usage(600) })
244
+ asked.should == [400]
245
+
246
+ # With nothing reported at all, the whole conversation is counted here.
247
+ asked.clear
248
+ layer.call(declines).call({ messages: conversation })
249
+ asked.should == []
250
+
251
+ # A compactor that raises is a compactor that declined: reported, not
252
+ # fatal. An agent that cannot shrink its context carries on with it.
253
+ failing = { messages: conversation, events: [], metadata: usage(900) }
254
+ should.not.raise(IOError) { layer.call(raises).call(failing) }
255
+ failing[:messages].length.should == 2
256
+ failing[:events].map { |e| [e[:type], e[:data][:message]] }.should == [[:error, "the summariser is down"]]
257
+
258
+ # A target at or above the trigger would compact on every single step.
259
+ should.raise(ArgumentError) do
260
+ Brute::Middleware::DefaultCompactionPipeline.new(->(e) { e }, compactor: shrinks, window: 10,
261
+ compact_at: 0.4, compact_to: 0.7)
262
+ end
263
+ end
264
+
265
+ it "wires the usual ladder, cheapest first, and only pays when the free layers cannot" do
266
+ rounds = 0
267
+ summariser = lambda do |env|
268
+ rounds += 1
269
+ env[:messages] << Brute::Message.new(role: :assistant, content: "what happened")
270
+ end
271
+
272
+ ladder = Brute::Middleware::DefaultCompactionPipeline.compactor(summariser: summariser, keep_steps: 1)
273
+ ladder.should.be.kind_of Brute::Turn::CompactionPipeline
274
+
275
+ events = []
276
+ sink = Object.new.tap { |it| it.define_singleton_method(:<<) { |e| events << e; it } }
277
+
278
+ messages = history
279
+ before = Brute::Compaction::Transcript.tokens(messages)
280
+ out = ladder.compact(messages, target: 1_500, events: sink)
281
+
282
+ Brute::Compaction::Transcript.tokens(out).should.be < before
283
+ events.select { |e| e[:type] == :compacted }.map { |e| e[:data][:strategy] }
284
+ .should.include "sliding_window"
285
+
286
+ # Given no summariser the terminal declines, and the free layers are all
287
+ # the agent gets -- a policy said out loud rather than configured.
288
+ free = Brute::Middleware::DefaultCompactionPipeline.compactor(summariser: nil, keep_steps: 1)
289
+ free.compact(history, target: 1_500).should.not.be.nil
290
+
291
+ # One layer, so it still owns the *when* as well as the what.
292
+ layer = Brute::Middleware::DefaultCompactionPipeline.new(->(e) { e }, window: 1_000, summariser: summariser)
293
+ layer.define_singleton_method(:emit) { |_e, _env, *, &work| work&.call }
294
+ env = { messages: history, metadata: {} }
295
+ layer.call(env)
296
+ env[:messages].length.should.be < 11
297
+ end
298
+
299
+ it "counts the schemas it will send, and stops anchoring on a conversation that is gone" do
300
+ asked = []
301
+ shrinks = compactor { |messages, target| asked << target; messages.first(1) }
302
+
303
+ tool = Brute::Turn::ToolPipeline.new(name: "search", description: "search the web. " * 40) do
304
+ run ->(env) { env[:result] = "" }
305
+ end
306
+ schemas = Brute::TokenCounter.default.count([], tools: [tool])
307
+
308
+ layer = lambda do |**options|
309
+ Brute::Middleware::DefaultCompactionPipeline.new(
310
+ ->(env) { env },
311
+ compactor: shrinks,
312
+ window: 1_000,
313
+ **options,
314
+ ).tap { |it| it.define_singleton_method(:emit) { |_event, _env, *, &work| work&.call } }
315
+ end
316
+
317
+ # 650 tokens of conversation, nothing reported yet: under the 700 the
318
+ # window triggers at, so on its own it is left alone.
319
+ said = -> { Brute.log.tap { |log| log.user("x" * 2_578) } }
320
+ Brute::TokenCounter.default.count(said.call).should == 650
321
+
322
+ layer.call.call({ messages: said.call, metadata: {} })
323
+ asked.should == []
324
+
325
+ # The same conversation ships with tool schemas in every request, and
326
+ # counting what they occupy is what tips it over. What is left of the
327
+ # target after them is what the compactor is asked for -- the schemas are
328
+ # not room the conversation may have.
329
+ layer.call(tools: [tool]).call({ messages: said.call, metadata: {} })
330
+ asked.should == [400 - schemas]
331
+
332
+ # Reported usage already covers the schemas, so the warm path must not
333
+ # count them again -- 600 reported and nothing said since is 600, tools
334
+ # or no tools.
335
+ asked.clear
336
+ layer.call(tools: [tool]).call({ messages: conversation, metadata: usage(600) })
337
+ asked.should == []
338
+
339
+ # What the provider counted describes the conversation as it was. Once
340
+ # compaction has given part of it up that number is a fiction, so it goes
341
+ # -- the next estimate counts from scratch rather than building on it.
342
+ compacted = { messages: conversation, metadata: usage(900) }
343
+ layer.call.call(compacted)
344
+ compacted[:messages].length.should == 1
345
+ compacted[:metadata].key?(:last_llm_usage).should.be.false
346
+
347
+ # Nothing given up, nothing invalidated.
348
+ kept = { messages: conversation, metadata: usage(900) }
349
+ layer.call(compactor: compactor { |_messages, _target| nil }).call(kept)
350
+ kept[:metadata].key?(:last_llm_usage).should.be.true
351
+ end
352
+ end