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.
- checksums.yaml +4 -4
- data/lib/brute/message_transport/anthropic.rb +143 -0
- data/lib/brute/message_transport/llm.rb +94 -0
- data/lib/brute/message_transport/openai.rb +113 -0
- data/lib/brute/message_transport/ruby_llm.rb +78 -0
- data/lib/brute/message_transport.rb +133 -0
- data/lib/brute/messages.rb +79 -6
- data/lib/brute/middleware/002_session_log.rb +1 -1
- data/lib/brute/middleware/004_summarize.rb +7 -7
- data/lib/brute/middleware/006_loop.rb +4 -4
- data/lib/brute/middleware/008_checkpoint.rb +268 -0
- data/lib/brute/middleware/010_max_iterations.rb +1 -1
- data/lib/brute/middleware/020_system_prompt.rb +1 -1
- data/lib/brute/middleware/040_compaction_check.rb +2 -2
- data/lib/brute/middleware/070_tool_pipeline.rb +29 -26
- data/lib/brute/tool.rb +124 -0
- data/lib/brute/tools/adapter.rb +22 -60
- data/lib/brute/tools/fs_patch.rb +1 -1
- data/lib/brute/tools/fs_read.rb +1 -1
- data/lib/brute/tools/fs_remove.rb +1 -1
- data/lib/brute/tools/fs_search.rb +1 -1
- data/lib/brute/tools/fs_undo.rb +1 -1
- data/lib/brute/tools/fs_write.rb +1 -1
- data/lib/brute/tools/net_fetch.rb +1 -1
- data/lib/brute/tools/question.rb +1 -1
- data/lib/brute/tools/shell.rb +1 -1
- data/lib/brute/tools/skill_load.rb +1 -1
- data/lib/brute/tools/sub_agent.rb +5 -22
- data/lib/brute/tools/todo_read.rb +1 -1
- data/lib/brute/tools/todo_write.rb +1 -1
- data/lib/brute/turn/agent_pipeline.rb +2 -1
- data/lib/brute/turn/pipeline.rb +1 -1
- data/lib/brute/turn/tool_pipeline.rb +2 -12
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +17 -18
- data/lib/brute_cli/providers/shell_response.rb +6 -10
- metadata +23 -17
- 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 << ::
|
|
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] <<
|
|
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] <<
|
|
62
|
+
env[:messages] << Brute::Message.new(role: :tool, content: "some result", tool_call_id: "tc1")
|
|
63
63
|
else
|
|
64
|
-
env[:messages] <<
|
|
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] <<
|
|
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] <<
|
|
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] <<
|
|
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] <<
|
|
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] <<
|
|
111
|
+
env[:messages] << Brute::Message.new(role: :tool, content: "result", tool_call_id: "tc1")
|
|
112
112
|
else
|
|
113
|
-
env[:messages] <<
|
|
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] <<
|
|
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] <<
|
|
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
|
|
@@ -39,8 +39,8 @@ module Brute
|
|
|
39
39
|
# }
|
|
40
40
|
# # Replace the message history with the summary
|
|
41
41
|
# env[:messages] = [
|
|
42
|
-
#
|
|
43
|
-
#
|
|
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 |
|
|
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
|
|
90
|
-
#
|
|
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 << [
|
|
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 << [
|
|
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
|
-
|
|
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 |
|
|
117
|
+
results.each do |tool_call, content|
|
|
119
118
|
env[:events] << { type: :tool_result, data: { name: tool_call.name, content: content } }
|
|
120
|
-
env[:messages] <<
|
|
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
|
|
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 { |
|
|
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] <<
|
|
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(
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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] <<
|
|
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(
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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] <<
|
|
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
|