brute 3.0.0 → 3.1.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/message_transport/anthropic.rb +143 -0
  3. data/lib/brute/message_transport/llm.rb +94 -0
  4. data/lib/brute/message_transport/openai.rb +113 -0
  5. data/lib/brute/message_transport/ruby_llm.rb +78 -0
  6. data/lib/brute/message_transport.rb +133 -0
  7. data/lib/brute/messages.rb +79 -6
  8. data/lib/brute/middleware/002_session_log.rb +1 -1
  9. data/lib/brute/middleware/004_summarize.rb +7 -7
  10. data/lib/brute/middleware/006_loop.rb +4 -4
  11. data/lib/brute/middleware/008_checkpoint.rb +268 -0
  12. data/lib/brute/middleware/010_max_iterations.rb +1 -1
  13. data/lib/brute/middleware/020_system_prompt.rb +1 -1
  14. data/lib/brute/middleware/040_compaction_check.rb +2 -2
  15. data/lib/brute/middleware/070_tool_pipeline.rb +29 -26
  16. data/lib/brute/tool.rb +124 -0
  17. data/lib/brute/tools/adapter.rb +22 -60
  18. data/lib/brute/tools/fs_patch.rb +1 -1
  19. data/lib/brute/tools/fs_read.rb +1 -1
  20. data/lib/brute/tools/fs_remove.rb +1 -1
  21. data/lib/brute/tools/fs_search.rb +1 -1
  22. data/lib/brute/tools/fs_undo.rb +1 -1
  23. data/lib/brute/tools/fs_write.rb +1 -1
  24. data/lib/brute/tools/net_fetch.rb +1 -1
  25. data/lib/brute/tools/question.rb +1 -1
  26. data/lib/brute/tools/shell.rb +1 -1
  27. data/lib/brute/tools/skill_load.rb +1 -1
  28. data/lib/brute/tools/sub_agent.rb +5 -22
  29. data/lib/brute/tools/todo_read.rb +1 -1
  30. data/lib/brute/tools/todo_write.rb +1 -1
  31. data/lib/brute/turn/agent_pipeline.rb +2 -1
  32. data/lib/brute/turn/pipeline.rb +1 -1
  33. data/lib/brute/turn/tool_pipeline.rb +2 -12
  34. data/lib/brute/version.rb +1 -1
  35. data/lib/brute.rb +17 -18
  36. data/lib/brute_cli/providers/shell_response.rb +6 -10
  37. metadata +23 -17
  38. data/lib/ruby_llm/message_transport.rb +0 -117
@@ -42,7 +42,7 @@ module Brute
42
42
  loaded = []
43
43
  File.foreach(@path) do |line|
44
44
  line = line.strip
45
- loaded << ::RubyLLM::Message.new(**JSON.parse(line, symbolize_names: true)) unless line.empty?
45
+ loaded << Brute::Message.new(**JSON.parse(line, symbolize_names: true)) unless line.empty?
46
46
  end
47
47
  messages.unshift(*loaded)
48
48
  end
@@ -36,7 +36,7 @@ module Brute
36
36
  saved_tools = env[:tools]
37
37
  env[:tools] = []
38
38
  env[:current_iteration] = 1
39
- env[:messages] << RubyLLM::Message.new(role: :user, content: @prompt)
39
+ env[:messages] << Brute::Message.new(role: :user, content: @prompt)
40
40
  @app.call(env)
41
41
  env[:tools] = saved_tools
42
42
 
@@ -59,9 +59,9 @@ describe "brute/middleware/004_summarize" do
59
59
  inner = ->(env) do
60
60
  call_count += 1
61
61
  if call_count == 1
62
- env[:messages] << RubyLLM::Message.new(role: :tool, content: "some result", tool_call_id: "tc1")
62
+ env[:messages] << Brute::Message.new(role: :tool, content: "some result", tool_call_id: "tc1")
63
63
  else
64
- env[:messages] << RubyLLM::Message.new(role: :assistant, content: "Here is my complete summary.")
64
+ env[:messages] << Brute::Message.new(role: :assistant, content: "Here is my complete summary.")
65
65
  end
66
66
  end
67
67
 
@@ -77,7 +77,7 @@ describe "brute/middleware/004_summarize" do
77
77
 
78
78
  it "restores tools after summary call" do
79
79
  inner = ->(env) {
80
- env[:messages] << RubyLLM::Message.new(role: :assistant, content: "done")
80
+ env[:messages] << Brute::Message.new(role: :assistant, content: "done")
81
81
  }
82
82
 
83
83
  mw = Brute::Middleware::Summarize.new(inner)
@@ -93,7 +93,7 @@ describe "brute/middleware/004_summarize" do
93
93
  captured_iteration = nil
94
94
  inner = ->(env) {
95
95
  captured_iteration = env[:current_iteration]
96
- env[:messages] << RubyLLM::Message.new(role: :assistant, content: "done")
96
+ env[:messages] << Brute::Message.new(role: :assistant, content: "done")
97
97
  }
98
98
 
99
99
  mw = Brute::Middleware::Summarize.new(inner)
@@ -111,7 +111,7 @@ describe "brute/middleware/004_summarize" do
111
111
  inner = ->(env) {
112
112
  call_count += 1
113
113
  messages_at_second_call = env[:messages].map(&:content) if call_count == 2
114
- env[:messages] << RubyLLM::Message.new(role: :assistant, content: "done")
114
+ env[:messages] << Brute::Message.new(role: :assistant, content: "done")
115
115
  }
116
116
 
117
117
  mw = Brute::Middleware::Summarize.new(inner)
@@ -128,7 +128,7 @@ describe "brute/middleware/004_summarize" do
128
128
  inner = ->(env) {
129
129
  call_count += 1
130
130
  messages_at_second_call = env[:messages].map(&:content) if call_count == 2
131
- env[:messages] << RubyLLM::Message.new(role: :assistant, content: "done")
131
+ env[:messages] << Brute::Message.new(role: :assistant, content: "done")
132
132
  }
133
133
 
134
134
  mw = Brute::Middleware::Summarize.new(inner, prompt: "Give me the TL;DR.")
@@ -108,9 +108,9 @@ describe "brute/middleware/006_loop" do
108
108
  inner = ->(env) do
109
109
  call_count += 1
110
110
  if call_count == 1
111
- env[:messages] << RubyLLM::Message.new(role: :tool, content: "result", tool_call_id: "tc1")
111
+ env[:messages] << Brute::Message.new(role: :tool, content: "result", tool_call_id: "tc1")
112
112
  else
113
- env[:messages] << RubyLLM::Message.new(role: :assistant, content: "done")
113
+ env[:messages] << Brute::Message.new(role: :assistant, content: "done")
114
114
  end
115
115
  end
116
116
 
@@ -128,7 +128,7 @@ describe "brute/middleware/006_loop" do
128
128
  call_count = 0
129
129
  inner = ->(env) do
130
130
  call_count += 1
131
- env[:messages] << RubyLLM::Message.new(role: :tool, content: "result", tool_call_id: "tc#{call_count}")
131
+ env[:messages] << Brute::Message.new(role: :tool, content: "result", tool_call_id: "tc#{call_count}")
132
132
  env[:should_exit] = { reason: "max" } if call_count >= 2
133
133
  end
134
134
 
@@ -144,7 +144,7 @@ describe "brute/middleware/006_loop" do
144
144
  call_count = 0
145
145
  inner = ->(env) do
146
146
  call_count += 1
147
- env[:messages] << RubyLLM::Message.new(role: :assistant, content: "hello")
147
+ env[:messages] << Brute::Message.new(role: :assistant, content: "hello")
148
148
  end
149
149
 
150
150
  mw = Brute::Middleware::Loop::ToolResult.new(inner)
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "json"
6
+ require "securerandom"
7
+ require "fileutils"
8
+
9
+ module Brute
10
+ module Middleware
11
+ # Durable execution for the tool loop. Where SessionLog persists the
12
+ # conversation once per turn (outermost), Checkpoint snapshots it after
13
+ # every pass through the inner stack — one checkpoint per LLM call + tool
14
+ # execution. Place it just inside Loop::ToolResult:
15
+ #
16
+ # use Brute::Middleware::Loop::ToolResult
17
+ # use Brute::Middleware::Checkpoint, path: "tmp/checkpoints.jsonl"
18
+ # use Brute::Middleware::MaxIterations
19
+ # use Brute::Middleware::ToolPipeline, tools: Brute::Tools::ALL
20
+ #
21
+ # The store is just a JSONL log of snapshots — one line per checkpoint,
22
+ # each carrying the full message log plus its own id and the id of the
23
+ # parent checkpoint it grew from. That append-only chain buys three
24
+ # things:
25
+ #
26
+ # resume pass resume: :latest — a crash mid-turn costs at most
27
+ # one iteration instead of the whole turn
28
+ # time travel pass resume: "<checkpoint id>" — restart from any
29
+ # snapshot in the chain
30
+ # forking checkpoints written after a time-travel resume carry the
31
+ # resumed id as parent_id, branching the chain in place
32
+ #
33
+ # System messages are not persisted (SystemPrompt re-adds them each
34
+ # turn); restored history is inserted after any leading system message
35
+ # and before the current turn's input.
36
+ class Checkpoint
37
+ def initialize(app, path:, resume: nil)
38
+ @app = app
39
+ @path = path
40
+ @resume = resume
41
+ end
42
+
43
+ def call(env)
44
+ restore(env) unless env[:metadata][:checkpoint]
45
+ @app.call(env)
46
+ persist(env)
47
+ env
48
+ end
49
+
50
+ # Parsed checkpoint records (symbol keys, messages as plain hashes),
51
+ # oldest first.
52
+ def self.list(path)
53
+ return [] unless path && File.exist?(path)
54
+
55
+ File.foreach(path).filter_map do |line|
56
+ line = line.strip
57
+ JSON.parse(line, symbolize_names: true) unless line.empty?
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def restore(env)
64
+ env[:metadata][:checkpoint] = { path: @path }
65
+ return unless @resume
66
+
67
+ record = find_record
68
+ if record.nil?
69
+ return if @resume == :latest
70
+
71
+ raise KeyError, "no checkpoint #{@resume.inspect} in #{@path}"
72
+ end
73
+
74
+ history = record[:messages].map { |h| Brute::Message.new(**h) }
75
+ index = env[:messages].index { |m| m.role != :system } || env[:messages].size
76
+ env[:messages].insert(index, *history)
77
+ env[:metadata][:checkpoint][:id] = record[:id]
78
+ end
79
+
80
+ def find_record
81
+ records = self.class.list(@path)
82
+ @resume == :latest ? records.last : records.find { |r| r[:id] == @resume.to_s }
83
+ end
84
+
85
+ def persist(env)
86
+ FileUtils.mkdir_p(File.dirname(@path))
87
+ record = {
88
+ id: SecureRandom.hex(6),
89
+ parent_id: env[:metadata][:checkpoint][:id],
90
+ ts: Time.now.utc.iso8601,
91
+ iteration: env[:current_iteration],
92
+ messages: env[:messages].reject { |m| m.role == :system }.map(&:to_h),
93
+ }
94
+ File.open(@path, "a") { |f| f.puts(JSON.generate(record)) }
95
+ env[:metadata][:checkpoint][:id] = record[:id]
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ __END__
102
+
103
+ describe "brute/middleware/008_checkpoint" do
104
+ require "brute/messages"
105
+ require "tmpdir"
106
+
107
+ it "snapshots after every pass through the loop" do
108
+ Dir.mktmpdir do |dir|
109
+ path = File.join(dir, "cp.jsonl")
110
+ calls = 0
111
+ inner = ->(env) do
112
+ calls += 1
113
+ if calls < 3
114
+ env[:messages] << Brute::Message.new(role: :tool, content: "r#{calls}", tool_call_id: "tc#{calls}")
115
+ else
116
+ env[:messages].assistant("done")
117
+ end
118
+ end
119
+
120
+ stack = Brute::Middleware::Loop::ToolResult.new(
121
+ Brute::Middleware::Checkpoint.new(inner, path: path)
122
+ )
123
+ env = { messages: Brute.log.tap { |l| l.user("go") }, metadata: {}, current_iteration: 1 }
124
+ stack.call(env)
125
+
126
+ records = Brute::Middleware::Checkpoint.list(path)
127
+ records.size.should == 3
128
+ records.map { |r| r[:iteration] }.should == [1, 2, 3]
129
+ records.last[:messages].last[:content].should == "done"
130
+ end
131
+ end
132
+
133
+ it "chains checkpoints via parent_id" do
134
+ Dir.mktmpdir do |dir|
135
+ path = File.join(dir, "cp.jsonl")
136
+ mw = Brute::Middleware::Checkpoint.new(->(env) { env[:messages].assistant("a") }, path: path)
137
+ env = { messages: Brute.log.tap { |l| l.user("go") }, metadata: {}, current_iteration: 1 }
138
+ mw.call(env)
139
+ mw.call(env)
140
+
141
+ records = Brute::Middleware::Checkpoint.list(path)
142
+ records[0][:parent_id].should == nil
143
+ records[1][:parent_id].should == records[0][:id]
144
+ end
145
+ end
146
+
147
+ it "resume: :latest restores the conversation before this turn's input" do
148
+ Dir.mktmpdir do |dir|
149
+ path = File.join(dir, "cp.jsonl")
150
+ Brute::Middleware::Checkpoint.new(->(env) { env[:messages].assistant("first reply") }, path: path)
151
+ .call({ messages: Brute.log.tap { |l| l.user("hi") }, metadata: {}, current_iteration: 1 })
152
+
153
+ env = { messages: Brute.log.tap { |l| l.user("again") }, metadata: {}, current_iteration: 1 }
154
+ Brute::Middleware::Checkpoint.new(->(_e) {}, path: path, resume: :latest).call(env)
155
+
156
+ env[:messages].map(&:content).should == ["hi", "first reply", "again"]
157
+ end
158
+ end
159
+
160
+ it "inserts restored history after the system message" do
161
+ Dir.mktmpdir do |dir|
162
+ path = File.join(dir, "cp.jsonl")
163
+ Brute::Middleware::Checkpoint.new(->(env) { env[:messages].assistant("old") }, path: path)
164
+ .call({ messages: Brute.log.tap { |l| l.user("hi") }, metadata: {}, current_iteration: 1 })
165
+
166
+ env = { messages: Brute.log, metadata: {}, current_iteration: 1 }
167
+ env[:messages].system("rules")
168
+ env[:messages].user("new input")
169
+ Brute::Middleware::Checkpoint.new(->(_e) {}, path: path, resume: :latest).call(env)
170
+
171
+ env[:messages].map(&:role).should == [:system, :user, :assistant, :user]
172
+ env[:messages].last.content.should == "new input"
173
+ end
174
+ end
175
+
176
+ it "resumes from a specific checkpoint id (time travel)" do
177
+ Dir.mktmpdir do |dir|
178
+ path = File.join(dir, "cp.jsonl")
179
+ mw = Brute::Middleware::Checkpoint.new(->(env) { env[:messages].assistant("reply #{env[:messages].size}") }, path: path)
180
+ env = { messages: Brute.log.tap { |l| l.user("go") }, metadata: {}, current_iteration: 1 }
181
+ mw.call(env)
182
+ mw.call(env)
183
+
184
+ first = Brute::Middleware::Checkpoint.list(path).first
185
+ env2 = { messages: Brute.log, metadata: {}, current_iteration: 1 }
186
+ Brute::Middleware::Checkpoint.new(->(_e) {}, path: path, resume: first[:id]).call(env2)
187
+
188
+ env2[:messages].size.should == first[:messages].size
189
+ end
190
+ end
191
+
192
+ it "forks: checkpoints after a time-travel resume carry the resumed id as parent" do
193
+ Dir.mktmpdir do |dir|
194
+ path = File.join(dir, "cp.jsonl")
195
+ mw = Brute::Middleware::Checkpoint.new(->(env) { env[:messages].assistant("a") }, path: path)
196
+ env = { messages: Brute.log.tap { |l| l.user("go") }, metadata: {}, current_iteration: 1 }
197
+ mw.call(env)
198
+ mw.call(env)
199
+
200
+ root = Brute::Middleware::Checkpoint.list(path).first
201
+ Brute::Middleware::Checkpoint.new(->(env) { env[:messages].assistant("fork") }, path: path, resume: root[:id])
202
+ .call({ messages: Brute.log, metadata: {}, current_iteration: 1 })
203
+
204
+ Brute::Middleware::Checkpoint.list(path).last[:parent_id].should == root[:id]
205
+ end
206
+ end
207
+
208
+ it "resume: :latest with no store yet starts fresh" do
209
+ Dir.mktmpdir do |dir|
210
+ env = { messages: Brute.log.tap { |l| l.user("go") }, metadata: {}, current_iteration: 1 }
211
+ Brute::Middleware::Checkpoint.new(->(_e) {}, path: File.join(dir, "cp.jsonl"), resume: :latest).call(env)
212
+ env[:messages].map(&:content).should == ["go"]
213
+ end
214
+ end
215
+
216
+ it "raises on an unknown checkpoint id" do
217
+ Dir.mktmpdir do |dir|
218
+ path = File.join(dir, "cp.jsonl")
219
+ Brute::Middleware::Checkpoint.new(->(_e) {}, path: path).call(
220
+ { messages: Brute.log, metadata: {}, current_iteration: 1 }
221
+ )
222
+
223
+ lambda do
224
+ Brute::Middleware::Checkpoint.new(->(_e) {}, path: path, resume: "nope").call(
225
+ { messages: Brute.log, metadata: {}, current_iteration: 1 }
226
+ )
227
+ end.should.raise(KeyError)
228
+ end
229
+ end
230
+
231
+ it "does not persist system messages" do
232
+ Dir.mktmpdir do |dir|
233
+ path = File.join(dir, "cp.jsonl")
234
+ env = { messages: Brute.log, metadata: {}, current_iteration: 1 }
235
+ env[:messages].system("rules")
236
+ env[:messages].user("go")
237
+ Brute::Middleware::Checkpoint.new(->(_e) {}, path: path).call(env)
238
+
239
+ roles = Brute::Middleware::Checkpoint.list(path).last[:messages].map { |m| m[:role] }
240
+ roles.should == ["user"]
241
+ end
242
+ end
243
+
244
+ it "restores only once even though the loop re-enters" do
245
+ Dir.mktmpdir do |dir|
246
+ path = File.join(dir, "cp.jsonl")
247
+ Brute::Middleware::Checkpoint.new(->(env) { env[:messages].assistant("old") }, path: path)
248
+ .call({ messages: Brute.log.tap { |l| l.user("hi") }, metadata: {}, current_iteration: 1 })
249
+
250
+ calls = 0
251
+ inner = ->(env) do
252
+ calls += 1
253
+ if calls < 2
254
+ env[:messages] << Brute::Message.new(role: :tool, content: "r", tool_call_id: "tc1")
255
+ else
256
+ env[:messages].assistant("done")
257
+ end
258
+ end
259
+ stack = Brute::Middleware::Loop::ToolResult.new(
260
+ Brute::Middleware::Checkpoint.new(inner, path: path, resume: :latest)
261
+ )
262
+ env = { messages: Brute.log.tap { |l| l.user("again") }, metadata: {}, current_iteration: 1 }
263
+ stack.call(env)
264
+
265
+ env[:messages].count { |m| m.content == "old" }.should == 1
266
+ end
267
+ end
268
+ end
@@ -22,7 +22,7 @@ module Brute
22
22
 
23
23
  def call(env)
24
24
  if max_iterations_reached?(env)
25
- env[:messages] << RubyLLM::Message.new(
25
+ env[:messages] << Brute::Message.new(
26
26
  role: :user,
27
27
  content: "Maximum iterations reached.",
28
28
  )
@@ -37,7 +37,7 @@ module Brute
37
37
  result = @system_prompt.prepare(ctx)
38
38
  unless result.empty?
39
39
  env[:messages].unshift(
40
- RubyLLM::Message.new(role: :system, content: result.to_s)
40
+ Brute::Message.new(role: :system, content: result.to_s)
41
41
  )
42
42
  end
43
43
  end
@@ -39,8 +39,8 @@ module Brute
39
39
  # }
40
40
  # # Replace the message history with the summary
41
41
  # env[:messages] = [
42
- # RubyLLM::Message.new(role: :system, content: @system_prompt),
43
- # RubyLLM::Message.new(role: :user, content: "[Previous conversation summary]\n\n#{summary_text}"),
42
+ # Brute::Message.new(role: :system, content: @system_prompt),
43
+ # Brute::Message.new(role: :user, content: "[Previous conversation summary]\n\n#{summary_text}"),
44
44
  # ]
45
45
  # end
46
46
  #end
@@ -81,13 +81,13 @@ module Brute
81
81
  Sync do
82
82
  barrier = Async::Barrier.new
83
83
 
84
- tools_to_run.each do |id, tool_call|
84
+ tools_to_run.each do |tool_call|
85
85
  barrier.async do
86
86
  tool = available_tools[tool_call.name.to_sym]
87
87
  result = tool.call(tool_call.arguments)
88
88
 
89
- # Coerce to String so RubyLLM::Message doesn't treat Hash results
90
- # (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.
91
91
  content = result.is_a?(String) ? result : result.to_s
92
92
 
93
93
  # Universal truncation safety net — skip if already truncated
@@ -95,13 +95,13 @@ module Brute
95
95
  content = Brute::Truncation.truncate(content)
96
96
  end
97
97
 
98
- results << [id, tool_call, content]
98
+ results << [tool_call, content]
99
99
  rescue => e
100
100
  # Capture the error as a tool result so the LLM can see it
101
101
  # and reason about the failure, rather than crashing the
102
102
  # entire middleware chain.
103
103
  env[:events] << { type: :error, data: { error: e, message: e.message } }
104
- results << [id, tool_call, "Error: #{e.class}: #{e.message}"]
104
+ results << [tool_call, "Error: #{e.class}: #{e.message}"]
105
105
  end
106
106
  end
107
107
 
@@ -112,12 +112,11 @@ module Brute
112
112
 
113
113
  # Append events and messages in the original tool_call order so the
114
114
  # LLM sees a deterministic sequence regardless of completion order.
115
- order = tools_to_run.keys
116
- results.sort_by! { |id, _, _| order.index(id) }
115
+ results.sort_by! { |tool_call, _| tools_to_run.index(tool_call) }
117
116
 
118
- results.each do |_id, tool_call, content|
117
+ results.each do |tool_call, content|
119
118
  env[:events] << { type: :tool_result, data: { name: tool_call.name, content: content } }
120
- 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)
121
120
  end
122
121
  end
123
122
 
@@ -126,8 +125,14 @@ module Brute
126
125
 
127
126
  private
128
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.
129
132
  def pending_tool_calls(message)
130
- 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" }
131
136
  end
132
137
 
133
138
  def resolve_tools(tools)
@@ -137,7 +142,7 @@ module Brute
137
142
  def on_tool_call_start_event(pending_tools)
138
143
  {
139
144
  type: :tool_call_start,
140
- data: pending_tools.map { |_id, tc|
145
+ data: pending_tools.map { |tc|
141
146
  {
142
147
  name: tc.name,
143
148
  call_id: tc.id,
@@ -158,7 +163,7 @@ describe "brute/middleware/070_tool_pipeline" do
158
163
 
159
164
  it "passes through when no tool calls pending" do
160
165
  inner = ->(env) {
161
- env[:messages] << RubyLLM::Message.new(role: :assistant, content: "hi")
166
+ env[:messages] << Brute::Message.new(role: :assistant, content: "hi")
162
167
  }
163
168
  mw = Brute::Middleware::ToolPipeline.new(inner, tools: [])
164
169
  env = {
@@ -185,7 +190,7 @@ describe "brute/middleware/070_tool_pipeline" do
185
190
 
186
191
  it "truncates large tool results via Truncation" do
187
192
  # A fake tool that returns a huge string
188
- big_tool = Class.new(RubyLLM::Tool) do
193
+ big_tool = Class.new(Brute::Tool) do
189
194
  description "test tool"
190
195
  param :input, type: "string", desc: "input"
191
196
  def name; "big_tool"; end
@@ -194,17 +199,16 @@ describe "brute/middleware/070_tool_pipeline" do
194
199
  end
195
200
  end
196
201
 
197
- call_id = "tc_1"
198
- tool_calls = {
199
- call_id => RubyLLM::ToolCall.new(
200
- id: call_id,
202
+ tool_calls = [
203
+ Brute::ToolCall.new(
204
+ id: "tc_1",
201
205
  name: "big_tool",
202
206
  arguments: { "input" => "go" },
203
207
  )
204
- }
208
+ ]
205
209
 
206
210
  inner = ->(env) {
207
- 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)
208
212
  }
209
213
  mw = Brute::Middleware::ToolPipeline.new(inner, tools: [big_tool])
210
214
  env = {
@@ -223,7 +227,7 @@ describe "brute/middleware/070_tool_pipeline" do
223
227
 
224
228
  it "does not double-truncate already-truncated output" do
225
229
  # A fake tool that returns output already containing the truncation marker
226
- pre_truncated_tool = Class.new(RubyLLM::Tool) do
230
+ pre_truncated_tool = Class.new(Brute::Tool) do
227
231
  description "test tool"
228
232
  param :input, type: "string", desc: "input"
229
233
  def name; "pre_truncated_tool"; end
@@ -232,17 +236,16 @@ describe "brute/middleware/070_tool_pipeline" do
232
236
  end
233
237
  end
234
238
 
235
- call_id = "tc_2"
236
- tool_calls = {
237
- call_id => RubyLLM::ToolCall.new(
238
- id: call_id,
239
+ tool_calls = [
240
+ Brute::ToolCall.new(
241
+ id: "tc_2",
239
242
  name: "pre_truncated_tool",
240
243
  arguments: { "input" => "go" },
241
244
  )
242
- }
245
+ ]
243
246
 
244
247
  inner = ->(env) {
245
- 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)
246
249
  }
247
250
  mw = Brute::Middleware::ToolPipeline.new(inner, tools: [pre_truncated_tool])
248
251
  env = {
data/lib/brute/tool.rb ADDED
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ # Base class for Brute's built-in tools — a tiny, framework-agnostic tool
8
+ # DSL. It intentionally mirrors the common tool-library shape (description +
9
+ # params) so tools read the same as they would in any LLM library, without
10
+ # depending on one:
11
+ #
12
+ # class Shell < Brute::Tool
13
+ # description "Execute a shell command"
14
+ # param :command, type: 'string', desc: "The command", required: true
15
+ #
16
+ # def name; "shell"; end
17
+ #
18
+ # def execute(command:)
19
+ # ...
20
+ # end
21
+ # end
22
+ #
23
+ # For tools whose arguments don't fit the flat param list, pass a raw JSON
24
+ # schema instead:
25
+ #
26
+ # params({ type: 'object', properties: { ... }, required: [...] })
27
+ #
28
+ # Instances expose the neutral interface Brute::Tools::Adapter understands:
29
+ # #name, #description, #params, #params_schema, #call.
30
+ class Tool
31
+ class << self
32
+ def description(text = nil)
33
+ return @description unless text
34
+
35
+ @description = text
36
+ end
37
+
38
+ # Declare one parameter: param :key, type:, desc:, required:
39
+ def param(name, type: "string", desc: nil, required: true, **opts)
40
+ param_definitions[name.to_sym] = { type: type, desc: desc, required: required, **opts }.compact
41
+ end
42
+
43
+ def param_definitions
44
+ @param_definitions ||= {}
45
+ end
46
+
47
+ # Raw JSON-schema override for complex argument shapes.
48
+ def params(schema = nil)
49
+ return @params_schema unless schema
50
+
51
+ @params_schema = schema.deep_symbolize_keys
52
+ end
53
+ end
54
+
55
+ # Tool name; subclasses usually override with an explicit short name.
56
+ def name
57
+ self.class.name.demodulize.underscore
58
+ end
59
+
60
+ def description
61
+ self.class.description.to_s
62
+ end
63
+
64
+ # { key => { type:, desc:, required: } }
65
+ def params
66
+ self.class.param_definitions
67
+ end
68
+
69
+ # The raw JSON schema, when declared via params({...}).
70
+ def params_schema
71
+ self.class.params
72
+ end
73
+
74
+ # Execute with a string- or symbol-keyed argument hash, as delivered
75
+ # by LLM providers.
76
+ def call(arguments = {})
77
+ execute(**arguments.to_h.transform_keys(&:to_sym))
78
+ end
79
+
80
+ def execute(**)
81
+ raise NotImplementedError, "#{self.class} must implement #execute"
82
+ end
83
+ end
84
+ end
85
+
86
+ __END__
87
+
88
+ describe "brute/tool" do
89
+ it "exposes description and params from the DSL" do
90
+ klass = Class.new(Brute::Tool) do
91
+ description "test tool"
92
+ param :input, type: "string", desc: "the input"
93
+ def name; "t"; end
94
+ def execute(input:); "got #{input}"; end
95
+ end
96
+
97
+ tool = klass.new
98
+ tool.description.should == "test tool"
99
+ tool.params[:input][:type].should == "string"
100
+ tool.params[:input][:required].should.be.true
101
+ end
102
+
103
+ it "calls execute with symbolized keys" do
104
+ klass = Class.new(Brute::Tool) do
105
+ description "echo"
106
+ param :msg, type: "string"
107
+ def name; "echo"; end
108
+ def execute(msg:); msg; end
109
+ end
110
+
111
+ klass.new.call("msg" => "hi").should == "hi"
112
+ end
113
+
114
+ it "accepts a raw JSON schema via params({...})" do
115
+ klass = Class.new(Brute::Tool) do
116
+ description "schema tool"
117
+ params({ type: "object", properties: { a: { type: "string" } }, required: %w[a] })
118
+ def name; "s"; end
119
+ def execute(a:); a; end
120
+ end
121
+
122
+ klass.new.params_schema[:properties][:a][:type].should == "string"
123
+ end
124
+ end