brute 0.4.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/brute/agent.rb +14 -0
- data/lib/brute/diff.rb +18 -28
- data/lib/brute/loop/agent_stream.rb +118 -0
- data/lib/brute/loop/agent_turn.rb +520 -0
- data/lib/brute/{compactor.rb → loop/compactor.rb} +2 -0
- data/lib/brute/{doom_loop.rb → loop/doom_loop.rb} +2 -0
- data/lib/brute/loop/step.rb +332 -0
- data/lib/brute/loop/tool_call_step.rb +90 -0
- data/lib/brute/middleware/compaction_check.rb +60 -146
- data/lib/brute/middleware/doom_loop_detection.rb +95 -92
- data/lib/brute/middleware/llm_call.rb +78 -80
- data/lib/brute/middleware/message_tracking.rb +115 -162
- data/lib/brute/middleware/otel/span.rb +25 -106
- data/lib/brute/middleware/otel/token_usage.rb +29 -84
- data/lib/brute/middleware/otel/tool_calls.rb +23 -107
- data/lib/brute/middleware/otel/tool_results.rb +22 -86
- data/lib/brute/middleware/reasoning_normalizer.rb +78 -103
- data/lib/brute/middleware/retry.rb +95 -76
- data/lib/brute/middleware/session_persistence.rb +38 -37
- data/lib/brute/middleware/token_tracking.rb +64 -63
- data/lib/brute/middleware/tool_error_tracking.rb +108 -82
- data/lib/brute/middleware/tool_use_guard.rb +57 -90
- data/lib/brute/middleware/tracing.rb +53 -63
- data/lib/brute/middleware.rb +18 -0
- data/lib/brute/orchestrator/turn.rb +105 -0
- data/lib/brute/pipeline.rb +77 -133
- data/lib/brute/prompts/build_switch.rb +21 -25
- data/lib/brute/prompts/environment.rb +31 -35
- data/lib/brute/prompts/identity.rb +22 -29
- data/lib/brute/prompts/instructions.rb +15 -18
- data/lib/brute/prompts/max_steps.rb +18 -25
- data/lib/brute/prompts/plan_reminder.rb +18 -26
- data/lib/brute/prompts/skills.rb +8 -30
- data/lib/brute/prompts.rb +28 -0
- data/lib/brute/providers/ollama.rb +135 -0
- data/lib/brute/providers/shell.rb +2 -2
- data/lib/brute/providers/shell_response.rb +2 -2
- data/lib/brute/providers.rb +62 -0
- data/lib/brute/queue/base_queue.rb +222 -0
- data/lib/brute/{file_mutation_queue.rb → queue/file_mutation_queue.rb} +28 -26
- data/lib/brute/queue/parallel_queue.rb +66 -0
- data/lib/brute/queue/sequential_queue.rb +63 -0
- data/lib/brute/store/message_store.rb +362 -0
- data/lib/brute/store/session.rb +106 -0
- data/lib/brute/{snapshot_store.rb → store/snapshot_store.rb} +2 -0
- data/lib/brute/{todo_store.rb → store/todo_store.rb} +2 -0
- data/lib/brute/system_prompt.rb +81 -194
- data/lib/brute/tools/delegate.rb +46 -116
- data/lib/brute/tools/fs_patch.rb +36 -37
- data/lib/brute/tools/fs_remove.rb +2 -2
- data/lib/brute/tools/fs_undo.rb +2 -2
- data/lib/brute/tools/fs_write.rb +29 -41
- data/lib/brute/tools/todo_read.rb +1 -1
- data/lib/brute/tools/todo_write.rb +1 -1
- data/lib/brute/tools.rb +31 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +40 -204
- metadata +31 -20
- data/lib/brute/agent_stream.rb +0 -181
- data/lib/brute/hooks.rb +0 -84
- data/lib/brute/message_store.rb +0 -463
- data/lib/brute/orchestrator.rb +0 -550
- data/lib/brute/session.rb +0 -161
data/lib/brute/system_prompt.rb
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
require "brute"
|
|
6
|
-
end
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
7
5
|
|
|
8
6
|
module Brute
|
|
9
7
|
# Deferred system prompt builder.
|
|
@@ -110,6 +108,18 @@ module Brute
|
|
|
110
108
|
Prompts::Instructions,
|
|
111
109
|
],
|
|
112
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
|
+
|
|
113
123
|
# Fallback — conservative, concise, fewer than 4 lines
|
|
114
124
|
"default" => [
|
|
115
125
|
Prompts::Identity,
|
|
@@ -170,211 +180,88 @@ module Brute
|
|
|
170
180
|
end
|
|
171
181
|
end
|
|
172
182
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
it "stores a block and executes it on prepare" do
|
|
179
|
-
sp = described_class.build do |prompt, ctx|
|
|
180
|
-
prompt << "hello #{ctx[:name]}"
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
result = sp.prepare(name: "world")
|
|
184
|
-
expect(result.to_s).to eq("hello world")
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
it "returns a Result with sections" do
|
|
188
|
-
sp = described_class.build do |prompt, _ctx|
|
|
189
|
-
prompt << "section one"
|
|
190
|
-
prompt << "section two"
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
result = sp.prepare({})
|
|
194
|
-
expect(result.sections).to eq(["section one", "section two"])
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
it "strips nil and empty sections" do
|
|
198
|
-
sp = described_class.build do |prompt, _ctx|
|
|
199
|
-
prompt << "kept"
|
|
200
|
-
prompt << nil
|
|
201
|
-
prompt << ""
|
|
202
|
-
prompt << "also kept"
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
result = sp.prepare({})
|
|
206
|
-
expect(result.sections).to eq(["kept", "also kept"])
|
|
207
|
-
end
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
describe Brute::SystemPrompt::Result do
|
|
211
|
-
subject(:result) { described_class.new(["a", "b", "c"]) }
|
|
212
|
-
|
|
213
|
-
it "joins sections with double newlines via to_s" do
|
|
214
|
-
expect(result.to_s).to eq("a\n\nb\n\nc")
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
it "iterates over sections via each" do
|
|
218
|
-
collected = []
|
|
219
|
-
result.each { |s| collected << s }
|
|
220
|
-
expect(collected).to eq(["a", "b", "c"])
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
it "reports empty? correctly" do
|
|
224
|
-
expect(described_class.new([]).empty?).to be true
|
|
225
|
-
expect(result.empty?).to be false
|
|
226
|
-
end
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
describe ".default" do
|
|
230
|
-
# Minimal context for all tests — enough to avoid nil errors
|
|
231
|
-
let(:base_ctx) do
|
|
232
|
-
{
|
|
233
|
-
provider_name: "anthropic",
|
|
234
|
-
model_name: "test-model",
|
|
235
|
-
cwd: Dir.pwd,
|
|
236
|
-
custom_rules: nil,
|
|
237
|
-
agent: nil,
|
|
238
|
-
agent_switched: nil,
|
|
239
|
-
max_steps_reached: nil,
|
|
240
|
-
}
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
let(:builder) { described_class.default }
|
|
244
|
-
|
|
245
|
-
# ── Provider stack selection ──
|
|
246
|
-
|
|
247
|
-
described_class::STACKS.each_key do |provider|
|
|
248
|
-
context "with provider '#{provider}'" do
|
|
249
|
-
it "produces non-empty sections" do
|
|
250
|
-
ctx = base_ctx.merge(provider_name: provider)
|
|
251
|
-
result = builder.prepare(ctx)
|
|
252
|
-
expect(result.sections).not_to be_empty
|
|
253
|
-
result.sections.each do |section|
|
|
254
|
-
expect(section).to be_a(String)
|
|
255
|
-
end
|
|
256
|
-
end
|
|
257
|
-
end
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
it "falls back to 'default' stack for unknown providers" do
|
|
261
|
-
default_result = builder.prepare(base_ctx.merge(provider_name: "default"))
|
|
262
|
-
unknown_result = builder.prepare(base_ctx.merge(provider_name: "unknown_provider"))
|
|
263
|
-
|
|
264
|
-
# Both should produce the same number of sections (same stack)
|
|
265
|
-
expect(unknown_result.sections.size).to eq(default_result.sections.size)
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
# ── Plan mode conditional ──
|
|
269
|
-
|
|
270
|
-
context "when agent is 'plan'" do
|
|
271
|
-
it "includes PlanReminder" do
|
|
272
|
-
result = builder.prepare(base_ctx.merge(agent: "plan"))
|
|
273
|
-
expect(result.to_s).to include("Plan Mode")
|
|
274
|
-
expect(result.to_s).to include("<system-reminder>")
|
|
275
|
-
expect(result.to_s).to include("READ-ONLY")
|
|
276
|
-
end
|
|
277
|
-
end
|
|
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
|
|
278
188
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
expect(result.to_s).not_to include("READ-ONLY")
|
|
284
|
-
end
|
|
285
|
-
end
|
|
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
|
|
286
193
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
end
|
|
292
|
-
end
|
|
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
|
|
293
198
|
|
|
294
|
-
|
|
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
|
|
295
203
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
expect(result.to_s).to include("operational mode has changed from plan to build")
|
|
300
|
-
end
|
|
301
|
-
end
|
|
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
|
|
302
207
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
expect(result.to_s).not_to include("operational mode has changed")
|
|
307
|
-
end
|
|
308
|
-
end
|
|
208
|
+
it "reports empty? correctly for empty result" do
|
|
209
|
+
Brute::SystemPrompt::Result.new([]).empty?.should.be.true
|
|
210
|
+
end
|
|
309
211
|
|
|
310
|
-
|
|
212
|
+
it "reports empty? correctly for non-empty result" do
|
|
213
|
+
Brute::SystemPrompt::Result.new(["a"]).empty?.should.be.false
|
|
214
|
+
end
|
|
311
215
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
end
|
|
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
|
|
319
222
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
end
|
|
325
|
-
end
|
|
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
|
|
326
227
|
|
|
327
|
-
|
|
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
|
|
328
232
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
expect(result.to_s).not_to include("operational mode has changed")
|
|
334
|
-
end
|
|
335
|
-
end
|
|
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
|
|
336
237
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
expect(result.to_s).not_to include("Plan Mode - System Reminder")
|
|
342
|
-
end
|
|
343
|
-
end
|
|
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
|
|
344
242
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
expect(result.to_s).to include("MAXIMUM STEPS REACHED")
|
|
350
|
-
end
|
|
351
|
-
end
|
|
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
|
|
352
247
|
|
|
353
|
-
|
|
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
|
|
354
252
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
expect(text).to include("Professional objectivity")
|
|
360
|
-
expect(text).to include("Following conventions")
|
|
361
|
-
expect(text).to include("Git safety")
|
|
362
|
-
end
|
|
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
|
|
363
257
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
expect(text).to include("Following conventions")
|
|
369
|
-
expect(text).to include("Git safety")
|
|
370
|
-
end
|
|
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
|
|
371
262
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
expect(text).to include("Following conventions")
|
|
376
|
-
expect(text).to include("Git safety")
|
|
377
|
-
end
|
|
378
|
-
end
|
|
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/
|
|
379
266
|
end
|
|
380
267
|
end
|
data/lib/brute/tools/delegate.rb
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
require "brute"
|
|
6
|
-
end
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
7
5
|
|
|
8
6
|
module Brute
|
|
9
7
|
module Tools
|
|
@@ -63,126 +61,58 @@ module Brute
|
|
|
63
61
|
end
|
|
64
62
|
end
|
|
65
63
|
|
|
66
|
-
|
|
67
|
-
require_relative "../../../spec/
|
|
68
|
-
|
|
69
|
-
RSpec.describe Brute::Tools::Delegate do
|
|
70
|
-
let(:provider) { MockProvider.new }
|
|
71
|
-
|
|
72
|
-
# Simple stand-in for LLM::Message in context history.
|
|
73
|
-
FakeMessage = Struct.new(:role, :content) do
|
|
74
|
-
def assistant?
|
|
75
|
-
role == :assistant
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
before do
|
|
80
|
-
allow(Brute).to receive(:provider).and_return(provider)
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def fake_context(messages)
|
|
84
|
-
msgs_obj = Object.new
|
|
85
|
-
msgs_obj.define_singleton_method(:to_a) { messages }
|
|
86
|
-
ctx = Object.new
|
|
87
|
-
ctx.define_singleton_method(:messages) { msgs_obj }
|
|
88
|
-
ctx
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
describe "#call" do
|
|
92
|
-
it "returns the sub-agent's text content" do
|
|
93
|
-
result = described_class.new.call(task: "What files exist?")
|
|
94
|
-
expect(result).to be_a(Hash)
|
|
95
|
-
expect(result[:result]).to eq("mock response")
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
describe "#extract_content" do
|
|
100
|
-
let(:delegate) { described_class.new }
|
|
101
|
-
|
|
102
|
-
it "returns content when response has text" do
|
|
103
|
-
res = MockResponse.new(content: "analysis complete")
|
|
104
|
-
context = fake_context([])
|
|
105
|
-
result = delegate.send(:extract_content, res, context)
|
|
106
|
-
expect(result).to eq("analysis complete")
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
context "when res.content raises NoMethodError (tool-only response)" do
|
|
110
|
-
let(:bad_res) do
|
|
111
|
-
obj = Object.new
|
|
112
|
-
obj.define_singleton_method(:content) do
|
|
113
|
-
raise NoMethodError, "undefined method 'content' for nil"
|
|
114
|
-
end
|
|
115
|
-
obj
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
it "falls back to the last assistant text in context messages" do
|
|
119
|
-
context = fake_context([
|
|
120
|
-
FakeMessage.new(:user, "input"),
|
|
121
|
-
FakeMessage.new(:assistant, "found the answer"),
|
|
122
|
-
])
|
|
123
|
-
|
|
124
|
-
result = delegate.send(:extract_content, bad_res, context)
|
|
125
|
-
expect(result).to eq("found the answer")
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
it "returns fallback text when no assistant messages exist" do
|
|
129
|
-
context = fake_context([])
|
|
130
|
-
result = delegate.send(:extract_content, bad_res, context)
|
|
131
|
-
expect(result).to eq("(sub-agent completed but produced no text response)")
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
it "skips assistant messages with empty content" do
|
|
135
|
-
context = fake_context([
|
|
136
|
-
FakeMessage.new(:assistant, "real answer"),
|
|
137
|
-
FakeMessage.new(:assistant, ""),
|
|
138
|
-
])
|
|
139
|
-
|
|
140
|
-
result = delegate.send(:extract_content, bad_res, context)
|
|
141
|
-
expect(result).to eq("real answer")
|
|
142
|
-
end
|
|
64
|
+
test do
|
|
65
|
+
require_relative "../../../spec/support/mock_provider"
|
|
66
|
+
require_relative "../../../spec/support/mock_response"
|
|
143
67
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
FakeMessage.new(:assistant, [{"type" => "tool_use"}]),
|
|
148
|
-
])
|
|
68
|
+
FakeMsg = Struct.new(:role, :content) do
|
|
69
|
+
def assistant?; role == :assistant; end
|
|
70
|
+
end
|
|
149
71
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
154
79
|
|
|
155
|
-
|
|
156
|
-
let(:nil_res) { Struct.new(:content).new(nil) }
|
|
80
|
+
delegate = Brute::Tools::Delegate.new
|
|
157
81
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
162
86
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
166
93
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
end
|
|
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
|
|
173
99
|
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
176
106
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
181
112
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
end
|
|
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"
|
|
187
117
|
end
|
|
188
118
|
end
|
data/lib/brute/tools/fs_patch.rb
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
require "brute"
|
|
6
|
-
end
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
7
5
|
|
|
8
6
|
module Brute
|
|
9
7
|
module Tools
|
|
@@ -19,13 +17,13 @@ module Brute
|
|
|
19
17
|
|
|
20
18
|
def call(file_path:, old_string:, new_string:, replace_all: false)
|
|
21
19
|
path = File.expand_path(file_path)
|
|
22
|
-
Brute::FileMutationQueue.serialize(path) do
|
|
20
|
+
Brute::Queue::FileMutationQueue.serialize(path) do
|
|
23
21
|
raise "File not found: #{path}" unless File.exist?(path)
|
|
24
22
|
|
|
25
23
|
original = File.read(path)
|
|
26
24
|
raise "old_string not found in #{path}" unless original.include?(old_string)
|
|
27
25
|
|
|
28
|
-
Brute::SnapshotStore.save(path)
|
|
26
|
+
Brute::Store::SnapshotStore.save(path)
|
|
29
27
|
|
|
30
28
|
updated = if replace_all
|
|
31
29
|
original.gsub(old_string, new_string)
|
|
@@ -43,50 +41,51 @@ module Brute
|
|
|
43
41
|
end
|
|
44
42
|
end
|
|
45
43
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
RSpec.describe Brute::Tools::FSPatch do
|
|
50
|
-
around(:each) { |ex| Dir.mktmpdir { |d| @dir = d; ex.run } }
|
|
44
|
+
test do
|
|
45
|
+
require "tmpdir"
|
|
51
46
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
path = File.join(@dir, "test.rb")
|
|
47
|
+
it "replaces old_string with new_string" do
|
|
48
|
+
Dir.mktmpdir do |dir|
|
|
49
|
+
path = File.join(dir, "test.rb")
|
|
56
50
|
File.write(path, "hello world\n")
|
|
57
|
-
result =
|
|
58
|
-
|
|
59
|
-
expect(File.read(path)).to eq("hello ruby\n")
|
|
51
|
+
result = Brute::Tools::FSPatch.new.call(file_path: path, old_string: "world", new_string: "ruby")
|
|
52
|
+
File.read(path).should == "hello ruby\n"
|
|
60
53
|
end
|
|
54
|
+
end
|
|
61
55
|
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
it "returns a unified diff" do
|
|
57
|
+
Dir.mktmpdir do |dir|
|
|
58
|
+
path = File.join(dir, "test.rb")
|
|
64
59
|
File.write(path, "line1\nold line\nline3\n")
|
|
65
|
-
result =
|
|
66
|
-
|
|
67
|
-
expect(result[:diff]).to include("+new line")
|
|
60
|
+
result = Brute::Tools::FSPatch.new.call(file_path: path, old_string: "old line", new_string: "new line")
|
|
61
|
+
result[:diff].should =~ /\-old line/
|
|
68
62
|
end
|
|
63
|
+
end
|
|
69
64
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
65
|
+
it "raises when file not found" do
|
|
66
|
+
Dir.mktmpdir do |dir|
|
|
67
|
+
lambda {
|
|
68
|
+
Brute::Tools::FSPatch.new.call(file_path: File.join(dir, "nope.rb"), old_string: "a", new_string: "b")
|
|
69
|
+
}.should.raise(RuntimeError)
|
|
74
70
|
end
|
|
71
|
+
end
|
|
75
72
|
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
it "raises when old_string not found" do
|
|
74
|
+
Dir.mktmpdir do |dir|
|
|
75
|
+
path = File.join(dir, "test.rb")
|
|
78
76
|
File.write(path, "hello\n")
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}.
|
|
77
|
+
lambda {
|
|
78
|
+
Brute::Tools::FSPatch.new.call(file_path: path, old_string: "missing", new_string: "new")
|
|
79
|
+
}.should.raise(RuntimeError)
|
|
82
80
|
end
|
|
81
|
+
end
|
|
83
82
|
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
it "supports replace_all" do
|
|
84
|
+
Dir.mktmpdir do |dir|
|
|
85
|
+
path = File.join(dir, "test.rb")
|
|
86
86
|
File.write(path, "aaa bbb aaa\n")
|
|
87
|
-
result =
|
|
88
|
-
|
|
89
|
-
expect(File.read(path)).to eq("ccc bbb ccc\n")
|
|
87
|
+
result = Brute::Tools::FSPatch.new.call(file_path: path, old_string: "aaa", new_string: "ccc", replace_all: true)
|
|
88
|
+
result[:replacements].should == 2
|
|
90
89
|
end
|
|
91
90
|
end
|
|
92
91
|
end
|
|
@@ -17,10 +17,10 @@ module Brute
|
|
|
17
17
|
|
|
18
18
|
def call(path:)
|
|
19
19
|
target = File.expand_path(path)
|
|
20
|
-
Brute::FileMutationQueue.serialize(target) do
|
|
20
|
+
Brute::Queue::FileMutationQueue.serialize(target) do
|
|
21
21
|
raise "Path not found: #{target}" unless File.exist?(target)
|
|
22
22
|
|
|
23
|
-
Brute::SnapshotStore.save(target) if File.file?(target)
|
|
23
|
+
Brute::Store::SnapshotStore.save(target) if File.file?(target)
|
|
24
24
|
|
|
25
25
|
if File.directory?(target)
|
|
26
26
|
Dir.rmdir(target)
|
data/lib/brute/tools/fs_undo.rb
CHANGED
|
@@ -16,8 +16,8 @@ module Brute
|
|
|
16
16
|
|
|
17
17
|
def call(path:)
|
|
18
18
|
target = File.expand_path(path)
|
|
19
|
-
Brute::FileMutationQueue.serialize(target) do
|
|
20
|
-
snapshot = Brute::SnapshotStore.pop(target)
|
|
19
|
+
Brute::Queue::FileMutationQueue.serialize(target) do
|
|
20
|
+
snapshot = Brute::Store::SnapshotStore.pop(target)
|
|
21
21
|
raise "No undo history available for: #{target}" unless snapshot
|
|
22
22
|
|
|
23
23
|
if snapshot == :did_not_exist
|