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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd46c42475fdbdaef5ec4f22cd15ff43d6d1bcf2dc2c37c744fb4c6dbbdc2289
|
|
4
|
+
data.tar.gz: 2ef42a57205047a0a0f0783a736a333ef7ee0d1fcbe583c0dbf7b97c7e2ba514
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cf83163546bc17c6ed562581067db5e6341088d2746fac3476ac56fcfeb2ac5230d3a7317a02c94a42a9b3ba6c43203578ecc995517d561741d788946b168064
|
|
7
|
+
data.tar.gz: 9f42fb22bc76a611043dadbcd2acb6f6b4862efda77e31af58c862d0ad1cb99587b46169055cd8ccd74936eeef8d1af2b42faa14b14fa6b6548c2ccf8b0dbdf0
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Compaction
|
|
8
|
+
module Middleware
|
|
9
|
+
# When rewriting the tool output was not enough, whole stretches of the
|
|
10
|
+
# conversation go.
|
|
11
|
+
#
|
|
12
|
+
# The instructions and the message the current task hangs off are never
|
|
13
|
+
# given up. Past that, the oldest complete turns go first; only once every
|
|
14
|
+
# turn is gone does the current task start losing its own oldest steps, and
|
|
15
|
+
# never the newest.
|
|
16
|
+
#
|
|
17
|
+
# A note stands where the removed messages were, so the model can see that
|
|
18
|
+
# something was there rather than quietly reasoning from a gap.
|
|
19
|
+
class SlidingWindow < Strategy
|
|
20
|
+
NOTE = "%d earlier messages were dropped to free context. They cannot be recovered."
|
|
21
|
+
|
|
22
|
+
def initialize(app = nil, keep_steps: 1)
|
|
23
|
+
@app = app
|
|
24
|
+
@keep_steps = keep_steps
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def strategy = "sliding_window"
|
|
28
|
+
|
|
29
|
+
def rewrite(messages, target:)
|
|
30
|
+
kept, note_index, dropped = split(messages, target)
|
|
31
|
+
|
|
32
|
+
# Swapping one note for another frees nothing. The base would refuse
|
|
33
|
+
# it anyway; declining here says why.
|
|
34
|
+
if dropped.any? && !dropped.all? { |index| Brute::Compaction::Transcript.marked?(messages[index], strategy) }
|
|
35
|
+
note = Brute::Message.new(
|
|
36
|
+
role: :user,
|
|
37
|
+
content: Brute::Compaction::Transcript.mark(strategy, format(NOTE, dropped.length)),
|
|
38
|
+
)
|
|
39
|
+
[*kept[...note_index], note, *kept[note_index..]]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def split(messages, target)
|
|
46
|
+
system_end = Brute::Compaction::Transcript.system_end(messages)
|
|
47
|
+
anchor = Array(Brute::Compaction::Transcript.task_index(messages))
|
|
48
|
+
history = Brute::Compaction::Transcript.turns(messages, from: system_end, to: anchor.first || system_end).map(&:to_a)
|
|
49
|
+
steps = Brute::Compaction::Transcript.steps(messages, from: Brute::Compaction::Transcript.step_start(messages)).map(&:to_a)
|
|
50
|
+
|
|
51
|
+
budget = target - tokens(Brute::Compaction::Transcript.at(messages, [*0...system_end, *anchor]))
|
|
52
|
+
first_turn, first_step = frontier(
|
|
53
|
+
messages,
|
|
54
|
+
history,
|
|
55
|
+
steps,
|
|
56
|
+
budget,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
turn_indices = history[first_turn..].flatten
|
|
60
|
+
step_indices = steps[first_step..].flatten
|
|
61
|
+
kept = [*0...system_end, *turn_indices, *anchor, *step_indices]
|
|
62
|
+
|
|
63
|
+
# The note goes where the messages it stands for used to sit: after
|
|
64
|
+
# the instructions when only history went, after the anchor when the
|
|
65
|
+
# task's own steps did.
|
|
66
|
+
note_index = system_end
|
|
67
|
+
if first_step.positive?
|
|
68
|
+
note_index = system_end + turn_indices.length + anchor.length
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
[Brute::Compaction::Transcript.at(messages, kept), note_index, (0...messages.length).to_a - kept]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# How far into the history, and into the task's own steps, keeping has
|
|
75
|
+
# to start for what remains to fit.
|
|
76
|
+
def frontier(messages, history, steps, budget)
|
|
77
|
+
spent = tokens(Brute::Compaction::Transcript.at(messages, steps.flatten))
|
|
78
|
+
|
|
79
|
+
if spent > budget
|
|
80
|
+
# The task alone overruns, so all history goes and its oldest steps
|
|
81
|
+
# follow -- down to the newest, which stay regardless.
|
|
82
|
+
floor = [steps.length - @keep_steps, 0].max
|
|
83
|
+
[history.length, [first_kept(messages, steps, budget), floor].min]
|
|
84
|
+
else
|
|
85
|
+
[first_kept(messages, history, budget - spent), 0]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Newest group backwards, stopping at the first that does not fit.
|
|
90
|
+
def first_kept(messages, groups, budget)
|
|
91
|
+
position = groups.length
|
|
92
|
+
remaining = budget
|
|
93
|
+
|
|
94
|
+
while position.positive?
|
|
95
|
+
cost = tokens(Brute::Compaction::Transcript.at(messages, groups[position - 1]))
|
|
96
|
+
|
|
97
|
+
if cost > remaining
|
|
98
|
+
break
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
remaining -= cost
|
|
102
|
+
position -= 1
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
position
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
__END__
|
|
113
|
+
|
|
114
|
+
describe "brute/compaction/middleware/sliding_window" do
|
|
115
|
+
def said(role, content) = Brute::Message.new(role: role, content: content)
|
|
116
|
+
|
|
117
|
+
def compacted(conversation, target, **options)
|
|
118
|
+
env = { conversation: conversation, target: target, applied: [], events: [] }
|
|
119
|
+
Brute::Compaction::Middleware::SlidingWindow.new(->(e) { e }, **options).call(env)
|
|
120
|
+
env
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "gives up the oldest turns first, then the task's oldest steps, and says what went" do
|
|
124
|
+
conversation = [
|
|
125
|
+
said(:system, "instructions"),
|
|
126
|
+
said(:user, "first question"),
|
|
127
|
+
said(:assistant, "a" * 4_000),
|
|
128
|
+
said(:user, "second question"),
|
|
129
|
+
said(:assistant, "b" * 4_000),
|
|
130
|
+
said(:user, "third question"),
|
|
131
|
+
said(:assistant, "c" * 400),
|
|
132
|
+
said(:assistant, "d" * 400),
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
# Room for the task and one historical turn, so the oldest turn goes and
|
|
136
|
+
# the note takes its place, directly after the instructions.
|
|
137
|
+
env = compacted(conversation, 1_400, keep_steps: 1)
|
|
138
|
+
env[:conversation].map { |m| m.content[0, 24] }.should == [
|
|
139
|
+
"instructions",
|
|
140
|
+
"[compacted:sliding_windo",
|
|
141
|
+
"second question",
|
|
142
|
+
"bbbbbbbbbbbbbbbbbbbbbbbb",
|
|
143
|
+
"third question",
|
|
144
|
+
"cccccccccccccccccccccccc",
|
|
145
|
+
"dddddddddddddddddddddddd",
|
|
146
|
+
]
|
|
147
|
+
env[:conversation][1].content.should ==
|
|
148
|
+
"[compacted:sliding_window] 2 earlier messages were dropped to free context. They cannot be recovered."
|
|
149
|
+
|
|
150
|
+
# The conversation it was handed is left as it was found.
|
|
151
|
+
conversation.length.should == 8
|
|
152
|
+
|
|
153
|
+
# A target the task cannot fit inside: every turn goes, and the task's own
|
|
154
|
+
# oldest step goes with them -- the note now sits after the anchor.
|
|
155
|
+
tight = compacted(conversation, 150, keep_steps: 1)[:conversation]
|
|
156
|
+
tight.map { |m| m.content[0, 24] }.should == [
|
|
157
|
+
"instructions",
|
|
158
|
+
"third question",
|
|
159
|
+
"[compacted:sliding_windo",
|
|
160
|
+
"dddddddddddddddddddddddd",
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
# keep_steps holds the floor: the newest step survives any target at all.
|
|
164
|
+
compacted(conversation, 0, keep_steps: 1)[:conversation].length.should == 4
|
|
165
|
+
|
|
166
|
+
# Already inside the target, and a conversation that is nothing but a note.
|
|
167
|
+
compacted(conversation, 100_000, keep_steps: 1)[:applied].should == []
|
|
168
|
+
compacted(tight, 0, keep_steps: 1)[:applied].should == []
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Compaction
|
|
8
|
+
# Compaction strategies, as the layers of a Brute::Turn::CompactionPipeline.
|
|
9
|
+
module Middleware
|
|
10
|
+
# One strategy in the ladder.
|
|
11
|
+
#
|
|
12
|
+
# The stack order is the policy. A layer that got the conversation under
|
|
13
|
+
# target never calls the next one, so the strategies that cost nothing
|
|
14
|
+
# sit at the top and the one that spends a model call is only reached
|
|
15
|
+
# when they could not get there.
|
|
16
|
+
#
|
|
17
|
+
# A subclass answers #strategy and #rewrite, and nothing else. The rules
|
|
18
|
+
# every strategy has to keep are here: it is only asked while the
|
|
19
|
+
# conversation is over target, and what it answers is only taken when it
|
|
20
|
+
# actually made the conversation smaller -- otherwise the pipeline above
|
|
21
|
+
# would write the same size back on every step, forever.
|
|
22
|
+
#
|
|
23
|
+
# The same class serves as a layer or as the terminal, which is what the
|
|
24
|
+
# ladder is built out of: the strategies that cost nothing are `use`d,
|
|
25
|
+
# and the one that spends a model call is `run`, reached only when they
|
|
26
|
+
# could not get under target.
|
|
27
|
+
#
|
|
28
|
+
# Brute::Turn::CompactionPipeline.new do
|
|
29
|
+
# use Brute::Compaction::Middleware::ToolResults
|
|
30
|
+
# use Brute::Compaction::Middleware::SlidingWindow
|
|
31
|
+
# run Brute::Compaction::Middleware::Summary.new(summarize: summarize)
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
class Strategy < Brute::Middleware::Base
|
|
35
|
+
def call(env)
|
|
36
|
+
@env = env
|
|
37
|
+
|
|
38
|
+
if Brute::Compaction.over_target?(env)
|
|
39
|
+
apply(env)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Under target now, so the rest of the ladder is not needed.
|
|
43
|
+
if Brute::Compaction.over_target?(env)
|
|
44
|
+
@app.call(env)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
env
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The name this strategy compacts under, recorded on what it produces.
|
|
51
|
+
def strategy = raise(NotImplementedError, "#{self.class} must answer #strategy")
|
|
52
|
+
|
|
53
|
+
# Answer a smaller conversation, or nil to decline. Build a new list:
|
|
54
|
+
# the one handed over belongs to the caller.
|
|
55
|
+
def rewrite(_conversation, target:) = raise(NotImplementedError, "#{self.class} must answer #rewrite")
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Measured with whatever counter the turn decided on, so a strategy
|
|
60
|
+
# weighs the conversation the same way the trigger did.
|
|
61
|
+
def tokens(messages) = Brute::Compaction.counter(@env).count(messages)
|
|
62
|
+
|
|
63
|
+
def apply(env)
|
|
64
|
+
rewritten = rewrite(env[:conversation], target: env[:target])
|
|
65
|
+
|
|
66
|
+
unless rewritten.nil?
|
|
67
|
+
before = tokens(env[:conversation])
|
|
68
|
+
after = tokens(rewritten)
|
|
69
|
+
|
|
70
|
+
if after < before
|
|
71
|
+
env[:conversation] = rewritten
|
|
72
|
+
env[:applied] << { strategy: strategy, before: before, after: after }
|
|
73
|
+
env[:events] << { type: :compacted, data: env[:applied].last }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
__END__
|
|
83
|
+
|
|
84
|
+
describe "brute/compaction/middleware/strategy" do
|
|
85
|
+
def said(content) = Brute::Message.new(role: :user, content: content)
|
|
86
|
+
|
|
87
|
+
def strategy(name, &block)
|
|
88
|
+
Class.new(Brute::Compaction::Middleware::Strategy) do
|
|
89
|
+
define_method(:strategy) { name }
|
|
90
|
+
define_method(:rewrite) { |conversation, target:| block.call(conversation, target) }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "asks a strategy only while over target, takes only what shrank, and stops the ladder there" do
|
|
95
|
+
asked = []
|
|
96
|
+
halves = strategy("halves") { |c, _t| asked << "halves"; c.first(c.length / 2) }
|
|
97
|
+
nothing = strategy("nothing") { |c, _t| asked << "nothing"; c.dup }
|
|
98
|
+
never = strategy("never") { |_c, _t| asked << "never"; nil }
|
|
99
|
+
|
|
100
|
+
run = lambda do |layers, conversation, target|
|
|
101
|
+
app = ->(env) { env }
|
|
102
|
+
layers.reverse_each { |layer| app = layer.new(app) }
|
|
103
|
+
env = { conversation: conversation, target: target, applied: [], events: [] }
|
|
104
|
+
app.call(env)
|
|
105
|
+
env
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
four = [said("a" * 4_000), said("b" * 4_000), said("c" * 4_000), said("d" * 4_000)]
|
|
109
|
+
|
|
110
|
+
# Halving is enough, so nothing below it is reached -- which is what keeps
|
|
111
|
+
# the strategy that costs money out of a turn the free ones could handle.
|
|
112
|
+
env = run.call([halves, never], four, 3_000)
|
|
113
|
+
env[:conversation].map { |m| m.content[0, 1] }.should == ["a", "b"]
|
|
114
|
+
asked.should == ["halves"]
|
|
115
|
+
env[:applied].should == [{ strategy: "halves", before: 4_022, after: 2_011 }]
|
|
116
|
+
env[:events].should == [{ type: :compacted, data: { strategy: "halves", before: 4_022, after: 2_011 } }]
|
|
117
|
+
|
|
118
|
+
# A rewrite that saved nothing is refused and the ladder carries on past
|
|
119
|
+
# it, so the strategy itself never has to check.
|
|
120
|
+
asked.clear
|
|
121
|
+
env = run.call([nothing, halves], four, 3_000)
|
|
122
|
+
asked.should == ["nothing", "halves"]
|
|
123
|
+
env[:applied].map { |a| a[:strategy] }.should == ["halves"]
|
|
124
|
+
|
|
125
|
+
# Already under target: nothing is asked at all.
|
|
126
|
+
asked.clear
|
|
127
|
+
run.call([halves, never], four, 100_000)[:applied].should == []
|
|
128
|
+
asked.should == []
|
|
129
|
+
|
|
130
|
+
# A strategy that declines lets the next one try.
|
|
131
|
+
asked.clear
|
|
132
|
+
run.call([never, halves], four, 3_000)[:applied].map { |a| a[:strategy] }.should == ["halves"]
|
|
133
|
+
|
|
134
|
+
# The base insists a subclass says what it is and what it does.
|
|
135
|
+
bare = Class.new(Brute::Compaction::Middleware::Strategy).new(->(e) { e })
|
|
136
|
+
should.raise(NotImplementedError) { bare.strategy }
|
|
137
|
+
should.raise(NotImplementedError) { bare.rewrite([], target: 0) }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Compaction
|
|
8
|
+
module Middleware
|
|
9
|
+
# The cheapest thing to give up first: what the tools said.
|
|
10
|
+
#
|
|
11
|
+
# Tool output dominates a long run, and most of it stops being useful the
|
|
12
|
+
# moment the model has acted on it. So the results are rewritten in place
|
|
13
|
+
# rather than removed -- every call keeps the result that answers it, the
|
|
14
|
+
# model can still see what it ran, and it can run it again if it turns out
|
|
15
|
+
# it still needed the answer.
|
|
16
|
+
#
|
|
17
|
+
# Oldest first, and it stops the moment the transcript is under target, so
|
|
18
|
+
# the most recent output survives the longest.
|
|
19
|
+
class ToolResults < Strategy
|
|
20
|
+
PLACEHOLDER = "Result dropped to free context. Call %s again if you still need it."
|
|
21
|
+
|
|
22
|
+
def initialize(app = nil, keep_steps: 1, min_tokens: 200)
|
|
23
|
+
if keep_steps < 1
|
|
24
|
+
raise ArgumentError, "keep_steps must be at least 1: the model has not acted on the newest results yet"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@app = app
|
|
28
|
+
@keep_steps = keep_steps
|
|
29
|
+
@min_tokens = min_tokens
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def strategy = "tool_results"
|
|
33
|
+
|
|
34
|
+
def rewrite(messages, target:)
|
|
35
|
+
guarded = guarded_indices(messages)
|
|
36
|
+
names = tool_names(messages)
|
|
37
|
+
pruned = messages.dup
|
|
38
|
+
running = tokens(messages)
|
|
39
|
+
changed = false
|
|
40
|
+
|
|
41
|
+
messages.each_with_index do |message, index|
|
|
42
|
+
if running <= target
|
|
43
|
+
break
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
replacement = prune(message, names[message.tool_call_id])
|
|
47
|
+
|
|
48
|
+
unless guarded.include?(index) || replacement.nil?
|
|
49
|
+
running -= tokens([message]) - tokens([replacement])
|
|
50
|
+
pruned[index] = replacement
|
|
51
|
+
changed = true
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if changed
|
|
56
|
+
pruned
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# The newest tool-calling steps are never touched, however far over
|
|
63
|
+
# target that leaves the transcript.
|
|
64
|
+
def guarded_indices(messages)
|
|
65
|
+
Brute::Compaction::Transcript.steps(messages, from: 0)
|
|
66
|
+
.select { |span| span.count > 1 }
|
|
67
|
+
.last(@keep_steps)
|
|
68
|
+
.flat_map(&:to_a)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A tool result carries an id, not a name -- the name is on the call it
|
|
72
|
+
# answers, so the placeholder has to go and find it.
|
|
73
|
+
def tool_names(messages)
|
|
74
|
+
messages.each_with_object({}) do |message, names|
|
|
75
|
+
message.tool_calls&.each { |call| names[call.id] = call.name }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def prune(message, name)
|
|
80
|
+
if message.role == :tool && !Brute::Compaction::Transcript.marked?(message) &&
|
|
81
|
+
tokens([message]) > @min_tokens
|
|
82
|
+
Brute::Message.new(
|
|
83
|
+
role: :tool,
|
|
84
|
+
content: Brute::Compaction::Transcript.mark(strategy, format(PLACEHOLDER, name || "the tool")),
|
|
85
|
+
tool_call_id: message.tool_call_id,
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
__END__
|
|
95
|
+
|
|
96
|
+
describe "brute/compaction/middleware/tool_results" do
|
|
97
|
+
def call(id) = { id: id, name: "shell", arguments: { "command" => "ls" } }
|
|
98
|
+
def result(id, size) = Brute::Message.new(role: :tool, content: "x" * size, tool_call_id: id)
|
|
99
|
+
def calling(id) = Brute::Message.new(role: :assistant, content: "", tool_calls: [call(id)])
|
|
100
|
+
|
|
101
|
+
def compacted(conversation, target, **options)
|
|
102
|
+
env = { conversation: conversation, target: target, applied: [], events: [] }
|
|
103
|
+
Brute::Compaction::Middleware::ToolResults.new(->(e) { e }, **options).call(env)
|
|
104
|
+
env
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "replaces the oldest fat tool results, guarding the newest step, and stops at the target" do
|
|
108
|
+
conversation = [
|
|
109
|
+
Brute::Message.new(role: :system, content: "instructions"),
|
|
110
|
+
Brute::Message.new(role: :user, content: "go"),
|
|
111
|
+
calling("tc1"), result("tc1", 8_000),
|
|
112
|
+
calling("tc2"), result("tc2", 8_000),
|
|
113
|
+
calling("tc3"), result("tc3", 8_000),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
env = compacted(conversation, 4_500, keep_steps: 1, min_tokens: 200)
|
|
117
|
+
kept = env[:conversation]
|
|
118
|
+
|
|
119
|
+
# The oldest result went. The placeholder names the tool that produced it,
|
|
120
|
+
# and it keeps the id, so the call it answers is still answered.
|
|
121
|
+
kept[3].content.should ==
|
|
122
|
+
"[compacted:tool_results] Result dropped to free context. Call shell again if you still need it."
|
|
123
|
+
kept[3].tool_call_id.should == "tc1"
|
|
124
|
+
|
|
125
|
+
# One was enough to reach the target, so the next is untouched...
|
|
126
|
+
kept[5].content.should == "x" * 8_000
|
|
127
|
+
# ...and the newest step is guarded whatever the target says.
|
|
128
|
+
kept[7].content.should == "x" * 8_000
|
|
129
|
+
|
|
130
|
+
# The conversation it was handed is left as it was found.
|
|
131
|
+
conversation[3].content.should == "x" * 8_000
|
|
132
|
+
env[:applied].should == [{ strategy: "tool_results", before: 6_088, after: 4_111 }]
|
|
133
|
+
|
|
134
|
+
# A harsher target reaches further back, but never past the guard.
|
|
135
|
+
again = compacted(kept, 1, keep_steps: 1, min_tokens: 200)[:conversation]
|
|
136
|
+
Brute::Compaction::Transcript.marked?(again[5]).should.be.true
|
|
137
|
+
again[7].content.should == "x" * 8_000
|
|
138
|
+
|
|
139
|
+
# Nothing left to give up, already under target, or nothing fat enough.
|
|
140
|
+
compacted(again, 1, keep_steps: 1, min_tokens: 200)[:applied].should == []
|
|
141
|
+
compacted(conversation, 100_000, keep_steps: 1)[:applied].should == []
|
|
142
|
+
compacted(conversation, 1, keep_steps: 1, min_tokens: 100_000)[:applied].should == []
|
|
143
|
+
|
|
144
|
+
should.raise(ArgumentError) { Brute::Compaction::Middleware::ToolResults.new(->(e) { e }, keep_steps: 0) }
|
|
145
|
+
end
|
|
146
|
+
end
|