brute 0.4.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent.rb +14 -0
  3. data/lib/brute/diff.rb +18 -28
  4. data/lib/brute/loop/agent_stream.rb +118 -0
  5. data/lib/brute/loop/agent_turn.rb +520 -0
  6. data/lib/brute/{compactor.rb → loop/compactor.rb} +2 -0
  7. data/lib/brute/{doom_loop.rb → loop/doom_loop.rb} +2 -0
  8. data/lib/brute/loop/step.rb +332 -0
  9. data/lib/brute/loop/tool_call_step.rb +90 -0
  10. data/lib/brute/middleware/compaction_check.rb +60 -146
  11. data/lib/brute/middleware/doom_loop_detection.rb +95 -92
  12. data/lib/brute/middleware/llm_call.rb +78 -80
  13. data/lib/brute/middleware/message_tracking.rb +115 -162
  14. data/lib/brute/middleware/otel/span.rb +25 -106
  15. data/lib/brute/middleware/otel/token_usage.rb +29 -84
  16. data/lib/brute/middleware/otel/tool_calls.rb +23 -107
  17. data/lib/brute/middleware/otel/tool_results.rb +22 -86
  18. data/lib/brute/middleware/reasoning_normalizer.rb +78 -103
  19. data/lib/brute/middleware/retry.rb +95 -76
  20. data/lib/brute/middleware/session_persistence.rb +38 -37
  21. data/lib/brute/middleware/token_tracking.rb +64 -63
  22. data/lib/brute/middleware/tool_error_tracking.rb +108 -82
  23. data/lib/brute/middleware/tool_use_guard.rb +57 -90
  24. data/lib/brute/middleware/tracing.rb +53 -63
  25. data/lib/brute/middleware.rb +18 -0
  26. data/lib/brute/orchestrator/turn.rb +105 -0
  27. data/lib/brute/pipeline.rb +77 -133
  28. data/lib/brute/prompts/build_switch.rb +21 -25
  29. data/lib/brute/prompts/environment.rb +31 -35
  30. data/lib/brute/prompts/identity.rb +22 -29
  31. data/lib/brute/prompts/instructions.rb +15 -18
  32. data/lib/brute/prompts/max_steps.rb +18 -25
  33. data/lib/brute/prompts/plan_reminder.rb +18 -26
  34. data/lib/brute/prompts/skills.rb +8 -30
  35. data/lib/brute/prompts.rb +28 -0
  36. data/lib/brute/providers/ollama.rb +135 -0
  37. data/lib/brute/providers/shell.rb +2 -2
  38. data/lib/brute/providers/shell_response.rb +2 -2
  39. data/lib/brute/providers.rb +62 -0
  40. data/lib/brute/queue/base_queue.rb +222 -0
  41. data/lib/brute/{file_mutation_queue.rb → queue/file_mutation_queue.rb} +28 -26
  42. data/lib/brute/queue/parallel_queue.rb +66 -0
  43. data/lib/brute/queue/sequential_queue.rb +63 -0
  44. data/lib/brute/store/message_store.rb +362 -0
  45. data/lib/brute/store/session.rb +106 -0
  46. data/lib/brute/{snapshot_store.rb → store/snapshot_store.rb} +2 -0
  47. data/lib/brute/{todo_store.rb → store/todo_store.rb} +2 -0
  48. data/lib/brute/system_prompt.rb +81 -194
  49. data/lib/brute/tools/delegate.rb +46 -116
  50. data/lib/brute/tools/fs_patch.rb +36 -37
  51. data/lib/brute/tools/fs_remove.rb +2 -2
  52. data/lib/brute/tools/fs_undo.rb +2 -2
  53. data/lib/brute/tools/fs_write.rb +29 -41
  54. data/lib/brute/tools/todo_read.rb +1 -1
  55. data/lib/brute/tools/todo_write.rb +1 -1
  56. data/lib/brute/tools.rb +31 -0
  57. data/lib/brute/version.rb +1 -1
  58. data/lib/brute.rb +40 -204
  59. metadata +31 -20
  60. data/lib/brute/agent_stream.rb +0 -181
  61. data/lib/brute/hooks.rb +0 -84
  62. data/lib/brute/message_store.rb +0 -463
  63. data/lib/brute/orchestrator.rb +0 -550
  64. data/lib/brute/session.rb +0 -161
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if __FILE__ == $0
4
- require "bundler/setup"
5
- require "brute"
6
- end
3
+ require "bundler/setup"
4
+ require "brute"
7
5
 
8
6
  module Brute
9
7
  module Middleware
@@ -14,7 +12,7 @@ module Brute
14
12
  # propagate immediately.
15
13
  #
16
14
  # Unlike forgecode's separate retry.rs, this middleware wraps the LLM call
17
- # directly — it sees the error and retries without the orchestrator knowing.
15
+ # directly — it sees the error and retries without the agent loop knowing.
18
16
  #
19
17
  class Retry < Base
20
18
  DEFAULT_MAX_ATTEMPTS = 3
@@ -49,90 +47,111 @@ module Brute
49
47
  end
50
48
  end
51
49
 
52
- if __FILE__ == $0
53
- require_relative "../../../spec/spec_helper"
54
-
55
- RSpec.describe Brute::Middleware::Retry do
56
- let(:response) { MockResponse.new(content: "success") }
57
-
58
- it "returns the response on first successful call" do
59
- app, calls = mock_inner_app(response: response)
60
- middleware = described_class.new(app)
61
- env = build_env
62
-
63
- result = middleware.call(env)
64
-
65
- expect(result).to eq(response)
66
- expect(calls.size).to eq(1)
67
- end
68
-
69
- it "retries on LLM::RateLimitError and succeeds" do
70
- app = flaky_inner_app(LLM::RateLimitError, fail_count: 2, response: response)
71
- middleware = described_class.new(app, max_attempts: 3, base_delay: 2)
72
- allow(middleware).to receive(:sleep)
73
- env = build_env
50
+ test do
51
+ require_relative "../../../spec/support/mock_provider"
52
+ require_relative "../../../spec/support/mock_response"
74
53
 
75
- result = middleware.call(env)
76
-
77
- expect(result).to eq(response)
78
- expect(env[:metadata][:retry_attempt]).to eq(2)
79
- end
54
+ def build_env(**overrides)
55
+ { provider: MockProvider.new, model: nil, input: "test prompt", tools: [],
56
+ messages: [], stream: nil, params: {}, metadata: {}, callbacks: {},
57
+ tool_results: nil, streaming: false, should_exit: nil, pending_functions: [] }.merge(overrides)
58
+ end
80
59
 
81
- it "retries on LLM::ServerError and succeeds" do
82
- app = flaky_inner_app(LLM::ServerError, fail_count: 1, response: response)
83
- middleware = described_class.new(app, max_attempts: 3, base_delay: 2)
84
- allow(middleware).to receive(:sleep)
85
- env = build_env
60
+ def mock_inner_app(response:)
61
+ calls = []
62
+ app = ->(env) { calls << env; response }
63
+ [app, calls]
64
+ end
86
65
 
87
- result = middleware.call(env)
66
+ def flaky_inner_app(error_class, fail_count:, response:)
67
+ attempt = 0
68
+ ->(env) { attempt += 1; raise error_class, "transient" if attempt <= fail_count; response }
69
+ end
88
70
 
89
- expect(result).to eq(response)
90
- expect(env[:metadata][:retry_attempt]).to eq(1)
91
- end
71
+ def no_sleep_retry(*args, **kwargs)
72
+ mw = Brute::Middleware::Retry.new(*args, **kwargs)
73
+ mw.define_singleton_method(:sleep) { |_| }
74
+ mw
75
+ end
92
76
 
93
- it "re-raises after exhausting all attempts" do
94
- app = failing_inner_app(LLM::RateLimitError, message: "rate limited")
95
- middleware = described_class.new(app, max_attempts: 3, base_delay: 2)
96
- allow(middleware).to receive(:sleep)
97
- env = build_env
77
+ it "returns the response on first successful call" do
78
+ response = MockResponse.new(content: "success")
79
+ app, calls = mock_inner_app(response: response)
80
+ middleware = Brute::Middleware::Retry.new(app)
81
+ result = middleware.call(build_env)
82
+ result.should == response
83
+ end
98
84
 
99
- expect { middleware.call(env) }.to raise_error(LLM::RateLimitError, "rate limited")
100
- expect(env[:metadata][:last_error]).to eq("rate limited")
101
- end
85
+ it "calls inner app exactly once on success" do
86
+ response = MockResponse.new(content: "success")
87
+ app, calls = mock_inner_app(response: response)
88
+ Brute::Middleware::Retry.new(app).call(build_env)
89
+ calls.size.should == 1
90
+ end
102
91
 
103
- it "does not retry non-retryable errors" do
104
- call_count = 0
105
- app = ->(_env) { call_count += 1; raise ArgumentError, "bad input" }
106
- middleware = described_class.new(app)
107
- env = build_env
92
+ it "retries on LLM::RateLimitError and succeeds" do
93
+ response = MockResponse.new(content: "success")
94
+ app = flaky_inner_app(LLM::RateLimitError, fail_count: 2, response: response)
95
+ middleware = no_sleep_retry(app, max_attempts: 3, base_delay: 2)
96
+ env = build_env
97
+ result = middleware.call(env)
98
+ result.should == response
99
+ end
108
100
 
109
- expect { middleware.call(env) }.to raise_error(ArgumentError)
110
- expect(call_count).to eq(1)
111
- end
101
+ it "records retry_attempt in metadata after retries" do
102
+ response = MockResponse.new(content: "success")
103
+ app = flaky_inner_app(LLM::RateLimitError, fail_count: 2, response: response)
104
+ middleware = no_sleep_retry(app, max_attempts: 3, base_delay: 2)
105
+ env = build_env
106
+ middleware.call(env)
107
+ env[:metadata][:retry_attempt].should == 2
108
+ end
112
109
 
113
- it "sleeps with exponential backoff delays" do
114
- app = flaky_inner_app(LLM::RateLimitError, fail_count: 2, response: response)
115
- middleware = described_class.new(app, max_attempts: 3, base_delay: 2)
116
- allow(middleware).to receive(:sleep)
117
- env = build_env
110
+ it "retries on LLM::ServerError and succeeds" do
111
+ response = MockResponse.new(content: "success")
112
+ app = flaky_inner_app(LLM::ServerError, fail_count: 1, response: response)
113
+ middleware = no_sleep_retry(app, max_attempts: 3, base_delay: 2)
114
+ result = middleware.call(build_env)
115
+ result.should == response
116
+ end
118
117
 
119
- middleware.call(env)
118
+ it "re-raises after exhausting all attempts" do
119
+ app = ->(_env) { raise LLM::RateLimitError, "rate limited" }
120
+ middleware = no_sleep_retry(app, max_attempts: 3, base_delay: 2)
121
+ lambda { middleware.call(build_env) }.should.raise(LLM::RateLimitError)
122
+ end
120
123
 
121
- # base_delay ** attempts: 2**1 = 2, 2**2 = 4
122
- expect(middleware).to have_received(:sleep).with(2).ordered
123
- expect(middleware).to have_received(:sleep).with(4).ordered
124
- end
124
+ it "does not retry non-retryable errors" do
125
+ call_count = 0
126
+ app = ->(_env) { call_count += 1; raise ArgumentError, "bad input" }
127
+ middleware = Brute::Middleware::Retry.new(app)
128
+ lambda { middleware.call(build_env) }.should.raise(ArgumentError)
129
+ end
125
130
 
126
- it "records retry_delay in metadata" do
127
- app = flaky_inner_app(LLM::RateLimitError, fail_count: 1, response: response)
128
- middleware = described_class.new(app, max_attempts: 3, base_delay: 3)
129
- allow(middleware).to receive(:sleep)
130
- env = build_env
131
+ it "only calls inner app once for non-retryable errors" do
132
+ call_count = 0
133
+ app = ->(_env) { call_count += 1; raise ArgumentError, "bad input" }
134
+ middleware = Brute::Middleware::Retry.new(app)
135
+ begin; middleware.call(build_env); rescue ArgumentError; end
136
+ call_count.should == 1
137
+ end
131
138
 
132
- middleware.call(env)
139
+ it "records retry_delay in metadata" do
140
+ response = MockResponse.new(content: "success")
141
+ app = flaky_inner_app(LLM::RateLimitError, fail_count: 1, response: response)
142
+ middleware = no_sleep_retry(app, max_attempts: 3, base_delay: 3)
143
+ env = build_env
144
+ middleware.call(env)
145
+ env[:metadata][:retry_delay].should == 3
146
+ end
133
147
 
134
- # base_delay ** attempts: 3**1 = 3
135
- expect(env[:metadata][:retry_delay]).to eq(3)
136
- end
148
+ it "tracks sleep delays for exponential backoff" do
149
+ response = MockResponse.new(content: "success")
150
+ app = flaky_inner_app(LLM::RateLimitError, fail_count: 2, response: response)
151
+ delays = []
152
+ mw = Brute::Middleware::Retry.new(app, max_attempts: 3, base_delay: 2)
153
+ mw.define_singleton_method(:sleep) { |d| delays << d }
154
+ mw.call(build_env)
155
+ delays.should == [2, 4]
137
156
  end
138
157
  end
@@ -1,16 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if __FILE__ == $0
4
- require "bundler/setup"
5
- require "brute"
6
- end
3
+ require "bundler/setup"
4
+ require "brute"
7
5
 
8
6
  module Brute
9
7
  module Middleware
10
8
  # Saves the conversation to disk after each LLM call.
11
9
  #
12
- # Runs POST-call: delegates to Session#save. Failures are non-fatal —
13
- # a broken session save should never crash the agent loop.
10
+ # Runs POST-call: serializes env[:messages] via Session#save_messages.
11
+ # Failures are non-fatal — a broken session save should never crash
12
+ # the agent loop.
14
13
  #
15
14
  class SessionPersistence < Base
16
15
  def initialize(app, session:)
@@ -22,7 +21,7 @@ module Brute
22
21
  response = @app.call(env)
23
22
 
24
23
  begin
25
- @session.save(env[:context])
24
+ @session.save_messages(env[:messages])
26
25
  rescue => e
27
26
  warn "[brute] Session save failed: #{e.message}"
28
27
  end
@@ -33,39 +32,41 @@ module Brute
33
32
  end
34
33
  end
35
34
 
36
- if __FILE__ == $0
37
- require_relative "../../../spec/spec_helper"
38
-
39
- RSpec.describe Brute::Middleware::SessionPersistence do
40
- let(:response) { MockResponse.new(content: "saved response") }
41
- let(:inner_app) { ->(_env) { response } }
42
- let(:session) { double("session", save: nil) }
43
- let(:middleware) { described_class.new(inner_app, session: session) }
44
-
45
- it "passes the response through unchanged" do
46
- env = build_env
47
- result = middleware.call(env)
48
- expect(result).to eq(response)
49
- end
50
-
51
- it "calls session.save with the context after a successful LLM call" do
52
- env = build_env
53
- middleware.call(env)
54
- expect(session).to have_received(:save).with(env[:context])
55
- end
35
+ test do
36
+ require_relative "../../../spec/support/mock_provider"
37
+ require_relative "../../../spec/support/mock_response"
56
38
 
57
- it "does not propagate session save failures" do
58
- allow(session).to receive(:save).and_raise(RuntimeError, "disk full")
59
- env = build_env
39
+ def build_env(**overrides)
40
+ { provider: MockProvider.new, model: nil, input: "test prompt", tools: [],
41
+ messages: [], stream: nil, params: {}, metadata: {}, callbacks: {},
42
+ tool_results: nil, streaming: false, should_exit: nil, pending_functions: [] }.merge(overrides)
43
+ end
60
44
 
61
- expect { middleware.call(env) }.not_to raise_error
62
- end
45
+ it "passes the response through unchanged" do
46
+ response = MockResponse.new(content: "saved response")
47
+ session = Struct.new(:saved) { def save_messages(m); self.saved = m; end }.new
48
+ inner_app = ->(_env) { response }
49
+ middleware = Brute::Middleware::SessionPersistence.new(inner_app, session: session)
50
+ result = middleware.call(build_env)
51
+ result.should == response
52
+ end
63
53
 
64
- it "prints a warning to stderr on save failure" do
65
- allow(session).to receive(:save).and_raise(RuntimeError, "disk full")
66
- env = build_env
54
+ it "calls session.save_messages with env messages" do
55
+ response = MockResponse.new(content: "saved response")
56
+ session = Struct.new(:saved) { def save_messages(m); self.saved = m; end }.new
57
+ inner_app = ->(_env) { response }
58
+ middleware = Brute::Middleware::SessionPersistence.new(inner_app, session: session)
59
+ messages = [LLM::Message.new(:user, "hello")]
60
+ middleware.call(build_env(messages: messages))
61
+ session.saved.should == messages
62
+ end
67
63
 
68
- expect { middleware.call(env) }.to output(/Session save failed: disk full/).to_stderr
69
- end
64
+ it "does not propagate session save failures" do
65
+ response = MockResponse.new(content: "saved response")
66
+ session = Object.new
67
+ session.define_singleton_method(:save_messages) { |_| raise RuntimeError, "disk full" }
68
+ inner_app = ->(_env) { response }
69
+ middleware = Brute::Middleware::SessionPersistence.new(inner_app, session: session)
70
+ lambda { middleware.call(build_env) }.should.not.raise
70
71
  end
71
72
  end
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if __FILE__ == $0
4
- require "bundler/setup"
5
- require "brute"
6
- end
3
+ require "bundler/setup"
4
+ require "brute"
7
5
 
8
6
  module Brute
9
7
  module Middleware
@@ -50,74 +48,77 @@ module Brute
50
48
  end
51
49
  end
52
50
 
53
- if __FILE__ == $0
54
- require_relative "../../../spec/spec_helper"
55
-
56
- RSpec.describe Brute::Middleware::TokenTracking do
57
- let(:response) do
58
- MockResponse.new(
59
- content: "hello",
60
- usage: LLM::Usage.new(input_tokens: 100, output_tokens: 50, reasoning_tokens: 10, total_tokens: 160)
61
- )
62
- end
51
+ test do
52
+ require_relative "../../../spec/support/mock_provider"
53
+ require_relative "../../../spec/support/mock_response"
63
54
 
64
- let(:inner_app) { ->(_env) { response } }
65
- let(:middleware) { described_class.new(inner_app) }
66
-
67
- it "passes the response through unchanged" do
68
- env = build_env
69
- result = middleware.call(env)
70
- expect(result).to eq(response)
71
- end
55
+ def build_env(**overrides)
56
+ { provider: MockProvider.new, model: nil, input: "test prompt", tools: [],
57
+ messages: [], stream: nil, params: {}, metadata: {}, callbacks: {},
58
+ tool_results: nil, streaming: false, should_exit: nil, pending_functions: [] }.merge(overrides)
59
+ end
72
60
 
73
- it "populates env[:metadata][:tokens] with correct values" do
74
- env = build_env
75
- middleware.call(env)
76
-
77
- tokens = env[:metadata][:tokens]
78
- expect(tokens[:total_input]).to eq(100)
79
- expect(tokens[:total_output]).to eq(50)
80
- expect(tokens[:total_reasoning]).to eq(10)
81
- expect(tokens[:total]).to eq(150) # input + output
82
- expect(tokens[:call_count]).to eq(1)
83
- expect(tokens[:last_call]).to eq(input: 100, output: 50, total: 160)
84
- end
61
+ def make_response
62
+ MockResponse.new(content: "hello",
63
+ usage: LLM::Usage.new(input_tokens: 100, output_tokens: 50, reasoning_tokens: 10, total_tokens: 160))
64
+ end
85
65
 
86
- it "accumulates token counts across multiple calls" do
87
- env = build_env
88
- middleware.call(env)
89
- middleware.call(env)
66
+ it "passes the response through unchanged" do
67
+ response = make_response
68
+ middleware = Brute::Middleware::TokenTracking.new(->(_env) { response })
69
+ result = middleware.call(build_env)
70
+ result.should == response
71
+ end
90
72
 
91
- tokens = env[:metadata][:tokens]
92
- expect(tokens[:total_input]).to eq(200)
93
- expect(tokens[:total_output]).to eq(100)
94
- expect(tokens[:total_reasoning]).to eq(20)
95
- expect(tokens[:call_count]).to eq(2)
96
- end
73
+ it "populates total_input tokens" do
74
+ middleware = Brute::Middleware::TokenTracking.new(->(_env) { make_response })
75
+ env = build_env
76
+ middleware.call(env)
77
+ env[:metadata][:tokens][:total_input].should == 100
78
+ end
97
79
 
98
- it "handles a response without usage gracefully" do
99
- no_usage_response = double("response")
100
- allow(no_usage_response).to receive(:respond_to?).with(:usage).and_return(false)
101
- app = ->(_env) { no_usage_response }
102
- mw = described_class.new(app)
80
+ it "populates total_output tokens" do
81
+ middleware = Brute::Middleware::TokenTracking.new(->(_env) { make_response })
82
+ env = build_env
83
+ middleware.call(env)
84
+ env[:metadata][:tokens][:total_output].should == 50
85
+ end
103
86
 
104
- env = build_env
105
- result = mw.call(env)
87
+ it "populates total_reasoning tokens" do
88
+ middleware = Brute::Middleware::TokenTracking.new(->(_env) { make_response })
89
+ env = build_env
90
+ middleware.call(env)
91
+ env[:metadata][:tokens][:total_reasoning].should == 10
92
+ end
106
93
 
107
- expect(result).to eq(no_usage_response)
108
- expect(env[:metadata][:tokens]).to be_nil
109
- end
94
+ it "populates call_count" do
95
+ middleware = Brute::Middleware::TokenTracking.new(->(_env) { make_response })
96
+ env = build_env
97
+ middleware.call(env)
98
+ env[:metadata][:tokens][:call_count].should == 1
99
+ end
110
100
 
111
- it "handles a response where usage returns nil" do
112
- nil_usage_response = double("response", usage: nil)
113
- allow(nil_usage_response).to receive(:respond_to?).with(:usage).and_return(true)
114
- app = ->(_env) { nil_usage_response }
115
- mw = described_class.new(app)
101
+ it "accumulates token counts across multiple calls" do
102
+ middleware = Brute::Middleware::TokenTracking.new(->(_env) { make_response })
103
+ env = build_env
104
+ middleware.call(env)
105
+ middleware.call(env)
106
+ env[:metadata][:tokens][:total_input].should == 200
107
+ end
116
108
 
117
- env = build_env
118
- mw.call(env)
109
+ it "handles a response without usage gracefully" do
110
+ no_usage = Object.new
111
+ middleware = Brute::Middleware::TokenTracking.new(->(_env) { no_usage })
112
+ env = build_env
113
+ middleware.call(env)
114
+ env[:metadata][:tokens].should.be.nil
115
+ end
119
116
 
120
- expect(env[:metadata][:tokens]).to be_nil
121
- end
117
+ it "handles a response where usage returns nil" do
118
+ nil_usage = Struct.new(:usage).new(nil)
119
+ middleware = Brute::Middleware::TokenTracking.new(->(_env) { nil_usage })
120
+ env = build_env
121
+ middleware.call(env)
122
+ env[:metadata][:tokens].should.be.nil
122
123
  end
123
124
  end