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.
- 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/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/compaction_pipeline.rb +122 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +15 -1
- metadata +19 -1
|
@@ -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
|
|
@@ -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::
|
|
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::
|
|
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
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::
|
|
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
|
|
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
|