brute 2.0.5 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/brute/events/handler.rb +3 -1
- data/lib/brute/events/prefixed_terminal_output.rb +3 -1
- data/lib/brute/events/terminal_output_handler.rb +3 -1
- data/lib/brute/messages.rb +47 -0
- data/lib/brute/middleware/001_otel_span.rb +4 -2
- data/lib/brute/middleware/002_session_log.rb +100 -0
- data/lib/brute/middleware/004_summarize.rb +14 -12
- data/lib/brute/middleware/006_loop.rb +158 -0
- data/lib/brute/middleware/010_max_iterations.rb +8 -6
- data/lib/brute/middleware/015_otel_token_usage.rb +3 -1
- data/lib/brute/middleware/020_system_prompt.rb +5 -3
- data/lib/brute/middleware/040_compaction_check.rb +3 -1
- data/lib/brute/middleware/060_questions.rb +3 -1
- data/lib/brute/middleware/{070_tool_call.rb → 070_tool_pipeline.rb} +31 -20
- data/lib/brute/middleware/073_otel_tool_call.rb +3 -1
- data/lib/brute/middleware/075_otel_tool_results.rb +3 -1
- data/lib/brute/middleware/event_handler.rb +3 -1
- data/lib/brute/middleware/user_queue.rb +3 -1
- data/lib/brute/prompts/autonomy.rb +3 -0
- data/lib/brute/prompts/base.rb +3 -0
- data/lib/brute/prompts/build_switch.rb +3 -1
- data/lib/brute/prompts/code_references.rb +3 -0
- data/lib/brute/prompts/code_style.rb +3 -0
- data/lib/brute/prompts/conventions.rb +3 -0
- data/lib/brute/prompts/doing_tasks.rb +3 -0
- data/lib/brute/prompts/editing_approach.rb +3 -0
- data/lib/brute/prompts/editing_constraints.rb +3 -0
- data/lib/brute/prompts/environment.rb +3 -1
- data/lib/brute/prompts/frontend_tasks.rb +3 -0
- data/lib/brute/prompts/git_safety.rb +3 -0
- data/lib/brute/prompts/identity.rb +3 -1
- data/lib/brute/prompts/instructions.rb +3 -1
- data/lib/brute/prompts/max_steps.rb +3 -1
- data/lib/brute/prompts/objectivity.rb +3 -0
- data/lib/brute/prompts/plan_reminder.rb +3 -1
- data/lib/brute/prompts/proactiveness.rb +3 -0
- data/lib/brute/prompts/security_and_safety.rb +3 -0
- data/lib/brute/prompts/skills.rb +6 -2
- data/lib/brute/prompts/task_management.rb +3 -0
- data/lib/brute/prompts/tone_and_style.rb +3 -0
- data/lib/brute/prompts/tool_usage.rb +3 -0
- data/lib/brute/prompts.rb +5 -0
- data/lib/brute/rack/adapter.rb +237 -0
- data/lib/brute/skill.rb +192 -15
- data/lib/brute/system_prompt.rb +3 -1
- data/lib/brute/tools/adapter.rb +296 -0
- data/lib/brute/tools/fs/file_mutation_queue.rb +107 -0
- data/lib/brute/tools/fs/snapshot_store.rb +41 -0
- data/lib/brute/tools/fs_patch.rb +5 -3
- data/lib/brute/tools/fs_read.rb +4 -2
- data/lib/brute/tools/fs_remove.rb +2 -2
- data/lib/brute/tools/fs_search.rb +3 -1
- data/lib/brute/tools/fs_undo.rb +2 -2
- data/lib/brute/tools/fs_write.rb +5 -3
- data/lib/brute/tools/shell.rb +3 -1
- data/lib/brute/tools/skill_load.rb +156 -0
- data/lib/brute/tools/sub_agent.rb +118 -0
- data/lib/brute/tools/todo_list/store.rb +36 -0
- data/lib/brute/tools/todo_read.rb +1 -1
- data/lib/brute/tools/todo_write.rb +1 -1
- data/lib/brute/tools.rb +2 -1
- data/lib/brute/truncation.rb +3 -1
- data/lib/brute/turn/agent_pipeline.rb +168 -0
- data/lib/brute/turn/pipeline.rb +95 -0
- data/lib/brute/turn/tool_pipeline.rb +106 -0
- data/lib/brute/utils/diff.rb +3 -1
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +46 -71
- data/lib/{brute → brute_cli}/providers/shell.rb +4 -1
- data/lib/{brute → brute_cli}/providers/shell_response.rb +3 -5
- data/lib/ruby_llm/message_transport.rb +117 -0
- metadata +32 -28
- data/lib/brute/agent.rb +0 -82
- data/lib/brute/middleware/003_tool_result_loop.rb +0 -103
- data/lib/brute/middleware/100_llm_call.rb +0 -63
- data/lib/brute/pipeline.rb +0 -97
- data/lib/brute/queue/file_mutation_queue.rb +0 -102
- data/lib/brute/session.rb +0 -52
- data/lib/brute/store/snapshot_store.rb +0 -36
- data/lib/brute/store/todo_store.rb +0 -30
- data/lib/brute/sub_agent.rb +0 -106
- data/lib/brute/tool.rb +0 -107
data/lib/brute/skill.rb
CHANGED
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
|
|
5
|
+
require "bundler/setup"
|
|
6
|
+
require "brute"
|
|
7
|
+
|
|
5
8
|
module Brute
|
|
6
|
-
# Discovers and loads SKILL.md files from standard directories.
|
|
9
|
+
# Discovers, validates, and loads SKILL.md files from standard directories.
|
|
7
10
|
#
|
|
8
|
-
# A skill is a markdown file with YAML
|
|
11
|
+
# A skill is a directory containing a SKILL.md markdown file with YAML
|
|
12
|
+
# frontmatter:
|
|
9
13
|
#
|
|
10
14
|
# ---
|
|
11
15
|
# name: debugging
|
|
@@ -18,14 +22,28 @@ module Brute
|
|
|
18
22
|
# 1. .brute/skills/**/SKILL.md (project-local)
|
|
19
23
|
# 2. ~/.config/brute/skills/**/SKILL.md (global)
|
|
20
24
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
25
|
+
# Parsing and validation mirror the Agent Skills specification
|
|
26
|
+
# (https://agentskills.io/specification) and its reference validator
|
|
27
|
+
# (https://github.com/agentskills/agentskills/tree/main/skills-ref). A skill
|
|
28
|
+
# whose frontmatter violates a rule is skipped with a stderr warning naming
|
|
29
|
+
# the violated rule, never raised.
|
|
23
30
|
#
|
|
24
31
|
module Skill
|
|
25
|
-
Info = Struct.new(
|
|
32
|
+
Info = Struct.new(
|
|
33
|
+
:name, :description, :location, :content,
|
|
34
|
+
:license, :compatibility, :metadata, :allowed_tools,
|
|
35
|
+
keyword_init: true,
|
|
36
|
+
)
|
|
26
37
|
|
|
27
38
|
FILENAME = "SKILL.md"
|
|
28
39
|
|
|
40
|
+
# Frontmatter keys permitted by the spec. Anything else is a violation.
|
|
41
|
+
ALLOWED_FIELDS = %w[name description license allowed-tools metadata compatibility].freeze
|
|
42
|
+
|
|
43
|
+
MAX_NAME_LENGTH = 64
|
|
44
|
+
MAX_DESCRIPTION_LENGTH = 1024
|
|
45
|
+
MAX_COMPATIBILITY_LENGTH = 500
|
|
46
|
+
|
|
29
47
|
# Scan all skill directories and return an array of Info structs.
|
|
30
48
|
def self.all(cwd: Dir.pwd)
|
|
31
49
|
skills = {}
|
|
@@ -62,28 +80,94 @@ module Brute
|
|
|
62
80
|
lines.join("\n")
|
|
63
81
|
end
|
|
64
82
|
|
|
65
|
-
# Parse a SKILL.md file into an Info struct.
|
|
66
|
-
# Returns nil if the file is invalid
|
|
83
|
+
# Parse and validate a SKILL.md file into an Info struct.
|
|
84
|
+
# Returns nil (with a stderr warning) if the file is invalid.
|
|
67
85
|
def self.load(path)
|
|
68
86
|
raw = File.read(path)
|
|
69
|
-
frontmatter, content = parse_frontmatter(raw)
|
|
87
|
+
frontmatter, content = parse_frontmatter(path, raw)
|
|
70
88
|
return nil unless frontmatter
|
|
71
89
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
90
|
+
dir_name = File.basename(File.dirname(path))
|
|
91
|
+
# Spec requires `name`; brute keeps the convenience of defaulting to the
|
|
92
|
+
# directory name when omitted (which trivially satisfies the dir-match rule).
|
|
93
|
+
frontmatter = { "name" => dir_name }.merge(frontmatter) unless frontmatter.key?("name")
|
|
94
|
+
|
|
95
|
+
# Unknown fields are a soft violation: warn and drop them rather than
|
|
96
|
+
# reject the skill. The reference validator hard-fails here, but a runtime
|
|
97
|
+
# loader must tolerate vendor/forward extensions (e.g. `tags`), which real
|
|
98
|
+
# published skills carry, instead of silently dropping the whole skill.
|
|
99
|
+
extra = frontmatter.keys - ALLOWED_FIELDS
|
|
100
|
+
warn "Skill #{path} has unexpected frontmatter fields (ignored): #{extra.sort.join(', ')}" unless extra.empty?
|
|
101
|
+
|
|
102
|
+
errors = validate(frontmatter, dir_name)
|
|
103
|
+
unless errors.empty?
|
|
104
|
+
warn "Skipping invalid skill #{path}: #{errors.join('; ')}"
|
|
105
|
+
return nil
|
|
106
|
+
end
|
|
75
107
|
|
|
76
108
|
Info.new(
|
|
77
|
-
name: name.to_s.strip,
|
|
78
|
-
description: description.to_s.strip,
|
|
109
|
+
name: frontmatter["name"].to_s.strip,
|
|
110
|
+
description: frontmatter["description"].to_s.strip,
|
|
79
111
|
location: path,
|
|
80
112
|
content: content.to_s.strip,
|
|
113
|
+
license: frontmatter["license"]&.to_s,
|
|
114
|
+
compatibility: frontmatter["compatibility"]&.to_s,
|
|
115
|
+
metadata: frontmatter["metadata"],
|
|
116
|
+
allowed_tools: parse_allowed_tools(frontmatter["allowed-tools"]),
|
|
81
117
|
)
|
|
82
118
|
rescue => e
|
|
83
119
|
warn "Failed to load skill #{path}: #{e.message}"
|
|
84
120
|
nil
|
|
85
121
|
end
|
|
86
122
|
|
|
123
|
+
# Validate frontmatter against the spec. Returns an array of error strings
|
|
124
|
+
# (empty means valid), each naming the violated rule.
|
|
125
|
+
def self.validate(frontmatter, dir_name)
|
|
126
|
+
errors = []
|
|
127
|
+
|
|
128
|
+
errors.concat(validate_name(frontmatter["name"], dir_name))
|
|
129
|
+
errors.concat(validate_description(frontmatter["description"]))
|
|
130
|
+
|
|
131
|
+
if frontmatter.key?("compatibility")
|
|
132
|
+
compatibility = frontmatter["compatibility"]
|
|
133
|
+
if !compatibility.is_a?(String)
|
|
134
|
+
errors << "'compatibility' must be a string"
|
|
135
|
+
elsif compatibility.length > MAX_COMPATIBILITY_LENGTH
|
|
136
|
+
errors << "'compatibility' exceeds #{MAX_COMPATIBILITY_LENGTH} characters"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
errors
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.validate_name(name, dir_name)
|
|
144
|
+
return ["missing required field 'name'"] unless name.is_a?(String) && !name.strip.empty?
|
|
145
|
+
|
|
146
|
+
name = name.strip.unicode_normalize(:nfkc)
|
|
147
|
+
errors = []
|
|
148
|
+
errors << "'name' exceeds #{MAX_NAME_LENGTH} characters" if name.length > MAX_NAME_LENGTH
|
|
149
|
+
errors << "'name' must be lowercase" if name != name.downcase
|
|
150
|
+
errors << "'name' cannot start or end with a hyphen" if name.start_with?("-") || name.end_with?("-")
|
|
151
|
+
errors << "'name' cannot contain consecutive hyphens" if name.include?("--")
|
|
152
|
+
errors << "'name' may only contain letters, digits, and hyphens" unless name.match?(/\A[\p{Alnum}\-]+\z/)
|
|
153
|
+
errors << "directory name '#{dir_name}' must match skill name '#{name}'" if dir_name.unicode_normalize(:nfkc) != name
|
|
154
|
+
errors
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def self.validate_description(description)
|
|
158
|
+
return ["missing required field 'description'"] unless description.is_a?(String) && !description.strip.empty?
|
|
159
|
+
return ["'description' exceeds #{MAX_DESCRIPTION_LENGTH} characters"] if description.length > MAX_DESCRIPTION_LENGTH
|
|
160
|
+
|
|
161
|
+
[]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# `allowed-tools` is an experimental, space-separated list of tool names.
|
|
165
|
+
def self.parse_allowed_tools(value)
|
|
166
|
+
return nil if value.nil?
|
|
167
|
+
|
|
168
|
+
value.to_s.split(/\s+/).reject(&:empty?)
|
|
169
|
+
end
|
|
170
|
+
|
|
87
171
|
# Directories to scan for skills, in priority order.
|
|
88
172
|
def self.scan_dirs(cwd)
|
|
89
173
|
dirs = []
|
|
@@ -101,7 +185,7 @@ module Brute
|
|
|
101
185
|
|
|
102
186
|
# Split YAML frontmatter from markdown body.
|
|
103
187
|
# Returns [hash, string] or [nil, nil].
|
|
104
|
-
def self.parse_frontmatter(raw)
|
|
188
|
+
def self.parse_frontmatter(path, raw)
|
|
105
189
|
return [nil, nil] unless raw.start_with?("---")
|
|
106
190
|
|
|
107
191
|
parts = raw.split(/^---\s*$/, 3)
|
|
@@ -111,8 +195,101 @@ module Brute
|
|
|
111
195
|
return [nil, nil] unless frontmatter.is_a?(Hash)
|
|
112
196
|
|
|
113
197
|
[frontmatter, parts[2]]
|
|
198
|
+
rescue => e
|
|
199
|
+
warn "Failed to parse frontmatter in #{path}: #{e.message}"
|
|
200
|
+
[nil, nil]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private_class_method :scan_dirs, :parse_frontmatter,
|
|
204
|
+
:validate, :validate_name, :validate_description, :parse_allowed_tools
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
__END__
|
|
209
|
+
|
|
210
|
+
describe "brute/skill" do
|
|
211
|
+
require "tmpdir"
|
|
212
|
+
require "fileutils"
|
|
213
|
+
|
|
214
|
+
def make_skill_dir(root, dir_name, frontmatter, body: "Body.")
|
|
215
|
+
dir = File.join(root, ".brute", "skills", dir_name)
|
|
216
|
+
FileUtils.mkdir_p(dir)
|
|
217
|
+
path = File.join(dir, "SKILL.md")
|
|
218
|
+
File.write(path, "---\n#{frontmatter}\n---\n\n#{body}\n")
|
|
219
|
+
path
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
it "loads a valid skill" do
|
|
223
|
+
Dir.mktmpdir do |root|
|
|
224
|
+
path = make_skill_dir(root, "debugging", "name: debugging\ndescription: Debug things")
|
|
225
|
+
info = Brute::Skill.load(path)
|
|
226
|
+
info.name.should == "debugging"
|
|
227
|
+
info.description.should == "Debug things"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
it "defaults the name to the directory when omitted" do
|
|
232
|
+
Dir.mktmpdir do |root|
|
|
233
|
+
path = make_skill_dir(root, "deploy", "description: Deploy things")
|
|
234
|
+
Brute::Skill.load(path).name.should == "deploy"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
it "skips a skill whose name does not match its directory" do
|
|
239
|
+
Dir.mktmpdir do |root|
|
|
240
|
+
path = make_skill_dir(root, "deploy", "name: shipit\ndescription: Ship it")
|
|
241
|
+
Brute::Skill.load(path).should.be.nil
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
it "skips a skill with an uppercase name" do
|
|
246
|
+
Dir.mktmpdir do |root|
|
|
247
|
+
path = make_skill_dir(root, "Deploy", "name: Deploy\ndescription: Deploy")
|
|
248
|
+
Brute::Skill.load(path).should.be.nil
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
it "skips a skill with consecutive hyphens in the name" do
|
|
253
|
+
Dir.mktmpdir do |root|
|
|
254
|
+
path = make_skill_dir(root, "a--b", "name: a--b\ndescription: nope")
|
|
255
|
+
Brute::Skill.load(path).should.be.nil
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
it "skips a skill without a description" do
|
|
260
|
+
Dir.mktmpdir do |root|
|
|
261
|
+
path = make_skill_dir(root, "nodesc", "name: nodesc")
|
|
262
|
+
Brute::Skill.load(path).should.be.nil
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
it "loads a skill with an unexpected frontmatter field, dropping the extra" do
|
|
267
|
+
Dir.mktmpdir do |root|
|
|
268
|
+
path = make_skill_dir(root, "extra", "name: extra\ndescription: x\ntags: [a, b]")
|
|
269
|
+
info = Brute::Skill.load(path)
|
|
270
|
+
info.name.should == "extra"
|
|
271
|
+
info.respond_to?(:tags).should.be.false
|
|
114
272
|
end
|
|
273
|
+
end
|
|
115
274
|
|
|
116
|
-
|
|
275
|
+
it "parses optional spec fields" do
|
|
276
|
+
Dir.mktmpdir do |root|
|
|
277
|
+
path = make_skill_dir(
|
|
278
|
+
root, "full",
|
|
279
|
+
"name: full\ndescription: x\nlicense: MIT\ncompatibility: claude\nallowed-tools: read shell\nmetadata:\n team: core",
|
|
280
|
+
)
|
|
281
|
+
info = Brute::Skill.load(path)
|
|
282
|
+
info.license.should == "MIT"
|
|
283
|
+
info.compatibility.should == "claude"
|
|
284
|
+
info.allowed_tools.should == %w[read shell]
|
|
285
|
+
info.metadata.should == { "team" => "core" }
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
it "skips a skill whose description exceeds the length bound" do
|
|
290
|
+
Dir.mktmpdir do |root|
|
|
291
|
+
path = make_skill_dir(root, "long", "name: long\ndescription: #{"x" * 1025}")
|
|
292
|
+
Brute::Skill.load(path).should.be.nil
|
|
293
|
+
end
|
|
117
294
|
end
|
|
118
295
|
end
|
data/lib/brute/system_prompt.rb
CHANGED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Tools
|
|
8
|
+
# Normalizes any tool shape into one neutral interface so the rest of
|
|
9
|
+
# Brute never has to care which tools library (if any) a tool was
|
|
10
|
+
# written with.
|
|
11
|
+
#
|
|
12
|
+
# This solves three problems:
|
|
13
|
+
#
|
|
14
|
+
# 1. Using any tools library — anything that quacks like a tool
|
|
15
|
+
# (RubyLLM::Tool today, others via their own adapters) is wrapped
|
|
16
|
+
# into the same interface.
|
|
17
|
+
# 2. Avoiding tool libraries entirely — Brute::Turn::ToolPipeline and
|
|
18
|
+
# Tools::SubAgent work without inheriting from a library class.
|
|
19
|
+
# 3. Quickly adding tools — a plain Hash with a proc is enough:
|
|
20
|
+
#
|
|
21
|
+
# Brute::Tools::Adapter.wrap(
|
|
22
|
+
# name: "echo",
|
|
23
|
+
# description: "Echo the input back",
|
|
24
|
+
# params: { msg: { type: "string", required: true } },
|
|
25
|
+
# execute: ->(msg:) { msg },
|
|
26
|
+
# )
|
|
27
|
+
#
|
|
28
|
+
# The neutral interface:
|
|
29
|
+
#
|
|
30
|
+
# adapter.name # String
|
|
31
|
+
# adapter.description # String
|
|
32
|
+
# adapter.params # { key => { type:, desc:, required: } }
|
|
33
|
+
# adapter.call(args) # execute with a (string- or symbol-keyed) Hash
|
|
34
|
+
#
|
|
35
|
+
# Completion middlewares convert adapters into whatever their LLM
|
|
36
|
+
# library expects (e.g. #to_ruby_llm); ToolPipeline executes them via #call.
|
|
37
|
+
#
|
|
38
|
+
class Adapter
|
|
39
|
+
attr_reader :name, :description, :params
|
|
40
|
+
|
|
41
|
+
# Wrap a single tool of any supported shape. Idempotent.
|
|
42
|
+
def self.wrap(tool)
|
|
43
|
+
return tool if tool.is_a?(Adapter)
|
|
44
|
+
|
|
45
|
+
tool = tool.new if tool.is_a?(Class)
|
|
46
|
+
|
|
47
|
+
case tool
|
|
48
|
+
when Hash then from_hash(tool)
|
|
49
|
+
when ::RubyLLM::Tool then from_ruby_llm(tool)
|
|
50
|
+
when Brute::Tools::SubAgent then new(
|
|
51
|
+
name: tool.name,
|
|
52
|
+
description: tool.description,
|
|
53
|
+
params: tool.params,
|
|
54
|
+
handler: ->(**args) { tool.execute(args) },
|
|
55
|
+
original: tool,
|
|
56
|
+
)
|
|
57
|
+
when Brute::Turn::ToolPipeline then new(
|
|
58
|
+
name: tool.name,
|
|
59
|
+
description: tool.description,
|
|
60
|
+
params: tool.params,
|
|
61
|
+
handler: ->(**args) { tool.call(**args) },
|
|
62
|
+
original: tool,
|
|
63
|
+
)
|
|
64
|
+
else
|
|
65
|
+
from_duck_type(tool)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Wrap a list of tools into a { name_sym => adapter } lookup hash —
|
|
70
|
+
# the shape ToolPipeline works with.
|
|
71
|
+
def self.wrap_all(tools)
|
|
72
|
+
Array(tools).each_with_object({}) do |tool, hash|
|
|
73
|
+
adapter = wrap(tool)
|
|
74
|
+
hash[adapter.name.to_sym] = adapter
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Quick inline tool: { name:, description:, params:, execute: }
|
|
79
|
+
def self.from_hash(definition)
|
|
80
|
+
definition = definition.transform_keys(&:to_sym)
|
|
81
|
+
handler = definition.fetch(:execute) { definition[:handler] }
|
|
82
|
+
raise ArgumentError, "inline tool needs an :execute proc" unless handler.respond_to?(:call)
|
|
83
|
+
|
|
84
|
+
new(
|
|
85
|
+
name: definition.fetch(:name).to_s,
|
|
86
|
+
description: definition.fetch(:description, ""),
|
|
87
|
+
params: definition.fetch(:params, {}),
|
|
88
|
+
handler: ->(**args) { handler.call(**args) },
|
|
89
|
+
original: definition,
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A RubyLLM::Tool instance (the library's own arg normalization and
|
|
94
|
+
# validation stays in play via tool.call). Tools declared with the
|
|
95
|
+
# params(...) schema DSL keep their full JSON schema.
|
|
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
|
+
|
|
101
|
+
new(
|
|
102
|
+
name: tool.name.to_s,
|
|
103
|
+
description: tool.description,
|
|
104
|
+
params: params,
|
|
105
|
+
schema: (tool.params_schema if tool.respond_to?(:params_schema)),
|
|
106
|
+
handler: ->(**args) { tool.call(args) },
|
|
107
|
+
original: tool,
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Anything tool-shaped: needs #name and #call or #execute. Honors
|
|
112
|
+
# #to_ruby_llm for backward compatibility with existing adapters.
|
|
113
|
+
def self.from_duck_type(tool)
|
|
114
|
+
return from_ruby_llm(tool.to_ruby_llm) if tool.respond_to?(:to_ruby_llm)
|
|
115
|
+
|
|
116
|
+
unless tool.respond_to?(:name) && (tool.respond_to?(:call) || tool.respond_to?(:execute))
|
|
117
|
+
raise ArgumentError, "don't know how to adapt #{tool.inspect} into a tool"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
entry = tool.respond_to?(:execute) ? tool.method(:execute) : tool.method(:call)
|
|
121
|
+
new(
|
|
122
|
+
name: tool.name.to_s,
|
|
123
|
+
description: tool.respond_to?(:description) ? tool.description : "",
|
|
124
|
+
params: tool.respond_to?(:params) ? tool.params : {},
|
|
125
|
+
handler: ->(**args) { entry.call(**args) },
|
|
126
|
+
original: tool,
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def initialize(name:, description:, params:, handler:, schema: nil, original: nil)
|
|
131
|
+
@name = name
|
|
132
|
+
@description = description
|
|
133
|
+
@params = params || {}
|
|
134
|
+
@schema = schema
|
|
135
|
+
@handler = handler
|
|
136
|
+
@original = original
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The tool object this adapter wraps (RubyLLM::Tool, Brute::Turn::ToolPipeline,
|
|
140
|
+
# SubAgent, Hash definition, ...).
|
|
141
|
+
attr_reader :original
|
|
142
|
+
|
|
143
|
+
# Execute the tool. Accepts string- or symbol-keyed argument hashes,
|
|
144
|
+
# as delivered by LLM providers.
|
|
145
|
+
def call(arguments = {})
|
|
146
|
+
args = arguments.to_h.transform_keys(&:to_sym)
|
|
147
|
+
@handler.call(**args)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Convert to a RubyLLM::Tool so ruby_llm-backed completion can hand
|
|
151
|
+
# the tool to its providers. Returns the wrapped tool untouched when
|
|
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.
|
|
167
|
+
def to_h
|
|
168
|
+
return { name: @name, description: @description, parameters: @schema.deep_symbolize_keys } if @schema
|
|
169
|
+
|
|
170
|
+
properties = @params.transform_values do |opts|
|
|
171
|
+
{
|
|
172
|
+
type: opts[:type] || "string",
|
|
173
|
+
description: opts[:desc] || opts[:description],
|
|
174
|
+
items: opts[:items],
|
|
175
|
+
enum: opts[:enum],
|
|
176
|
+
}.compact
|
|
177
|
+
end
|
|
178
|
+
required = @params.select { |_k, opts| opts[:required] }.keys
|
|
179
|
+
|
|
180
|
+
{
|
|
181
|
+
name: @name,
|
|
182
|
+
description: @description,
|
|
183
|
+
parameters: {
|
|
184
|
+
type: "object",
|
|
185
|
+
properties: properties,
|
|
186
|
+
required: required.map(&:to_s),
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
__END__
|
|
195
|
+
|
|
196
|
+
describe "brute/tools/adapter" do
|
|
197
|
+
it "wraps a RubyLLM::Tool class" do
|
|
198
|
+
klass = Class.new(::RubyLLM::Tool) do
|
|
199
|
+
description "test tool"
|
|
200
|
+
param :input, type: "string", desc: "the input"
|
|
201
|
+
def name; "rl_tool"; end
|
|
202
|
+
def execute(input:); "got #{input}"; end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
adapter = Brute::Tools::Adapter.wrap(klass)
|
|
206
|
+
adapter.name.should == "rl_tool"
|
|
207
|
+
adapter.description.should == "test tool"
|
|
208
|
+
adapter.params[:input][:type].should == "string"
|
|
209
|
+
adapter.call("input" => "x").should == "got x"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
it "wraps a Brute::Turn::ToolPipeline" do
|
|
213
|
+
t = Brute::Turn::ToolPipeline.new(name: "echo", description: "echo input") do
|
|
214
|
+
run ->(env) { env[:result] = env[:arguments][:msg] }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
adapter = Brute::Tools::Adapter.wrap(t)
|
|
218
|
+
adapter.name.should == "echo"
|
|
219
|
+
adapter.call(msg: "hi").should == "hi"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
it "wraps a SubAgent" do
|
|
223
|
+
sa = Brute::Tools::SubAgent.new(name: "research", description: "test") do
|
|
224
|
+
run ->(env) { env[:messages].assistant("result text") }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
adapter = Brute::Tools::Adapter.wrap(sa)
|
|
228
|
+
adapter.name.should == "research"
|
|
229
|
+
adapter.call(task: "go").should == "result text"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
it "wraps an inline hash definition" do
|
|
233
|
+
adapter = Brute::Tools::Adapter.wrap(
|
|
234
|
+
name: "adder",
|
|
235
|
+
description: "Add two numbers",
|
|
236
|
+
params: { a: { type: "number", required: true }, b: { type: "number", required: true } },
|
|
237
|
+
execute: ->(a:, b:) { a + b },
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
adapter.name.should == "adder"
|
|
241
|
+
adapter.call(a: 1, b: 2).should == 3
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
it "is idempotent" do
|
|
245
|
+
adapter = Brute::Tools::Adapter.wrap(name: "x", description: "", execute: -> {})
|
|
246
|
+
Brute::Tools::Adapter.wrap(adapter).should == adapter
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it "wrap_all keys adapters by name symbol" do
|
|
250
|
+
tools = Brute::Tools::Adapter.wrap_all([
|
|
251
|
+
{ name: "a", description: "", execute: -> {} },
|
|
252
|
+
{ name: "b", description: "", execute: -> {} },
|
|
253
|
+
])
|
|
254
|
+
tools.keys.should == [:a, :b]
|
|
255
|
+
tools[:a].should.be.kind_of?(Brute::Tools::Adapter)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it "converts to a RubyLLM::Tool" do
|
|
259
|
+
adapter = Brute::Tools::Adapter.wrap(
|
|
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
|
|
274
|
+
description "test tool"
|
|
275
|
+
def name; "original"; end
|
|
276
|
+
def execute; "ok"; end
|
|
277
|
+
end
|
|
278
|
+
instance = klass.new
|
|
279
|
+
|
|
280
|
+
Brute::Tools::Adapter.wrap(instance).to_ruby_llm.should == instance
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
it "produces a neutral JSON-schema-ish definition" do
|
|
284
|
+
adapter = Brute::Tools::Adapter.wrap(
|
|
285
|
+
name: "echo",
|
|
286
|
+
description: "Echo",
|
|
287
|
+
params: { msg: { type: "string", desc: "message", required: true } },
|
|
288
|
+
execute: ->(msg:) { msg },
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
defn = adapter.to_h
|
|
292
|
+
defn[:name].should == "echo"
|
|
293
|
+
defn[:parameters][:properties][:msg][:type].should == "string"
|
|
294
|
+
defn[:parameters][:required].should == ["msg"]
|
|
295
|
+
end
|
|
296
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Tools
|
|
8
|
+
# @namespace
|
|
9
|
+
module FS
|
|
10
|
+
# Per-file serialization queue for concurrent tool execution.
|
|
11
|
+
#
|
|
12
|
+
# When tools run in parallel (via threads or async fibers), multiple tools
|
|
13
|
+
# may target the same file simultaneously. Without serialization, a sequence
|
|
14
|
+
# like [read → patch → write] on the same file would race and lose edits.
|
|
15
|
+
#
|
|
16
|
+
# This module provides a single public method:
|
|
17
|
+
#
|
|
18
|
+
# Brute::Tools::FS::FileMutationQueue.serialize("/path/to/file") do
|
|
19
|
+
# # snapshot + read + modify + write — all atomic for this path
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# Design (mirrors pi-mono's withFileMutationQueue):
|
|
23
|
+
# - Operations on the SAME file are serialized (run one at a time)
|
|
24
|
+
# - Operations on DIFFERENT files run fully in parallel (independent mutexes)
|
|
25
|
+
# - Symlink-aware: resolves real paths so aliases share one mutex
|
|
26
|
+
# - Error-safe: mutex is always released in `ensure`, so failures never deadlock
|
|
27
|
+
# - Self-cleaning: per-file mutexes are removed when no longer in use
|
|
28
|
+
#
|
|
29
|
+
# Ruby 3.4's Mutex is fiber-scheduler-aware, so this works correctly with
|
|
30
|
+
# both :thread and :task (Async) concurrency strategies.
|
|
31
|
+
#
|
|
32
|
+
module FileMutationQueue
|
|
33
|
+
@mutexes = {} # path → Mutex
|
|
34
|
+
@waiters = Hash.new(0) # path → number of threads/fibers waiting or holding
|
|
35
|
+
@guard = Mutex.new # protects @mutexes and @waiters
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Serialize a block of work for a given file path.
|
|
39
|
+
#
|
|
40
|
+
# Concurrent calls targeting the same canonical path will execute
|
|
41
|
+
# sequentially in FIFO order. Calls targeting different paths
|
|
42
|
+
# proceed in parallel with zero contention.
|
|
43
|
+
#
|
|
44
|
+
# @parameter path [String] The file path to serialize on.
|
|
45
|
+
# @yields {block} The mutation work to perform (snapshot, read, write, etc.)
|
|
46
|
+
# @returns Whatever the block returns.
|
|
47
|
+
def serialize(path, &block)
|
|
48
|
+
key = canonical_path(path)
|
|
49
|
+
mutex = acquire_mutex(key)
|
|
50
|
+
|
|
51
|
+
mutex.synchronize(&block)
|
|
52
|
+
ensure
|
|
53
|
+
release_mutex(key)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Clear all tracked mutexes. Used in tests and session resets.
|
|
57
|
+
def clear!
|
|
58
|
+
@guard.synchronize do
|
|
59
|
+
@mutexes.clear
|
|
60
|
+
@waiters.clear
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Number of file paths currently tracked (for diagnostics).
|
|
65
|
+
def size
|
|
66
|
+
@guard.synchronize { @mutexes.size }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Resolve a file path to a canonical key.
|
|
72
|
+
# Uses File.realpath to follow symlinks so that aliases to the
|
|
73
|
+
# same underlying file share one mutex. Falls back to
|
|
74
|
+
# File.expand_path for files that don't exist yet (e.g., new writes).
|
|
75
|
+
def canonical_path(path)
|
|
76
|
+
resolved = File.expand_path(path)
|
|
77
|
+
begin
|
|
78
|
+
File.realpath(resolved)
|
|
79
|
+
rescue Errno::ENOENT
|
|
80
|
+
resolved
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Get (or create) a mutex for a file path and increment the waiter count.
|
|
85
|
+
def acquire_mutex(key)
|
|
86
|
+
@guard.synchronize do
|
|
87
|
+
@mutexes[key] ||= Mutex.new
|
|
88
|
+
@waiters[key] += 1
|
|
89
|
+
@mutexes[key]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Decrement the waiter count and clean up the mutex if no one else needs it.
|
|
94
|
+
def release_mutex(key)
|
|
95
|
+
@guard.synchronize do
|
|
96
|
+
@waiters[key] -= 1
|
|
97
|
+
if @waiters[key] <= 0
|
|
98
|
+
@mutexes.delete(key)
|
|
99
|
+
@waiters.delete(key)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|