brute 0.4.0 → 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 +24 -0
- 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 +70 -23
- data/lib/brute/middleware/doom_loop_detection.rb +110 -7
- data/lib/brute/middleware/llm_call.rb +88 -1
- data/lib/brute/middleware/message_tracking.rb +140 -10
- data/lib/brute/middleware/otel/span.rb +32 -2
- data/lib/brute/middleware/otel/token_usage.rb +38 -0
- data/lib/brute/middleware/otel/tool_calls.rb +30 -1
- data/lib/brute/middleware/otel/tool_results.rb +29 -1
- data/lib/brute/middleware/otel.rb +5 -0
- data/lib/brute/middleware/reasoning_normalizer.rb +94 -0
- data/lib/brute/middleware/retry.rb +113 -1
- data/lib/brute/middleware/session_persistence.rb +46 -3
- data/lib/brute/middleware/token_tracking.rb +78 -0
- data/lib/brute/middleware/tool_error_tracking.rb +128 -1
- data/lib/brute/middleware/tool_use_guard.rb +64 -28
- data/lib/brute/middleware/tracing.rb +63 -2
- data/lib/brute/middleware.rb +18 -0
- data/lib/brute/orchestrator/turn.rb +105 -0
- data/lib/brute/patches/buffer_nil_guard.rb +5 -0
- data/lib/brute/pipeline.rb +86 -7
- data/lib/brute/prompts/build_switch.rb +29 -0
- data/lib/brute/prompts/environment.rb +43 -0
- data/lib/brute/prompts/identity.rb +29 -0
- data/lib/brute/prompts/instructions.rb +21 -0
- data/lib/brute/prompts/max_steps.rb +25 -0
- data/lib/brute/prompts/plan_reminder.rb +25 -0
- data/lib/brute/prompts/skills.rb +13 -0
- data/lib/brute/prompts.rb +28 -0
- data/lib/brute/providers/ollama.rb +135 -0
- data/lib/brute/providers/opencode_go.rb +5 -0
- data/lib/brute/providers/opencode_zen.rb +7 -2
- data/lib/brute/providers/shell.rb +2 -2
- data/lib/brute/providers/shell_response.rb +7 -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/{message_store.rb → store/message_store.rb} +155 -62
- 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 +101 -0
- data/lib/brute/tools/delegate.rb +59 -0
- data/lib/brute/tools/fs_patch.rb +54 -2
- data/lib/brute/tools/fs_read.rb +5 -0
- data/lib/brute/tools/fs_remove.rb +7 -2
- data/lib/brute/tools/fs_search.rb +5 -0
- data/lib/brute/tools/fs_undo.rb +7 -2
- data/lib/brute/tools/fs_write.rb +40 -2
- data/lib/brute/tools/net_fetch.rb +5 -0
- data/lib/brute/tools/question.rb +5 -0
- data/lib/brute/tools/shell.rb +5 -0
- data/lib/brute/tools/todo_read.rb +6 -1
- data/lib/brute/tools/todo_write.rb +6 -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 -63
- data/lib/brute/hooks.rb +0 -84
- data/lib/brute/orchestrator.rb +0 -391
- data/lib/brute/session.rb +0 -161
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
3
5
|
require "json"
|
|
4
6
|
require "fileutils"
|
|
5
7
|
require "securerandom"
|
|
6
8
|
|
|
7
9
|
module Brute
|
|
10
|
+
module Store
|
|
8
11
|
# Stores session messages as individual JSON files in the OpenCode
|
|
9
12
|
# {info, parts} format. Each session gets a directory; each message
|
|
10
13
|
# is a numbered JSON file inside it.
|
|
@@ -35,9 +38,6 @@ module Brute
|
|
|
35
38
|
load_existing
|
|
36
39
|
end
|
|
37
40
|
|
|
38
|
-
# ── Append messages ──────────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
# Record a user message.
|
|
41
41
|
def append_user(text:, message_id: nil)
|
|
42
42
|
id = message_id || next_message_id
|
|
43
43
|
msg = {
|
|
@@ -56,8 +56,6 @@ module Brute
|
|
|
56
56
|
id
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
# Record the start of an assistant message. Returns the message ID.
|
|
60
|
-
# Call complete_assistant later to fill in tokens/timing.
|
|
61
59
|
def append_assistant(message_id: nil, parent_id: nil, model_id: nil, provider_id: nil)
|
|
62
60
|
id = message_id || next_message_id
|
|
63
61
|
msg = {
|
|
@@ -78,9 +76,6 @@ module Brute
|
|
|
78
76
|
id
|
|
79
77
|
end
|
|
80
78
|
|
|
81
|
-
# ── Parts ────────────────────────────────────────────────────────
|
|
82
|
-
|
|
83
|
-
# Add a text part to an existing message.
|
|
84
79
|
def add_text_part(message_id:, text:)
|
|
85
80
|
@mutex.synchronize do
|
|
86
81
|
msg = @messages[message_id]
|
|
@@ -94,7 +89,6 @@ module Brute
|
|
|
94
89
|
end
|
|
95
90
|
end
|
|
96
91
|
|
|
97
|
-
# Add a tool part in "running" state. Returns the part ID.
|
|
98
92
|
def add_tool_part(message_id:, tool:, call_id:, input:)
|
|
99
93
|
@mutex.synchronize do
|
|
100
94
|
msg = @messages[message_id]
|
|
@@ -115,7 +109,6 @@ module Brute
|
|
|
115
109
|
end
|
|
116
110
|
end
|
|
117
111
|
|
|
118
|
-
# Mark a tool part as completed with output.
|
|
119
112
|
def complete_tool_part(message_id:, call_id:, output:)
|
|
120
113
|
@mutex.synchronize do
|
|
121
114
|
msg = @messages[message_id]
|
|
@@ -131,7 +124,6 @@ module Brute
|
|
|
131
124
|
end
|
|
132
125
|
end
|
|
133
126
|
|
|
134
|
-
# Mark a tool part as errored.
|
|
135
127
|
def error_tool_part(message_id:, call_id:, error:)
|
|
136
128
|
@mutex.synchronize do
|
|
137
129
|
msg = @messages[message_id]
|
|
@@ -147,7 +139,6 @@ module Brute
|
|
|
147
139
|
end
|
|
148
140
|
end
|
|
149
141
|
|
|
150
|
-
# Add a step-finish part to an assistant message.
|
|
151
142
|
def add_step_finish(message_id:, tokens: nil)
|
|
152
143
|
@mutex.synchronize do
|
|
153
144
|
msg = @messages[message_id]
|
|
@@ -164,8 +155,6 @@ module Brute
|
|
|
164
155
|
end
|
|
165
156
|
end
|
|
166
157
|
|
|
167
|
-
# ── Complete / update ────────────────────────────────────────────
|
|
168
|
-
|
|
169
158
|
# Finalize an assistant message with token counts and completion time.
|
|
170
159
|
def complete_assistant(message_id:, tokens: nil)
|
|
171
160
|
@mutex.synchronize do
|
|
@@ -185,85 +174,189 @@ module Brute
|
|
|
185
174
|
end
|
|
186
175
|
end
|
|
187
176
|
|
|
188
|
-
# ── Queries ──────────────────────────────────────────────────────
|
|
189
|
-
|
|
190
|
-
# All messages in order.
|
|
191
177
|
def messages
|
|
192
178
|
@mutex.synchronize { @messages.values }
|
|
193
179
|
end
|
|
194
180
|
|
|
195
|
-
# Single message by ID.
|
|
196
181
|
def message(id)
|
|
197
182
|
@mutex.synchronize { @messages[id] }
|
|
198
183
|
end
|
|
199
184
|
|
|
200
|
-
# Number of stored messages.
|
|
201
185
|
def count
|
|
202
186
|
@mutex.synchronize { @messages.size }
|
|
203
187
|
end
|
|
204
188
|
|
|
205
189
|
private
|
|
206
190
|
|
|
207
|
-
|
|
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
|
|
208
261
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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"
|
|
212
266
|
end
|
|
267
|
+
end
|
|
213
268
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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"
|
|
217
273
|
end
|
|
274
|
+
end
|
|
218
275
|
|
|
219
|
-
|
|
220
|
-
|
|
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"
|
|
221
281
|
end
|
|
282
|
+
end
|
|
222
283
|
|
|
223
|
-
|
|
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
|
|
224
291
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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"
|
|
230
297
|
end
|
|
298
|
+
end
|
|
231
299
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
236
309
|
|
|
237
|
-
|
|
238
|
-
|
|
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"
|
|
239
316
|
end
|
|
317
|
+
end
|
|
240
318
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
244
326
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
249
335
|
|
|
250
|
-
|
|
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
|
|
251
343
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
257
353
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
end
|
|
265
|
-
end
|
|
266
|
-
end
|
|
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"
|
|
267
360
|
end
|
|
268
361
|
end
|
|
269
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
|
data/lib/brute/system_prompt.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
3
6
|
module Brute
|
|
4
7
|
# Deferred system prompt builder.
|
|
5
8
|
#
|
|
@@ -105,6 +108,18 @@ module Brute
|
|
|
105
108
|
Prompts::Instructions,
|
|
106
109
|
],
|
|
107
110
|
|
|
111
|
+
# Ollama — lean stack for local models with smaller context windows
|
|
112
|
+
"ollama" => [
|
|
113
|
+
Prompts::Identity,
|
|
114
|
+
Prompts::ToneAndStyle,
|
|
115
|
+
Prompts::Conventions,
|
|
116
|
+
Prompts::DoingTasks,
|
|
117
|
+
Prompts::ToolUsage,
|
|
118
|
+
Prompts::GitSafety,
|
|
119
|
+
Prompts::Environment,
|
|
120
|
+
Prompts::Instructions,
|
|
121
|
+
],
|
|
122
|
+
|
|
108
123
|
# Fallback — conservative, concise, fewer than 4 lines
|
|
109
124
|
"default" => [
|
|
110
125
|
Prompts::Identity,
|
|
@@ -164,3 +179,89 @@ module Brute
|
|
|
164
179
|
end
|
|
165
180
|
end
|
|
166
181
|
end
|
|
182
|
+
|
|
183
|
+
test do
|
|
184
|
+
def base_ctx
|
|
185
|
+
{ provider_name: "anthropic", model_name: "test-model", cwd: Dir.pwd,
|
|
186
|
+
custom_rules: nil, agent: nil, agent_switched: nil, max_steps_reached: nil }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
it "stores a block and executes it on prepare" do
|
|
190
|
+
sp = Brute::SystemPrompt.build { |p, ctx| p << "hello #{ctx[:name]}" }
|
|
191
|
+
sp.prepare(name: "world").to_s.should == "hello world"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it "returns a Result with sections" do
|
|
195
|
+
sp = Brute::SystemPrompt.build { |p, _| p << "section one"; p << "section two" }
|
|
196
|
+
sp.prepare({}).sections.should == ["section one", "section two"]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it "strips nil and empty sections" do
|
|
200
|
+
sp = Brute::SystemPrompt.build { |p, _| p << "kept"; p << nil; p << ""; p << "also kept" }
|
|
201
|
+
sp.prepare({}).sections.should == ["kept", "also kept"]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it "joins sections with double newlines via to_s" do
|
|
205
|
+
Brute::SystemPrompt::Result.new(["a", "b", "c"]).to_s.should == "a\n\nb\n\nc"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it "reports empty? correctly for empty result" do
|
|
209
|
+
Brute::SystemPrompt::Result.new([]).empty?.should.be.true
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
it "reports empty? correctly for non-empty result" do
|
|
213
|
+
Brute::SystemPrompt::Result.new(["a"]).empty?.should.be.false
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it "falls back to default stack for unknown providers" do
|
|
217
|
+
builder = Brute::SystemPrompt.default
|
|
218
|
+
default_r = builder.prepare(base_ctx.merge(provider_name: "default"))
|
|
219
|
+
unknown_r = builder.prepare(base_ctx.merge(provider_name: "unknown_provider"))
|
|
220
|
+
unknown_r.sections.size.should == default_r.sections.size
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
it "includes PlanReminder when agent is plan" do
|
|
224
|
+
builder = Brute::SystemPrompt.default
|
|
225
|
+
builder.prepare(base_ctx.merge(agent: "plan")).to_s.should =~ /READ-ONLY/
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
it "excludes PlanReminder when agent is build" do
|
|
229
|
+
builder = Brute::SystemPrompt.default
|
|
230
|
+
(builder.prepare(base_ctx.merge(agent: "build")).to_s =~ /Plan Mode - System Reminder/).should.be.nil
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
it "includes BuildSwitch when agent_switched is build" do
|
|
234
|
+
builder = Brute::SystemPrompt.default
|
|
235
|
+
builder.prepare(base_ctx.merge(agent_switched: "build")).to_s.should =~ /operational mode has changed/
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
it "excludes BuildSwitch when agent_switched is nil" do
|
|
239
|
+
builder = Brute::SystemPrompt.default
|
|
240
|
+
(builder.prepare(base_ctx.merge(agent_switched: nil)).to_s =~ /operational mode has changed/).should.be.nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it "includes MaxSteps when max_steps_reached" do
|
|
244
|
+
builder = Brute::SystemPrompt.default
|
|
245
|
+
builder.prepare(base_ctx.merge(max_steps_reached: true)).to_s.should =~ /MAXIMUM STEPS REACHED/
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it "excludes MaxSteps when max_steps_reached is nil" do
|
|
249
|
+
builder = Brute::SystemPrompt.default
|
|
250
|
+
(builder.prepare(base_ctx.merge(max_steps_reached: nil)).to_s =~ /MAXIMUM STEPS REACHED/).should.be.nil
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
it "anthropic stack includes conventions" do
|
|
254
|
+
builder = Brute::SystemPrompt.default
|
|
255
|
+
builder.prepare(base_ctx.merge(provider_name: "anthropic")).to_s.should =~ /Following conventions/
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it "openai stack includes conventions" do
|
|
259
|
+
builder = Brute::SystemPrompt.default
|
|
260
|
+
builder.prepare(base_ctx.merge(provider_name: "openai")).to_s.should =~ /Following conventions/
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
it "google stack includes conventions" do
|
|
264
|
+
builder = Brute::SystemPrompt.default
|
|
265
|
+
builder.prepare(base_ctx.merge(provider_name: "google")).to_s.should =~ /Following conventions/
|
|
266
|
+
end
|
|
267
|
+
end
|
data/lib/brute/tools/delegate.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
3
6
|
module Brute
|
|
4
7
|
module Tools
|
|
5
8
|
class Delegate < LLM::Tool
|
|
@@ -57,3 +60,59 @@ module Brute
|
|
|
57
60
|
end
|
|
58
61
|
end
|
|
59
62
|
end
|
|
63
|
+
|
|
64
|
+
test do
|
|
65
|
+
require_relative "../../../spec/support/mock_provider"
|
|
66
|
+
require_relative "../../../spec/support/mock_response"
|
|
67
|
+
|
|
68
|
+
FakeMsg = Struct.new(:role, :content) do
|
|
69
|
+
def assistant?; role == :assistant; end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def fake_context(messages)
|
|
73
|
+
msgs_obj = Object.new
|
|
74
|
+
msgs_obj.define_singleton_method(:to_a) { messages }
|
|
75
|
+
ctx = Object.new
|
|
76
|
+
ctx.define_singleton_method(:messages) { msgs_obj }
|
|
77
|
+
ctx
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
delegate = Brute::Tools::Delegate.new
|
|
81
|
+
|
|
82
|
+
it "returns content when response has text" do
|
|
83
|
+
res = MockResponse.new(content: "analysis complete")
|
|
84
|
+
delegate.send(:extract_content, res, fake_context([])).should == "analysis complete"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "falls back to last assistant text on NoMethodError" do
|
|
88
|
+
bad_res = Object.new
|
|
89
|
+
bad_res.define_singleton_method(:content) { raise NoMethodError }
|
|
90
|
+
ctx = fake_context([FakeMsg.new(:user, "input"), FakeMsg.new(:assistant, "found the answer")])
|
|
91
|
+
delegate.send(:extract_content, bad_res, ctx).should == "found the answer"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "returns fallback when no assistant messages exist" do
|
|
95
|
+
bad_res = Object.new
|
|
96
|
+
bad_res.define_singleton_method(:content) { raise NoMethodError }
|
|
97
|
+
delegate.send(:extract_content, bad_res, fake_context([])).should == "(sub-agent completed but produced no text response)"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "skips assistant messages with empty content" do
|
|
101
|
+
bad_res = Object.new
|
|
102
|
+
bad_res.define_singleton_method(:content) { raise NoMethodError }
|
|
103
|
+
ctx = fake_context([FakeMsg.new(:assistant, "real answer"), FakeMsg.new(:assistant, "")])
|
|
104
|
+
delegate.send(:extract_content, bad_res, ctx).should == "real answer"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "falls back to last assistant on nil content" do
|
|
108
|
+
nil_res = Struct.new(:content).new(nil)
|
|
109
|
+
ctx = fake_context([FakeMsg.new(:assistant, "previous answer")])
|
|
110
|
+
delegate.send(:extract_content, nil_res, ctx).should == "previous answer"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it "falls back to last assistant on empty string content" do
|
|
114
|
+
empty_res = Struct.new(:content).new("")
|
|
115
|
+
ctx = fake_context([FakeMsg.new(:assistant, "previous answer")])
|
|
116
|
+
delegate.send(:extract_content, empty_res, ctx).should == "previous answer"
|
|
117
|
+
end
|
|
118
|
+
end
|