brute 0.3.0 → 0.4.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_stream.rb +126 -2
- data/lib/brute/diff.rb +34 -0
- data/lib/brute/message_store.rb +194 -0
- data/lib/brute/middleware/compaction_check.rb +133 -0
- data/lib/brute/middleware/doom_loop_detection.rb +100 -0
- data/lib/brute/middleware/llm_call.rb +89 -0
- data/lib/brute/middleware/message_tracking.rb +177 -0
- data/lib/brute/middleware/otel/span.rb +111 -0
- data/lib/brute/middleware/otel/token_usage.rb +93 -0
- data/lib/brute/middleware/otel/tool_calls.rb +113 -0
- data/lib/brute/middleware/otel/tool_results.rb +92 -0
- data/lib/brute/middleware/otel.rb +5 -0
- data/lib/brute/middleware/reasoning_normalizer.rb +119 -0
- data/lib/brute/middleware/retry.rb +93 -0
- data/lib/brute/middleware/session_persistence.rb +42 -0
- data/lib/brute/middleware/token_tracking.rb +77 -0
- data/lib/brute/middleware/tool_error_tracking.rb +101 -0
- data/lib/brute/middleware/tool_use_guard.rb +70 -1
- data/lib/brute/middleware/tracing.rb +71 -0
- data/lib/brute/orchestrator.rb +169 -3
- data/lib/brute/patches/buffer_nil_guard.rb +5 -0
- data/lib/brute/pipeline.rb +135 -0
- data/lib/brute/prompts/build_switch.rb +33 -0
- data/lib/brute/prompts/environment.rb +47 -0
- data/lib/brute/prompts/identity.rb +36 -0
- data/lib/brute/prompts/instructions.rb +24 -0
- data/lib/brute/prompts/max_steps.rb +32 -0
- data/lib/brute/prompts/plan_reminder.rb +33 -0
- data/lib/brute/prompts/skills.rb +35 -0
- data/lib/brute/providers/opencode_go.rb +5 -0
- data/lib/brute/providers/opencode_zen.rb +7 -2
- data/lib/brute/providers/shell_response.rb +5 -0
- data/lib/brute/system_prompt.rb +214 -0
- data/lib/brute/tools/delegate.rb +129 -0
- data/lib/brute/tools/fs_patch.rb +53 -0
- data/lib/brute/tools/fs_read.rb +5 -0
- data/lib/brute/tools/fs_remove.rb +5 -0
- data/lib/brute/tools/fs_search.rb +5 -0
- data/lib/brute/tools/fs_undo.rb +5 -0
- data/lib/brute/tools/fs_write.rb +50 -0
- 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 +5 -0
- data/lib/brute/tools/todo_write.rb +5 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +8 -8
- metadata +2 -2
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
if __FILE__ == $0
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "brute"
|
|
6
|
+
end
|
|
7
|
+
|
|
3
8
|
# Ensure the OpenAI provider is loaded (llm.rb lazy-loads providers).
|
|
4
9
|
unless defined?(LLM::OpenAI)
|
|
5
10
|
require "llm/providers/openai"
|
|
@@ -41,10 +46,10 @@ module LLM
|
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
##
|
|
44
|
-
# Returns the default model
|
|
49
|
+
# Returns the default model.
|
|
45
50
|
# @return [String]
|
|
46
51
|
def default_model
|
|
47
|
-
"
|
|
52
|
+
"zen-bickpickle"
|
|
48
53
|
end
|
|
49
54
|
|
|
50
55
|
##
|
data/lib/brute/system_prompt.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
if __FILE__ == $0
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "brute"
|
|
6
|
+
end
|
|
7
|
+
|
|
3
8
|
module Brute
|
|
4
9
|
# Deferred system prompt builder.
|
|
5
10
|
#
|
|
@@ -164,3 +169,212 @@ module Brute
|
|
|
164
169
|
end
|
|
165
170
|
end
|
|
166
171
|
end
|
|
172
|
+
|
|
173
|
+
if __FILE__ == $0
|
|
174
|
+
require_relative "../../spec/spec_helper"
|
|
175
|
+
|
|
176
|
+
RSpec.describe Brute::SystemPrompt do
|
|
177
|
+
describe ".build" do
|
|
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
|
|
278
|
+
|
|
279
|
+
context "when agent is 'build'" do
|
|
280
|
+
it "does NOT include PlanReminder" do
|
|
281
|
+
result = builder.prepare(base_ctx.merge(agent: "build"))
|
|
282
|
+
expect(result.to_s).not_to include("Plan Mode - System Reminder")
|
|
283
|
+
expect(result.to_s).not_to include("READ-ONLY")
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
context "when agent is nil" do
|
|
288
|
+
it "does NOT include PlanReminder" do
|
|
289
|
+
result = builder.prepare(base_ctx.merge(agent: nil))
|
|
290
|
+
expect(result.to_s).not_to include("Plan Mode - System Reminder")
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# ── Build switch conditional ──
|
|
295
|
+
|
|
296
|
+
context "when agent_switched is 'build'" do
|
|
297
|
+
it "includes BuildSwitch" do
|
|
298
|
+
result = builder.prepare(base_ctx.merge(agent_switched: "build"))
|
|
299
|
+
expect(result.to_s).to include("operational mode has changed from plan to build")
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
context "when agent_switched is nil" do
|
|
304
|
+
it "does NOT include BuildSwitch" do
|
|
305
|
+
result = builder.prepare(base_ctx.merge(agent_switched: nil))
|
|
306
|
+
expect(result.to_s).not_to include("operational mode has changed")
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# ── Max steps conditional ──
|
|
311
|
+
|
|
312
|
+
context "when max_steps_reached is truthy" do
|
|
313
|
+
it "includes MaxSteps" do
|
|
314
|
+
result = builder.prepare(base_ctx.merge(max_steps_reached: true))
|
|
315
|
+
expect(result.to_s).to include("MAXIMUM STEPS REACHED")
|
|
316
|
+
expect(result.to_s).to include("Tools are disabled")
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
context "when max_steps_reached is falsy" do
|
|
321
|
+
it "does NOT include MaxSteps" do
|
|
322
|
+
result = builder.prepare(base_ctx.merge(max_steps_reached: nil))
|
|
323
|
+
expect(result.to_s).not_to include("MAXIMUM STEPS REACHED")
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# ── Combined states (mid-session switch scenarios) ──
|
|
328
|
+
|
|
329
|
+
context "plan mode includes PlanReminder but excludes BuildSwitch" do
|
|
330
|
+
it "contains only plan-specific content" do
|
|
331
|
+
result = builder.prepare(base_ctx.merge(agent: "plan"))
|
|
332
|
+
expect(result.to_s).to include("READ-ONLY")
|
|
333
|
+
expect(result.to_s).not_to include("operational mode has changed")
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
context "build mode with agent_switched includes BuildSwitch but excludes PlanReminder" do
|
|
338
|
+
it "contains only build-switch content" do
|
|
339
|
+
result = builder.prepare(base_ctx.merge(agent: "build", agent_switched: "build"))
|
|
340
|
+
expect(result.to_s).to include("operational mode has changed from plan to build")
|
|
341
|
+
expect(result.to_s).not_to include("Plan Mode - System Reminder")
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
context "plan mode with max_steps_reached includes both" do
|
|
346
|
+
it "contains both PlanReminder and MaxSteps" do
|
|
347
|
+
result = builder.prepare(base_ctx.merge(agent: "plan", max_steps_reached: true))
|
|
348
|
+
expect(result.to_s).to include("READ-ONLY")
|
|
349
|
+
expect(result.to_s).to include("MAXIMUM STEPS REACHED")
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# ── Provider stack composition ──
|
|
354
|
+
|
|
355
|
+
it "anthropic stack includes expected modules" do
|
|
356
|
+
result = builder.prepare(base_ctx.merge(provider_name: "anthropic"))
|
|
357
|
+
text = result.to_s
|
|
358
|
+
# Anthropic has Identity, Objectivity, Conventions, GitSafety etc.
|
|
359
|
+
expect(text).to include("Professional objectivity")
|
|
360
|
+
expect(text).to include("Following conventions")
|
|
361
|
+
expect(text).to include("Git safety")
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
it "openai stack includes editing-focused modules" do
|
|
365
|
+
result = builder.prepare(base_ctx.merge(provider_name: "openai"))
|
|
366
|
+
text = result.to_s
|
|
367
|
+
# OpenAI has EditingApproach, Autonomy, EditingConstraints
|
|
368
|
+
expect(text).to include("Following conventions")
|
|
369
|
+
expect(text).to include("Git safety")
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
it "google stack includes security module" do
|
|
373
|
+
result = builder.prepare(base_ctx.merge(provider_name: "google"))
|
|
374
|
+
text = result.to_s
|
|
375
|
+
expect(text).to include("Following conventions")
|
|
376
|
+
expect(text).to include("Git safety")
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
data/lib/brute/tools/delegate.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
if __FILE__ == $0
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "brute"
|
|
6
|
+
end
|
|
7
|
+
|
|
3
8
|
module Brute
|
|
4
9
|
module Tools
|
|
5
10
|
class Delegate < LLM::Tool
|
|
@@ -57,3 +62,127 @@ module Brute
|
|
|
57
62
|
end
|
|
58
63
|
end
|
|
59
64
|
end
|
|
65
|
+
|
|
66
|
+
if __FILE__ == $0
|
|
67
|
+
require_relative "../../../spec/spec_helper"
|
|
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
|
|
143
|
+
|
|
144
|
+
it "skips assistant messages with non-string content" do
|
|
145
|
+
context = fake_context([
|
|
146
|
+
FakeMessage.new(:assistant, "text answer"),
|
|
147
|
+
FakeMessage.new(:assistant, [{"type" => "tool_use"}]),
|
|
148
|
+
])
|
|
149
|
+
|
|
150
|
+
result = delegate.send(:extract_content, bad_res, context)
|
|
151
|
+
expect(result).to eq("text answer")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
context "when res.content returns nil (empty response)" do
|
|
156
|
+
let(:nil_res) { Struct.new(:content).new(nil) }
|
|
157
|
+
|
|
158
|
+
it "falls back to the last assistant text in context messages" do
|
|
159
|
+
context = fake_context([
|
|
160
|
+
FakeMessage.new(:assistant, "previous answer"),
|
|
161
|
+
])
|
|
162
|
+
|
|
163
|
+
result = delegate.send(:extract_content, nil_res, context)
|
|
164
|
+
expect(result).to eq("previous answer")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it "returns fallback text when no assistant messages exist" do
|
|
168
|
+
context = fake_context([])
|
|
169
|
+
result = delegate.send(:extract_content, nil_res, context)
|
|
170
|
+
expect(result).to eq("(sub-agent completed but produced no text response)")
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
context "when res.content returns empty string" do
|
|
175
|
+
let(:empty_res) { Struct.new(:content).new("") }
|
|
176
|
+
|
|
177
|
+
it "falls back to the last assistant text in context messages" do
|
|
178
|
+
context = fake_context([
|
|
179
|
+
FakeMessage.new(:assistant, "previous answer"),
|
|
180
|
+
])
|
|
181
|
+
|
|
182
|
+
result = delegate.send(:extract_content, empty_res, context)
|
|
183
|
+
expect(result).to eq("previous answer")
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
data/lib/brute/tools/fs_patch.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
if __FILE__ == $0
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "brute"
|
|
6
|
+
end
|
|
7
|
+
|
|
3
8
|
module Brute
|
|
4
9
|
module Tools
|
|
5
10
|
class FSPatch < LLM::Tool
|
|
@@ -37,3 +42,51 @@ module Brute
|
|
|
37
42
|
end
|
|
38
43
|
end
|
|
39
44
|
end
|
|
45
|
+
|
|
46
|
+
if __FILE__ == $0
|
|
47
|
+
require_relative "../../../spec/spec_helper"
|
|
48
|
+
|
|
49
|
+
RSpec.describe Brute::Tools::FSPatch do
|
|
50
|
+
around(:each) { |ex| Dir.mktmpdir { |d| @dir = d; ex.run } }
|
|
51
|
+
|
|
52
|
+
let(:tool) { described_class.new }
|
|
53
|
+
|
|
54
|
+
it "replaces old_string with new_string" do
|
|
55
|
+
path = File.join(@dir, "test.rb")
|
|
56
|
+
File.write(path, "hello world\n")
|
|
57
|
+
result = tool.call(file_path: path, old_string: "world", new_string: "ruby")
|
|
58
|
+
expect(result[:success]).to be true
|
|
59
|
+
expect(File.read(path)).to eq("hello ruby\n")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it "returns a unified diff" do
|
|
63
|
+
path = File.join(@dir, "test.rb")
|
|
64
|
+
File.write(path, "line1\nold line\nline3\n")
|
|
65
|
+
result = tool.call(file_path: path, old_string: "old line", new_string: "new line")
|
|
66
|
+
expect(result[:diff]).to include("-old line")
|
|
67
|
+
expect(result[:diff]).to include("+new line")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it "raises when file not found" do
|
|
71
|
+
expect {
|
|
72
|
+
tool.call(file_path: File.join(@dir, "nope.rb"), old_string: "a", new_string: "b")
|
|
73
|
+
}.to raise_error(/File not found/)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "raises when old_string not found" do
|
|
77
|
+
path = File.join(@dir, "test.rb")
|
|
78
|
+
File.write(path, "hello\n")
|
|
79
|
+
expect {
|
|
80
|
+
tool.call(file_path: path, old_string: "missing", new_string: "new")
|
|
81
|
+
}.to raise_error(/old_string not found/)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "supports replace_all" do
|
|
85
|
+
path = File.join(@dir, "test.rb")
|
|
86
|
+
File.write(path, "aaa bbb aaa\n")
|
|
87
|
+
result = tool.call(file_path: path, old_string: "aaa", new_string: "ccc", replace_all: true)
|
|
88
|
+
expect(result[:replacements]).to eq(2)
|
|
89
|
+
expect(File.read(path)).to eq("ccc bbb ccc\n")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/brute/tools/fs_read.rb
CHANGED
data/lib/brute/tools/fs_undo.rb
CHANGED
data/lib/brute/tools/fs_write.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
if __FILE__ == $0
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "brute"
|
|
6
|
+
end
|
|
7
|
+
|
|
3
8
|
require 'fileutils'
|
|
4
9
|
|
|
5
10
|
module Brute
|
|
@@ -26,3 +31,48 @@ module Brute
|
|
|
26
31
|
end
|
|
27
32
|
end
|
|
28
33
|
end
|
|
34
|
+
|
|
35
|
+
if __FILE__ == $0
|
|
36
|
+
require_relative "../../../spec/spec_helper"
|
|
37
|
+
|
|
38
|
+
RSpec.describe Brute::Tools::FSWrite do
|
|
39
|
+
around(:each) { |ex| Dir.mktmpdir { |d| @dir = d; ex.run } }
|
|
40
|
+
|
|
41
|
+
let(:tool) { described_class.new }
|
|
42
|
+
|
|
43
|
+
it "writes content to a new file" do
|
|
44
|
+
path = File.join(@dir, "new.rb")
|
|
45
|
+
result = tool.call(file_path: path, content: "hello\n")
|
|
46
|
+
expect(result[:success]).to be true
|
|
47
|
+
expect(File.read(path)).to eq("hello\n")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "returns a diff for new files" do
|
|
51
|
+
path = File.join(@dir, "new.rb")
|
|
52
|
+
result = tool.call(file_path: path, content: "line1\nline2\n")
|
|
53
|
+
expect(result[:diff]).to include("+line1")
|
|
54
|
+
expect(result[:diff]).to include("+line2")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "returns a diff for overwritten files" do
|
|
58
|
+
path = File.join(@dir, "existing.rb")
|
|
59
|
+
File.write(path, "old content\n")
|
|
60
|
+
result = tool.call(file_path: path, content: "new content\n")
|
|
61
|
+
expect(result[:diff]).to include("-old content")
|
|
62
|
+
expect(result[:diff]).to include("+new content")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "creates parent directories" do
|
|
66
|
+
path = File.join(@dir, "deep", "nested", "file.rb")
|
|
67
|
+
result = tool.call(file_path: path, content: "nested\n")
|
|
68
|
+
expect(result[:success]).to be true
|
|
69
|
+
expect(File.exist?(path)).to be true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "returns byte count" do
|
|
73
|
+
path = File.join(@dir, "test.rb")
|
|
74
|
+
result = tool.call(file_path: path, content: "hello")
|
|
75
|
+
expect(result[:bytes]).to eq(5)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/brute/tools/question.rb
CHANGED
data/lib/brute/tools/shell.rb
CHANGED
data/lib/brute/version.rb
CHANGED
data/lib/brute.rb
CHANGED
|
@@ -194,16 +194,19 @@ module Brute
|
|
|
194
194
|
#
|
|
195
195
|
# Checks in order:
|
|
196
196
|
# 1. LLM_API_KEY + LLM_PROVIDER (explicit)
|
|
197
|
-
# 2.
|
|
198
|
-
# 3.
|
|
199
|
-
# 4.
|
|
200
|
-
# 5.
|
|
197
|
+
# 2. OPENCODE_API_KEY (implicit: provider = opencode_zen)
|
|
198
|
+
# 3. ANTHROPIC_API_KEY (implicit: provider = anthropic)
|
|
199
|
+
# 4. OPENAI_API_KEY (implicit: provider = openai)
|
|
200
|
+
# 5. GOOGLE_API_KEY (implicit: provider = google)
|
|
201
201
|
#
|
|
202
202
|
# Returns nil if no key is found. Error is deferred to Orchestrator#run.
|
|
203
203
|
def self.resolve_provider
|
|
204
204
|
if ENV['LLM_API_KEY']
|
|
205
205
|
key = ENV['LLM_API_KEY']
|
|
206
|
-
name = ENV.fetch('LLM_PROVIDER', '
|
|
206
|
+
name = ENV.fetch('LLM_PROVIDER', 'opencode_zen').downcase
|
|
207
|
+
elsif ENV['OPENCODE_API_KEY']
|
|
208
|
+
key = ENV['OPENCODE_API_KEY']
|
|
209
|
+
name = 'opencode_zen'
|
|
207
210
|
elsif ENV['ANTHROPIC_API_KEY']
|
|
208
211
|
key = ENV['ANTHROPIC_API_KEY']
|
|
209
212
|
name = 'anthropic'
|
|
@@ -213,9 +216,6 @@ module Brute
|
|
|
213
216
|
elsif ENV['GOOGLE_API_KEY']
|
|
214
217
|
key = ENV['GOOGLE_API_KEY']
|
|
215
218
|
name = 'google'
|
|
216
|
-
elsif ENV['OPENCODE_API_KEY']
|
|
217
|
-
key = ENV['OPENCODE_API_KEY']
|
|
218
|
-
name = 'opencode_zen'
|
|
219
219
|
else
|
|
220
220
|
return nil
|
|
221
221
|
end
|