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
@@ -20,7 +18,7 @@ module Brute
20
18
 
21
19
  span = env[:span]
22
20
  if span
23
- functions = env[:context].functions
21
+ functions = env[:pending_functions]
24
22
  if functions && !functions.empty?
25
23
  span.set_attribute("brute.tool_calls.count", functions.size)
26
24
 
@@ -43,110 +41,28 @@ module Brute
43
41
  end
44
42
  end
45
43
 
46
- if __FILE__ == $0
47
- require_relative "../../../../spec/spec_helper"
48
-
49
- RSpec.describe Brute::Middleware::OTel::ToolCalls do
50
- let(:response) { MockResponse.new(content: "here's my plan") }
51
- let(:inner_app) { ->(_env) { response } }
52
- let(:middleware) { described_class.new(inner_app) }
53
-
54
- it "passes the response through unchanged" do
55
- env = build_env
56
- result = middleware.call(env)
57
- expect(result).to eq(response)
58
- end
59
-
60
- context "when env[:span] is nil" do
61
- it "passes through without error even with pending functions" do
62
- ctx = build_env[:context]
63
- fn = double("function", name: "fs_read", id: "tc_001", arguments: { "path" => "/tmp" })
64
- allow(ctx).to receive(:functions).and_return([fn])
65
-
66
- env = build_env(context: ctx)
67
- result = middleware.call(env)
68
- expect(result).to eq(response)
69
- end
70
- end
71
-
72
- context "when env[:span] is present" do
73
- let(:span) { mock_span }
74
-
75
- it "does nothing when there are no pending functions" do
76
- ctx = build_env[:context]
77
- allow(ctx).to receive(:functions).and_return([])
78
-
79
- env = build_env(context: ctx, span: span)
80
- middleware.call(env)
81
-
82
- expect(span).not_to have_received(:add_event)
83
- expect(span).not_to have_received(:set_attribute)
84
- end
85
-
86
- it "does nothing when functions is nil" do
87
- ctx = build_env[:context]
88
- allow(ctx).to receive(:functions).and_return(nil)
89
-
90
- env = build_env(context: ctx, span: span)
91
- middleware.call(env)
44
+ test do
45
+ require_relative "../../../../spec/support/mock_provider"
46
+ require_relative "../../../../spec/support/mock_response"
92
47
 
93
- expect(span).not_to have_received(:add_event)
94
- end
95
-
96
- it "records a tool_call event per pending function" do
97
- ctx = build_env[:context]
98
- fn1 = double("function", name: "fs_read", id: "tc_001", arguments: { "path" => "/src/main.rb" })
99
- fn2 = double("function", name: "shell", id: "tc_002", arguments: { "command" => "rspec" })
100
- allow(ctx).to receive(:functions).and_return([fn1, fn2])
101
-
102
- env = build_env(context: ctx, span: span)
103
- middleware.call(env)
104
-
105
- expect(span).to have_received(:set_attribute).with("brute.tool_calls.count", 2)
106
- expect(span).to have_received(:add_event).with(
107
- "tool_call",
108
- attributes: hash_including(
109
- "tool.name" => "fs_read",
110
- "tool.id" => "tc_001"
111
- )
112
- )
113
- expect(span).to have_received(:add_event).with(
114
- "tool_call",
115
- attributes: hash_including(
116
- "tool.name" => "shell",
117
- "tool.id" => "tc_002"
118
- )
119
- )
120
- end
121
-
122
- it "serializes arguments as JSON" do
123
- ctx = build_env[:context]
124
- args = { "path" => "/tmp/test.rb", "content" => "puts 'hi'" }
125
- fn = double("function", name: "fs_write", id: "tc_003", arguments: args)
126
- allow(ctx).to receive(:functions).and_return([fn])
127
-
128
- env = build_env(context: ctx, span: span)
129
- middleware.call(env)
130
-
131
- expect(span).to have_received(:add_event).with(
132
- "tool_call",
133
- attributes: hash_including("tool.arguments" => args.to_json)
134
- )
135
- end
136
-
137
- it "handles nil arguments" do
138
- ctx = build_env[:context]
139
- fn = double("function", name: "todo_read", id: "tc_004", arguments: nil)
140
- allow(ctx).to receive(:functions).and_return([fn])
48
+ def build_env(**overrides)
49
+ { provider: MockProvider.new, model: nil, input: "test prompt", tools: [],
50
+ messages: [], stream: nil, params: {}, metadata: {}, callbacks: {},
51
+ tool_results: nil, streaming: false, should_exit: nil, pending_functions: [] }.merge(overrides)
52
+ end
141
53
 
142
- env = build_env(context: ctx, span: span)
143
- middleware.call(env)
54
+ it "passes the response through unchanged" do
55
+ response = MockResponse.new(content: "here's my plan")
56
+ middleware = Brute::Middleware::OTel::ToolCalls.new(->(_env) { response })
57
+ result = middleware.call(build_env)
58
+ result.should == response
59
+ end
144
60
 
145
- expect(span).to have_received(:add_event).with(
146
- "tool_call",
147
- attributes: { "tool.name" => "todo_read", "tool.id" => "tc_004" }
148
- )
149
- end
150
- end
61
+ it "passes through without error when span is nil with pending functions" do
62
+ response = MockResponse.new(content: "here's my plan")
63
+ fn = Struct.new(:name, :id, :arguments, keyword_init: true).new(name: "fs_read", id: "tc_001", arguments: { "path" => "/tmp" })
64
+ middleware = Brute::Middleware::OTel::ToolCalls.new(->(_env) { response })
65
+ result = middleware.call(build_env(pending_functions: [fn]))
66
+ result.should == response
151
67
  end
152
68
  end
@@ -1,16 +1,14 @@
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
  module OTel
11
9
  # Records tool results being sent back to the LLM as span events.
12
10
  #
13
- # Runs PRE-call: when env[:tool_results] is present, the orchestrator
11
+ # Runs PRE-call: when env[:tool_results] is present, the agent loop
14
12
  # is sending tool execution results back to the LLM. Each result gets
15
13
  # a span event with the tool name and success/error status.
16
14
  #
@@ -41,89 +39,27 @@ module Brute
41
39
  end
42
40
  end
43
41
 
44
- if __FILE__ == $0
45
- require_relative "../../../../spec/spec_helper"
46
-
47
- RSpec.describe Brute::Middleware::OTel::ToolResults do
48
- let(:response) { MockResponse.new(content: "processed") }
49
- let(:inner_app) { ->(_env) { response } }
50
- let(:middleware) { described_class.new(inner_app) }
51
-
52
- it "passes the response through unchanged" do
53
- env = build_env
54
- result = middleware.call(env)
55
- expect(result).to eq(response)
56
- end
57
-
58
- context "when env[:span] is nil" do
59
- it "passes through without error" do
60
- results = [["fs_read", { content: "data" }]]
61
- env = build_env(tool_results: results)
42
+ test do
43
+ require_relative "../../../../spec/support/mock_provider"
44
+ require_relative "../../../../spec/support/mock_response"
62
45
 
63
- result = middleware.call(env)
64
- expect(result).to eq(response)
65
- end
66
- end
67
-
68
- context "when env[:span] is present" do
69
- let(:span) { mock_span }
70
-
71
- it "does nothing when tool_results is nil" do
72
- env = build_env(span: span, tool_results: nil)
73
- middleware.call(env)
74
-
75
- expect(span).not_to have_received(:add_event)
76
- expect(span).not_to have_received(:set_attribute)
77
- end
78
-
79
- it "records a tool_result event per result" do
80
- results = [
81
- ["fs_read", { content: "file data" }],
82
- ["shell", { output: "ok" }],
83
- ]
84
- env = build_env(span: span, tool_results: results)
85
- middleware.call(env)
86
-
87
- expect(span).to have_received(:set_attribute).with("brute.tool_results.count", 2)
88
- expect(span).to have_received(:add_event).with(
89
- "tool_result",
90
- attributes: hash_including("tool.name" => "fs_read", "tool.status" => "ok")
91
- )
92
- expect(span).to have_received(:add_event).with(
93
- "tool_result",
94
- attributes: hash_including("tool.name" => "shell", "tool.status" => "ok")
95
- )
96
- end
97
-
98
- it "records error status and message for failed tool results" do
99
- results = [
100
- ["fs_read", { error: "not found" }],
101
- ]
102
- env = build_env(span: span, tool_results: results)
103
- middleware.call(env)
104
-
105
- expect(span).to have_received(:add_event).with(
106
- "tool_result",
107
- attributes: hash_including(
108
- "tool.name" => "fs_read",
109
- "tool.status" => "error",
110
- "tool.error" => "not found"
111
- )
112
- )
113
- end
46
+ def build_env(**overrides)
47
+ { provider: MockProvider.new, model: nil, input: "test prompt", tools: [],
48
+ messages: [], stream: nil, params: {}, metadata: {}, callbacks: {},
49
+ tool_results: nil, streaming: false, should_exit: nil, pending_functions: [] }.merge(overrides)
50
+ end
114
51
 
115
- it "handles a mix of successful and failed results" do
116
- results = [
117
- ["fs_read", { content: "ok" }],
118
- ["shell", { error: "exit code 1" }],
119
- ["fs_write", { success: true }],
120
- ]
121
- env = build_env(span: span, tool_results: results)
122
- middleware.call(env)
52
+ it "passes the response through unchanged" do
53
+ response = MockResponse.new(content: "processed")
54
+ middleware = Brute::Middleware::OTel::ToolResults.new(->(_env) { response })
55
+ result = middleware.call(build_env)
56
+ result.should == response
57
+ end
123
58
 
124
- expect(span).to have_received(:set_attribute).with("brute.tool_results.count", 3)
125
- expect(span).to have_received(:add_event).exactly(3).times
126
- end
127
- end
59
+ it "passes through without error when span is nil" do
60
+ response = MockResponse.new(content: "processed")
61
+ middleware = Brute::Middleware::OTel::ToolResults.new(->(_env) { response })
62
+ result = middleware.call(build_env(tool_results: [["fs_read", { content: "data" }]]))
63
+ result.should == response
128
64
  end
129
65
  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
@@ -102,116 +100,93 @@ module Brute
102
100
  end
103
101
  end
104
102
 
105
- if __FILE__ == $0
106
- require_relative "../../../spec/spec_helper"
107
-
108
- RSpec.describe Brute::Middleware::ReasoningNormalizer do
109
- let(:response) { MockResponse.new(content: "reasoned response") }
110
- let(:inner_app) { ->(_env) { response } }
111
-
112
- # Build a provider whose class name contains the given string.
113
- def make_provider(type_name)
114
- klass = Class.new do
115
- define_method(:name) { :mock }
116
- define_method(:default_model) { "mock-model" }
117
- define_method(:user_role) { :user }
118
- define_method(:system_role) { :system }
119
- define_method(:assistant_role) { :assistant }
120
- define_method(:tool_role) { :tool }
121
- define_method(:tracer) { nil }
122
- define_method(:tracer=) { |*| }
123
- define_method(:complete) { |*_args, **_kw| MockResponse.new(content: "ok") }
124
- end
125
- # Override class name to trigger provider_type detection
126
- klass.define_method(:class) do
127
- c = super()
128
- name_str = "LLM::#{type_name}"
129
- c.define_singleton_method(:name) { name_str }
130
- c
131
- end
132
- klass.new
133
- end
134
-
135
- context "with Anthropic provider and budget_tokens" do
136
- it "injects thinking param into env[:params]" do
137
- provider = make_provider("Anthropic")
138
- middleware = described_class.new(inner_app, model_id: "claude-4", budget_tokens: 8000, enabled: true)
139
- env = build_env(provider: provider, params: {})
103
+ test do
104
+ require_relative "../../../spec/support/mock_provider"
105
+ require_relative "../../../spec/support/mock_response"
140
106
 
141
- middleware.call(env)
107
+ def build_env(**overrides)
108
+ { provider: MockProvider.new, model: nil, input: "test prompt", tools: [],
109
+ messages: [], stream: nil, params: {}, metadata: {}, callbacks: {},
110
+ tool_results: nil, streaming: false, should_exit: nil, pending_functions: [] }.merge(overrides)
111
+ end
142
112
 
143
- expect(env[:params][:thinking]).to eq({ type: "enabled", budget_tokens: 8000 })
144
- end
113
+ def make_provider(type_name)
114
+ klass = Class.new do
115
+ define_method(:name) { :mock }
116
+ define_method(:default_model) { "mock-model" }
117
+ define_method(:user_role) { :user }
118
+ define_method(:system_role) { :system }
119
+ define_method(:assistant_role) { :assistant }
120
+ define_method(:tool_role) { :tool }
121
+ define_method(:tracer) { nil }
122
+ define_method(:tracer=) { |*| }
123
+ define_method(:complete) { |*_args, **_kw| MockResponse.new(content: "ok") }
145
124
  end
146
-
147
- context "with Anthropic provider without budget_tokens" do
148
- it "does not inject thinking param" do
149
- provider = make_provider("Anthropic")
150
- middleware = described_class.new(inner_app, model_id: "claude-4", enabled: true)
151
- env = build_env(provider: provider, params: {})
152
-
153
- middleware.call(env)
154
-
155
- expect(env[:params][:thinking]).to be_nil
156
- end
125
+ klass.define_method(:class) do
126
+ c = super()
127
+ name_str = "LLM::#{type_name}"
128
+ c.define_singleton_method(:name) { name_str }
129
+ c
157
130
  end
131
+ klass.new
132
+ end
158
133
 
159
- context "with OpenAI provider" do
160
- it "injects reasoning_effort based on effort level" do
161
- provider = make_provider("OpenAI")
162
- middleware = described_class.new(inner_app, model_id: "o3", effort: :high, enabled: true)
163
- env = build_env(provider: provider, params: {})
164
-
165
- middleware.call(env)
166
-
167
- expect(env[:params][:reasoning_effort]).to eq("high")
168
- end
169
-
170
- it "maps effort levels correctly" do
171
- provider = make_provider("OpenAI")
172
-
173
- { low: "low", medium: "medium", high: "high", minimal: "low", max: "high" }.each do |effort, expected|
174
- middleware = described_class.new(inner_app, model_id: "o3", effort: effort, enabled: true)
175
- env = build_env(provider: provider, params: {})
176
- middleware.call(env)
177
- expect(env[:params][:reasoning_effort]).to eq(expected), "Expected effort #{effort} to map to #{expected}"
178
- end
179
- end
180
- end
181
-
182
- context "with unknown provider" do
183
- it "does not inject any reasoning params" do
184
- provider = make_provider("Mistral")
185
- middleware = described_class.new(inner_app, model_id: "mistral-large", enabled: true)
186
- env = build_env(provider: provider, params: {})
187
-
188
- middleware.call(env)
134
+ inner_app = ->(_env) { MockResponse.new(content: "reasoned response") }
189
135
 
190
- expect(env[:params]).to eq({})
191
- end
192
- end
136
+ it "injects thinking param for Anthropic with budget_tokens" do
137
+ provider = make_provider("Anthropic")
138
+ middleware = Brute::Middleware::ReasoningNormalizer.new(inner_app, model_id: "claude-4", budget_tokens: 8000, enabled: true)
139
+ env = build_env(provider: provider, params: {})
140
+ middleware.call(env)
141
+ env[:params][:thinking].should == { type: "enabled", budget_tokens: 8000 }
142
+ end
193
143
 
194
- context "when disabled" do
195
- it "does not inject reasoning params" do
196
- provider = make_provider("Anthropic")
197
- middleware = described_class.new(inner_app, model_id: "claude-4", budget_tokens: 8000, enabled: false)
198
- env = build_env(provider: provider, params: {})
144
+ it "does not inject thinking param for Anthropic without budget_tokens" do
145
+ provider = make_provider("Anthropic")
146
+ middleware = Brute::Middleware::ReasoningNormalizer.new(inner_app, model_id: "claude-4", enabled: true)
147
+ env = build_env(provider: provider, params: {})
148
+ middleware.call(env)
149
+ env[:params][:thinking].should.be.nil
150
+ end
199
151
 
200
- middleware.call(env)
152
+ it "injects reasoning_effort for OpenAI" do
153
+ provider = make_provider("OpenAI")
154
+ middleware = Brute::Middleware::ReasoningNormalizer.new(inner_app, model_id: "o3", effort: :high, enabled: true)
155
+ env = build_env(provider: provider, params: {})
156
+ middleware.call(env)
157
+ env[:params][:reasoning_effort].should == "high"
158
+ end
201
159
 
202
- expect(env[:params]).to eq({})
203
- end
204
- end
160
+ it "maps low effort correctly for OpenAI" do
161
+ provider = make_provider("OpenAI")
162
+ middleware = Brute::Middleware::ReasoningNormalizer.new(inner_app, model_id: "o3", effort: :low, enabled: true)
163
+ env = build_env(provider: provider, params: {})
164
+ middleware.call(env)
165
+ env[:params][:reasoning_effort].should == "low"
166
+ end
205
167
 
206
- it "allows model_id to be updated mid-session" do
207
- middleware = described_class.new(inner_app, model_id: "old-model", enabled: true)
208
- middleware.model_id = "new-model"
168
+ it "does not inject params for unknown provider" do
169
+ provider = make_provider("Mistral")
170
+ middleware = Brute::Middleware::ReasoningNormalizer.new(inner_app, model_id: "mistral-large", enabled: true)
171
+ env = build_env(provider: provider, params: {})
172
+ middleware.call(env)
173
+ env[:params].should == {}
174
+ end
209
175
 
210
- provider = make_provider("OpenAI")
211
- env = build_env(provider: provider, params: {})
212
- middleware.call(env)
176
+ it "does not inject params when disabled" do
177
+ provider = make_provider("Anthropic")
178
+ middleware = Brute::Middleware::ReasoningNormalizer.new(inner_app, model_id: "claude-4", budget_tokens: 8000, enabled: false)
179
+ env = build_env(provider: provider, params: {})
180
+ middleware.call(env)
181
+ env[:params].should == {}
182
+ end
213
183
 
214
- expect(env[:params][:reasoning_effort]).not_to be_nil
215
- end
184
+ it "allows model_id to be updated mid-session" do
185
+ middleware = Brute::Middleware::ReasoningNormalizer.new(inner_app, model_id: "old", enabled: true)
186
+ middleware.model_id = "new"
187
+ provider = make_provider("OpenAI")
188
+ env = build_env(provider: provider, params: {})
189
+ middleware.call(env)
190
+ env[:params][:reasoning_effort].should.not.be.nil
216
191
  end
217
192
  end