brute 5.0.4 → 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.
@@ -2,146 +2,20 @@
2
2
 
3
3
  require "bundler/setup"
4
4
  require "brute"
5
- require "brute/truncation"
6
- require "async"
7
- require "async/barrier"
5
+ require "gem_kit"
8
6
 
9
7
  module Brute
10
8
  module Middleware
11
- class ToolPipeline < Brute::Middleware::Base
12
- def initialize(app, tools: [])
13
- @app = app
14
- @tools = tools
15
- end
16
-
17
- def call(env)
18
- env[:tools] = @tools
19
- @app.call(env)
20
-
21
- response = env[:messages].last
22
-
23
- if response.respond_to?(:tool_calls) && response.tool_calls.present?
24
- tools_to_run = response.tool_calls
25
-
26
- # tool_to_run may be an Array (Brute::ToolCall) or an id-keyed Hash (some libraries' native shape)
27
- if tools_to_run.respond_to?(:values)
28
- tools_to_run = tools_to_run.values
29
- end
30
-
31
- # No idea why we use Array() here... probably in case it's a hash or something...
32
- # because reject would work on the hash...
33
- tools_to_run = Array(tools_to_run).reject { |tc| tc.name == "question" }
34
-
35
- available_tools = Brute::Tools::Adapter.wrap_all(env[:tools])
36
- env[:events] << on_tool_call_start_event(tools_to_run)
37
-
38
- results = []
39
-
40
- # Async::Barrier blocks until all tasks are complete.
41
- # Tasks run in parrallel.
42
- #
43
- Sync do
44
- barrier = Async::Barrier.new
45
-
46
- tools_to_run.each do |tool_call|
47
- barrier.async do
48
- name = tool_call.name.to_sym
49
- args = tool_call.arguments
50
-
51
- # Lifecycle hooks (Brute::Hooks): before_tool may rewrite
52
- # :arguments or short-circuit with a :result; approve_tool
53
- # denies on a false (or String) return; after_tool may
54
- # rewrite :result.
55
- call_env = {
56
- name: name.to_s,
57
- arguments: args,
58
- result: nil,
59
- denied: nil,
60
- events: env[:events],
61
- metadata: {},
62
- turn_env: env,
63
- }
64
- # A subscriber takes part by mutating the call env: set
65
- # :result to answer without executing, set :denied to refuse.
66
- emit(BEFORE_TOOL_EVENT, env, call_env)
67
-
68
- if call_env[:result].nil?
69
- emit(APPROVE_TOOL_EVENT, env, call_env)
70
-
71
- if (denial = call_env[:denied])
72
- call_env[:result] = denial.is_a?(String) ? denial : %(Tool call to "#{name}" was denied.)
73
- end
74
- end
75
-
76
- # Only the tool's own execution is timed: a call that
77
- # before_tool answered, or approve_tool denied, never ran.
78
- result = call_env[:result]
79
-
80
- if result.nil?
81
- emit(TOOL_DURATION_EVENT, env, call_env) do
82
- result = available_tools[name].call(call_env[:arguments])
83
- end
84
- end
85
-
86
- call_env[:result] = result
87
- emit(AFTER_TOOL_EVENT, env, call_env)
88
- result = call_env[:result]
89
-
90
- # Coerce to String so Hash results (e.g. Shell's
91
- # {stdout:, stderr:, exit_code:}) serialize predictably.
92
- if result.is_a?(String)
93
- content = result
94
- else
95
- content = result.to_s
96
- end
97
-
98
- # Universal truncation safety net — skip if already truncated
99
- unless Brute::Truncation.already_truncated?(content)
100
- content = Brute::Truncation.truncate(content)
101
- end
102
-
103
- results << [tool_call, content]
104
- rescue => e
105
- # Capture the error as a tool result so the LLM can see it
106
- # and reason about the failure, rather than crashing the
107
- # entire middleware chain.
108
- env[:events] << { type: :error, data: { error: e, message: e.message } }
109
- results << [tool_call, "Error: #{e.class}: #{e.message}"]
110
- end
111
- end
112
-
113
- barrier.wait
114
- ensure
115
- barrier&.cancel
116
- end
117
-
118
- # Append events and messages in the original tool_call order so the
119
- # LLM sees a deterministic sequence regardless of completion order.
120
- results.sort_by! { |tool_call, _| tools_to_run.index(tool_call) }
121
-
122
- results.each do |tool_call, content|
123
- env[:events] << { type: :tool_result, data: { name: tool_call.name, content: content } }
124
- env[:messages] << Brute::Message.new(role: :tool, content: content, tool_call_id: tool_call.id)
125
- end
126
- end
127
-
128
- env
129
- end
130
-
131
- private
132
-
133
- def on_tool_call_start_event(pending_tools)
134
- {
135
- type: :tool_call_start,
136
- data: pending_tools.map { |tc|
137
- {
138
- name: tc.name,
139
- call_id: tc.id,
140
- arguments: tc.arguments
141
- }
142
- }
143
- }
144
- end
9
+ # The old name for DefaultToolPipeline, kept working while it is
10
+ # deprecated. The middleware is one particular wiring of tool dispatch and
11
+ # the name now says so, leaving Brute::Turn::ToolPipeline as the mechanism
12
+ # to compose when that wiring is not what you want.
13
+ #
14
+ # use Brute::Middleware::DefaultToolPipeline, tools: tools
15
+ #
16
+ class ToolPipeline < DefaultToolPipeline
17
+ extend GemKit::Deprecate
18
+ superseded_by "Brute::Middleware::DefaultToolPipeline", "6.0"
145
19
  end
146
20
  end
147
21
  end
@@ -149,179 +23,39 @@ end
149
23
  __END__
150
24
 
151
25
  describe "brute/middleware/070_tool_pipeline" do
152
- require "brute/messages"
153
- require "brute/truncation"
154
-
155
- it "passes through when no tool calls pending" do
156
- inner = ->(env) {
157
- env[:messages] << Brute::Message.new(role: :assistant, content: "hi")
158
- }
159
- mw = Brute::Middleware::ToolPipeline.new(inner, tools: [])
160
- env = {
161
- messages: Brute.log,
162
- events: [],
163
- }
164
- env[:messages].user("hello")
165
- mw.call(env)
166
- env[:messages].last.content.should == "hi"
167
- end
168
-
169
- it "advertises its tools on env[:tools] on the way in" do
170
- seen = nil
171
- inner = ->(env) { seen = env[:tools] }
172
- tool = { name: "echo", description: "", execute: ->(**) { "ok" } }
173
- mw = Brute::Middleware::ToolPipeline.new(inner, tools: [tool])
174
- env = { messages: Brute.log, events: [] }
175
- env[:messages].user("hi")
176
- mw.call(env)
177
- seen.should == [tool]
178
- end
179
-
180
- # --- lifecycle hooks (Brute::Hooks) ---
181
-
182
- # A layer only gets its emit from the builder that made it, so a hook spec
183
- # builds a real pipeline rather than instantiating the middleware alone.
184
- def hooked(inner, tools:, &subscribe)
185
- pipeline = Brute::Turn::Pipeline.new
186
- pipeline.use Brute::Middleware::ToolPipeline, tools: tools
187
- pipeline.run(Object.new.tap { |o| o.define_singleton_method(:call, &inner) })
188
- subscribe.call(pipeline)
189
- pipeline
190
- end
191
-
192
- def hook_env
193
- { messages: Brute.log, events: [] }
194
- end
195
-
196
- it "before_tool may rewrite arguments and short-circuit with a result" do
197
- tool = { name: "echo", description: "", execute: ->(text:) { "ran:#{text}" } }
198
- inner = ->(env) do
199
- env[:messages] << Brute::Message.new(role: :assistant, content: "",
200
- tool_calls: [{ id: "tc1", name: "echo", arguments: { "text" => "orig" } }])
201
- end
202
-
203
- pipeline = hooked(inner, tools: [tool]) do |p|
204
- p.on(:before_tool) { |_env, call| call[:arguments] = { text: "rewritten" } }
205
- end
206
- env = hook_env
207
- env[:messages].user("hi")
208
- pipeline.call(env)
209
- env[:messages].last.content.should == "ran:rewritten"
210
-
211
- canned = hooked(inner, tools: [tool]) { |p| p.on(:before_tool) { |_env, call| call[:result] = "canned" } }
212
- env2 = hook_env
213
- env2[:messages].user("hi")
214
- canned.call(env2)
215
- env2[:messages].last.content.should == "canned" # never executed
216
- end
217
-
218
- it "approve_tool denies on false (generic message) or String (custom)" do
219
- tool = { name: "exec", description: "", execute: ->(**) { "ran" } }
220
- inner = ->(env) do
221
- env[:messages] << Brute::Message.new(role: :assistant, content: "",
222
- tool_calls: [{ id: "tc1", name: "exec", arguments: {} }])
223
- end
224
-
225
- denied = hooked(inner, tools: [tool]) { |p| p.on(:approve_tool) { |_env, call| call[:denied] = true } }
226
- env = hook_env
227
- env[:messages].user("hi")
228
- denied.call(env)
229
- env[:messages].last.content.should == %(Tool call to "exec" was denied.)
230
-
231
- by_policy = hooked(inner, tools: [tool]) { |p| p.on(:approve_tool) { |_env, call| call[:denied] = "denied by policy" } }
232
- env2 = hook_env
233
- env2[:messages].user("hi")
234
- by_policy.call(env2)
235
- env2[:messages].last.content.should == "denied by policy"
236
- end
237
-
238
- it "after_tool may rewrite the result" do
239
- tool = { name: "echo", description: "", execute: ->(**) { "raw" } }
240
- inner = ->(env) do
241
- env[:messages] << Brute::Message.new(role: :assistant, content: "",
242
- tool_calls: [{ id: "tc1", name: "echo", arguments: {} }])
243
- end
244
-
245
- pipeline = hooked(inner, tools: [tool]) do |p|
246
- p.on(:after_tool) { |_env, call| call[:result] = "rewrote(#{call[:result]})" }
247
- end
248
- env = hook_env
249
- env[:messages].user("hi")
250
- pipeline.call(env)
251
- env[:messages].last.content.should == "rewrote(raw)"
252
- end
253
-
254
- # --- Universal output truncation ---
255
-
256
- it "truncates large tool results via Truncation" do
257
- # A fake tool that returns a huge string
258
- big_tool = Class.new(Brute::Tool) do
259
- description "test tool"
260
- param :input, type: "string", desc: "input"
261
- def name; "big_tool"; end
262
- def execute(input:)
263
- "line\n" * 3000
26
+ it "still dispatches tools under the old name, and says what to use instead" do
27
+ warned = []
28
+ original = GemKit::Deprecate.method(:warn)
29
+ GemKit::Deprecate.define_singleton_method(:warn) { |message| warned << message }
30
+
31
+ begin
32
+ tool = Brute::Turn::ToolPipeline.new(name: "echo", description: "echo") do
33
+ run ->(env) { env[:result] = "echoed" }
264
34
  end
265
- end
266
35
 
267
- tool_calls = [
268
- Brute::ToolCall.new(
269
- id: "tc_1",
270
- name: "big_tool",
271
- arguments: { "input" => "go" },
272
- )
273
- ]
274
-
275
- inner = ->(env) {
276
- env[:messages] << Brute::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
277
- }
278
- pipeline = hooked(inner, tools: [big_tool]) { |_p| nil }
279
- env = {
280
- messages: Brute.log,
281
- events: [],
282
- }
283
- env[:messages].user("hello")
284
- pipeline.call(env)
285
-
286
- tool_msg = env[:messages].select { |m| m.role == :tool }.last
287
- tool_msg.content.lines.size.should.be < 2100
288
- tool_msg.content.should =~ /truncated/i
289
- end
290
-
291
- # --- Skip double-truncation ---
36
+ agent = Brute.agent
37
+ .use(Brute::Middleware::ToolPipeline, tools: [tool])
38
+ .run(
39
+ ->(env) {
40
+ env[:messages] << Brute::Message.new(
41
+ role: :assistant,
42
+ content: "",
43
+ tool_calls: [{ id: "1", name: "echo", arguments: {} }]
44
+ )
45
+ }
46
+ )
292
47
 
293
- it "does not double-truncate already-truncated output" do
294
- # A fake tool that returns output already containing the truncation marker
295
- pre_truncated_tool = Class.new(Brute::Tool) do
296
- description "test tool"
297
- param :input, type: "string", desc: "input"
298
- def name; "pre_truncated_tool"; end
299
- def execute(input:)
300
- "some result\n[Output truncated: showing 100 of 5000 lines]"
301
- end
48
+ agent.start("go")[:messages].last.content.should == "echoed"
49
+ ensure
50
+ GemKit::Deprecate.define_singleton_method(:warn, original)
302
51
  end
303
52
 
304
- tool_calls = [
305
- Brute::ToolCall.new(
306
- id: "tc_2",
307
- name: "pre_truncated_tool",
308
- arguments: { "input" => "go" },
309
- )
310
- ]
311
-
312
- inner = ->(env) {
313
- env[:messages] << Brute::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
314
- }
315
- pipeline = hooked(inner, tools: [pre_truncated_tool]) { |_p| nil }
316
- env = {
317
- messages: Brute.log,
318
- events: [],
319
- }
320
- env[:messages].user("hello")
321
- pipeline.call(env)
53
+ Brute::Middleware::ToolPipeline.ancestors.should.include Brute::Middleware::DefaultToolPipeline
54
+ warned.first.should.include "Brute::Middleware::DefaultToolPipeline"
55
+ warned.first.should.include "6.0"
322
56
 
323
- tool_msg = env[:messages].select { |m| m.role == :tool }.last
324
- # Should contain exactly one truncation marker, not two
325
- tool_msg.content.scan(/Output truncated/).size.should == 1
57
+ declared = GemKit::Deprecate.registry.find { |entry| entry.name == "Brute::Middleware::ToolPipeline" }
58
+ declared.replacement.should == "Brute::Middleware::DefaultToolPipeline"
59
+ declared.removed_in.should == Gem::Version.new("6.0")
326
60
  end
327
61
  end
@@ -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