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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c25e73819e947f92a37f144411ba27b09f93cc48088bc0cc474247bc81149658
4
- data.tar.gz: 635eff80312c14656cf2a6168f2f4ee9279413ac285e580a58e959812a9e5d4d
3
+ metadata.gz: 1fe7f1994a944255146721ea3030261622735e33d412f07ba6fd118287b76517
4
+ data.tar.gz: 0b36c9d8c38afb152c16f0bc26c305145ed94f1c53e84316399d4f32b7f8752a
5
5
  SHA512:
6
- metadata.gz: c43c126ae04f4642df67df70ab780dfc11ef2070709580cc409d6b32652a2f0be41730d252a06d9029f8d156657877f046af6a06d5c2ba1d4ee9a2d5fda75d97
7
- data.tar.gz: 26599b1c63616c87af1c8537dfe4e473be5a70ee408058b31fb8a67c5203f72b81d41a868e59ceb8831ac570a74a9132c6c2df42794b116ea91e67b70a4441dc
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brute
4
- VERSION = "3.0.1"
4
+ VERSION = "3.1.0"
5
5
  end
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.1
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