brute 1.0.0 → 2.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 +72 -6
- data/lib/brute/events/handler.rb +69 -0
- data/lib/brute/events/prefixed_terminal_output.rb +72 -0
- data/lib/brute/events/terminal_output_handler.rb +68 -0
- data/lib/brute/middleware/001_otel_span.rb +77 -0
- data/lib/brute/middleware/003_tool_result_loop.rb +103 -0
- data/lib/brute/middleware/004_summarize.rb +139 -0
- data/lib/brute/middleware/005_tracing.rb +86 -0
- data/lib/brute/middleware/010_max_iterations.rb +73 -0
- data/lib/brute/middleware/015_otel_token_usage.rb +42 -0
- data/lib/brute/middleware/020_system_prompt.rb +128 -0
- data/lib/brute/middleware/040_compaction_check.rb +155 -0
- data/lib/brute/middleware/060_questions.rb +41 -0
- data/lib/brute/middleware/070_tool_call.rb +247 -0
- data/lib/brute/middleware/073_otel_tool_call.rb +49 -0
- data/lib/brute/middleware/075_otel_tool_results.rb +46 -0
- data/lib/brute/middleware/100_llm_call.rb +62 -0
- data/lib/brute/middleware/event_handler.rb +25 -0
- data/lib/brute/middleware/user_queue.rb +35 -0
- data/lib/brute/pipeline.rb +44 -107
- data/lib/brute/prompts/skills.rb +2 -2
- data/lib/brute/prompts.rb +23 -23
- data/lib/brute/providers/shell.rb +6 -19
- data/lib/brute/providers/shell_response.rb +22 -30
- data/lib/brute/session.rb +52 -0
- data/lib/brute/store/snapshot_store.rb +21 -37
- data/lib/brute/sub_agent.rb +106 -0
- data/lib/brute/system_prompt.rb +1 -83
- data/lib/brute/tool.rb +107 -0
- data/lib/brute/tools/delegate.rb +61 -70
- data/lib/brute/tools/fs_patch.rb +9 -7
- data/lib/brute/tools/fs_read.rb +233 -20
- data/lib/brute/tools/fs_remove.rb +8 -9
- data/lib/brute/tools/fs_search.rb +98 -16
- data/lib/brute/tools/fs_undo.rb +8 -8
- data/lib/brute/tools/fs_write.rb +7 -5
- data/lib/brute/tools/net_fetch.rb +8 -8
- data/lib/brute/tools/question.rb +36 -24
- data/lib/brute/tools/shell.rb +74 -16
- data/lib/brute/tools/todo_read.rb +8 -8
- data/lib/brute/tools/todo_write.rb +25 -18
- data/lib/brute/tools.rb +8 -12
- data/lib/brute/truncation.rb +219 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +82 -45
- metadata +59 -46
- data/lib/brute/loop/agent_stream.rb +0 -118
- data/lib/brute/loop/agent_turn.rb +0 -520
- data/lib/brute/loop/compactor.rb +0 -107
- data/lib/brute/loop/doom_loop.rb +0 -86
- data/lib/brute/loop/step.rb +0 -332
- data/lib/brute/loop/tool_call_step.rb +0 -90
- data/lib/brute/middleware/base.rb +0 -27
- data/lib/brute/middleware/compaction_check.rb +0 -106
- data/lib/brute/middleware/doom_loop_detection.rb +0 -136
- data/lib/brute/middleware/llm_call.rb +0 -128
- data/lib/brute/middleware/message_tracking.rb +0 -339
- data/lib/brute/middleware/otel/span.rb +0 -105
- data/lib/brute/middleware/otel/token_usage.rb +0 -68
- data/lib/brute/middleware/otel/tool_calls.rb +0 -68
- data/lib/brute/middleware/otel/tool_results.rb +0 -65
- data/lib/brute/middleware/otel.rb +0 -34
- data/lib/brute/middleware/reasoning_normalizer.rb +0 -192
- data/lib/brute/middleware/retry.rb +0 -157
- data/lib/brute/middleware/session_persistence.rb +0 -72
- data/lib/brute/middleware/token_tracking.rb +0 -124
- data/lib/brute/middleware/tool_error_tracking.rb +0 -179
- data/lib/brute/middleware/tool_use_guard.rb +0 -133
- data/lib/brute/middleware/tracing.rb +0 -124
- data/lib/brute/middleware.rb +0 -18
- data/lib/brute/orchestrator/turn.rb +0 -105
- data/lib/brute/patches/anthropic_tool_role.rb +0 -35
- data/lib/brute/patches/buffer_nil_guard.rb +0 -26
- data/lib/brute/providers/models_dev.rb +0 -111
- data/lib/brute/providers/ollama.rb +0 -135
- data/lib/brute/providers/opencode_go.rb +0 -43
- data/lib/brute/providers/opencode_zen.rb +0 -87
- data/lib/brute/providers.rb +0 -62
- data/lib/brute/queue/base_queue.rb +0 -222
- data/lib/brute/queue/parallel_queue.rb +0 -66
- data/lib/brute/queue/sequential_queue.rb +0 -63
- data/lib/brute/store/message_store.rb +0 -362
- data/lib/brute/store/session.rb +0 -106
- /data/lib/brute/{diff.rb → utils/diff.rb} +0 -0
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "brute"
|
|
5
|
-
|
|
6
|
-
require "async"
|
|
7
|
-
require "async/queue"
|
|
8
|
-
require "async/barrier"
|
|
9
|
-
require "async/semaphore"
|
|
10
|
-
|
|
11
|
-
module Brute
|
|
12
|
-
module Queue
|
|
13
|
-
# A queue that dequeues Step objects and runs them, honoring cancellation.
|
|
14
|
-
#
|
|
15
|
-
# Composes four async primitives:
|
|
16
|
-
# - An inbox (Async::Queue) that holds pending steps
|
|
17
|
-
# - A barrier (Async::Barrier) that tracks every task the queue spawns
|
|
18
|
-
# - A semaphore (Async::Semaphore) parented to the barrier, limiting concurrency
|
|
19
|
-
# - Workers — long-lived tasks that dequeue from the inbox and run steps
|
|
20
|
-
#
|
|
21
|
-
# The barrier-semaphore composition via parent: means every task the
|
|
22
|
-
# semaphore spawns is also tracked by the barrier. One call site
|
|
23
|
-
# (semaphore.async), two guarantees (scoped lifetime + bounded concurrency).
|
|
24
|
-
#
|
|
25
|
-
class BaseQueue
|
|
26
|
-
attr_reader :steps
|
|
27
|
-
|
|
28
|
-
def initialize(concurrency:, worker_count:, parent: Async::Task.current)
|
|
29
|
-
@steps = []
|
|
30
|
-
@inbox = Async::Queue.new
|
|
31
|
-
@barrier = Async::Barrier.new(parent: parent)
|
|
32
|
-
@semaphore = Async::Semaphore.new(concurrency, parent: @barrier)
|
|
33
|
-
@worker_count = worker_count
|
|
34
|
-
@started = false
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def <<(step)
|
|
38
|
-
@steps << step
|
|
39
|
-
@inbox.push(step)
|
|
40
|
-
self
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def first = @steps.first
|
|
44
|
-
def last = @steps.last
|
|
45
|
-
|
|
46
|
-
def start
|
|
47
|
-
return self if @started
|
|
48
|
-
@started = true
|
|
49
|
-
|
|
50
|
-
@worker_count.times do
|
|
51
|
-
@barrier.async do
|
|
52
|
-
while (step = @inbox.dequeue)
|
|
53
|
-
@semaphore.async do |task|
|
|
54
|
-
step.call(task)
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
self
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Graceful: stop accepting, wait for running work to finish.
|
|
63
|
-
def drain
|
|
64
|
-
@inbox.close
|
|
65
|
-
@barrier.wait
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# Hard: close inbox, cancel pending steps, cancel running work.
|
|
69
|
-
def cancel
|
|
70
|
-
@inbox.close
|
|
71
|
-
@steps.each do |step|
|
|
72
|
-
step.cancel if step.state == :pending
|
|
73
|
-
end
|
|
74
|
-
@barrier.cancel
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
test do
|
|
81
|
-
class CountStep < Brute::Loop::Step
|
|
82
|
-
def perform(task)
|
|
83
|
-
@attributes[:counter] << @attributes[:value]
|
|
84
|
-
@attributes[:value]
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
class SleepStep < Brute::Loop::Step
|
|
89
|
-
def perform(task)
|
|
90
|
-
sleep(@attributes[:duration])
|
|
91
|
-
"slept"
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
# -- enqueue --
|
|
96
|
-
|
|
97
|
-
it "appends steps to the steps list" do
|
|
98
|
-
Sync do
|
|
99
|
-
q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
|
|
100
|
-
q << CountStep.new(counter: [], value: 1)
|
|
101
|
-
q.steps.size.should == 1
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
it "returns self from <<" do
|
|
106
|
-
Sync do
|
|
107
|
-
q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
|
|
108
|
-
(q << CountStep.new(counter: [], value: 1)).should.be.identical_to q
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# -- first / last --
|
|
113
|
-
|
|
114
|
-
it "returns the first step" do
|
|
115
|
-
Sync do
|
|
116
|
-
q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
|
|
117
|
-
s1 = CountStep.new(counter: [], value: 1)
|
|
118
|
-
s2 = CountStep.new(counter: [], value: 2)
|
|
119
|
-
q << s1 << s2
|
|
120
|
-
q.first.should.be.identical_to s1
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
it "returns the last step" do
|
|
125
|
-
Sync do
|
|
126
|
-
q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
|
|
127
|
-
s1 = CountStep.new(counter: [], value: 1)
|
|
128
|
-
s2 = CountStep.new(counter: [], value: 2)
|
|
129
|
-
q << s1 << s2
|
|
130
|
-
q.last.should.be.identical_to s2
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
# -- start + drain --
|
|
135
|
-
|
|
136
|
-
it "runs steps to completion on drain" do
|
|
137
|
-
Sync do
|
|
138
|
-
results = []
|
|
139
|
-
q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
|
|
140
|
-
q << CountStep.new(counter: results, value: "a")
|
|
141
|
-
q << CountStep.new(counter: results, value: "b")
|
|
142
|
-
q.start
|
|
143
|
-
q.drain
|
|
144
|
-
results.should == ["a", "b"]
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
it "completes all steps" do
|
|
149
|
-
Sync do
|
|
150
|
-
q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
|
|
151
|
-
q << CountStep.new(counter: [], value: 1)
|
|
152
|
-
q << CountStep.new(counter: [], value: 2)
|
|
153
|
-
q.start
|
|
154
|
-
q.drain
|
|
155
|
-
q.steps.all? { |s| s.state == :completed }.should.be.true
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
it "captures results on each step" do
|
|
160
|
-
Sync do
|
|
161
|
-
q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
|
|
162
|
-
q << CountStep.new(counter: [], value: 42)
|
|
163
|
-
q.start
|
|
164
|
-
q.drain
|
|
165
|
-
q.first.result.should == 42
|
|
166
|
-
end
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
it "is idempotent on start" do
|
|
170
|
-
Sync do
|
|
171
|
-
q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
|
|
172
|
-
q << CountStep.new(counter: [], value: 1)
|
|
173
|
-
q.start
|
|
174
|
-
q.start # should not double-spawn workers
|
|
175
|
-
q.drain
|
|
176
|
-
q.steps.size.should == 1
|
|
177
|
-
end
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
# -- cancel --
|
|
181
|
-
|
|
182
|
-
it "marks pending steps as cancelled" do
|
|
183
|
-
Sync do
|
|
184
|
-
q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
|
|
185
|
-
s1 = SleepStep.new(duration: 10)
|
|
186
|
-
s2 = SleepStep.new(duration: 10)
|
|
187
|
-
s3 = SleepStep.new(duration: 10)
|
|
188
|
-
q << s1 << s2 << s3
|
|
189
|
-
q.start
|
|
190
|
-
sleep 0.01 # let worker pick up s1
|
|
191
|
-
q.cancel
|
|
192
|
-
# s2 and s3 should be cancelled (they were pending)
|
|
193
|
-
[s2, s3].all? { |s| s.state == :cancelled }.should.be.true
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
# -- concurrent execution --
|
|
198
|
-
|
|
199
|
-
it "runs multiple steps concurrently" do
|
|
200
|
-
Sync do
|
|
201
|
-
started = []
|
|
202
|
-
done = []
|
|
203
|
-
|
|
204
|
-
steps = 3.times.map do |i|
|
|
205
|
-
Class.new(Brute::Loop::Step) do
|
|
206
|
-
define_method(:perform) do |task|
|
|
207
|
-
started << i
|
|
208
|
-
sleep 0.05
|
|
209
|
-
done << i
|
|
210
|
-
i
|
|
211
|
-
end
|
|
212
|
-
end.new
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
q = Brute::Queue::BaseQueue.new(concurrency: 3, worker_count: 3)
|
|
216
|
-
steps.each { |s| q << s }
|
|
217
|
-
q.start
|
|
218
|
-
q.drain
|
|
219
|
-
done.size.should == 3
|
|
220
|
-
end
|
|
221
|
-
end
|
|
222
|
-
end
|
|
@@ -1,66 +0,0 @@
|
|
|
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
|
|
@@ -1,63 +0,0 @@
|
|
|
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
|
|
@@ -1,362 +0,0 @@
|
|
|
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
|