brute 0.4.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 +118 -0
- 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 +69 -0
- data/lib/brute/middleware/tracing.rb +71 -0
- data/lib/brute/orchestrator.rb +160 -1
- 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 +1 -1
|
@@ -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 Middleware
|
|
5
10
|
# Logs timing and token usage for every LLM call, and tracks cumulative
|
|
@@ -61,3 +66,69 @@ module Brute
|
|
|
61
66
|
end
|
|
62
67
|
end
|
|
63
68
|
end
|
|
69
|
+
|
|
70
|
+
if __FILE__ == $0
|
|
71
|
+
require_relative "../../../spec/spec_helper"
|
|
72
|
+
|
|
73
|
+
RSpec.describe Brute::Middleware::Tracing do
|
|
74
|
+
let(:response) { MockResponse.new(content: "traced response") }
|
|
75
|
+
let(:inner_app) { ->(_env) { response } }
|
|
76
|
+
let(:log_output) { StringIO.new }
|
|
77
|
+
let(:logger) { Logger.new(log_output) }
|
|
78
|
+
let(:middleware) { described_class.new(inner_app, logger: logger) }
|
|
79
|
+
|
|
80
|
+
it "passes the response through unchanged" do
|
|
81
|
+
env = build_env(tool_results: nil)
|
|
82
|
+
result = middleware.call(env)
|
|
83
|
+
expect(result).to eq(response)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it "populates env[:metadata][:timing] with all required keys" do
|
|
87
|
+
env = build_env(tool_results: nil)
|
|
88
|
+
middleware.call(env)
|
|
89
|
+
|
|
90
|
+
timing = env[:metadata][:timing]
|
|
91
|
+
expect(timing).to include(
|
|
92
|
+
:total_elapsed,
|
|
93
|
+
:total_llm_elapsed,
|
|
94
|
+
:llm_call_count,
|
|
95
|
+
:last_call_elapsed
|
|
96
|
+
)
|
|
97
|
+
expect(timing[:llm_call_count]).to eq(1)
|
|
98
|
+
expect(timing[:last_call_elapsed]).to be >= 0
|
|
99
|
+
expect(timing[:total_llm_elapsed]).to be >= 0
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "resets turn timing when tool_results is nil (new turn)" do
|
|
103
|
+
env = build_env(tool_results: nil)
|
|
104
|
+
middleware.call(env)
|
|
105
|
+
first_elapsed = env[:metadata][:timing][:total_llm_elapsed]
|
|
106
|
+
|
|
107
|
+
# Simulate continuation within the same turn (tool_results present)
|
|
108
|
+
env[:tool_results] = [["read", { content: "file data" }]]
|
|
109
|
+
middleware.call(env)
|
|
110
|
+
|
|
111
|
+
expect(env[:metadata][:timing][:llm_call_count]).to eq(2)
|
|
112
|
+
expect(env[:metadata][:timing][:total_llm_elapsed]).to be >= first_elapsed
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "accumulates call count across multiple calls" do
|
|
116
|
+
env = build_env(tool_results: nil)
|
|
117
|
+
middleware.call(env)
|
|
118
|
+
env[:tool_results] = [["read", {}]]
|
|
119
|
+
middleware.call(env)
|
|
120
|
+
middleware.call(env)
|
|
121
|
+
|
|
122
|
+
expect(env[:metadata][:timing][:llm_call_count]).to eq(3)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it "logs debug and info messages" do
|
|
126
|
+
env = build_env(tool_results: nil)
|
|
127
|
+
middleware.call(env)
|
|
128
|
+
|
|
129
|
+
log_text = log_output.string
|
|
130
|
+
expect(log_text).to include("LLM call #1")
|
|
131
|
+
expect(log_text).to include("LLM response #1")
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
data/lib/brute/orchestrator.rb
CHANGED
|
@@ -111,7 +111,7 @@ module Brute
|
|
|
111
111
|
# Returns the final assistant response.
|
|
112
112
|
def run(user_message)
|
|
113
113
|
unless @provider
|
|
114
|
-
raise "No LLM provider configured. Set LLM_API_KEY and optionally LLM_PROVIDER (default:
|
|
114
|
+
raise "No LLM provider configured. Set LLM_API_KEY and optionally LLM_PROVIDER (default: opencode_zen)"
|
|
115
115
|
end
|
|
116
116
|
|
|
117
117
|
@request_count = 0
|
|
@@ -389,3 +389,162 @@ module Brute
|
|
|
389
389
|
end
|
|
390
390
|
end
|
|
391
391
|
end
|
|
392
|
+
|
|
393
|
+
if __FILE__ == $0
|
|
394
|
+
require_relative "../../spec/spec_helper"
|
|
395
|
+
|
|
396
|
+
RSpec.describe Brute::Orchestrator, "system prompt" do
|
|
397
|
+
let(:provider) { MockProvider.new }
|
|
398
|
+
|
|
399
|
+
def build_orchestrator(agent_name: nil, cwd: Dir.pwd)
|
|
400
|
+
described_class.new(
|
|
401
|
+
provider: provider,
|
|
402
|
+
model: "test-model",
|
|
403
|
+
tools: [],
|
|
404
|
+
cwd: cwd,
|
|
405
|
+
agent_name: agent_name,
|
|
406
|
+
logger: Logger.new(File::NULL),
|
|
407
|
+
)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def system_prompt_for(orchestrator)
|
|
411
|
+
orchestrator.instance_variable_get(:@system_prompt)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# ── Build mode (default) ──
|
|
415
|
+
|
|
416
|
+
context "build mode (default, agent_name: nil)" do
|
|
417
|
+
subject(:prompt) { system_prompt_for(build_orchestrator(agent_name: nil)) }
|
|
418
|
+
|
|
419
|
+
it "does NOT contain PlanReminder" do
|
|
420
|
+
expect(prompt).not_to include("Plan Mode - System Reminder")
|
|
421
|
+
expect(prompt).not_to include("READ-ONLY")
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
it "does NOT contain BuildSwitch" do
|
|
425
|
+
expect(prompt).not_to include("operational mode has changed")
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
it "does NOT contain MaxSteps" do
|
|
429
|
+
expect(prompt).not_to include("MAXIMUM STEPS REACHED")
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
it "contains identity section" do
|
|
433
|
+
expect(prompt).to include("Brute")
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
it "contains environment section" do
|
|
437
|
+
expect(prompt).to include("<env>")
|
|
438
|
+
expect(prompt).to include("Working directory:")
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
context "build mode (explicit agent_name: 'build')" do
|
|
443
|
+
subject(:prompt) { system_prompt_for(build_orchestrator(agent_name: "build")) }
|
|
444
|
+
|
|
445
|
+
it "does NOT contain PlanReminder" do
|
|
446
|
+
expect(prompt).not_to include("Plan Mode - System Reminder")
|
|
447
|
+
expect(prompt).not_to include("READ-ONLY")
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
it "contains identity section" do
|
|
451
|
+
expect(prompt).to include("Brute")
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# ── Plan mode ──
|
|
456
|
+
|
|
457
|
+
context "plan mode (agent_name: 'plan')" do
|
|
458
|
+
subject(:prompt) { system_prompt_for(build_orchestrator(agent_name: "plan")) }
|
|
459
|
+
|
|
460
|
+
it "includes PlanReminder" do
|
|
461
|
+
expect(prompt).to include("Plan Mode - System Reminder")
|
|
462
|
+
expect(prompt).to include("<system-reminder>")
|
|
463
|
+
expect(prompt).to include("READ-ONLY")
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
it "includes the supersede warning" do
|
|
467
|
+
expect(prompt).to include("supersedes any other instructions")
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
it "does NOT include BuildSwitch" do
|
|
471
|
+
expect(prompt).not_to include("operational mode has changed")
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
it "still includes identity section" do
|
|
475
|
+
expect(prompt).to include("Brute")
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
it "still includes environment section" do
|
|
479
|
+
expect(prompt).to include("<env>")
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# ── Switching from plan to build (simulating mid-session agent recreation) ──
|
|
484
|
+
|
|
485
|
+
context "switching from plan to build (new orchestrator, same session)" do
|
|
486
|
+
let(:session) { Brute::Session.new }
|
|
487
|
+
|
|
488
|
+
it "plan orchestrator has PlanReminder, build orchestrator does not" do
|
|
489
|
+
plan_orch = described_class.new(
|
|
490
|
+
provider: provider,
|
|
491
|
+
model: "test-model",
|
|
492
|
+
tools: [],
|
|
493
|
+
session: session,
|
|
494
|
+
agent_name: "plan",
|
|
495
|
+
logger: Logger.new(File::NULL),
|
|
496
|
+
)
|
|
497
|
+
plan_prompt = system_prompt_for(plan_orch)
|
|
498
|
+
expect(plan_prompt).to include("READ-ONLY")
|
|
499
|
+
|
|
500
|
+
build_orch = described_class.new(
|
|
501
|
+
provider: provider,
|
|
502
|
+
model: "test-model",
|
|
503
|
+
tools: [],
|
|
504
|
+
session: session,
|
|
505
|
+
agent_name: "build",
|
|
506
|
+
logger: Logger.new(File::NULL),
|
|
507
|
+
)
|
|
508
|
+
build_prompt = system_prompt_for(build_orch)
|
|
509
|
+
expect(build_prompt).not_to include("READ-ONLY")
|
|
510
|
+
expect(build_prompt).not_to include("Plan Mode")
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
it "build orchestrator does NOT contain BuildSwitch (agent_switched never set)" do
|
|
514
|
+
build_orch = described_class.new(
|
|
515
|
+
provider: provider,
|
|
516
|
+
model: "test-model",
|
|
517
|
+
tools: [],
|
|
518
|
+
session: session,
|
|
519
|
+
agent_name: "build",
|
|
520
|
+
logger: Logger.new(File::NULL),
|
|
521
|
+
)
|
|
522
|
+
build_prompt = system_prompt_for(build_orch)
|
|
523
|
+
# This documents the current behavior: BuildSwitch is dead code
|
|
524
|
+
expect(build_prompt).not_to include("operational mode has changed from plan to build")
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# ── Provider-specific stacks ──
|
|
529
|
+
|
|
530
|
+
context "provider-specific identity text" do
|
|
531
|
+
it "uses mock provider (falls back to default stack)" do
|
|
532
|
+
prompt = system_prompt_for(build_orchestrator)
|
|
533
|
+
# MockProvider.name returns :mock, which isn't a known stack, so falls back to "default"
|
|
534
|
+
expect(prompt).to be_a(String)
|
|
535
|
+
expect(prompt).not_to be_empty
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# ── Working directory is embedded ──
|
|
540
|
+
|
|
541
|
+
context "cwd propagation" do
|
|
542
|
+
it "embeds the given cwd in the system prompt" do
|
|
543
|
+
Dir.mktmpdir do |dir|
|
|
544
|
+
prompt = system_prompt_for(build_orchestrator(cwd: dir))
|
|
545
|
+
expect(prompt).to include(dir)
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
end
|
data/lib/brute/pipeline.rb
CHANGED
|
@@ -79,3 +79,138 @@ module Brute
|
|
|
79
79
|
end
|
|
80
80
|
end
|
|
81
81
|
end
|
|
82
|
+
|
|
83
|
+
if __FILE__ == $0
|
|
84
|
+
require_relative "../../spec/spec_helper"
|
|
85
|
+
|
|
86
|
+
RSpec.describe "Middleware Pipeline Integration" do
|
|
87
|
+
let(:provider) { MockProvider.new }
|
|
88
|
+
let(:log_output) { StringIO.new }
|
|
89
|
+
let(:logger) { Logger.new(log_output) }
|
|
90
|
+
let(:session) { double("session", save: nil) }
|
|
91
|
+
let(:compactor) { double("compactor", should_compact?: false) }
|
|
92
|
+
|
|
93
|
+
describe "full orchestrator pipeline" do
|
|
94
|
+
it "passes env through all middleware and returns the response" do
|
|
95
|
+
response = MockResponse.new(content: "integrated response")
|
|
96
|
+
allow(provider).to receive(:complete).and_return(response)
|
|
97
|
+
|
|
98
|
+
ctx = LLM::Context.new(provider, tools: [])
|
|
99
|
+
prompt = ctx.prompt { |p| p.system("sys"); p.user("hello") }
|
|
100
|
+
|
|
101
|
+
pipeline = Brute::Pipeline.new
|
|
102
|
+
pipeline.use(Brute::Middleware::Tracing, logger: logger)
|
|
103
|
+
pipeline.use(Brute::Middleware::Retry, max_attempts: 3, base_delay: 2)
|
|
104
|
+
pipeline.use(Brute::Middleware::SessionPersistence, session: session)
|
|
105
|
+
pipeline.use(Brute::Middleware::TokenTracking)
|
|
106
|
+
pipeline.use(Brute::Middleware::CompactionCheck, compactor: compactor, system_prompt: "sys", tools: [])
|
|
107
|
+
pipeline.use(Brute::Middleware::ToolErrorTracking)
|
|
108
|
+
pipeline.use(Brute::Middleware::DoomLoopDetection, threshold: 3)
|
|
109
|
+
pipeline.use(Brute::Middleware::ToolUseGuard)
|
|
110
|
+
pipeline.run(Brute::Middleware::LLMCall.new)
|
|
111
|
+
|
|
112
|
+
env = {
|
|
113
|
+
context: ctx,
|
|
114
|
+
provider: provider,
|
|
115
|
+
input: prompt,
|
|
116
|
+
tools: [],
|
|
117
|
+
params: {},
|
|
118
|
+
metadata: {},
|
|
119
|
+
callbacks: {},
|
|
120
|
+
tool_results: nil,
|
|
121
|
+
streaming: false,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
result = pipeline.call(env)
|
|
125
|
+
|
|
126
|
+
expect(result).not_to be_nil
|
|
127
|
+
expect(env[:metadata][:timing]).to include(:llm_call_count, :total_llm_elapsed)
|
|
128
|
+
expect(env[:metadata][:tokens]).to include(:total_input, :total_output, :call_count)
|
|
129
|
+
expect(session).to have_received(:save)
|
|
130
|
+
expect(log_output.string).to include("LLM call #1")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
describe "Retry + Tracing combo" do
|
|
135
|
+
it "Tracing sees the full elapsed time including retries" do
|
|
136
|
+
call_count = 0
|
|
137
|
+
response = MockResponse.new(content: "recovered")
|
|
138
|
+
|
|
139
|
+
allow(provider).to receive(:complete) do |*_args|
|
|
140
|
+
call_count += 1
|
|
141
|
+
raise LLM::RateLimitError, "rate limited" if call_count <= 1
|
|
142
|
+
response
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
ctx = LLM::Context.new(provider, tools: [])
|
|
146
|
+
prompt = ctx.prompt { |p| p.system("sys"); p.user("hi") }
|
|
147
|
+
|
|
148
|
+
pipeline = Brute::Pipeline.new
|
|
149
|
+
pipeline.use(Brute::Middleware::Tracing, logger: logger)
|
|
150
|
+
pipeline.use(Brute::Middleware::Retry, max_attempts: 3, base_delay: 0)
|
|
151
|
+
pipeline.run(Brute::Middleware::LLMCall.new)
|
|
152
|
+
|
|
153
|
+
env = {
|
|
154
|
+
context: ctx,
|
|
155
|
+
provider: provider,
|
|
156
|
+
input: prompt,
|
|
157
|
+
tools: [],
|
|
158
|
+
params: {},
|
|
159
|
+
metadata: {},
|
|
160
|
+
callbacks: {},
|
|
161
|
+
tool_results: nil,
|
|
162
|
+
streaming: false,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
result = pipeline.call(env)
|
|
166
|
+
|
|
167
|
+
expect(result).not_to be_nil
|
|
168
|
+
expect(env[:metadata][:timing][:llm_call_count]).to eq(1)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
describe "TokenTracking + SessionPersistence combo" do
|
|
173
|
+
it "session receives save after tokens are tracked" do
|
|
174
|
+
response = MockResponse.new(content: "tracked and saved")
|
|
175
|
+
allow(provider).to receive(:complete).and_return(response)
|
|
176
|
+
|
|
177
|
+
ctx = LLM::Context.new(provider, tools: [])
|
|
178
|
+
prompt = ctx.prompt { |p| p.system("sys"); p.user("hi") }
|
|
179
|
+
|
|
180
|
+
save_args = []
|
|
181
|
+
allow(session).to receive(:save) { |ctx_arg| save_args << ctx_arg }
|
|
182
|
+
|
|
183
|
+
pipeline = Brute::Pipeline.new
|
|
184
|
+
pipeline.use(Brute::Middleware::SessionPersistence, session: session)
|
|
185
|
+
pipeline.use(Brute::Middleware::TokenTracking)
|
|
186
|
+
pipeline.run(Brute::Middleware::LLMCall.new)
|
|
187
|
+
|
|
188
|
+
env = {
|
|
189
|
+
context: ctx,
|
|
190
|
+
provider: provider,
|
|
191
|
+
input: prompt,
|
|
192
|
+
tools: [],
|
|
193
|
+
params: {},
|
|
194
|
+
metadata: {},
|
|
195
|
+
callbacks: {},
|
|
196
|
+
tool_results: nil,
|
|
197
|
+
streaming: false,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
pipeline.call(env)
|
|
201
|
+
|
|
202
|
+
expect(env[:metadata][:tokens]).to include(:total_input)
|
|
203
|
+
expect(save_args.size).to eq(1)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
describe "Pipeline builder" do
|
|
208
|
+
it "raises when no terminal app is set" do
|
|
209
|
+
pipeline = Brute::Pipeline.new
|
|
210
|
+
pipeline.use(Brute::Middleware::TokenTracking)
|
|
211
|
+
|
|
212
|
+
expect { pipeline.call({}) }.to raise_error(RuntimeError, /no terminal app/)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -17,3 +17,36 @@ module Brute
|
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
|
+
|
|
21
|
+
if __FILE__ == $0
|
|
22
|
+
require_relative "../../../spec/spec_helper"
|
|
23
|
+
|
|
24
|
+
RSpec.describe Brute::Prompts::BuildSwitch do
|
|
25
|
+
subject(:text) { described_class.call({}) }
|
|
26
|
+
|
|
27
|
+
it "returns a string" do
|
|
28
|
+
expect(text).to be_a(String)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "wraps content in system-reminder tags" do
|
|
32
|
+
expect(text).to include("<system-reminder>")
|
|
33
|
+
expect(text).to include("</system-reminder>")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "announces the mode change from plan to build" do
|
|
37
|
+
expect(text).to include("plan to build")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "states the agent is no longer in read-only mode" do
|
|
41
|
+
expect(text).to include("no longer in read-only mode")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "permits tool use" do
|
|
45
|
+
expect(text).to include("permitted to make file changes")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "ignores context (static content)" do
|
|
49
|
+
expect(described_class.call({ agent_switched: "build" })).to eq(described_class.call({}))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -23,3 +23,50 @@ module Brute
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
|
+
|
|
27
|
+
if __FILE__ == $0
|
|
28
|
+
require_relative "../../../spec/spec_helper"
|
|
29
|
+
|
|
30
|
+
RSpec.describe Brute::Prompts::Environment do
|
|
31
|
+
it "includes cwd from context" do
|
|
32
|
+
text = described_class.call(cwd: "/some/path", model_name: "test")
|
|
33
|
+
expect(text).to include("/some/path")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "includes model name from context" do
|
|
37
|
+
text = described_class.call(cwd: "/tmp", model_name: "claude-sonnet-4-20250514")
|
|
38
|
+
expect(text).to include("claude-sonnet-4-20250514")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "wraps environment info in <env> tags" do
|
|
42
|
+
text = described_class.call(cwd: "/tmp", model_name: "test")
|
|
43
|
+
expect(text).to include("<env>")
|
|
44
|
+
expect(text).to include("</env>")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "detects git repo when .git exists" do
|
|
48
|
+
Dir.mktmpdir do |dir|
|
|
49
|
+
Dir.mkdir(File.join(dir, ".git"))
|
|
50
|
+
text = described_class.call(cwd: dir, model_name: "test")
|
|
51
|
+
expect(text).to include("Is directory a git repo: yes")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "detects non-git directory" do
|
|
56
|
+
Dir.mktmpdir do |dir|
|
|
57
|
+
text = described_class.call(cwd: dir, model_name: "test")
|
|
58
|
+
expect(text).to include("Is directory a git repo: no")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it "includes the platform" do
|
|
63
|
+
text = described_class.call(cwd: "/tmp", model_name: "test")
|
|
64
|
+
expect(text).to include(RUBY_PLATFORM)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "defaults cwd to Dir.pwd when not provided" do
|
|
68
|
+
text = described_class.call(model_name: "test")
|
|
69
|
+
expect(text).to include(Dir.pwd)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -9,3 +9,39 @@ module Brute
|
|
|
9
9
|
end
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
|
+
|
|
13
|
+
if __FILE__ == $0
|
|
14
|
+
require_relative "../../../spec/spec_helper"
|
|
15
|
+
|
|
16
|
+
RSpec.describe Brute::Prompts::Identity do
|
|
17
|
+
it "returns provider-specific text for anthropic" do
|
|
18
|
+
text = described_class.call(provider_name: "anthropic")
|
|
19
|
+
expect(text).to be_a(String)
|
|
20
|
+
expect(text).not_to be_empty
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "returns provider-specific text for openai" do
|
|
24
|
+
text = described_class.call(provider_name: "openai")
|
|
25
|
+
expect(text).to be_a(String)
|
|
26
|
+
expect(text).not_to be_empty
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "returns provider-specific text for google" do
|
|
30
|
+
text = described_class.call(provider_name: "google")
|
|
31
|
+
expect(text).to be_a(String)
|
|
32
|
+
expect(text).not_to be_empty
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "falls back to default.txt for unknown providers" do
|
|
36
|
+
default_text = described_class.call(provider_name: "default")
|
|
37
|
+
unknown_text = described_class.call(provider_name: "nonexistent_provider")
|
|
38
|
+
expect(unknown_text).to eq(default_text)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "returns different text for different providers" do
|
|
42
|
+
anthropic = described_class.call(provider_name: "anthropic")
|
|
43
|
+
openai = described_class.call(provider_name: "openai")
|
|
44
|
+
expect(anthropic).not_to eq(openai)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -16,3 +16,27 @@ module Brute
|
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
|
+
|
|
20
|
+
if __FILE__ == $0
|
|
21
|
+
require_relative "../../../spec/spec_helper"
|
|
22
|
+
|
|
23
|
+
RSpec.describe Brute::Prompts::Instructions do
|
|
24
|
+
it "returns nil when custom_rules is nil" do
|
|
25
|
+
expect(described_class.call(custom_rules: nil)).to be_nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "returns nil when custom_rules is empty" do
|
|
29
|
+
expect(described_class.call(custom_rules: "")).to be_nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "returns nil when custom_rules is whitespace-only" do
|
|
33
|
+
expect(described_class.call(custom_rules: " \n ")).to be_nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "wraps custom_rules in a Project-Specific Rules header" do
|
|
37
|
+
text = described_class.call(custom_rules: "Always use tabs.")
|
|
38
|
+
expect(text).to include("Project-Specific Rules")
|
|
39
|
+
expect(text).to include("Always use tabs.")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -28,3 +28,35 @@ module Brute
|
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
|
+
|
|
32
|
+
if __FILE__ == $0
|
|
33
|
+
require_relative "../../../spec/spec_helper"
|
|
34
|
+
|
|
35
|
+
RSpec.describe Brute::Prompts::MaxSteps do
|
|
36
|
+
subject(:text) { described_class.call({}) }
|
|
37
|
+
|
|
38
|
+
it "returns a string" do
|
|
39
|
+
expect(text).to be_a(String)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "announces maximum steps reached" do
|
|
43
|
+
expect(text).to include("MAXIMUM STEPS REACHED")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "states tools are disabled" do
|
|
47
|
+
expect(text).to include("Tools are disabled")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "requires a text-only response" do
|
|
51
|
+
expect(text).to include("text ONLY")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "requires summary of work done" do
|
|
55
|
+
expect(text).to include("Summary of what has been accomplished")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "ignores context (static content)" do
|
|
59
|
+
expect(described_class.call({ max_steps_reached: true })).to eq(described_class.call({}))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -38,3 +38,36 @@ module Brute
|
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
|
+
|
|
42
|
+
if __FILE__ == $0
|
|
43
|
+
require_relative "../../../spec/spec_helper"
|
|
44
|
+
|
|
45
|
+
RSpec.describe Brute::Prompts::PlanReminder do
|
|
46
|
+
subject(:text) { described_class.call({}) }
|
|
47
|
+
|
|
48
|
+
it "returns a string" do
|
|
49
|
+
expect(text).to be_a(String)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "wraps content in system-reminder tags" do
|
|
53
|
+
expect(text).to include("<system-reminder>")
|
|
54
|
+
expect(text).to include("</system-reminder>")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "declares READ-ONLY mode" do
|
|
58
|
+
expect(text).to include("READ-ONLY")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "forbids file edits" do
|
|
62
|
+
expect(text).to include("STRICTLY FORBIDDEN")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "states it supersedes other instructions" do
|
|
66
|
+
expect(text).to include("supersedes any other instructions")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "ignores context (static content)" do
|
|
70
|
+
expect(described_class.call({ agent: "plan" })).to eq(described_class.call({}))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/brute/prompts/skills.rb
CHANGED
|
@@ -20,3 +20,38 @@ module Brute
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
|
+
|
|
24
|
+
if __FILE__ == $0
|
|
25
|
+
require_relative "../../../spec/spec_helper"
|
|
26
|
+
|
|
27
|
+
RSpec.describe Brute::Prompts::Skills do
|
|
28
|
+
it "returns nil when no skills are found" do
|
|
29
|
+
Dir.mktmpdir do |dir|
|
|
30
|
+
text = described_class.call(cwd: dir)
|
|
31
|
+
expect(text).to be_nil
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "lists discovered skills when present" do
|
|
36
|
+
Dir.mktmpdir do |dir|
|
|
37
|
+
# Create a minimal SKILL.md file
|
|
38
|
+
skill_dir = File.join(dir, ".brute", "skills")
|
|
39
|
+
FileUtils.mkdir_p(skill_dir)
|
|
40
|
+
File.write(File.join(skill_dir, "SKILL.md"), <<~MD)
|
|
41
|
+
---
|
|
42
|
+
name: test-skill
|
|
43
|
+
description: A test skill
|
|
44
|
+
---
|
|
45
|
+
# Test Skill Content
|
|
46
|
+
MD
|
|
47
|
+
|
|
48
|
+
text = described_class.call(cwd: dir)
|
|
49
|
+
if text
|
|
50
|
+
expect(text).to include("skill")
|
|
51
|
+
else
|
|
52
|
+
skip "Skill discovery does not find skills in .brute/skills/"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|