brute 2.0.6 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/events/handler.rb +3 -1
  3. data/lib/brute/events/prefixed_terminal_output.rb +3 -1
  4. data/lib/brute/events/terminal_output_handler.rb +3 -1
  5. data/lib/brute/message_transport/anthropic.rb +143 -0
  6. data/lib/brute/message_transport/llm.rb +94 -0
  7. data/lib/brute/message_transport/openai.rb +113 -0
  8. data/lib/brute/message_transport/ruby_llm.rb +78 -0
  9. data/lib/brute/message_transport.rb +133 -0
  10. data/lib/brute/messages.rb +120 -0
  11. data/lib/brute/middleware/001_otel_span.rb +4 -2
  12. data/lib/brute/middleware/002_session_log.rb +100 -0
  13. data/lib/brute/middleware/004_summarize.rb +21 -19
  14. data/lib/brute/middleware/006_loop.rb +158 -0
  15. data/lib/brute/middleware/010_max_iterations.rb +9 -7
  16. data/lib/brute/middleware/015_otel_token_usage.rb +3 -1
  17. data/lib/brute/middleware/020_system_prompt.rb +6 -4
  18. data/lib/brute/middleware/040_compaction_check.rb +5 -3
  19. data/lib/brute/middleware/060_questions.rb +3 -1
  20. data/lib/brute/middleware/{070_tool_call.rb → 070_tool_pipeline.rb} +60 -46
  21. data/lib/brute/middleware/073_otel_tool_call.rb +3 -1
  22. data/lib/brute/middleware/075_otel_tool_results.rb +3 -1
  23. data/lib/brute/middleware/event_handler.rb +3 -1
  24. data/lib/brute/middleware/user_queue.rb +3 -1
  25. data/lib/brute/prompts/autonomy.rb +3 -0
  26. data/lib/brute/prompts/base.rb +3 -0
  27. data/lib/brute/prompts/build_switch.rb +3 -1
  28. data/lib/brute/prompts/code_references.rb +3 -0
  29. data/lib/brute/prompts/code_style.rb +3 -0
  30. data/lib/brute/prompts/conventions.rb +3 -0
  31. data/lib/brute/prompts/doing_tasks.rb +3 -0
  32. data/lib/brute/prompts/editing_approach.rb +3 -0
  33. data/lib/brute/prompts/editing_constraints.rb +3 -0
  34. data/lib/brute/prompts/environment.rb +3 -1
  35. data/lib/brute/prompts/frontend_tasks.rb +3 -0
  36. data/lib/brute/prompts/git_safety.rb +3 -0
  37. data/lib/brute/prompts/identity.rb +3 -1
  38. data/lib/brute/prompts/instructions.rb +3 -1
  39. data/lib/brute/prompts/max_steps.rb +3 -1
  40. data/lib/brute/prompts/objectivity.rb +3 -0
  41. data/lib/brute/prompts/plan_reminder.rb +3 -1
  42. data/lib/brute/prompts/proactiveness.rb +3 -0
  43. data/lib/brute/prompts/security_and_safety.rb +3 -0
  44. data/lib/brute/prompts/skills.rb +6 -2
  45. data/lib/brute/prompts/task_management.rb +3 -0
  46. data/lib/brute/prompts/tone_and_style.rb +3 -0
  47. data/lib/brute/prompts/tool_usage.rb +3 -0
  48. data/lib/brute/prompts.rb +5 -0
  49. data/lib/brute/rack/adapter.rb +237 -0
  50. data/lib/brute/skill.rb +192 -15
  51. data/lib/brute/system_prompt.rb +3 -1
  52. data/lib/brute/tool.rb +92 -75
  53. data/lib/brute/tools/adapter.rb +258 -0
  54. data/lib/brute/tools/fs/file_mutation_queue.rb +107 -0
  55. data/lib/brute/tools/fs/snapshot_store.rb +41 -0
  56. data/lib/brute/tools/fs_patch.rb +6 -4
  57. data/lib/brute/tools/fs_read.rb +5 -3
  58. data/lib/brute/tools/fs_remove.rb +3 -3
  59. data/lib/brute/tools/fs_search.rb +4 -2
  60. data/lib/brute/tools/fs_undo.rb +3 -3
  61. data/lib/brute/tools/fs_write.rb +6 -4
  62. data/lib/brute/tools/net_fetch.rb +1 -1
  63. data/lib/brute/tools/question.rb +1 -1
  64. data/lib/brute/tools/shell.rb +4 -2
  65. data/lib/brute/tools/skill_load.rb +156 -0
  66. data/lib/brute/tools/sub_agent.rb +101 -0
  67. data/lib/brute/tools/todo_list/store.rb +36 -0
  68. data/lib/brute/tools/todo_read.rb +2 -2
  69. data/lib/brute/tools/todo_write.rb +2 -2
  70. data/lib/brute/tools.rb +2 -1
  71. data/lib/brute/truncation.rb +3 -1
  72. data/lib/brute/turn/agent_pipeline.rb +169 -0
  73. data/lib/brute/turn/pipeline.rb +95 -0
  74. data/lib/brute/turn/tool_pipeline.rb +96 -0
  75. data/lib/brute/utils/diff.rb +3 -1
  76. data/lib/brute/version.rb +1 -1
  77. data/lib/brute.rb +45 -71
  78. data/lib/{brute → brute_cli}/providers/shell.rb +4 -1
  79. data/lib/{brute → brute_cli}/providers/shell_response.rb +9 -15
  80. metadata +52 -43
  81. data/lib/brute/agent.rb +0 -82
  82. data/lib/brute/middleware/003_tool_result_loop.rb +0 -103
  83. data/lib/brute/middleware/100_llm_call.rb +0 -63
  84. data/lib/brute/pipeline.rb +0 -97
  85. data/lib/brute/queue/file_mutation_queue.rb +0 -102
  86. data/lib/brute/session.rb +0 -51
  87. data/lib/brute/store/snapshot_store.rb +0 -36
  88. data/lib/brute/store/todo_store.rb +0 -30
  89. data/lib/brute/sub_agent.rb +0 -106
@@ -54,16 +54,21 @@ module Brute
54
54
  # one fiber runs at a time within a Sync block). No mutex needed.
55
55
  #
56
56
  # - FileMutationQueue compatibility — tools that mutate files use
57
- # Brute::Queue::FileMutationQueue.serialize, which uses Ruby 3.4's
57
+ # Brute::Tools::FS::FileMutationQueue.serialize, which uses Ruby 3.4's
58
58
  # fiber-scheduler-aware Mutex. Operations on the same file are serialized;
59
59
  # operations on different files proceed in parallel.
60
60
  #
61
- class ToolCall
62
- def initialize(app)
63
- @app = app
61
+ class ToolPipeline
62
+ def initialize(app, tools: [])
63
+ @app = app
64
+ @tools = tools
64
65
  end
65
66
 
67
+ # in -> advertise the tools to the turn (env[:tools], which the LLM-call
68
+ # proc reads to tell the model what it can call)
69
+ # out <- execute the tool calls the model made and append :tool results
66
70
  def call(env)
71
+ env[:tools] = @tools
67
72
  @app.call(env)
68
73
 
69
74
  tools_to_run = pending_tool_calls(env[:messages].last)
@@ -76,13 +81,13 @@ module Brute
76
81
  Sync do
77
82
  barrier = Async::Barrier.new
78
83
 
79
- tools_to_run.each do |id, tool_call|
84
+ tools_to_run.each do |tool_call|
80
85
  barrier.async do
81
86
  tool = available_tools[tool_call.name.to_sym]
82
87
  result = tool.call(tool_call.arguments)
83
88
 
84
- # Coerce to String so RubyLLM::Message doesn't treat Hash results
85
- # (e.g. Shell's {stdout:, stderr:, exit_code:}) as attachments.
89
+ # Coerce to String so Hash results (e.g. Shell's
90
+ # {stdout:, stderr:, exit_code:}) serialize predictably.
86
91
  content = result.is_a?(String) ? result : result.to_s
87
92
 
88
93
  # Universal truncation safety net — skip if already truncated
@@ -90,13 +95,13 @@ module Brute
90
95
  content = Brute::Truncation.truncate(content)
91
96
  end
92
97
 
93
- results << [id, tool_call, content]
98
+ results << [tool_call, content]
94
99
  rescue => e
95
100
  # Capture the error as a tool result so the LLM can see it
96
101
  # and reason about the failure, rather than crashing the
97
102
  # entire middleware chain.
98
103
  env[:events] << { type: :error, data: { error: e, message: e.message } }
99
- results << [id, tool_call, "Error: #{e.class}: #{e.message}"]
104
+ results << [tool_call, "Error: #{e.class}: #{e.message}"]
100
105
  end
101
106
  end
102
107
 
@@ -107,12 +112,11 @@ module Brute
107
112
 
108
113
  # Append events and messages in the original tool_call order so the
109
114
  # LLM sees a deterministic sequence regardless of completion order.
110
- order = tools_to_run.keys
111
- results.sort_by! { |id, _, _| order.index(id) }
115
+ results.sort_by! { |tool_call, _| tools_to_run.index(tool_call) }
112
116
 
113
- results.each do |_id, tool_call, content|
117
+ results.each do |tool_call, content|
114
118
  env[:events] << { type: :tool_result, data: { name: tool_call.name, content: content } }
115
- env[:messages] << RubyLLM::Message.new(role: :tool, content: content, tool_call_id: tool_call.id)
119
+ env[:messages] << Brute::Message.new(role: :tool, content: content, tool_call_id: tool_call.id)
116
120
  end
117
121
  end
118
122
 
@@ -121,22 +125,24 @@ module Brute
121
125
 
122
126
  private
123
127
 
128
+ # The last message's pending tool calls as a flat list. Duck-typed:
129
+ # tool_calls may be an Array (Brute::ToolCall) or an id-keyed Hash
130
+ # (some libraries' native shape); each entry needs #id, #name and
131
+ # #arguments.
124
132
  def pending_tool_calls(message)
125
- message.tool_calls.to_h.reject { |_id, tc| tc.name == "question" }
133
+ calls = message.respond_to?(:tool_calls) ? message.tool_calls : nil
134
+ calls = calls.values if calls.respond_to?(:values)
135
+ Array(calls).reject { |tc| tc.name == "question" }
126
136
  end
127
137
 
128
138
  def resolve_tools(tools)
129
- tools.each_with_object({}) do |tool, hash|
130
- instance = tool.is_a?(Class) ? tool.new : tool
131
- instance = instance.to_ruby_llm if instance.respond_to?(:to_ruby_llm)
132
- hash[instance.name.to_sym] = instance
133
- end
139
+ Brute::Tools::Adapter.wrap_all(tools)
134
140
  end
135
141
 
136
142
  def on_tool_call_start_event(pending_tools)
137
143
  {
138
144
  type: :tool_call_start,
139
- data: pending_tools.map { |_id, tc|
145
+ data: pending_tools.map { |tc|
140
146
  {
141
147
  name: tc.name,
142
148
  call_id: tc.id,
@@ -149,18 +155,19 @@ module Brute
149
155
  end
150
156
  end
151
157
 
152
- test do
153
- require "brute/session"
158
+ __END__
159
+
160
+ describe "brute/middleware/070_tool_pipeline" do
161
+ require "brute/messages"
154
162
  require "brute/truncation"
155
163
 
156
164
  it "passes through when no tool calls pending" do
157
165
  inner = ->(env) {
158
- env[:messages] << RubyLLM::Message.new(role: :assistant, content: "hi")
166
+ env[:messages] << Brute::Message.new(role: :assistant, content: "hi")
159
167
  }
160
- mw = Brute::Middleware::ToolCall.new(inner)
168
+ mw = Brute::Middleware::ToolPipeline.new(inner, tools: [])
161
169
  env = {
162
- messages: Brute::Session.new,
163
- tools: [],
170
+ messages: Brute.log,
164
171
  events: [],
165
172
  }
166
173
  env[:messages].user("hello")
@@ -168,11 +175,22 @@ test do
168
175
  env[:messages].last.content.should == "hi"
169
176
  end
170
177
 
178
+ it "advertises its tools on env[:tools] on the way in" do
179
+ seen = nil
180
+ inner = ->(env) { seen = env[:tools] }
181
+ tool = { name: "echo", description: "", execute: ->(**) { "ok" } }
182
+ mw = Brute::Middleware::ToolPipeline.new(inner, tools: [tool])
183
+ env = { messages: Brute.log, events: [] }
184
+ env[:messages].user("hi")
185
+ mw.call(env)
186
+ seen.should == [tool]
187
+ end
188
+
171
189
  # --- Universal output truncation ---
172
190
 
173
191
  it "truncates large tool results via Truncation" do
174
192
  # A fake tool that returns a huge string
175
- big_tool = Class.new(RubyLLM::Tool) do
193
+ big_tool = Class.new(Brute::Tool) do
176
194
  description "test tool"
177
195
  param :input, type: "string", desc: "input"
178
196
  def name; "big_tool"; end
@@ -181,22 +199,20 @@ test do
181
199
  end
182
200
  end
183
201
 
184
- call_id = "tc_1"
185
- tool_calls = {
186
- call_id => RubyLLM::ToolCall.new(
187
- id: call_id,
202
+ tool_calls = [
203
+ Brute::ToolCall.new(
204
+ id: "tc_1",
188
205
  name: "big_tool",
189
206
  arguments: { "input" => "go" },
190
207
  )
191
- }
208
+ ]
192
209
 
193
210
  inner = ->(env) {
194
- env[:messages] << RubyLLM::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
211
+ env[:messages] << Brute::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
195
212
  }
196
- mw = Brute::Middleware::ToolCall.new(inner)
213
+ mw = Brute::Middleware::ToolPipeline.new(inner, tools: [big_tool])
197
214
  env = {
198
- messages: Brute::Session.new,
199
- tools: [big_tool],
215
+ messages: Brute.log,
200
216
  events: [],
201
217
  }
202
218
  env[:messages].user("hello")
@@ -211,7 +227,7 @@ test do
211
227
 
212
228
  it "does not double-truncate already-truncated output" do
213
229
  # A fake tool that returns output already containing the truncation marker
214
- pre_truncated_tool = Class.new(RubyLLM::Tool) do
230
+ pre_truncated_tool = Class.new(Brute::Tool) do
215
231
  description "test tool"
216
232
  param :input, type: "string", desc: "input"
217
233
  def name; "pre_truncated_tool"; end
@@ -220,22 +236,20 @@ test do
220
236
  end
221
237
  end
222
238
 
223
- call_id = "tc_2"
224
- tool_calls = {
225
- call_id => RubyLLM::ToolCall.new(
226
- id: call_id,
239
+ tool_calls = [
240
+ Brute::ToolCall.new(
241
+ id: "tc_2",
227
242
  name: "pre_truncated_tool",
228
243
  arguments: { "input" => "go" },
229
244
  )
230
- }
245
+ ]
231
246
 
232
247
  inner = ->(env) {
233
- env[:messages] << RubyLLM::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
248
+ env[:messages] << Brute::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
234
249
  }
235
- mw = Brute::Middleware::ToolCall.new(inner)
250
+ mw = Brute::Middleware::ToolPipeline.new(inner, tools: [pre_truncated_tool])
236
251
  env = {
237
- messages: Brute::Session.new,
238
- tools: [pre_truncated_tool],
252
+ messages: Brute.log,
239
253
  events: [],
240
254
  }
241
255
  env[:messages].user("hello")
@@ -44,6 +44,8 @@ module Brute
44
44
  end
45
45
  end
46
46
 
47
- test do
47
+ __END__
48
+
49
+ describe "brute/middleware/073_otel_tool_call" do
48
50
  # not implemented
49
51
  end
@@ -41,6 +41,8 @@ module Brute
41
41
  end
42
42
  end
43
43
 
44
- test do
44
+ __END__
45
+
46
+ describe "brute/middleware/075_otel_tool_results" do
45
47
  # not implemented
46
48
  end
@@ -20,6 +20,8 @@ module Brute
20
20
  end
21
21
  end
22
22
 
23
- test do
23
+ __END__
24
+
25
+ describe "brute/middleware/event_handler" do
24
26
  # not implemented
25
27
  end
@@ -30,6 +30,8 @@ module Brute
30
30
  end
31
31
  end
32
32
 
33
- test do
33
+ __END__
34
+
35
+ describe "brute/middleware/user_queue" do
34
36
  # not implemented
35
37
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module Autonomy
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  TEXT_DIR = File.expand_path("text", __dir__)
@@ -21,7 +21,9 @@ module Brute
21
21
  end
22
22
  end
23
23
 
24
- test do
24
+ __END__
25
+
26
+ describe "brute/prompts/build_switch" do
25
27
  it "returns a string" do
26
28
  Brute::Prompts::BuildSwitch.call({}).should.be.kind_of(String)
27
29
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module CodeReferences
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module CodeStyle
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module Conventions
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module DoingTasks
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module EditingApproach
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module EditingConstraints
@@ -27,7 +27,9 @@ module Brute
27
27
  end
28
28
  end
29
29
 
30
- test do
30
+ __END__
31
+
32
+ describe "brute/prompts/environment" do
31
33
  require "tmpdir"
32
34
  require "fileutils"
33
35
 
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module FrontendTasks
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module GitSafety
@@ -13,7 +13,9 @@ module Brute
13
13
  end
14
14
  end
15
15
 
16
- test do
16
+ __END__
17
+
18
+ describe "brute/prompts/identity" do
17
19
  it "returns a string for anthropic" do
18
20
  Brute::Prompts::Identity.call(provider_name: "anthropic").should.be.kind_of(String)
19
21
  end
@@ -20,7 +20,9 @@ module Brute
20
20
  end
21
21
  end
22
22
 
23
- test do
23
+ __END__
24
+
25
+ describe "brute/prompts/instructions" do
24
26
  it "returns nil when custom_rules is nil" do
25
27
  Brute::Prompts::Instructions.call(custom_rules: nil).should.be.nil
26
28
  end
@@ -32,7 +32,9 @@ module Brute
32
32
  end
33
33
  end
34
34
 
35
- test do
35
+ __END__
36
+
37
+ describe "brute/prompts/max_steps" do
36
38
  it "returns a string" do
37
39
  Brute::Prompts::MaxSteps.call({}).should.be.kind_of(String)
38
40
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module Objectivity
@@ -42,7 +42,9 @@ module Brute
42
42
  end
43
43
  end
44
44
 
45
- test do
45
+ __END__
46
+
47
+ describe "brute/prompts/plan_reminder" do
46
48
  it "returns a string" do
47
49
  Brute::Prompts::PlanReminder.call({}).should.be.kind_of(String)
48
50
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module Proactiveness
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module SecurityAndSafety
@@ -15,7 +15,9 @@ module Brute
15
15
 
16
16
  <<~TXT
17
17
  Skills provide specialized instructions and workflows for specific tasks.
18
- When a task matches a skill's description, load the skill to get detailed guidance.
18
+ Use the skill tool to load a skill when a task matches its description. The tool
19
+ returns the skill's full instructions plus a base directory whose bundled files
20
+ (scripts, references, assets) you can read or run by relative path.
19
21
 
20
22
  #{listing}
21
23
  TXT
@@ -24,7 +26,9 @@ module Brute
24
26
  end
25
27
  end
26
28
 
27
- test do
29
+ __END__
30
+
31
+ describe "brute/prompts/skills" do
28
32
  require "tmpdir"
29
33
 
30
34
  it "returns nil when no skills are found" do
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module TaskManagement
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module ToneAndStyle
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module ToolUsage
data/lib/brute/prompts.rb CHANGED
@@ -1,3 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
1
6
  require 'brute/prompts/base'
2
7
  require 'brute/prompts/identity'
3
8
  require 'brute/prompts/tone_and_style'