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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 146d42f66c911ce3ce45384eaeadd96bb877befe2740a417f405fb693c73acc2
4
- data.tar.gz: 439445efa583943587f1d70d03c626a6d34e58d40509f03d3d3ce33c9edf03c9
3
+ metadata.gz: fd46c42475fdbdaef5ec4f22cd15ff43d6d1bcf2dc2c37c744fb4c6dbbdc2289
4
+ data.tar.gz: 2ef42a57205047a0a0f0783a736a333ef7ee0d1fcbe583c0dbf7b97c7e2ba514
5
5
  SHA512:
6
- metadata.gz: 36ed02483e9838d2d71272bf471f5ffa025cf980b56b54c576873c011c85085c89320f513a00002acb814fc01c333f8882c796af2a900828b5532b26aea5c800
7
- data.tar.gz: 925cc550884beadf57fbc631f7b796afc5484af8fea604e6b2cad19459159c2a664eff45a7429b0c385bc894320867ad0d12946ed26ccd42b7086b9c93b66926
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