brute 3.0.0 → 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.
- 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/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 +22 -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)
|
|
@@ -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
|
data/lib/brute/tools/adapter.rb
CHANGED
|
@@ -12,10 +12,9 @@ module Brute
|
|
|
12
12
|
# This solves three problems:
|
|
13
13
|
#
|
|
14
14
|
# 1. Using any tools library — anything that quacks like a tool
|
|
15
|
-
# (
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# Tools::SubAgent work without inheriting from a library class.
|
|
15
|
+
# (#name plus #call or #execute) is wrapped into the same interface.
|
|
16
|
+
# 2. Avoiding tool libraries entirely — Brute::Tool, Brute::Turn::ToolPipeline
|
|
17
|
+
# and Tools::SubAgent work without inheriting from a library class.
|
|
19
18
|
# 3. Quickly adding tools — a plain Hash with a proc is enough:
|
|
20
19
|
#
|
|
21
20
|
# Brute::Tools::Adapter.wrap(
|
|
@@ -32,8 +31,8 @@ module Brute
|
|
|
32
31
|
# adapter.params # { key => { type:, desc:, required: } }
|
|
33
32
|
# adapter.call(args) # execute with a (string- or symbol-keyed) Hash
|
|
34
33
|
#
|
|
35
|
-
#
|
|
36
|
-
# library expects
|
|
34
|
+
# The inline `run` proc converts adapters (via #to_h) into whatever its
|
|
35
|
+
# LLM library expects; ToolPipeline executes them via #call.
|
|
37
36
|
#
|
|
38
37
|
class Adapter
|
|
39
38
|
attr_reader :name, :description, :params
|
|
@@ -46,7 +45,7 @@ module Brute
|
|
|
46
45
|
|
|
47
46
|
case tool
|
|
48
47
|
when Hash then from_hash(tool)
|
|
49
|
-
when ::
|
|
48
|
+
when ::Brute::Tool then from_brute_tool(tool)
|
|
50
49
|
when Brute::Tools::SubAgent then new(
|
|
51
50
|
name: tool.name,
|
|
52
51
|
description: tool.description,
|
|
@@ -90,29 +89,21 @@ module Brute
|
|
|
90
89
|
)
|
|
91
90
|
end
|
|
92
91
|
|
|
93
|
-
# A
|
|
94
|
-
#
|
|
95
|
-
|
|
96
|
-
def self.from_ruby_llm(tool)
|
|
97
|
-
params = tool.parameters.each_with_object({}) do |(key, param), hash|
|
|
98
|
-
hash[key.to_sym] = { type: param.type, desc: param.description, required: param.required }.compact
|
|
99
|
-
end
|
|
100
|
-
|
|
92
|
+
# A Brute::Tool instance. Tools declared with the params({...}) schema
|
|
93
|
+
# DSL keep their full JSON schema.
|
|
94
|
+
def self.from_brute_tool(tool)
|
|
101
95
|
new(
|
|
102
96
|
name: tool.name.to_s,
|
|
103
97
|
description: tool.description,
|
|
104
|
-
params: params,
|
|
105
|
-
schema:
|
|
98
|
+
params: tool.params,
|
|
99
|
+
schema: tool.params_schema,
|
|
106
100
|
handler: ->(**args) { tool.call(args) },
|
|
107
101
|
original: tool,
|
|
108
102
|
)
|
|
109
103
|
end
|
|
110
104
|
|
|
111
|
-
# Anything tool-shaped: needs #name and #call or #execute.
|
|
112
|
-
# #to_ruby_llm for backward compatibility with existing adapters.
|
|
105
|
+
# Anything tool-shaped: needs #name and #call or #execute.
|
|
113
106
|
def self.from_duck_type(tool)
|
|
114
|
-
return from_ruby_llm(tool.to_ruby_llm) if tool.respond_to?(:to_ruby_llm)
|
|
115
|
-
|
|
116
107
|
unless tool.respond_to?(:name) && (tool.respond_to?(:call) || tool.respond_to?(:execute))
|
|
117
108
|
raise ArgumentError, "don't know how to adapt #{tool.inspect} into a tool"
|
|
118
109
|
end
|
|
@@ -136,7 +127,7 @@ module Brute
|
|
|
136
127
|
@original = original
|
|
137
128
|
end
|
|
138
129
|
|
|
139
|
-
# The tool object this adapter wraps (
|
|
130
|
+
# The tool object this adapter wraps (Brute::Tool, Brute::Turn::ToolPipeline,
|
|
140
131
|
# SubAgent, Hash definition, ...).
|
|
141
132
|
attr_reader :original
|
|
142
133
|
|
|
@@ -147,23 +138,8 @@ module Brute
|
|
|
147
138
|
@handler.call(**args)
|
|
148
139
|
end
|
|
149
140
|
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
# it already is one.
|
|
153
|
-
def to_ruby_llm
|
|
154
|
-
return @original if @original.is_a?(::RubyLLM::Tool)
|
|
155
|
-
|
|
156
|
-
adapter = self
|
|
157
|
-
Class.new(::RubyLLM::Tool) do
|
|
158
|
-
description adapter.description
|
|
159
|
-
adapter.params.each { |key, opts| param key, **opts.slice(:type, :desc, :required) }
|
|
160
|
-
define_method(:name) { adapter.name }
|
|
161
|
-
define_method(:execute) { |**args| adapter.call(args) }
|
|
162
|
-
end.new
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
# Library-neutral tool definition (JSON-Schema-ish), for completion
|
|
166
|
-
# middlewares that talk to an HTTP API directly.
|
|
141
|
+
# Library-neutral tool definition (JSON-Schema-ish). The inline `run`
|
|
142
|
+
# proc reshapes this into whatever its LLM library expects.
|
|
167
143
|
def to_h
|
|
168
144
|
return { name: @name, description: @description, parameters: @schema.deep_symbolize_keys } if @schema
|
|
169
145
|
|
|
@@ -194,16 +170,16 @@ end
|
|
|
194
170
|
__END__
|
|
195
171
|
|
|
196
172
|
describe "brute/tools/adapter" do
|
|
197
|
-
it "wraps a
|
|
198
|
-
klass = Class.new(::
|
|
173
|
+
it "wraps a Brute::Tool class" do
|
|
174
|
+
klass = Class.new(::Brute::Tool) do
|
|
199
175
|
description "test tool"
|
|
200
176
|
param :input, type: "string", desc: "the input"
|
|
201
|
-
def name; "
|
|
177
|
+
def name; "brute_tool"; end
|
|
202
178
|
def execute(input:); "got #{input}"; end
|
|
203
179
|
end
|
|
204
180
|
|
|
205
181
|
adapter = Brute::Tools::Adapter.wrap(klass)
|
|
206
|
-
adapter.name.should == "
|
|
182
|
+
adapter.name.should == "brute_tool"
|
|
207
183
|
adapter.description.should == "test tool"
|
|
208
184
|
adapter.params[:input][:type].should == "string"
|
|
209
185
|
adapter.call("input" => "x").should == "got x"
|
|
@@ -255,29 +231,15 @@ describe "brute/tools/adapter" do
|
|
|
255
231
|
tools[:a].should.be.kind_of?(Brute::Tools::Adapter)
|
|
256
232
|
end
|
|
257
233
|
|
|
258
|
-
it "
|
|
259
|
-
|
|
260
|
-
name: "echo",
|
|
261
|
-
description: "Echo",
|
|
262
|
-
params: { msg: { type: "string", desc: "message", required: true } },
|
|
263
|
-
execute: ->(msg:) { msg },
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
rl = adapter.to_ruby_llm
|
|
267
|
-
rl.should.be.kind_of?(::RubyLLM::Tool)
|
|
268
|
-
rl.name.should == "echo"
|
|
269
|
-
rl.call("msg" => "hello").should == "hello"
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
it "returns the original when it already is a RubyLLM::Tool" do
|
|
273
|
-
klass = Class.new(::RubyLLM::Tool) do
|
|
234
|
+
it "exposes the wrapped original" do
|
|
235
|
+
klass = Class.new(::Brute::Tool) do
|
|
274
236
|
description "test tool"
|
|
275
237
|
def name; "original"; end
|
|
276
238
|
def execute; "ok"; end
|
|
277
239
|
end
|
|
278
240
|
instance = klass.new
|
|
279
241
|
|
|
280
|
-
Brute::Tools::Adapter.wrap(instance).
|
|
242
|
+
Brute::Tools::Adapter.wrap(instance).original.should == instance
|
|
281
243
|
end
|
|
282
244
|
|
|
283
245
|
it "produces a neutral JSON-schema-ish definition" do
|
data/lib/brute/tools/fs_patch.rb
CHANGED
|
@@ -6,7 +6,7 @@ require "brute/tools"
|
|
|
6
6
|
|
|
7
7
|
module Brute
|
|
8
8
|
module Tools
|
|
9
|
-
class FSPatch <
|
|
9
|
+
class FSPatch < Brute::Tool
|
|
10
10
|
description 'Replace a specific string in a file. The old_string must match exactly ' \
|
|
11
11
|
'(including whitespace and indentation). Always read a file before patching it.'
|
|
12
12
|
|
data/lib/brute/tools/fs_read.rb
CHANGED
|
@@ -28,7 +28,7 @@ module Brute
|
|
|
28
28
|
# 8. Return a plain string instead of a Hash — avoids the .to_s repr
|
|
29
29
|
# bloat when ToolPipeline coerces the result for the LLM message.
|
|
30
30
|
#
|
|
31
|
-
class FSRead <
|
|
31
|
+
class FSRead < Brute::Tool
|
|
32
32
|
description "Read the contents of a file. Returns file content with line numbers. " \
|
|
33
33
|
"Use start_line/end_line for partial reads of large files."
|
|
34
34
|
|
|
@@ -7,7 +7,7 @@ require "fileutils"
|
|
|
7
7
|
|
|
8
8
|
module Brute
|
|
9
9
|
module Tools
|
|
10
|
-
class FSRemove <
|
|
10
|
+
class FSRemove < Brute::Tool
|
|
11
11
|
description "Remove a file or empty directory."
|
|
12
12
|
|
|
13
13
|
param :path, type: 'string', desc: "Path to the file or directory to remove", required: true
|
|
@@ -21,7 +21,7 @@ module Brute
|
|
|
21
21
|
# 5. Return a plain string instead of a Hash.
|
|
22
22
|
# 6. Align output cap with universal truncation (2000 lines / 50 KB).
|
|
23
23
|
#
|
|
24
|
-
class FSSearch <
|
|
24
|
+
class FSSearch < Brute::Tool
|
|
25
25
|
description "Search file contents using ripgrep (regex), or find files by glob pattern. " \
|
|
26
26
|
"Returns matching lines with file paths and line numbers."
|
|
27
27
|
|
data/lib/brute/tools/fs_undo.rb
CHANGED
data/lib/brute/tools/fs_write.rb
CHANGED
|
@@ -7,7 +7,7 @@ require 'fileutils'
|
|
|
7
7
|
|
|
8
8
|
module Brute
|
|
9
9
|
module Tools
|
|
10
|
-
class FSWrite <
|
|
10
|
+
class FSWrite < Brute::Tool
|
|
11
11
|
description "Write content to a file. Creates parent directories if they don't exist. " \
|
|
12
12
|
'Use this for creating new files or completely replacing file contents.'
|
|
13
13
|
|
|
@@ -9,7 +9,7 @@ require "uri"
|
|
|
9
9
|
|
|
10
10
|
module Brute
|
|
11
11
|
module Tools
|
|
12
|
-
class NetFetch <
|
|
12
|
+
class NetFetch < Brute::Tool
|
|
13
13
|
description "Fetch content from a URL. Returns the response body as text."
|
|
14
14
|
|
|
15
15
|
param :url, type: 'string', desc: "The URL to fetch", required: true
|
data/lib/brute/tools/question.rb
CHANGED
|
@@ -6,7 +6,7 @@ require "brute/tools"
|
|
|
6
6
|
|
|
7
7
|
module Brute
|
|
8
8
|
module Tools
|
|
9
|
-
class Question <
|
|
9
|
+
class Question < Brute::Tool
|
|
10
10
|
description "Ask the user questions during execution. Use this to gather preferences, " \
|
|
11
11
|
"clarify ambiguous instructions, get decisions on implementation choices, or " \
|
|
12
12
|
"offer choices about direction. Users can always select \"Other\" to provide " \
|
data/lib/brute/tools/shell.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Brute
|
|
|
22
22
|
# the LLM (defaults to 5 minutes).
|
|
23
23
|
# 5. Return a plain string instead of a Hash.
|
|
24
24
|
#
|
|
25
|
-
class Shell <
|
|
25
|
+
class Shell < Brute::Tool
|
|
26
26
|
description "Execute a shell command and return stdout, stderr, and exit code. " \
|
|
27
27
|
"Use for git operations, running tests, installing packages, etc."
|
|
28
28
|
|
|
@@ -28,7 +28,7 @@ module Brute
|
|
|
28
28
|
# tool with the matching cwd: Brute::Tools::SkillLoad.new(cwd: __dir__).
|
|
29
29
|
#
|
|
30
30
|
# Reference: opencode's tool/skill.ts.
|
|
31
|
-
class SkillLoad <
|
|
31
|
+
class SkillLoad < Brute::Tool
|
|
32
32
|
description "Load a specialized skill when the task at hand matches one of the " \
|
|
33
33
|
"available skills listed in the system context. This injects the skill's " \
|
|
34
34
|
"full instructions into the conversation, plus the skill's base directory " \
|