brute 3.0.1 → 3.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/middleware/008_checkpoint.rb +268 -0
- data/lib/brute/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1fe7f1994a944255146721ea3030261622735e33d412f07ba6fd118287b76517
|
|
4
|
+
data.tar.gz: 0b36c9d8c38afb152c16f0bc26c305145ed94f1c53e84316399d4f32b7f8752a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 92f1aaba43df5f7fc0dad527c2b52a47e5336d4efabd95a6211cd9cdff97ae1a583d29ab94364d48b56760c5d7126fd3f9fe8f6e51b16518a250178daac30ee5
|
|
7
|
+
data.tar.gz: fce06871143bd87726637ece09887261b87aec3042470c0bb38ace70db42546541ef6a12f0310a3f72433a6ab65ade8f8c894a70a2f865ba21d752e94145f235
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
|
|
9
|
+
module Brute
|
|
10
|
+
module Middleware
|
|
11
|
+
# Durable execution for the tool loop. Where SessionLog persists the
|
|
12
|
+
# conversation once per turn (outermost), Checkpoint snapshots it after
|
|
13
|
+
# every pass through the inner stack — one checkpoint per LLM call + tool
|
|
14
|
+
# execution. Place it just inside Loop::ToolResult:
|
|
15
|
+
#
|
|
16
|
+
# use Brute::Middleware::Loop::ToolResult
|
|
17
|
+
# use Brute::Middleware::Checkpoint, path: "tmp/checkpoints.jsonl"
|
|
18
|
+
# use Brute::Middleware::MaxIterations
|
|
19
|
+
# use Brute::Middleware::ToolPipeline, tools: Brute::Tools::ALL
|
|
20
|
+
#
|
|
21
|
+
# The store is just a JSONL log of snapshots — one line per checkpoint,
|
|
22
|
+
# each carrying the full message log plus its own id and the id of the
|
|
23
|
+
# parent checkpoint it grew from. That append-only chain buys three
|
|
24
|
+
# things:
|
|
25
|
+
#
|
|
26
|
+
# resume pass resume: :latest — a crash mid-turn costs at most
|
|
27
|
+
# one iteration instead of the whole turn
|
|
28
|
+
# time travel pass resume: "<checkpoint id>" — restart from any
|
|
29
|
+
# snapshot in the chain
|
|
30
|
+
# forking checkpoints written after a time-travel resume carry the
|
|
31
|
+
# resumed id as parent_id, branching the chain in place
|
|
32
|
+
#
|
|
33
|
+
# System messages are not persisted (SystemPrompt re-adds them each
|
|
34
|
+
# turn); restored history is inserted after any leading system message
|
|
35
|
+
# and before the current turn's input.
|
|
36
|
+
class Checkpoint
|
|
37
|
+
def initialize(app, path:, resume: nil)
|
|
38
|
+
@app = app
|
|
39
|
+
@path = path
|
|
40
|
+
@resume = resume
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call(env)
|
|
44
|
+
restore(env) unless env[:metadata][:checkpoint]
|
|
45
|
+
@app.call(env)
|
|
46
|
+
persist(env)
|
|
47
|
+
env
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Parsed checkpoint records (symbol keys, messages as plain hashes),
|
|
51
|
+
# oldest first.
|
|
52
|
+
def self.list(path)
|
|
53
|
+
return [] unless path && File.exist?(path)
|
|
54
|
+
|
|
55
|
+
File.foreach(path).filter_map do |line|
|
|
56
|
+
line = line.strip
|
|
57
|
+
JSON.parse(line, symbolize_names: true) unless line.empty?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def restore(env)
|
|
64
|
+
env[:metadata][:checkpoint] = { path: @path }
|
|
65
|
+
return unless @resume
|
|
66
|
+
|
|
67
|
+
record = find_record
|
|
68
|
+
if record.nil?
|
|
69
|
+
return if @resume == :latest
|
|
70
|
+
|
|
71
|
+
raise KeyError, "no checkpoint #{@resume.inspect} in #{@path}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
history = record[:messages].map { |h| Brute::Message.new(**h) }
|
|
75
|
+
index = env[:messages].index { |m| m.role != :system } || env[:messages].size
|
|
76
|
+
env[:messages].insert(index, *history)
|
|
77
|
+
env[:metadata][:checkpoint][:id] = record[:id]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def find_record
|
|
81
|
+
records = self.class.list(@path)
|
|
82
|
+
@resume == :latest ? records.last : records.find { |r| r[:id] == @resume.to_s }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def persist(env)
|
|
86
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
87
|
+
record = {
|
|
88
|
+
id: SecureRandom.hex(6),
|
|
89
|
+
parent_id: env[:metadata][:checkpoint][:id],
|
|
90
|
+
ts: Time.now.utc.iso8601,
|
|
91
|
+
iteration: env[:current_iteration],
|
|
92
|
+
messages: env[:messages].reject { |m| m.role == :system }.map(&:to_h),
|
|
93
|
+
}
|
|
94
|
+
File.open(@path, "a") { |f| f.puts(JSON.generate(record)) }
|
|
95
|
+
env[:metadata][:checkpoint][:id] = record[:id]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
__END__
|
|
102
|
+
|
|
103
|
+
describe "brute/middleware/008_checkpoint" do
|
|
104
|
+
require "brute/messages"
|
|
105
|
+
require "tmpdir"
|
|
106
|
+
|
|
107
|
+
it "snapshots after every pass through the loop" do
|
|
108
|
+
Dir.mktmpdir do |dir|
|
|
109
|
+
path = File.join(dir, "cp.jsonl")
|
|
110
|
+
calls = 0
|
|
111
|
+
inner = ->(env) do
|
|
112
|
+
calls += 1
|
|
113
|
+
if calls < 3
|
|
114
|
+
env[:messages] << Brute::Message.new(role: :tool, content: "r#{calls}", tool_call_id: "tc#{calls}")
|
|
115
|
+
else
|
|
116
|
+
env[:messages].assistant("done")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
stack = Brute::Middleware::Loop::ToolResult.new(
|
|
121
|
+
Brute::Middleware::Checkpoint.new(inner, path: path)
|
|
122
|
+
)
|
|
123
|
+
env = { messages: Brute.log.tap { |l| l.user("go") }, metadata: {}, current_iteration: 1 }
|
|
124
|
+
stack.call(env)
|
|
125
|
+
|
|
126
|
+
records = Brute::Middleware::Checkpoint.list(path)
|
|
127
|
+
records.size.should == 3
|
|
128
|
+
records.map { |r| r[:iteration] }.should == [1, 2, 3]
|
|
129
|
+
records.last[:messages].last[:content].should == "done"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "chains checkpoints via parent_id" do
|
|
134
|
+
Dir.mktmpdir do |dir|
|
|
135
|
+
path = File.join(dir, "cp.jsonl")
|
|
136
|
+
mw = Brute::Middleware::Checkpoint.new(->(env) { env[:messages].assistant("a") }, path: path)
|
|
137
|
+
env = { messages: Brute.log.tap { |l| l.user("go") }, metadata: {}, current_iteration: 1 }
|
|
138
|
+
mw.call(env)
|
|
139
|
+
mw.call(env)
|
|
140
|
+
|
|
141
|
+
records = Brute::Middleware::Checkpoint.list(path)
|
|
142
|
+
records[0][:parent_id].should == nil
|
|
143
|
+
records[1][:parent_id].should == records[0][:id]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it "resume: :latest restores the conversation before this turn's input" do
|
|
148
|
+
Dir.mktmpdir do |dir|
|
|
149
|
+
path = File.join(dir, "cp.jsonl")
|
|
150
|
+
Brute::Middleware::Checkpoint.new(->(env) { env[:messages].assistant("first reply") }, path: path)
|
|
151
|
+
.call({ messages: Brute.log.tap { |l| l.user("hi") }, metadata: {}, current_iteration: 1 })
|
|
152
|
+
|
|
153
|
+
env = { messages: Brute.log.tap { |l| l.user("again") }, metadata: {}, current_iteration: 1 }
|
|
154
|
+
Brute::Middleware::Checkpoint.new(->(_e) {}, path: path, resume: :latest).call(env)
|
|
155
|
+
|
|
156
|
+
env[:messages].map(&:content).should == ["hi", "first reply", "again"]
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it "inserts restored history after the system message" do
|
|
161
|
+
Dir.mktmpdir do |dir|
|
|
162
|
+
path = File.join(dir, "cp.jsonl")
|
|
163
|
+
Brute::Middleware::Checkpoint.new(->(env) { env[:messages].assistant("old") }, path: path)
|
|
164
|
+
.call({ messages: Brute.log.tap { |l| l.user("hi") }, metadata: {}, current_iteration: 1 })
|
|
165
|
+
|
|
166
|
+
env = { messages: Brute.log, metadata: {}, current_iteration: 1 }
|
|
167
|
+
env[:messages].system("rules")
|
|
168
|
+
env[:messages].user("new input")
|
|
169
|
+
Brute::Middleware::Checkpoint.new(->(_e) {}, path: path, resume: :latest).call(env)
|
|
170
|
+
|
|
171
|
+
env[:messages].map(&:role).should == [:system, :user, :assistant, :user]
|
|
172
|
+
env[:messages].last.content.should == "new input"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it "resumes from a specific checkpoint id (time travel)" do
|
|
177
|
+
Dir.mktmpdir do |dir|
|
|
178
|
+
path = File.join(dir, "cp.jsonl")
|
|
179
|
+
mw = Brute::Middleware::Checkpoint.new(->(env) { env[:messages].assistant("reply #{env[:messages].size}") }, path: path)
|
|
180
|
+
env = { messages: Brute.log.tap { |l| l.user("go") }, metadata: {}, current_iteration: 1 }
|
|
181
|
+
mw.call(env)
|
|
182
|
+
mw.call(env)
|
|
183
|
+
|
|
184
|
+
first = Brute::Middleware::Checkpoint.list(path).first
|
|
185
|
+
env2 = { messages: Brute.log, metadata: {}, current_iteration: 1 }
|
|
186
|
+
Brute::Middleware::Checkpoint.new(->(_e) {}, path: path, resume: first[:id]).call(env2)
|
|
187
|
+
|
|
188
|
+
env2[:messages].size.should == first[:messages].size
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it "forks: checkpoints after a time-travel resume carry the resumed id as parent" do
|
|
193
|
+
Dir.mktmpdir do |dir|
|
|
194
|
+
path = File.join(dir, "cp.jsonl")
|
|
195
|
+
mw = Brute::Middleware::Checkpoint.new(->(env) { env[:messages].assistant("a") }, path: path)
|
|
196
|
+
env = { messages: Brute.log.tap { |l| l.user("go") }, metadata: {}, current_iteration: 1 }
|
|
197
|
+
mw.call(env)
|
|
198
|
+
mw.call(env)
|
|
199
|
+
|
|
200
|
+
root = Brute::Middleware::Checkpoint.list(path).first
|
|
201
|
+
Brute::Middleware::Checkpoint.new(->(env) { env[:messages].assistant("fork") }, path: path, resume: root[:id])
|
|
202
|
+
.call({ messages: Brute.log, metadata: {}, current_iteration: 1 })
|
|
203
|
+
|
|
204
|
+
Brute::Middleware::Checkpoint.list(path).last[:parent_id].should == root[:id]
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it "resume: :latest with no store yet starts fresh" do
|
|
209
|
+
Dir.mktmpdir do |dir|
|
|
210
|
+
env = { messages: Brute.log.tap { |l| l.user("go") }, metadata: {}, current_iteration: 1 }
|
|
211
|
+
Brute::Middleware::Checkpoint.new(->(_e) {}, path: File.join(dir, "cp.jsonl"), resume: :latest).call(env)
|
|
212
|
+
env[:messages].map(&:content).should == ["go"]
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it "raises on an unknown checkpoint id" do
|
|
217
|
+
Dir.mktmpdir do |dir|
|
|
218
|
+
path = File.join(dir, "cp.jsonl")
|
|
219
|
+
Brute::Middleware::Checkpoint.new(->(_e) {}, path: path).call(
|
|
220
|
+
{ messages: Brute.log, metadata: {}, current_iteration: 1 }
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
lambda do
|
|
224
|
+
Brute::Middleware::Checkpoint.new(->(_e) {}, path: path, resume: "nope").call(
|
|
225
|
+
{ messages: Brute.log, metadata: {}, current_iteration: 1 }
|
|
226
|
+
)
|
|
227
|
+
end.should.raise(KeyError)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
it "does not persist system messages" do
|
|
232
|
+
Dir.mktmpdir do |dir|
|
|
233
|
+
path = File.join(dir, "cp.jsonl")
|
|
234
|
+
env = { messages: Brute.log, metadata: {}, current_iteration: 1 }
|
|
235
|
+
env[:messages].system("rules")
|
|
236
|
+
env[:messages].user("go")
|
|
237
|
+
Brute::Middleware::Checkpoint.new(->(_e) {}, path: path).call(env)
|
|
238
|
+
|
|
239
|
+
roles = Brute::Middleware::Checkpoint.list(path).last[:messages].map { |m| m[:role] }
|
|
240
|
+
roles.should == ["user"]
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
it "restores only once even though the loop re-enters" do
|
|
245
|
+
Dir.mktmpdir do |dir|
|
|
246
|
+
path = File.join(dir, "cp.jsonl")
|
|
247
|
+
Brute::Middleware::Checkpoint.new(->(env) { env[:messages].assistant("old") }, path: path)
|
|
248
|
+
.call({ messages: Brute.log.tap { |l| l.user("hi") }, metadata: {}, current_iteration: 1 })
|
|
249
|
+
|
|
250
|
+
calls = 0
|
|
251
|
+
inner = ->(env) do
|
|
252
|
+
calls += 1
|
|
253
|
+
if calls < 2
|
|
254
|
+
env[:messages] << Brute::Message.new(role: :tool, content: "r", tool_call_id: "tc1")
|
|
255
|
+
else
|
|
256
|
+
env[:messages].assistant("done")
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
stack = Brute::Middleware::Loop::ToolResult.new(
|
|
260
|
+
Brute::Middleware::Checkpoint.new(inner, path: path, resume: :latest)
|
|
261
|
+
)
|
|
262
|
+
env = { messages: Brute.log.tap { |l| l.user("again") }, metadata: {}, current_iteration: 1 }
|
|
263
|
+
stack.call(env)
|
|
264
|
+
|
|
265
|
+
env[:messages].count { |m| m.content == "old" }.should == 1
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
data/lib/brute/version.rb
CHANGED
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: 3.0
|
|
4
|
+
version: 3.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brute Contributors
|
|
@@ -184,6 +184,7 @@ files:
|
|
|
184
184
|
- lib/brute/middleware/004_summarize.rb
|
|
185
185
|
- lib/brute/middleware/005_tracing.rb
|
|
186
186
|
- lib/brute/middleware/006_loop.rb
|
|
187
|
+
- lib/brute/middleware/008_checkpoint.rb
|
|
187
188
|
- lib/brute/middleware/010_max_iterations.rb
|
|
188
189
|
- lib/brute/middleware/015_otel_token_usage.rb
|
|
189
190
|
- lib/brute/middleware/020_system_prompt.rb
|