brute 0.4.1 → 1.0.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/agent.rb +14 -0
- data/lib/brute/diff.rb +18 -28
- data/lib/brute/loop/agent_stream.rb +118 -0
- data/lib/brute/loop/agent_turn.rb +520 -0
- data/lib/brute/{compactor.rb → loop/compactor.rb} +2 -0
- data/lib/brute/{doom_loop.rb → loop/doom_loop.rb} +2 -0
- data/lib/brute/loop/step.rb +332 -0
- data/lib/brute/loop/tool_call_step.rb +90 -0
- data/lib/brute/middleware/compaction_check.rb +60 -146
- data/lib/brute/middleware/doom_loop_detection.rb +95 -92
- data/lib/brute/middleware/llm_call.rb +78 -80
- data/lib/brute/middleware/message_tracking.rb +115 -162
- data/lib/brute/middleware/otel/span.rb +25 -106
- data/lib/brute/middleware/otel/token_usage.rb +29 -84
- data/lib/brute/middleware/otel/tool_calls.rb +23 -107
- data/lib/brute/middleware/otel/tool_results.rb +22 -86
- data/lib/brute/middleware/reasoning_normalizer.rb +78 -103
- data/lib/brute/middleware/retry.rb +95 -76
- data/lib/brute/middleware/session_persistence.rb +38 -37
- data/lib/brute/middleware/token_tracking.rb +64 -63
- data/lib/brute/middleware/tool_error_tracking.rb +108 -82
- data/lib/brute/middleware/tool_use_guard.rb +57 -90
- data/lib/brute/middleware/tracing.rb +53 -63
- data/lib/brute/middleware.rb +18 -0
- data/lib/brute/orchestrator/turn.rb +105 -0
- data/lib/brute/pipeline.rb +77 -133
- data/lib/brute/prompts/build_switch.rb +21 -25
- data/lib/brute/prompts/environment.rb +31 -35
- data/lib/brute/prompts/identity.rb +22 -29
- data/lib/brute/prompts/instructions.rb +15 -18
- data/lib/brute/prompts/max_steps.rb +18 -25
- data/lib/brute/prompts/plan_reminder.rb +18 -26
- data/lib/brute/prompts/skills.rb +8 -30
- data/lib/brute/prompts.rb +28 -0
- data/lib/brute/providers/ollama.rb +135 -0
- data/lib/brute/providers/shell.rb +2 -2
- data/lib/brute/providers/shell_response.rb +2 -2
- data/lib/brute/providers.rb +62 -0
- data/lib/brute/queue/base_queue.rb +222 -0
- data/lib/brute/{file_mutation_queue.rb → queue/file_mutation_queue.rb} +28 -26
- data/lib/brute/queue/parallel_queue.rb +66 -0
- data/lib/brute/queue/sequential_queue.rb +63 -0
- data/lib/brute/store/message_store.rb +362 -0
- data/lib/brute/store/session.rb +106 -0
- data/lib/brute/{snapshot_store.rb → store/snapshot_store.rb} +2 -0
- data/lib/brute/{todo_store.rb → store/todo_store.rb} +2 -0
- data/lib/brute/system_prompt.rb +81 -194
- data/lib/brute/tools/delegate.rb +46 -116
- data/lib/brute/tools/fs_patch.rb +36 -37
- data/lib/brute/tools/fs_remove.rb +2 -2
- data/lib/brute/tools/fs_undo.rb +2 -2
- data/lib/brute/tools/fs_write.rb +29 -41
- data/lib/brute/tools/todo_read.rb +1 -1
- data/lib/brute/tools/todo_write.rb +1 -1
- data/lib/brute/tools.rb +31 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +40 -204
- metadata +31 -20
- data/lib/brute/agent_stream.rb +0 -181
- data/lib/brute/hooks.rb +0 -84
- data/lib/brute/message_store.rb +0 -463
- data/lib/brute/orchestrator.rb +0 -550
- data/lib/brute/session.rb +0 -161
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Queue
|
|
8
|
+
# A queue that processes steps concurrently up to a limit.
|
|
9
|
+
# Workers match concurrency slots.
|
|
10
|
+
class ParallelQueue < BaseQueue
|
|
11
|
+
def initialize(concurrency: 4, parent: Async::Task.current)
|
|
12
|
+
super(concurrency: concurrency, worker_count: concurrency, parent: parent)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
test do
|
|
19
|
+
it "runs steps concurrently" do
|
|
20
|
+
Sync do
|
|
21
|
+
concurrent = 0
|
|
22
|
+
max_concurrent = 0
|
|
23
|
+
|
|
24
|
+
steps = 4.times.map do
|
|
25
|
+
Class.new(Brute::Loop::Step) do
|
|
26
|
+
define_method(:perform) do |task|
|
|
27
|
+
concurrent += 1
|
|
28
|
+
max_concurrent = [max_concurrent, concurrent].max
|
|
29
|
+
sleep 0.05
|
|
30
|
+
concurrent -= 1
|
|
31
|
+
end
|
|
32
|
+
end.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
q = Brute::Queue::ParallelQueue.new(concurrency: 4)
|
|
36
|
+
steps.each { |s| q << s }
|
|
37
|
+
q.start
|
|
38
|
+
q.drain
|
|
39
|
+
(max_concurrent > 1).should.be.true
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "limits concurrency to the specified amount" do
|
|
44
|
+
Sync do
|
|
45
|
+
concurrent = 0
|
|
46
|
+
max_concurrent = 0
|
|
47
|
+
|
|
48
|
+
steps = 8.times.map do
|
|
49
|
+
Class.new(Brute::Loop::Step) do
|
|
50
|
+
define_method(:perform) do |task|
|
|
51
|
+
concurrent += 1
|
|
52
|
+
max_concurrent = [max_concurrent, concurrent].max
|
|
53
|
+
sleep 0.05
|
|
54
|
+
concurrent -= 1
|
|
55
|
+
end
|
|
56
|
+
end.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
q = Brute::Queue::ParallelQueue.new(concurrency: 2)
|
|
60
|
+
steps.each { |s| q << s }
|
|
61
|
+
q.start
|
|
62
|
+
q.drain
|
|
63
|
+
(max_concurrent <= 2).should.be.true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Queue
|
|
8
|
+
# A queue that processes steps one at a time, in order.
|
|
9
|
+
# One worker, one concurrency slot.
|
|
10
|
+
class SequentialQueue < BaseQueue
|
|
11
|
+
def initialize(parent: Async::Task.current)
|
|
12
|
+
super(concurrency: 1, worker_count: 1, parent: parent)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
test do
|
|
19
|
+
class OrderStep < Brute::Loop::Step
|
|
20
|
+
def perform(task)
|
|
21
|
+
@attributes[:log] << @attributes[:value]
|
|
22
|
+
sleep(@attributes[:delay]) if @attributes[:delay]
|
|
23
|
+
@attributes[:value]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "processes steps in order" do
|
|
28
|
+
Sync do
|
|
29
|
+
log = []
|
|
30
|
+
q = Brute::Queue::SequentialQueue.new
|
|
31
|
+
q << OrderStep.new(log: log, value: "a", delay: 0.01)
|
|
32
|
+
q << OrderStep.new(log: log, value: "b", delay: 0.01)
|
|
33
|
+
q << OrderStep.new(log: log, value: "c", delay: 0.01)
|
|
34
|
+
q.start
|
|
35
|
+
q.drain
|
|
36
|
+
log.should == ["a", "b", "c"]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "runs only one step at a time" do
|
|
41
|
+
Sync do
|
|
42
|
+
concurrent = 0
|
|
43
|
+
max_concurrent = 0
|
|
44
|
+
|
|
45
|
+
steps = 3.times.map do
|
|
46
|
+
Class.new(Brute::Loop::Step) do
|
|
47
|
+
define_method(:perform) do |task|
|
|
48
|
+
concurrent += 1
|
|
49
|
+
max_concurrent = [max_concurrent, concurrent].max
|
|
50
|
+
sleep 0.02
|
|
51
|
+
concurrent -= 1
|
|
52
|
+
end
|
|
53
|
+
end.new
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
q = Brute::Queue::SequentialQueue.new
|
|
57
|
+
steps.each { |s| q << s }
|
|
58
|
+
q.start
|
|
59
|
+
q.drain
|
|
60
|
+
max_concurrent.should == 1
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require "json"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
|
|
9
|
+
module Brute
|
|
10
|
+
module Store
|
|
11
|
+
# Stores session messages as individual JSON files in the OpenCode
|
|
12
|
+
# {info, parts} format. Each session gets a directory; each message
|
|
13
|
+
# is a numbered JSON file inside it.
|
|
14
|
+
#
|
|
15
|
+
# Storage layout:
|
|
16
|
+
#
|
|
17
|
+
# ~/.brute/sessions/{session-id}/
|
|
18
|
+
# session.meta.json
|
|
19
|
+
# msg_0001.json
|
|
20
|
+
# msg_0002.json
|
|
21
|
+
# ...
|
|
22
|
+
#
|
|
23
|
+
# Message format matches OpenCode's MessageV2.WithParts:
|
|
24
|
+
#
|
|
25
|
+
# { info: { id:, sessionID:, role:, time:, ... },
|
|
26
|
+
# parts: [{ id:, type:, ... }, ...] }
|
|
27
|
+
#
|
|
28
|
+
class MessageStore
|
|
29
|
+
attr_reader :session_id, :dir
|
|
30
|
+
|
|
31
|
+
def initialize(session_id:, dir: nil)
|
|
32
|
+
@session_id = session_id
|
|
33
|
+
@dir = dir || File.join(Dir.home, ".brute", "sessions", session_id)
|
|
34
|
+
@messages = {} # id => { info:, parts: }
|
|
35
|
+
@seq = 0
|
|
36
|
+
@part_seq = 0
|
|
37
|
+
@mutex = Mutex.new
|
|
38
|
+
load_existing
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def append_user(text:, message_id: nil)
|
|
42
|
+
id = message_id || next_message_id
|
|
43
|
+
msg = {
|
|
44
|
+
info: {
|
|
45
|
+
id: id,
|
|
46
|
+
sessionID: @session_id,
|
|
47
|
+
role: "user",
|
|
48
|
+
time: { created: now_ms },
|
|
49
|
+
},
|
|
50
|
+
parts: [
|
|
51
|
+
{ id: next_part_id, sessionID: @session_id, messageID: id,
|
|
52
|
+
type: "text", text: text },
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
save_message(id, msg)
|
|
56
|
+
id
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def append_assistant(message_id: nil, parent_id: nil, model_id: nil, provider_id: nil)
|
|
60
|
+
id = message_id || next_message_id
|
|
61
|
+
msg = {
|
|
62
|
+
info: {
|
|
63
|
+
id: id,
|
|
64
|
+
sessionID: @session_id,
|
|
65
|
+
role: "assistant",
|
|
66
|
+
parentID: parent_id,
|
|
67
|
+
time: { created: now_ms },
|
|
68
|
+
modelID: model_id,
|
|
69
|
+
providerID: provider_id,
|
|
70
|
+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
71
|
+
cost: 0.0,
|
|
72
|
+
},
|
|
73
|
+
parts: [],
|
|
74
|
+
}
|
|
75
|
+
save_message(id, msg)
|
|
76
|
+
id
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def add_text_part(message_id:, text:)
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
msg = @messages[message_id]
|
|
82
|
+
return unless msg
|
|
83
|
+
|
|
84
|
+
part = { id: next_part_id, sessionID: @session_id, messageID: message_id,
|
|
85
|
+
type: "text", text: text }
|
|
86
|
+
msg[:parts] << part
|
|
87
|
+
persist(message_id)
|
|
88
|
+
part[:id]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def add_tool_part(message_id:, tool:, call_id:, input:)
|
|
93
|
+
@mutex.synchronize do
|
|
94
|
+
msg = @messages[message_id]
|
|
95
|
+
return unless msg
|
|
96
|
+
|
|
97
|
+
part = {
|
|
98
|
+
id: next_part_id, sessionID: @session_id, messageID: message_id,
|
|
99
|
+
type: "tool", callID: call_id, tool: tool,
|
|
100
|
+
state: {
|
|
101
|
+
status: "running",
|
|
102
|
+
input: input,
|
|
103
|
+
time: { start: now_ms },
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
msg[:parts] << part
|
|
107
|
+
persist(message_id)
|
|
108
|
+
part[:id]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def complete_tool_part(message_id:, call_id:, output:)
|
|
113
|
+
@mutex.synchronize do
|
|
114
|
+
msg = @messages[message_id]
|
|
115
|
+
return unless msg
|
|
116
|
+
|
|
117
|
+
part = msg[:parts].find { |p| p[:type] == "tool" && p[:callID] == call_id }
|
|
118
|
+
return unless part
|
|
119
|
+
|
|
120
|
+
part[:state][:status] = "completed"
|
|
121
|
+
part[:state][:output] = output
|
|
122
|
+
part[:state][:time][:end] = now_ms
|
|
123
|
+
persist(message_id)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def error_tool_part(message_id:, call_id:, error:)
|
|
128
|
+
@mutex.synchronize do
|
|
129
|
+
msg = @messages[message_id]
|
|
130
|
+
return unless msg
|
|
131
|
+
|
|
132
|
+
part = msg[:parts].find { |p| p[:type] == "tool" && p[:callID] == call_id }
|
|
133
|
+
return unless part
|
|
134
|
+
|
|
135
|
+
part[:state][:status] = "error"
|
|
136
|
+
part[:state][:error] = error.to_s
|
|
137
|
+
part[:state][:time][:end] = now_ms
|
|
138
|
+
persist(message_id)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def add_step_finish(message_id:, tokens: nil)
|
|
143
|
+
@mutex.synchronize do
|
|
144
|
+
msg = @messages[message_id]
|
|
145
|
+
return unless msg
|
|
146
|
+
|
|
147
|
+
part = {
|
|
148
|
+
id: next_part_id, sessionID: @session_id, messageID: message_id,
|
|
149
|
+
type: "step-finish",
|
|
150
|
+
reason: "stop",
|
|
151
|
+
tokens: tokens || { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
152
|
+
}
|
|
153
|
+
msg[:parts] << part
|
|
154
|
+
persist(message_id)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Finalize an assistant message with token counts and completion time.
|
|
159
|
+
def complete_assistant(message_id:, tokens: nil)
|
|
160
|
+
@mutex.synchronize do
|
|
161
|
+
msg = @messages[message_id]
|
|
162
|
+
return unless msg
|
|
163
|
+
|
|
164
|
+
msg[:info][:time][:completed] = now_ms
|
|
165
|
+
if tokens
|
|
166
|
+
msg[:info][:tokens] = {
|
|
167
|
+
input: tokens[:input] || tokens[:total_input] || 0,
|
|
168
|
+
output: tokens[:output] || tokens[:total_output] || 0,
|
|
169
|
+
reasoning: tokens[:reasoning] || tokens[:total_reasoning] || 0,
|
|
170
|
+
cache: tokens[:cache] || { read: 0, write: 0 },
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
persist(message_id)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def messages
|
|
178
|
+
@mutex.synchronize { @messages.values }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def message(id)
|
|
182
|
+
@mutex.synchronize { @messages[id] }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def count
|
|
186
|
+
@mutex.synchronize { @messages.size }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def next_message_id
|
|
192
|
+
@seq += 1
|
|
193
|
+
format("msg_%04d", @seq)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def next_part_id
|
|
197
|
+
@part_seq += 1
|
|
198
|
+
format("prt_%04d", @part_seq)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def now_ms
|
|
202
|
+
(Time.now.to_f * 1000).to_i
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def save_message(id, msg)
|
|
206
|
+
@mutex.synchronize do
|
|
207
|
+
@messages[id] = msg
|
|
208
|
+
persist(id)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def persist(id)
|
|
213
|
+
FileUtils.mkdir_p(@dir)
|
|
214
|
+
msg = @messages[id]
|
|
215
|
+
return unless msg
|
|
216
|
+
|
|
217
|
+
path = File.join(@dir, "#{id}.json")
|
|
218
|
+
File.write(path, JSON.pretty_generate(msg))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def load_existing
|
|
222
|
+
return unless File.directory?(@dir)
|
|
223
|
+
|
|
224
|
+
Dir.glob(File.join(@dir, "msg_*.json")).sort.each do |path|
|
|
225
|
+
data = JSON.parse(File.read(path), symbolize_names: true)
|
|
226
|
+
id = data.dig(:info, :id)
|
|
227
|
+
next unless id
|
|
228
|
+
|
|
229
|
+
@messages[id] = data
|
|
230
|
+
|
|
231
|
+
# Track sequence numbers so new IDs don't collide
|
|
232
|
+
if (m = id.match(/\Amsg_(\d+)\z/))
|
|
233
|
+
n = m[1].to_i
|
|
234
|
+
@seq = n if n > @seq
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Track part sequences too
|
|
238
|
+
(data[:parts] || []).each do |part|
|
|
239
|
+
pid = part[:id]
|
|
240
|
+
if pid.is_a?(String) && (m = pid.match(/\Aprt_(\d+)\z/))
|
|
241
|
+
n = m[1].to_i
|
|
242
|
+
@part_seq = n if n > @part_seq
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
test do
|
|
252
|
+
require "tmpdir"
|
|
253
|
+
|
|
254
|
+
def with_store
|
|
255
|
+
dir = Dir.mktmpdir("brute_test_")
|
|
256
|
+
store = Brute::Store::MessageStore.new(session_id: "test-session-123", dir: dir)
|
|
257
|
+
yield store, dir
|
|
258
|
+
ensure
|
|
259
|
+
FileUtils.rm_rf(dir)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
it "creates a user message with text part" do
|
|
263
|
+
with_store do |store, _|
|
|
264
|
+
id = store.append_user(text: "Hello")
|
|
265
|
+
store.message(id)[:info][:role].should == "user"
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
it "stores user message text" do
|
|
270
|
+
with_store do |store, _|
|
|
271
|
+
id = store.append_user(text: "Hello")
|
|
272
|
+
store.message(id)[:parts][0][:text].should == "Hello"
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
it "generates sequential message IDs" do
|
|
277
|
+
with_store do |store, _|
|
|
278
|
+
id1 = store.append_user(text: "First")
|
|
279
|
+
id2 = store.append_user(text: "Second")
|
|
280
|
+
id1.should == "msg_0001"
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
it "creates an assistant message" do
|
|
285
|
+
with_store do |store, _|
|
|
286
|
+
uid = store.append_user(text: "Hi")
|
|
287
|
+
aid = store.append_assistant(parent_id: uid, model_id: "claude", provider_id: "anthropic")
|
|
288
|
+
store.message(aid)[:info][:role].should == "assistant"
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
it "appends a text part to an existing message" do
|
|
293
|
+
with_store do |store, _|
|
|
294
|
+
aid = store.append_assistant
|
|
295
|
+
store.add_text_part(message_id: aid, text: "Here is my response")
|
|
296
|
+
store.message(aid)[:parts][0][:text].should == "Here is my response"
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
it "tracks tool lifecycle running to completed" do
|
|
301
|
+
with_store do |store, _|
|
|
302
|
+
aid = store.append_assistant
|
|
303
|
+
store.add_tool_part(message_id: aid, tool: "read", call_id: "call_001", input: {})
|
|
304
|
+
store.message(aid)[:parts].find { |p| p[:type] == "tool" }[:state][:status].should == "running"
|
|
305
|
+
store.complete_tool_part(message_id: aid, call_id: "call_001", output: "done")
|
|
306
|
+
store.message(aid)[:parts].find { |p| p[:type] == "tool" }[:state][:status].should == "completed"
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
it "tracks tool lifecycle running to error" do
|
|
311
|
+
with_store do |store, _|
|
|
312
|
+
aid = store.append_assistant
|
|
313
|
+
store.add_tool_part(message_id: aid, tool: "shell", call_id: "call_002", input: {})
|
|
314
|
+
store.error_tool_part(message_id: aid, call_id: "call_002", error: "denied")
|
|
315
|
+
store.message(aid)[:parts].find { |p| p[:type] == "tool" }[:state][:status].should == "error"
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
it "sets token counts on complete_assistant" do
|
|
320
|
+
with_store do |store, _|
|
|
321
|
+
aid = store.append_assistant
|
|
322
|
+
store.complete_assistant(message_id: aid, tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 0, write: 0 } })
|
|
323
|
+
store.message(aid)[:info][:tokens][:input].should == 100
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
it "returns all messages in order" do
|
|
328
|
+
with_store do |store, _|
|
|
329
|
+
store.append_user(text: "Q1")
|
|
330
|
+
store.append_assistant
|
|
331
|
+
store.append_user(text: "Q2")
|
|
332
|
+
store.messages.size.should == 3
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
it "returns count of stored messages" do
|
|
337
|
+
with_store do |store, _|
|
|
338
|
+
store.count.should == 0
|
|
339
|
+
store.append_user(text: "Q1")
|
|
340
|
+
store.count.should == 1
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
it "restores messages from disk" do
|
|
345
|
+
with_store do |store, dir|
|
|
346
|
+
store.append_user(text: "Persisted Q")
|
|
347
|
+
aid = store.append_assistant(model_id: "claude")
|
|
348
|
+
store.add_text_part(message_id: aid, text: "Persisted A")
|
|
349
|
+
store2 = Brute::Store::MessageStore.new(session_id: "test-session-123", dir: dir)
|
|
350
|
+
store2.count.should == 2
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
it "continues sequence numbering from loaded messages" do
|
|
355
|
+
with_store do |store, dir|
|
|
356
|
+
store.append_user(text: "Q1")
|
|
357
|
+
store.append_user(text: "Q2")
|
|
358
|
+
store2 = Brute::Store::MessageStore.new(session_id: "test-session-123", dir: dir)
|
|
359
|
+
store2.append_user(text: "Q3").should == "msg_0003"
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Brute
|
|
8
|
+
module Store
|
|
9
|
+
# Manages session persistence. Each session is a conversation that can be
|
|
10
|
+
# saved to disk and resumed later.
|
|
11
|
+
#
|
|
12
|
+
# Storage layout (per-session directory):
|
|
13
|
+
#
|
|
14
|
+
# ~/.brute/sessions/{session-id}/
|
|
15
|
+
# session.meta.json # session metadata
|
|
16
|
+
# context.json # serialized conversation history
|
|
17
|
+
# msg_0001.json # structured messages (OpenCode format)
|
|
18
|
+
# msg_0002.json
|
|
19
|
+
# ...
|
|
20
|
+
#
|
|
21
|
+
class Session
|
|
22
|
+
attr_reader :id, :title, :path
|
|
23
|
+
|
|
24
|
+
def initialize(id: nil, dir: nil)
|
|
25
|
+
@id = id || SecureRandom.uuid
|
|
26
|
+
@base_dir = dir || File.join(Dir.home, ".brute", "sessions")
|
|
27
|
+
@session_dir = File.join(@base_dir, @id)
|
|
28
|
+
@path = File.join(@session_dir, "context.json")
|
|
29
|
+
@title = nil
|
|
30
|
+
@metadata = {}
|
|
31
|
+
FileUtils.mkdir_p(@session_dir)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def message_store
|
|
35
|
+
@message_store ||= MessageStore.new(session_id: @id, dir: @session_dir)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Serialize an array of LLM::Message objects to disk as JSON.
|
|
39
|
+
def save_messages(messages, title: nil, metadata: {})
|
|
40
|
+
@title = title if title
|
|
41
|
+
@metadata.merge!(metadata)
|
|
42
|
+
|
|
43
|
+
data = {
|
|
44
|
+
schema_version: 1,
|
|
45
|
+
messages: messages.map { |m| { role: m.role.to_s, content: m.content.to_s } },
|
|
46
|
+
}
|
|
47
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
48
|
+
File.write(@path, JSON.pretty_generate(data))
|
|
49
|
+
|
|
50
|
+
save_meta
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# List all saved sessions, newest first.
|
|
54
|
+
def self.list(dir: nil)
|
|
55
|
+
dir ||= File.join(Dir.home, ".brute", "sessions")
|
|
56
|
+
|
|
57
|
+
if File.directory?(dir)
|
|
58
|
+
sessions = Dir.glob(File.join(dir, "*", "session.meta.json")).filter_map do |meta_path|
|
|
59
|
+
data = JSON.parse(File.read(meta_path), symbolize_names: true)
|
|
60
|
+
id = data[:id]
|
|
61
|
+
next unless id
|
|
62
|
+
{
|
|
63
|
+
id: id,
|
|
64
|
+
title: data[:title],
|
|
65
|
+
saved_at: data[:saved_at],
|
|
66
|
+
path: File.join(File.dirname(meta_path), "context.json"),
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
sessions.sort_by { |s| s[:saved_at] || "" }.reverse
|
|
71
|
+
else
|
|
72
|
+
[]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def delete
|
|
77
|
+
FileUtils.rm_rf(@session_dir) if File.directory?(@session_dir)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def meta_path
|
|
83
|
+
File.join(@session_dir, "session.meta.json")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def save_meta
|
|
87
|
+
data = {
|
|
88
|
+
id: @id,
|
|
89
|
+
title: @title,
|
|
90
|
+
saved_at: Time.now.iso8601,
|
|
91
|
+
metadata: @metadata,
|
|
92
|
+
}
|
|
93
|
+
FileUtils.mkdir_p(@session_dir)
|
|
94
|
+
File.write(meta_path, JSON.pretty_generate(data))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def load_meta
|
|
98
|
+
return unless File.exist?(meta_path)
|
|
99
|
+
|
|
100
|
+
data = JSON.parse(File.read(meta_path), symbolize_names: true)
|
|
101
|
+
@title = data[:title]
|
|
102
|
+
@metadata = data[:metadata] || {}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Brute
|
|
4
|
+
module Store
|
|
4
5
|
# Copy-on-write snapshot storage for file undo support.
|
|
5
6
|
# Saves the previous content of a file before mutation so it can be restored.
|
|
6
7
|
# Each file maintains a stack of snapshots, supporting multiple undo levels.
|
|
@@ -46,4 +47,5 @@ module Brute
|
|
|
46
47
|
end
|
|
47
48
|
end
|
|
48
49
|
end
|
|
50
|
+
end
|
|
49
51
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Brute
|
|
4
|
+
module Store
|
|
4
5
|
# In-memory todo list storage. The agent uses this to track multi-step tasks.
|
|
5
6
|
# The list is replaced wholesale on each todo_write call.
|
|
6
7
|
module TodoStore
|
|
@@ -24,4 +25,5 @@ module Brute
|
|
|
24
25
|
end
|
|
25
26
|
end
|
|
26
27
|
end
|
|
28
|
+
end
|
|
27
29
|
end
|