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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/events/handler.rb +3 -1
  3. data/lib/brute/events/prefixed_terminal_output.rb +3 -1
  4. data/lib/brute/events/terminal_output_handler.rb +3 -1
  5. data/lib/brute/messages.rb +47 -0
  6. data/lib/brute/middleware/001_otel_span.rb +4 -2
  7. data/lib/brute/middleware/002_session_log.rb +100 -0
  8. data/lib/brute/middleware/004_summarize.rb +14 -12
  9. data/lib/brute/middleware/006_loop.rb +158 -0
  10. data/lib/brute/middleware/010_max_iterations.rb +8 -6
  11. data/lib/brute/middleware/015_otel_token_usage.rb +3 -1
  12. data/lib/brute/middleware/020_system_prompt.rb +5 -3
  13. data/lib/brute/middleware/040_compaction_check.rb +3 -1
  14. data/lib/brute/middleware/060_questions.rb +3 -1
  15. data/lib/brute/middleware/{070_tool_call.rb → 070_tool_pipeline.rb} +31 -20
  16. data/lib/brute/middleware/073_otel_tool_call.rb +3 -1
  17. data/lib/brute/middleware/075_otel_tool_results.rb +3 -1
  18. data/lib/brute/middleware/event_handler.rb +3 -1
  19. data/lib/brute/middleware/user_queue.rb +3 -1
  20. data/lib/brute/prompts/autonomy.rb +3 -0
  21. data/lib/brute/prompts/base.rb +3 -0
  22. data/lib/brute/prompts/build_switch.rb +3 -1
  23. data/lib/brute/prompts/code_references.rb +3 -0
  24. data/lib/brute/prompts/code_style.rb +3 -0
  25. data/lib/brute/prompts/conventions.rb +3 -0
  26. data/lib/brute/prompts/doing_tasks.rb +3 -0
  27. data/lib/brute/prompts/editing_approach.rb +3 -0
  28. data/lib/brute/prompts/editing_constraints.rb +3 -0
  29. data/lib/brute/prompts/environment.rb +3 -1
  30. data/lib/brute/prompts/frontend_tasks.rb +3 -0
  31. data/lib/brute/prompts/git_safety.rb +3 -0
  32. data/lib/brute/prompts/identity.rb +3 -1
  33. data/lib/brute/prompts/instructions.rb +3 -1
  34. data/lib/brute/prompts/max_steps.rb +3 -1
  35. data/lib/brute/prompts/objectivity.rb +3 -0
  36. data/lib/brute/prompts/plan_reminder.rb +3 -1
  37. data/lib/brute/prompts/proactiveness.rb +3 -0
  38. data/lib/brute/prompts/security_and_safety.rb +3 -0
  39. data/lib/brute/prompts/skills.rb +6 -2
  40. data/lib/brute/prompts/task_management.rb +3 -0
  41. data/lib/brute/prompts/tone_and_style.rb +3 -0
  42. data/lib/brute/prompts/tool_usage.rb +3 -0
  43. data/lib/brute/prompts.rb +5 -0
  44. data/lib/brute/rack/adapter.rb +237 -0
  45. data/lib/brute/skill.rb +192 -15
  46. data/lib/brute/system_prompt.rb +3 -1
  47. data/lib/brute/tools/adapter.rb +296 -0
  48. data/lib/brute/tools/fs/file_mutation_queue.rb +107 -0
  49. data/lib/brute/tools/fs/snapshot_store.rb +41 -0
  50. data/lib/brute/tools/fs_patch.rb +5 -3
  51. data/lib/brute/tools/fs_read.rb +4 -2
  52. data/lib/brute/tools/fs_remove.rb +2 -2
  53. data/lib/brute/tools/fs_search.rb +3 -1
  54. data/lib/brute/tools/fs_undo.rb +2 -2
  55. data/lib/brute/tools/fs_write.rb +5 -3
  56. data/lib/brute/tools/shell.rb +3 -1
  57. data/lib/brute/tools/skill_load.rb +156 -0
  58. data/lib/brute/tools/sub_agent.rb +118 -0
  59. data/lib/brute/tools/todo_list/store.rb +36 -0
  60. data/lib/brute/tools/todo_read.rb +1 -1
  61. data/lib/brute/tools/todo_write.rb +1 -1
  62. data/lib/brute/tools.rb +2 -1
  63. data/lib/brute/truncation.rb +3 -1
  64. data/lib/brute/turn/agent_pipeline.rb +168 -0
  65. data/lib/brute/turn/pipeline.rb +95 -0
  66. data/lib/brute/turn/tool_pipeline.rb +106 -0
  67. data/lib/brute/utils/diff.rb +3 -1
  68. data/lib/brute/version.rb +1 -1
  69. data/lib/brute.rb +46 -71
  70. data/lib/{brute → brute_cli}/providers/shell.rb +4 -1
  71. data/lib/{brute → brute_cli}/providers/shell_response.rb +3 -5
  72. data/lib/ruby_llm/message_transport.rb +117 -0
  73. metadata +32 -28
  74. data/lib/brute/agent.rb +0 -82
  75. data/lib/brute/middleware/003_tool_result_loop.rb +0 -103
  76. data/lib/brute/middleware/100_llm_call.rb +0 -63
  77. data/lib/brute/pipeline.rb +0 -97
  78. data/lib/brute/queue/file_mutation_queue.rb +0 -102
  79. data/lib/brute/session.rb +0 -52
  80. data/lib/brute/store/snapshot_store.rb +0 -36
  81. data/lib/brute/store/todo_store.rb +0 -30
  82. data/lib/brute/sub_agent.rb +0 -106
  83. 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 frontmatter:
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
- # The directory name containing SKILL.md becomes the skill name if frontmatter
22
- # doesn't specify one.
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(:name, :description, :location, :content, keyword_init: true)
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 or missing required fields.
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
- name = frontmatter["name"] || File.basename(File.dirname(path))
73
- description = frontmatter["description"]
74
- return nil unless description && !description.strip.empty?
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
- private_class_method :scan_dirs, :parse_frontmatter
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
@@ -180,6 +180,8 @@ module Brute
180
180
  end
181
181
  end
182
182
 
183
- test do
183
+ __END__
184
+
185
+ describe "brute/system_prompt" do
184
186
  # not implemented
185
187
  end
@@ -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