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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent.rb +14 -0
  3. data/lib/brute/diff.rb +18 -28
  4. data/lib/brute/loop/agent_stream.rb +118 -0
  5. data/lib/brute/loop/agent_turn.rb +520 -0
  6. data/lib/brute/{compactor.rb → loop/compactor.rb} +2 -0
  7. data/lib/brute/{doom_loop.rb → loop/doom_loop.rb} +2 -0
  8. data/lib/brute/loop/step.rb +332 -0
  9. data/lib/brute/loop/tool_call_step.rb +90 -0
  10. data/lib/brute/middleware/compaction_check.rb +60 -146
  11. data/lib/brute/middleware/doom_loop_detection.rb +95 -92
  12. data/lib/brute/middleware/llm_call.rb +78 -80
  13. data/lib/brute/middleware/message_tracking.rb +115 -162
  14. data/lib/brute/middleware/otel/span.rb +25 -106
  15. data/lib/brute/middleware/otel/token_usage.rb +29 -84
  16. data/lib/brute/middleware/otel/tool_calls.rb +23 -107
  17. data/lib/brute/middleware/otel/tool_results.rb +22 -86
  18. data/lib/brute/middleware/reasoning_normalizer.rb +78 -103
  19. data/lib/brute/middleware/retry.rb +95 -76
  20. data/lib/brute/middleware/session_persistence.rb +38 -37
  21. data/lib/brute/middleware/token_tracking.rb +64 -63
  22. data/lib/brute/middleware/tool_error_tracking.rb +108 -82
  23. data/lib/brute/middleware/tool_use_guard.rb +57 -90
  24. data/lib/brute/middleware/tracing.rb +53 -63
  25. data/lib/brute/middleware.rb +18 -0
  26. data/lib/brute/orchestrator/turn.rb +105 -0
  27. data/lib/brute/pipeline.rb +77 -133
  28. data/lib/brute/prompts/build_switch.rb +21 -25
  29. data/lib/brute/prompts/environment.rb +31 -35
  30. data/lib/brute/prompts/identity.rb +22 -29
  31. data/lib/brute/prompts/instructions.rb +15 -18
  32. data/lib/brute/prompts/max_steps.rb +18 -25
  33. data/lib/brute/prompts/plan_reminder.rb +18 -26
  34. data/lib/brute/prompts/skills.rb +8 -30
  35. data/lib/brute/prompts.rb +28 -0
  36. data/lib/brute/providers/ollama.rb +135 -0
  37. data/lib/brute/providers/shell.rb +2 -2
  38. data/lib/brute/providers/shell_response.rb +2 -2
  39. data/lib/brute/providers.rb +62 -0
  40. data/lib/brute/queue/base_queue.rb +222 -0
  41. data/lib/brute/{file_mutation_queue.rb → queue/file_mutation_queue.rb} +28 -26
  42. data/lib/brute/queue/parallel_queue.rb +66 -0
  43. data/lib/brute/queue/sequential_queue.rb +63 -0
  44. data/lib/brute/store/message_store.rb +362 -0
  45. data/lib/brute/store/session.rb +106 -0
  46. data/lib/brute/{snapshot_store.rb → store/snapshot_store.rb} +2 -0
  47. data/lib/brute/{todo_store.rb → store/todo_store.rb} +2 -0
  48. data/lib/brute/system_prompt.rb +81 -194
  49. data/lib/brute/tools/delegate.rb +46 -116
  50. data/lib/brute/tools/fs_patch.rb +36 -37
  51. data/lib/brute/tools/fs_remove.rb +2 -2
  52. data/lib/brute/tools/fs_undo.rb +2 -2
  53. data/lib/brute/tools/fs_write.rb +29 -41
  54. data/lib/brute/tools/todo_read.rb +1 -1
  55. data/lib/brute/tools/todo_write.rb +1 -1
  56. data/lib/brute/tools.rb +31 -0
  57. data/lib/brute/version.rb +1 -1
  58. data/lib/brute.rb +40 -204
  59. metadata +31 -20
  60. data/lib/brute/agent_stream.rb +0 -181
  61. data/lib/brute/hooks.rb +0 -84
  62. data/lib/brute/message_store.rb +0 -463
  63. data/lib/brute/orchestrator.rb +0 -550
  64. data/lib/brute/session.rb +0 -161
@@ -1,463 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "fileutils"
5
- require "securerandom"
6
-
7
- module Brute
8
- # Stores session messages as individual JSON files in the OpenCode
9
- # {info, parts} format. Each session gets a directory; each message
10
- # is a numbered JSON file inside it.
11
- #
12
- # Storage layout:
13
- #
14
- # ~/.brute/sessions/{session-id}/
15
- # session.meta.json
16
- # msg_0001.json
17
- # msg_0002.json
18
- # ...
19
- #
20
- # Message format matches OpenCode's MessageV2.WithParts:
21
- #
22
- # { info: { id:, sessionID:, role:, time:, ... },
23
- # parts: [{ id:, type:, ... }, ...] }
24
- #
25
- class MessageStore
26
- attr_reader :session_id, :dir
27
-
28
- def initialize(session_id:, dir: nil)
29
- @session_id = session_id
30
- @dir = dir || File.join(Dir.home, ".brute", "sessions", session_id)
31
- @messages = {} # id => { info:, parts: }
32
- @seq = 0
33
- @part_seq = 0
34
- @mutex = Mutex.new
35
- load_existing
36
- end
37
-
38
- # ── Append messages ──────────────────────────────────────────────
39
-
40
- # Record a user message.
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
- # Record the start of an assistant message. Returns the message ID.
60
- # Call complete_assistant later to fill in tokens/timing.
61
- def append_assistant(message_id: nil, parent_id: nil, model_id: nil, provider_id: nil)
62
- id = message_id || next_message_id
63
- msg = {
64
- info: {
65
- id: id,
66
- sessionID: @session_id,
67
- role: "assistant",
68
- parentID: parent_id,
69
- time: { created: now_ms },
70
- modelID: model_id,
71
- providerID: provider_id,
72
- tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
73
- cost: 0.0,
74
- },
75
- parts: [],
76
- }
77
- save_message(id, msg)
78
- id
79
- end
80
-
81
- # ── Parts ────────────────────────────────────────────────────────
82
-
83
- # Add a text part to an existing message.
84
- def add_text_part(message_id:, text:)
85
- @mutex.synchronize do
86
- msg = @messages[message_id]
87
- return unless msg
88
-
89
- part = { id: next_part_id, sessionID: @session_id, messageID: message_id,
90
- type: "text", text: text }
91
- msg[:parts] << part
92
- persist(message_id)
93
- part[:id]
94
- end
95
- end
96
-
97
- # Add a tool part in "running" state. Returns the part ID.
98
- def add_tool_part(message_id:, tool:, call_id:, input:)
99
- @mutex.synchronize do
100
- msg = @messages[message_id]
101
- return unless msg
102
-
103
- part = {
104
- id: next_part_id, sessionID: @session_id, messageID: message_id,
105
- type: "tool", callID: call_id, tool: tool,
106
- state: {
107
- status: "running",
108
- input: input,
109
- time: { start: now_ms },
110
- },
111
- }
112
- msg[:parts] << part
113
- persist(message_id)
114
- part[:id]
115
- end
116
- end
117
-
118
- # Mark a tool part as completed with output.
119
- def complete_tool_part(message_id:, call_id:, output:)
120
- @mutex.synchronize do
121
- msg = @messages[message_id]
122
- return unless msg
123
-
124
- part = msg[:parts].find { |p| p[:type] == "tool" && p[:callID] == call_id }
125
- return unless part
126
-
127
- part[:state][:status] = "completed"
128
- part[:state][:output] = output
129
- part[:state][:time][:end] = now_ms
130
- persist(message_id)
131
- end
132
- end
133
-
134
- # Mark a tool part as errored.
135
- def error_tool_part(message_id:, call_id:, error:)
136
- @mutex.synchronize do
137
- msg = @messages[message_id]
138
- return unless msg
139
-
140
- part = msg[:parts].find { |p| p[:type] == "tool" && p[:callID] == call_id }
141
- return unless part
142
-
143
- part[:state][:status] = "error"
144
- part[:state][:error] = error.to_s
145
- part[:state][:time][:end] = now_ms
146
- persist(message_id)
147
- end
148
- end
149
-
150
- # Add a step-finish part to an assistant message.
151
- def add_step_finish(message_id:, tokens: nil)
152
- @mutex.synchronize do
153
- msg = @messages[message_id]
154
- return unless msg
155
-
156
- part = {
157
- id: next_part_id, sessionID: @session_id, messageID: message_id,
158
- type: "step-finish",
159
- reason: "stop",
160
- tokens: tokens || { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
161
- }
162
- msg[:parts] << part
163
- persist(message_id)
164
- end
165
- end
166
-
167
- # ── Complete / update ────────────────────────────────────────────
168
-
169
- # Finalize an assistant message with token counts and completion time.
170
- def complete_assistant(message_id:, tokens: nil)
171
- @mutex.synchronize do
172
- msg = @messages[message_id]
173
- return unless msg
174
-
175
- msg[:info][:time][:completed] = now_ms
176
- if tokens
177
- msg[:info][:tokens] = {
178
- input: tokens[:input] || tokens[:total_input] || 0,
179
- output: tokens[:output] || tokens[:total_output] || 0,
180
- reasoning: tokens[:reasoning] || tokens[:total_reasoning] || 0,
181
- cache: tokens[:cache] || { read: 0, write: 0 },
182
- }
183
- end
184
- persist(message_id)
185
- end
186
- end
187
-
188
- # ── Queries ──────────────────────────────────────────────────────
189
-
190
- # All messages in order.
191
- def messages
192
- @mutex.synchronize { @messages.values }
193
- end
194
-
195
- # Single message by ID.
196
- def message(id)
197
- @mutex.synchronize { @messages[id] }
198
- end
199
-
200
- # Number of stored messages.
201
- def count
202
- @mutex.synchronize { @messages.size }
203
- end
204
-
205
- private
206
-
207
- # ── ID generation ────────────────────────────────────────────────
208
-
209
- def next_message_id
210
- @seq += 1
211
- format("msg_%04d", @seq)
212
- end
213
-
214
- def next_part_id
215
- @part_seq += 1
216
- format("prt_%04d", @part_seq)
217
- end
218
-
219
- def now_ms
220
- (Time.now.to_f * 1000).to_i
221
- end
222
-
223
- # ── Persistence ──────────────────────────────────────────────────
224
-
225
- def save_message(id, msg)
226
- @mutex.synchronize do
227
- @messages[id] = msg
228
- persist(id)
229
- end
230
- end
231
-
232
- def persist(id)
233
- FileUtils.mkdir_p(@dir)
234
- msg = @messages[id]
235
- return unless msg
236
-
237
- path = File.join(@dir, "#{id}.json")
238
- File.write(path, JSON.pretty_generate(msg))
239
- end
240
-
241
- # Load any existing message files from disk on init.
242
- def load_existing
243
- return unless File.directory?(@dir)
244
-
245
- Dir.glob(File.join(@dir, "msg_*.json")).sort.each do |path|
246
- data = JSON.parse(File.read(path), symbolize_names: true)
247
- id = data.dig(:info, :id)
248
- next unless id
249
-
250
- @messages[id] = data
251
-
252
- # Track sequence numbers so new IDs don't collide
253
- if (m = id.match(/\Amsg_(\d+)\z/))
254
- n = m[1].to_i
255
- @seq = n if n > @seq
256
- end
257
-
258
- # Track part sequences too
259
- (data[:parts] || []).each do |part|
260
- pid = part[:id]
261
- if pid.is_a?(String) && (m = pid.match(/\Aprt_(\d+)\z/))
262
- n = m[1].to_i
263
- @part_seq = n if n > @part_seq
264
- end
265
- end
266
- end
267
- end
268
- end
269
- end
270
-
271
- if __FILE__ == $0
272
- require_relative "../../spec/spec_helper"
273
-
274
- require "tmpdir"
275
-
276
- RSpec.describe Brute::MessageStore do
277
- let(:tmpdir) { Dir.mktmpdir("brute_test_") }
278
- let(:session_id) { "test-session-123" }
279
- let(:store) { described_class.new(session_id: session_id, dir: tmpdir) }
280
-
281
- after { FileUtils.rm_rf(tmpdir) }
282
-
283
- describe "#append_user" do
284
- it "creates a user message with text part" do
285
- id = store.append_user(text: "Hello")
286
-
287
- msg = store.message(id)
288
- expect(msg[:info][:role]).to eq("user")
289
- expect(msg[:info][:sessionID]).to eq(session_id)
290
- expect(msg[:parts].size).to eq(1)
291
- expect(msg[:parts][0][:type]).to eq("text")
292
- expect(msg[:parts][0][:text]).to eq("Hello")
293
- end
294
-
295
- it "generates sequential message IDs" do
296
- id1 = store.append_user(text: "First")
297
- id2 = store.append_user(text: "Second")
298
-
299
- expect(id1).to eq("msg_0001")
300
- expect(id2).to eq("msg_0002")
301
- end
302
-
303
- it "persists to disk as JSON" do
304
- id = store.append_user(text: "Persisted")
305
-
306
- path = File.join(tmpdir, "#{id}.json")
307
- expect(File.exist?(path)).to be true
308
-
309
- data = JSON.parse(File.read(path), symbolize_names: true)
310
- expect(data[:info][:role]).to eq("user")
311
- expect(data[:parts][0][:text]).to eq("Persisted")
312
- end
313
- end
314
-
315
- describe "#append_assistant" do
316
- it "creates an assistant message" do
317
- user_id = store.append_user(text: "Hi")
318
- asst_id = store.append_assistant(parent_id: user_id, model_id: "claude", provider_id: "anthropic")
319
-
320
- msg = store.message(asst_id)
321
- expect(msg[:info][:role]).to eq("assistant")
322
- expect(msg[:info][:parentID]).to eq(user_id)
323
- expect(msg[:info][:modelID]).to eq("claude")
324
- expect(msg[:info][:providerID]).to eq("anthropic")
325
- expect(msg[:info][:tokens]).to include(input: 0, output: 0)
326
- expect(msg[:parts]).to be_empty
327
- end
328
- end
329
-
330
- describe "#add_text_part" do
331
- it "appends a text part to an existing message" do
332
- asst_id = store.append_assistant
333
-
334
- store.add_text_part(message_id: asst_id, text: "Here is my response")
335
-
336
- msg = store.message(asst_id)
337
- expect(msg[:parts].size).to eq(1)
338
- expect(msg[:parts][0][:type]).to eq("text")
339
- expect(msg[:parts][0][:text]).to eq("Here is my response")
340
- end
341
- end
342
-
343
- describe "#add_tool_part / #complete_tool_part / #error_tool_part" do
344
- it "tracks tool lifecycle: running → completed" do
345
- asst_id = store.append_assistant
346
-
347
- store.add_tool_part(
348
- message_id: asst_id,
349
- tool: "read",
350
- call_id: "call_001",
351
- input: { file_path: "/tmp/test.rb" },
352
- )
353
-
354
- msg = store.message(asst_id)
355
- tool_part = msg[:parts].find { |p| p[:type] == "tool" }
356
- expect(tool_part[:tool]).to eq("read")
357
- expect(tool_part[:state][:status]).to eq("running")
358
-
359
- store.complete_tool_part(
360
- message_id: asst_id,
361
- call_id: "call_001",
362
- output: "file contents here",
363
- )
364
-
365
- msg = store.message(asst_id)
366
- tool_part = msg[:parts].find { |p| p[:type] == "tool" }
367
- expect(tool_part[:state][:status]).to eq("completed")
368
- expect(tool_part[:state][:output]).to eq("file contents here")
369
- expect(tool_part[:state][:time][:end]).to be_a(Integer)
370
- end
371
-
372
- it "tracks tool lifecycle: running → error" do
373
- asst_id = store.append_assistant
374
-
375
- store.add_tool_part(
376
- message_id: asst_id,
377
- tool: "shell",
378
- call_id: "call_002",
379
- input: { command: "rm -rf /" },
380
- )
381
-
382
- store.error_tool_part(
383
- message_id: asst_id,
384
- call_id: "call_002",
385
- error: "permission denied",
386
- )
387
-
388
- msg = store.message(asst_id)
389
- tool_part = msg[:parts].find { |p| p[:type] == "tool" }
390
- expect(tool_part[:state][:status]).to eq("error")
391
- expect(tool_part[:state][:error]).to eq("permission denied")
392
- end
393
- end
394
-
395
- describe "#complete_assistant" do
396
- it "sets completion time and token counts" do
397
- asst_id = store.append_assistant
398
-
399
- store.complete_assistant(
400
- message_id: asst_id,
401
- tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 20, write: 5 } },
402
- )
403
-
404
- msg = store.message(asst_id)
405
- expect(msg[:info][:time][:completed]).to be_a(Integer)
406
- expect(msg[:info][:tokens][:input]).to eq(100)
407
- expect(msg[:info][:tokens][:output]).to eq(50)
408
- expect(msg[:info][:tokens][:reasoning]).to eq(10)
409
- end
410
- end
411
-
412
- describe "#messages" do
413
- it "returns all messages in order" do
414
- store.append_user(text: "Q1")
415
- store.append_assistant
416
- store.append_user(text: "Q2")
417
-
418
- msgs = store.messages
419
- expect(msgs.size).to eq(3)
420
- expect(msgs[0][:info][:role]).to eq("user")
421
- expect(msgs[1][:info][:role]).to eq("assistant")
422
- expect(msgs[2][:info][:role]).to eq("user")
423
- end
424
- end
425
-
426
- describe "#count" do
427
- it "returns the number of stored messages" do
428
- expect(store.count).to eq(0)
429
-
430
- store.append_user(text: "Q1")
431
- expect(store.count).to eq(1)
432
-
433
- store.append_assistant
434
- expect(store.count).to eq(2)
435
- end
436
- end
437
-
438
- describe "loading from disk" do
439
- it "restores messages from existing files" do
440
- store.append_user(text: "Persisted Q")
441
- asst_id = store.append_assistant(model_id: "claude")
442
- store.add_text_part(message_id: asst_id, text: "Persisted A")
443
-
444
- # Create a new store from the same directory
445
- store2 = described_class.new(session_id: session_id, dir: tmpdir)
446
-
447
- expect(store2.count).to eq(2)
448
- expect(store2.messages[0][:parts][0][:text]).to eq("Persisted Q")
449
- expect(store2.messages[1][:parts][0][:text]).to eq("Persisted A")
450
- end
451
-
452
- it "continues sequence numbering from loaded messages" do
453
- store.append_user(text: "Q1")
454
- store.append_user(text: "Q2")
455
-
456
- store2 = described_class.new(session_id: session_id, dir: tmpdir)
457
- id = store2.append_user(text: "Q3")
458
-
459
- expect(id).to eq("msg_0003")
460
- end
461
- end
462
- end
463
- end