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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent.rb +14 -0
  3. data/lib/brute/diff.rb +24 -0
  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 +70 -23
  11. data/lib/brute/middleware/doom_loop_detection.rb +110 -7
  12. data/lib/brute/middleware/llm_call.rb +88 -1
  13. data/lib/brute/middleware/message_tracking.rb +140 -10
  14. data/lib/brute/middleware/otel/span.rb +32 -2
  15. data/lib/brute/middleware/otel/token_usage.rb +38 -0
  16. data/lib/brute/middleware/otel/tool_calls.rb +30 -1
  17. data/lib/brute/middleware/otel/tool_results.rb +29 -1
  18. data/lib/brute/middleware/otel.rb +5 -0
  19. data/lib/brute/middleware/reasoning_normalizer.rb +94 -0
  20. data/lib/brute/middleware/retry.rb +113 -1
  21. data/lib/brute/middleware/session_persistence.rb +46 -3
  22. data/lib/brute/middleware/token_tracking.rb +78 -0
  23. data/lib/brute/middleware/tool_error_tracking.rb +128 -1
  24. data/lib/brute/middleware/tool_use_guard.rb +64 -28
  25. data/lib/brute/middleware/tracing.rb +63 -2
  26. data/lib/brute/middleware.rb +18 -0
  27. data/lib/brute/orchestrator/turn.rb +105 -0
  28. data/lib/brute/patches/buffer_nil_guard.rb +5 -0
  29. data/lib/brute/pipeline.rb +86 -7
  30. data/lib/brute/prompts/build_switch.rb +29 -0
  31. data/lib/brute/prompts/environment.rb +43 -0
  32. data/lib/brute/prompts/identity.rb +29 -0
  33. data/lib/brute/prompts/instructions.rb +21 -0
  34. data/lib/brute/prompts/max_steps.rb +25 -0
  35. data/lib/brute/prompts/plan_reminder.rb +25 -0
  36. data/lib/brute/prompts/skills.rb +13 -0
  37. data/lib/brute/prompts.rb +28 -0
  38. data/lib/brute/providers/ollama.rb +135 -0
  39. data/lib/brute/providers/opencode_go.rb +5 -0
  40. data/lib/brute/providers/opencode_zen.rb +7 -2
  41. data/lib/brute/providers/shell.rb +2 -2
  42. data/lib/brute/providers/shell_response.rb +7 -2
  43. data/lib/brute/providers.rb +62 -0
  44. data/lib/brute/queue/base_queue.rb +222 -0
  45. data/lib/brute/{file_mutation_queue.rb → queue/file_mutation_queue.rb} +28 -26
  46. data/lib/brute/queue/parallel_queue.rb +66 -0
  47. data/lib/brute/queue/sequential_queue.rb +63 -0
  48. data/lib/brute/{message_store.rb → store/message_store.rb} +155 -62
  49. data/lib/brute/store/session.rb +106 -0
  50. data/lib/brute/{snapshot_store.rb → store/snapshot_store.rb} +2 -0
  51. data/lib/brute/{todo_store.rb → store/todo_store.rb} +2 -0
  52. data/lib/brute/system_prompt.rb +101 -0
  53. data/lib/brute/tools/delegate.rb +59 -0
  54. data/lib/brute/tools/fs_patch.rb +54 -2
  55. data/lib/brute/tools/fs_read.rb +5 -0
  56. data/lib/brute/tools/fs_remove.rb +7 -2
  57. data/lib/brute/tools/fs_search.rb +5 -0
  58. data/lib/brute/tools/fs_undo.rb +7 -2
  59. data/lib/brute/tools/fs_write.rb +40 -2
  60. data/lib/brute/tools/net_fetch.rb +5 -0
  61. data/lib/brute/tools/question.rb +5 -0
  62. data/lib/brute/tools/shell.rb +5 -0
  63. data/lib/brute/tools/todo_read.rb +6 -1
  64. data/lib/brute/tools/todo_write.rb +6 -1
  65. data/lib/brute/tools.rb +31 -0
  66. data/lib/brute/version.rb +1 -1
  67. data/lib/brute.rb +40 -204
  68. metadata +31 -20
  69. data/lib/brute/agent_stream.rb +0 -63
  70. data/lib/brute/hooks.rb +0 -84
  71. data/lib/brute/orchestrator.rb +0 -391
  72. 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
- # ── ID generation ────────────────────────────────────────────────
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
- def next_message_id
210
- @seq += 1
211
- format("msg_%04d", @seq)
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
- def next_part_id
215
- @part_seq += 1
216
- format("prt_%04d", @part_seq)
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
- def now_ms
220
- (Time.now.to_f * 1000).to_i
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
- # ── Persistence ──────────────────────────────────────────────────
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
- def save_message(id, msg)
226
- @mutex.synchronize do
227
- @messages[id] = msg
228
- persist(id)
229
- end
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
- def persist(id)
233
- FileUtils.mkdir_p(@dir)
234
- msg = @messages[id]
235
- return unless msg
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
- path = File.join(@dir, "#{id}.json")
238
- File.write(path, JSON.pretty_generate(msg))
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
- # Load any existing message files from disk on init.
242
- def load_existing
243
- return unless File.directory?(@dir)
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
- 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
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
- @messages[id] = data
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
- # 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
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
- # 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
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
@@ -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
@@ -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