brute 2.0.6 → 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.
Files changed (89) 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/message_transport/anthropic.rb +143 -0
  6. data/lib/brute/message_transport/llm.rb +94 -0
  7. data/lib/brute/message_transport/openai.rb +113 -0
  8. data/lib/brute/message_transport/ruby_llm.rb +78 -0
  9. data/lib/brute/message_transport.rb +133 -0
  10. data/lib/brute/messages.rb +120 -0
  11. data/lib/brute/middleware/001_otel_span.rb +4 -2
  12. data/lib/brute/middleware/002_session_log.rb +100 -0
  13. data/lib/brute/middleware/004_summarize.rb +21 -19
  14. data/lib/brute/middleware/006_loop.rb +158 -0
  15. data/lib/brute/middleware/010_max_iterations.rb +9 -7
  16. data/lib/brute/middleware/015_otel_token_usage.rb +3 -1
  17. data/lib/brute/middleware/020_system_prompt.rb +6 -4
  18. data/lib/brute/middleware/040_compaction_check.rb +5 -3
  19. data/lib/brute/middleware/060_questions.rb +3 -1
  20. data/lib/brute/middleware/{070_tool_call.rb → 070_tool_pipeline.rb} +60 -46
  21. data/lib/brute/middleware/073_otel_tool_call.rb +3 -1
  22. data/lib/brute/middleware/075_otel_tool_results.rb +3 -1
  23. data/lib/brute/middleware/event_handler.rb +3 -1
  24. data/lib/brute/middleware/user_queue.rb +3 -1
  25. data/lib/brute/prompts/autonomy.rb +3 -0
  26. data/lib/brute/prompts/base.rb +3 -0
  27. data/lib/brute/prompts/build_switch.rb +3 -1
  28. data/lib/brute/prompts/code_references.rb +3 -0
  29. data/lib/brute/prompts/code_style.rb +3 -0
  30. data/lib/brute/prompts/conventions.rb +3 -0
  31. data/lib/brute/prompts/doing_tasks.rb +3 -0
  32. data/lib/brute/prompts/editing_approach.rb +3 -0
  33. data/lib/brute/prompts/editing_constraints.rb +3 -0
  34. data/lib/brute/prompts/environment.rb +3 -1
  35. data/lib/brute/prompts/frontend_tasks.rb +3 -0
  36. data/lib/brute/prompts/git_safety.rb +3 -0
  37. data/lib/brute/prompts/identity.rb +3 -1
  38. data/lib/brute/prompts/instructions.rb +3 -1
  39. data/lib/brute/prompts/max_steps.rb +3 -1
  40. data/lib/brute/prompts/objectivity.rb +3 -0
  41. data/lib/brute/prompts/plan_reminder.rb +3 -1
  42. data/lib/brute/prompts/proactiveness.rb +3 -0
  43. data/lib/brute/prompts/security_and_safety.rb +3 -0
  44. data/lib/brute/prompts/skills.rb +6 -2
  45. data/lib/brute/prompts/task_management.rb +3 -0
  46. data/lib/brute/prompts/tone_and_style.rb +3 -0
  47. data/lib/brute/prompts/tool_usage.rb +3 -0
  48. data/lib/brute/prompts.rb +5 -0
  49. data/lib/brute/rack/adapter.rb +237 -0
  50. data/lib/brute/skill.rb +192 -15
  51. data/lib/brute/system_prompt.rb +3 -1
  52. data/lib/brute/tool.rb +92 -75
  53. data/lib/brute/tools/adapter.rb +258 -0
  54. data/lib/brute/tools/fs/file_mutation_queue.rb +107 -0
  55. data/lib/brute/tools/fs/snapshot_store.rb +41 -0
  56. data/lib/brute/tools/fs_patch.rb +6 -4
  57. data/lib/brute/tools/fs_read.rb +5 -3
  58. data/lib/brute/tools/fs_remove.rb +3 -3
  59. data/lib/brute/tools/fs_search.rb +4 -2
  60. data/lib/brute/tools/fs_undo.rb +3 -3
  61. data/lib/brute/tools/fs_write.rb +6 -4
  62. data/lib/brute/tools/net_fetch.rb +1 -1
  63. data/lib/brute/tools/question.rb +1 -1
  64. data/lib/brute/tools/shell.rb +4 -2
  65. data/lib/brute/tools/skill_load.rb +156 -0
  66. data/lib/brute/tools/sub_agent.rb +101 -0
  67. data/lib/brute/tools/todo_list/store.rb +36 -0
  68. data/lib/brute/tools/todo_read.rb +2 -2
  69. data/lib/brute/tools/todo_write.rb +2 -2
  70. data/lib/brute/tools.rb +2 -1
  71. data/lib/brute/truncation.rb +3 -1
  72. data/lib/brute/turn/agent_pipeline.rb +169 -0
  73. data/lib/brute/turn/pipeline.rb +95 -0
  74. data/lib/brute/turn/tool_pipeline.rb +96 -0
  75. data/lib/brute/utils/diff.rb +3 -1
  76. data/lib/brute/version.rb +1 -1
  77. data/lib/brute.rb +45 -71
  78. data/lib/{brute → brute_cli}/providers/shell.rb +4 -1
  79. data/lib/{brute → brute_cli}/providers/shell_response.rb +9 -15
  80. metadata +52 -43
  81. data/lib/brute/agent.rb +0 -82
  82. data/lib/brute/middleware/003_tool_result_loop.rb +0 -103
  83. data/lib/brute/middleware/100_llm_call.rb +0 -63
  84. data/lib/brute/pipeline.rb +0 -97
  85. data/lib/brute/queue/file_mutation_queue.rb +0 -102
  86. data/lib/brute/session.rb +0 -51
  87. data/lib/brute/store/snapshot_store.rb +0 -36
  88. data/lib/brute/store/todo_store.rb +0 -30
  89. data/lib/brute/sub_agent.rb +0 -106
data/lib/brute/tool.rb CHANGED
@@ -2,106 +2,123 @@
2
2
 
3
3
  require "bundler/setup"
4
4
  require "brute"
5
- require 'brute/pipeline'
6
5
 
7
6
  module Brute
8
- # A Tool is a Pipeline configured for tool execution. The terminal app
9
- # does the work; middleware wraps it with concerns like file mutation
10
- # queueing, snapshotting, validation, logging.
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
11
  #
12
- # Coexists with Brute::Tools::* (which inherit from RubyLLM::Tool).
13
- # Use Brute::Tool when you want middleware. Use RubyLLM::Tool subclasses
14
- # for simple cases.
12
+ # class Shell < Brute::Tool
13
+ # description "Execute a shell command"
14
+ # param :command, type: 'string', desc: "The command", required: true
15
15
  #
16
- # Usage:
16
+ # def name; "shell"; end
17
17
  #
18
- # read = Brute::Tool.new(
19
- # name: "read",
20
- # description: "Read a file's contents",
21
- # params: { file_path: { type: "string", required: true } },
22
- # ) do
23
- # use Brute::Middleware::Tool::ValidateParams
24
- # run ->(env) {
25
- # env[:result] = File.read(File.expand_path(env[:arguments][:file_path]))
26
- # }
18
+ # def execute(command:)
19
+ # ...
20
+ # end
27
21
  # end
28
22
  #
29
- # read.call(file_path: "lib/brute.rb")
23
+ # For tools whose arguments don't fit the flat param list, pass a raw JSON
24
+ # schema instead:
30
25
  #
31
- class Tool < Pipeline
32
- attr_reader :name, :description, :params
33
-
34
- def initialize(name:, description:, params: {}, &block)
35
- @name = name.to_s
36
- @description = description
37
- @params = params
38
- super(&block)
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
39
53
  end
40
54
 
41
- # Execute the tool. Arguments come in as kwargs; result is whatever
42
- # the terminal app puts into env[:result].
43
- def call(events: NullSink.new, **arguments)
44
- env = {
45
- name: @name,
46
- arguments: arguments,
47
- result: nil,
48
- events: events,
49
- metadata: {},
50
- }
51
- super(env)
52
- env[:result]
55
+ # Tool name; subclasses usually override with an explicit short name.
56
+ def name
57
+ self.class.name.demodulize.underscore
53
58
  end
54
59
 
55
- # Adapter so the LLM can call this tool through ruby_llm.
56
- # ToolCall middleware checks for to_ruby_llm and uses it if present.
57
- def to_ruby_llm
58
- tool = self
59
- Class.new(RubyLLM::Tool) do
60
- description tool.description
61
- tool.params.each { |k, opts| param k, **opts }
62
- define_method(:name) { tool.name }
63
- define_method(:execute) { |**args| tool.call(**args) }
64
- end.new
60
+ def description
61
+ self.class.description.to_s
65
62
  end
66
- end
67
- end
68
63
 
69
- test do
70
- it "exposes name, description, params" do
71
- t = Brute::Tool.new(name: "echo", description: "echo input") do
72
- run ->(env) { env[:result] = env[:arguments][:msg] }
64
+ # { key => { type:, desc:, required: } }
65
+ def params
66
+ self.class.param_definitions
73
67
  end
74
68
 
75
- t.name.should == "echo"
76
- t.description.should == "echo input"
77
- t.call(msg: "hi").should == "hi"
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
78
83
  end
84
+ end
85
+
86
+ __END__
79
87
 
80
- it "passes arguments through env to the terminal app" do
81
- captured = nil
82
- t = Brute::Tool.new(name: "x", description: "x") do
83
- run ->(env) { captured = env[:arguments]; env[:result] = nil }
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
84
95
  end
85
96
 
86
- t.call(a: 1, b: 2)
87
- captured.should == { a: 1, b: 2 }
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
88
101
  end
89
102
 
90
- it "runs middleware around the terminal app" do
91
- log = []
92
- wrap = Class.new do
93
- def initialize(app, tag:); @app = app; @tag = tag; end
94
- def call(env); (env[:metadata][:log] ||= []) << "in-#{@tag}"; @app.call(env); env[:metadata][:log] << "out-#{@tag}"; end
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
95
109
  end
96
110
 
97
- t = Brute::Tool.new(name: "x", description: "x") do
98
- use wrap, tag: "a"
99
- run ->(env) { env[:metadata][:log] << "core"; env[:result] = :ok }
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
100
120
  end
101
121
 
102
- # Pre-seed the log on the env that gets built — tool builds its own env,
103
- # so we capture via the middleware metadata channel
104
- result = t.call(input: 1)
105
- result.should == :ok
122
+ klass.new.params_schema[:properties][:a][:type].should == "string"
106
123
  end
107
124
  end
@@ -0,0 +1,258 @@
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
+ # (#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.
18
+ # 3. Quickly adding tools — a plain Hash with a proc is enough:
19
+ #
20
+ # Brute::Tools::Adapter.wrap(
21
+ # name: "echo",
22
+ # description: "Echo the input back",
23
+ # params: { msg: { type: "string", required: true } },
24
+ # execute: ->(msg:) { msg },
25
+ # )
26
+ #
27
+ # The neutral interface:
28
+ #
29
+ # adapter.name # String
30
+ # adapter.description # String
31
+ # adapter.params # { key => { type:, desc:, required: } }
32
+ # adapter.call(args) # execute with a (string- or symbol-keyed) Hash
33
+ #
34
+ # The inline `run` proc converts adapters (via #to_h) into whatever its
35
+ # LLM library expects; ToolPipeline executes them via #call.
36
+ #
37
+ class Adapter
38
+ attr_reader :name, :description, :params
39
+
40
+ # Wrap a single tool of any supported shape. Idempotent.
41
+ def self.wrap(tool)
42
+ return tool if tool.is_a?(Adapter)
43
+
44
+ tool = tool.new if tool.is_a?(Class)
45
+
46
+ case tool
47
+ when Hash then from_hash(tool)
48
+ when ::Brute::Tool then from_brute_tool(tool)
49
+ when Brute::Tools::SubAgent then new(
50
+ name: tool.name,
51
+ description: tool.description,
52
+ params: tool.params,
53
+ handler: ->(**args) { tool.execute(args) },
54
+ original: tool,
55
+ )
56
+ when Brute::Turn::ToolPipeline then new(
57
+ name: tool.name,
58
+ description: tool.description,
59
+ params: tool.params,
60
+ handler: ->(**args) { tool.call(**args) },
61
+ original: tool,
62
+ )
63
+ else
64
+ from_duck_type(tool)
65
+ end
66
+ end
67
+
68
+ # Wrap a list of tools into a { name_sym => adapter } lookup hash —
69
+ # the shape ToolPipeline works with.
70
+ def self.wrap_all(tools)
71
+ Array(tools).each_with_object({}) do |tool, hash|
72
+ adapter = wrap(tool)
73
+ hash[adapter.name.to_sym] = adapter
74
+ end
75
+ end
76
+
77
+ # Quick inline tool: { name:, description:, params:, execute: }
78
+ def self.from_hash(definition)
79
+ definition = definition.transform_keys(&:to_sym)
80
+ handler = definition.fetch(:execute) { definition[:handler] }
81
+ raise ArgumentError, "inline tool needs an :execute proc" unless handler.respond_to?(:call)
82
+
83
+ new(
84
+ name: definition.fetch(:name).to_s,
85
+ description: definition.fetch(:description, ""),
86
+ params: definition.fetch(:params, {}),
87
+ handler: ->(**args) { handler.call(**args) },
88
+ original: definition,
89
+ )
90
+ end
91
+
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)
95
+ new(
96
+ name: tool.name.to_s,
97
+ description: tool.description,
98
+ params: tool.params,
99
+ schema: tool.params_schema,
100
+ handler: ->(**args) { tool.call(args) },
101
+ original: tool,
102
+ )
103
+ end
104
+
105
+ # Anything tool-shaped: needs #name and #call or #execute.
106
+ def self.from_duck_type(tool)
107
+ unless tool.respond_to?(:name) && (tool.respond_to?(:call) || tool.respond_to?(:execute))
108
+ raise ArgumentError, "don't know how to adapt #{tool.inspect} into a tool"
109
+ end
110
+
111
+ entry = tool.respond_to?(:execute) ? tool.method(:execute) : tool.method(:call)
112
+ new(
113
+ name: tool.name.to_s,
114
+ description: tool.respond_to?(:description) ? tool.description : "",
115
+ params: tool.respond_to?(:params) ? tool.params : {},
116
+ handler: ->(**args) { entry.call(**args) },
117
+ original: tool,
118
+ )
119
+ end
120
+
121
+ def initialize(name:, description:, params:, handler:, schema: nil, original: nil)
122
+ @name = name
123
+ @description = description
124
+ @params = params || {}
125
+ @schema = schema
126
+ @handler = handler
127
+ @original = original
128
+ end
129
+
130
+ # The tool object this adapter wraps (Brute::Tool, Brute::Turn::ToolPipeline,
131
+ # SubAgent, Hash definition, ...).
132
+ attr_reader :original
133
+
134
+ # Execute the tool. Accepts string- or symbol-keyed argument hashes,
135
+ # as delivered by LLM providers.
136
+ def call(arguments = {})
137
+ args = arguments.to_h.transform_keys(&:to_sym)
138
+ @handler.call(**args)
139
+ end
140
+
141
+ # Library-neutral tool definition (JSON-Schema-ish). The inline `run`
142
+ # proc reshapes this into whatever its LLM library expects.
143
+ def to_h
144
+ return { name: @name, description: @description, parameters: @schema.deep_symbolize_keys } if @schema
145
+
146
+ properties = @params.transform_values do |opts|
147
+ {
148
+ type: opts[:type] || "string",
149
+ description: opts[:desc] || opts[:description],
150
+ items: opts[:items],
151
+ enum: opts[:enum],
152
+ }.compact
153
+ end
154
+ required = @params.select { |_k, opts| opts[:required] }.keys
155
+
156
+ {
157
+ name: @name,
158
+ description: @description,
159
+ parameters: {
160
+ type: "object",
161
+ properties: properties,
162
+ required: required.map(&:to_s),
163
+ },
164
+ }
165
+ end
166
+ end
167
+ end
168
+ end
169
+
170
+ __END__
171
+
172
+ describe "brute/tools/adapter" do
173
+ it "wraps a Brute::Tool class" do
174
+ klass = Class.new(::Brute::Tool) do
175
+ description "test tool"
176
+ param :input, type: "string", desc: "the input"
177
+ def name; "brute_tool"; end
178
+ def execute(input:); "got #{input}"; end
179
+ end
180
+
181
+ adapter = Brute::Tools::Adapter.wrap(klass)
182
+ adapter.name.should == "brute_tool"
183
+ adapter.description.should == "test tool"
184
+ adapter.params[:input][:type].should == "string"
185
+ adapter.call("input" => "x").should == "got x"
186
+ end
187
+
188
+ it "wraps a Brute::Turn::ToolPipeline" do
189
+ t = Brute::Turn::ToolPipeline.new(name: "echo", description: "echo input") do
190
+ run ->(env) { env[:result] = env[:arguments][:msg] }
191
+ end
192
+
193
+ adapter = Brute::Tools::Adapter.wrap(t)
194
+ adapter.name.should == "echo"
195
+ adapter.call(msg: "hi").should == "hi"
196
+ end
197
+
198
+ it "wraps a SubAgent" do
199
+ sa = Brute::Tools::SubAgent.new(name: "research", description: "test") do
200
+ run ->(env) { env[:messages].assistant("result text") }
201
+ end
202
+
203
+ adapter = Brute::Tools::Adapter.wrap(sa)
204
+ adapter.name.should == "research"
205
+ adapter.call(task: "go").should == "result text"
206
+ end
207
+
208
+ it "wraps an inline hash definition" do
209
+ adapter = Brute::Tools::Adapter.wrap(
210
+ name: "adder",
211
+ description: "Add two numbers",
212
+ params: { a: { type: "number", required: true }, b: { type: "number", required: true } },
213
+ execute: ->(a:, b:) { a + b },
214
+ )
215
+
216
+ adapter.name.should == "adder"
217
+ adapter.call(a: 1, b: 2).should == 3
218
+ end
219
+
220
+ it "is idempotent" do
221
+ adapter = Brute::Tools::Adapter.wrap(name: "x", description: "", execute: -> {})
222
+ Brute::Tools::Adapter.wrap(adapter).should == adapter
223
+ end
224
+
225
+ it "wrap_all keys adapters by name symbol" do
226
+ tools = Brute::Tools::Adapter.wrap_all([
227
+ { name: "a", description: "", execute: -> {} },
228
+ { name: "b", description: "", execute: -> {} },
229
+ ])
230
+ tools.keys.should == [:a, :b]
231
+ tools[:a].should.be.kind_of?(Brute::Tools::Adapter)
232
+ end
233
+
234
+ it "exposes the wrapped original" do
235
+ klass = Class.new(::Brute::Tool) do
236
+ description "test tool"
237
+ def name; "original"; end
238
+ def execute; "ok"; end
239
+ end
240
+ instance = klass.new
241
+
242
+ Brute::Tools::Adapter.wrap(instance).original.should == instance
243
+ end
244
+
245
+ it "produces a neutral JSON-schema-ish definition" do
246
+ adapter = Brute::Tools::Adapter.wrap(
247
+ name: "echo",
248
+ description: "Echo",
249
+ params: { msg: { type: "string", desc: "message", required: true } },
250
+ execute: ->(msg:) { msg },
251
+ )
252
+
253
+ defn = adapter.to_h
254
+ defn[:name].should == "echo"
255
+ defn[:parameters][:properties][:msg][:type].should == "string"
256
+ defn[:parameters][:required].should == ["msg"]
257
+ end
258
+ 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
@@ -0,0 +1,41 @@
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-path stack of file snapshots used by fs_write, fs_patch, fs_remove
11
+ # to enable undo. Each call to .save pushes the current content (or
12
+ # :did_not_exist for new files). .pop retrieves the most recent snapshot.
13
+ module SnapshotStore
14
+ @snapshots = Hash.new { |h, k| h[k] = [] }
15
+ @mutex = Mutex.new
16
+
17
+ class << self
18
+ # Push the current content of +path+ onto the snapshot stack.
19
+ # If the file doesn't exist yet, records +:did_not_exist+.
20
+ def save(path)
21
+ key = File.expand_path(path)
22
+ content = File.exist?(key) ? File.read(key) : :did_not_exist
23
+ @mutex.synchronize { @snapshots[key].push(content) }
24
+ end
25
+
26
+ # Pop and return the most recent snapshot for +path+, or +nil+
27
+ # if there is no history.
28
+ def pop(path)
29
+ key = File.expand_path(path)
30
+ @mutex.synchronize { @snapshots[key].pop }
31
+ end
32
+
33
+ # Clear all snapshots. Used in tests and session resets.
34
+ def clear!
35
+ @mutex.synchronize { @snapshots.clear }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -6,7 +6,7 @@ require "brute/tools"
6
6
 
7
7
  module Brute
8
8
  module Tools
9
- class FSPatch < RubyLLM::Tool
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
 
@@ -19,13 +19,13 @@ module Brute
19
19
 
20
20
  def execute(file_path:, old_string:, new_string:, replace_all: false)
21
21
  path = File.expand_path(file_path)
22
- Brute::Queue::FileMutationQueue.serialize(path) do
22
+ Brute::Tools::FS::FileMutationQueue.serialize(path) do
23
23
  raise "File not found: #{path}" unless File.exist?(path)
24
24
 
25
25
  original = File.read(path)
26
26
  raise "old_string not found in #{path}" unless original.include?(old_string)
27
27
 
28
- Brute::Store::SnapshotStore.save(path)
28
+ Brute::Tools::FS::SnapshotStore.save(path)
29
29
 
30
30
  updated = if replace_all
31
31
  original.gsub(old_string, new_string)
@@ -43,7 +43,9 @@ module Brute
43
43
  end
44
44
  end
45
45
 
46
- test do
46
+ __END__
47
+
48
+ describe "brute/tools/fs_patch" do
47
49
  require "tmpdir"
48
50
 
49
51
  it "replaces old_string with new_string" do
@@ -26,9 +26,9 @@ module Brute
26
26
  # 7. File-not-found suggestions — on miss, scan the parent directory for
27
27
  # similar names and suggest "Did you mean...?" candidates.
28
28
  # 8. Return a plain string instead of a Hash — avoids the .to_s repr
29
- # bloat when ToolCall coerces the result for the LLM message.
29
+ # bloat when ToolPipeline coerces the result for the LLM message.
30
30
  #
31
- class FSRead < RubyLLM::Tool
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
 
@@ -146,7 +146,9 @@ module Brute
146
146
  end
147
147
  end
148
148
 
149
- test do
149
+ __END__
150
+
151
+ describe "brute/tools/fs_read" do
150
152
  require "tmpdir"
151
153
 
152
154
  it "reads a file without error" do
@@ -7,7 +7,7 @@ require "fileutils"
7
7
 
8
8
  module Brute
9
9
  module Tools
10
- class FSRemove < RubyLLM::Tool
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
@@ -16,10 +16,10 @@ module Brute
16
16
 
17
17
  def execute(path:)
18
18
  target = File.expand_path(path)
19
- Brute::Queue::FileMutationQueue.serialize(target) do
19
+ Brute::Tools::FS::FileMutationQueue.serialize(target) do
20
20
  raise "Path not found: #{target}" unless File.exist?(target)
21
21
 
22
- Brute::Store::SnapshotStore.save(target) if File.file?(target)
22
+ Brute::Tools::FS::SnapshotStore.save(target) if File.file?(target)
23
23
 
24
24
  if File.directory?(target)
25
25
  Dir.rmdir(target)