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.
- checksums.yaml +4 -4
- data/lib/brute/compaction/middleware/sliding_window.rb +170 -0
- data/lib/brute/compaction/middleware/strategy.rb +139 -0
- data/lib/brute/compaction/middleware/tool_results.rb +146 -0
- data/lib/brute/compaction/summarize.rb +340 -0
- data/lib/brute/compaction/transcript.rb +166 -0
- data/lib/brute/compaction.rb +57 -0
- data/lib/brute/contrib/otel.rb +2 -2
- data/lib/brute/env.rb +54 -0
- data/lib/brute/eval/case.rb +249 -0
- data/lib/brute/eval/suite.rb +176 -0
- data/lib/brute/eval/transcript.rb +147 -0
- data/lib/brute/eval/world.rb +106 -0
- data/lib/brute/eval.rb +48 -0
- data/lib/brute/hooks.rb +11 -0
- data/lib/brute/middleware/000_base.rb +1 -1
- data/lib/brute/middleware/001_slash_commands.rb +88 -0
- data/lib/brute/middleware/008_checkpoint.rb +1 -1
- data/lib/brute/middleware/040_compaction_check.rb +51 -135
- data/lib/brute/middleware/040_default_compaction_pipeline.rb +352 -0
- data/lib/brute/middleware/070_default_tool_pipeline.rb +327 -0
- data/lib/brute/middleware/070_tool_pipeline.rb +39 -305
- data/lib/brute/token_counter/approximate.rb +54 -0
- data/lib/brute/token_counter/tiktoken.rb +80 -0
- data/lib/brute/token_counter.rb +148 -0
- data/lib/brute/tools/adapter.rb +4 -4
- data/lib/brute/tools/skill_load.rb +2 -2
- data/lib/brute/tools/sub_agent.rb +2 -2
- data/lib/brute/turn/agent_pipeline.rb +117 -38
- data/lib/brute/turn/compaction_pipeline.rb +122 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +16 -1
- metadata +20 -1
|
@@ -2,146 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
require "bundler/setup"
|
|
4
4
|
require "brute"
|
|
5
|
-
require "
|
|
6
|
-
require "async"
|
|
7
|
-
require "async/barrier"
|
|
5
|
+
require "gem_kit"
|
|
8
6
|
|
|
9
7
|
module Brute
|
|
10
8
|
module Middleware
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
data/lib/brute/tools/adapter.rb
CHANGED
|
@@ -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
|