brute 1.0.0 → 2.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent.rb +72 -6
  3. data/lib/brute/events/handler.rb +69 -0
  4. data/lib/brute/events/prefixed_terminal_output.rb +72 -0
  5. data/lib/brute/events/terminal_output_handler.rb +68 -0
  6. data/lib/brute/middleware/001_otel_span.rb +77 -0
  7. data/lib/brute/middleware/003_tool_result_loop.rb +103 -0
  8. data/lib/brute/middleware/004_summarize.rb +139 -0
  9. data/lib/brute/middleware/005_tracing.rb +86 -0
  10. data/lib/brute/middleware/010_max_iterations.rb +73 -0
  11. data/lib/brute/middleware/015_otel_token_usage.rb +42 -0
  12. data/lib/brute/middleware/020_system_prompt.rb +128 -0
  13. data/lib/brute/middleware/040_compaction_check.rb +155 -0
  14. data/lib/brute/middleware/060_questions.rb +41 -0
  15. data/lib/brute/middleware/070_tool_call.rb +247 -0
  16. data/lib/brute/middleware/073_otel_tool_call.rb +49 -0
  17. data/lib/brute/middleware/075_otel_tool_results.rb +46 -0
  18. data/lib/brute/middleware/100_llm_call.rb +62 -0
  19. data/lib/brute/middleware/event_handler.rb +25 -0
  20. data/lib/brute/middleware/user_queue.rb +35 -0
  21. data/lib/brute/pipeline.rb +44 -107
  22. data/lib/brute/prompts/skills.rb +2 -2
  23. data/lib/brute/prompts.rb +23 -23
  24. data/lib/brute/providers/shell.rb +6 -19
  25. data/lib/brute/providers/shell_response.rb +22 -30
  26. data/lib/brute/session.rb +52 -0
  27. data/lib/brute/store/snapshot_store.rb +21 -37
  28. data/lib/brute/sub_agent.rb +106 -0
  29. data/lib/brute/system_prompt.rb +1 -83
  30. data/lib/brute/tool.rb +107 -0
  31. data/lib/brute/tools/delegate.rb +61 -70
  32. data/lib/brute/tools/fs_patch.rb +9 -7
  33. data/lib/brute/tools/fs_read.rb +233 -20
  34. data/lib/brute/tools/fs_remove.rb +8 -9
  35. data/lib/brute/tools/fs_search.rb +98 -16
  36. data/lib/brute/tools/fs_undo.rb +8 -8
  37. data/lib/brute/tools/fs_write.rb +7 -5
  38. data/lib/brute/tools/net_fetch.rb +8 -8
  39. data/lib/brute/tools/question.rb +36 -24
  40. data/lib/brute/tools/shell.rb +74 -16
  41. data/lib/brute/tools/todo_read.rb +8 -8
  42. data/lib/brute/tools/todo_write.rb +25 -18
  43. data/lib/brute/tools.rb +8 -12
  44. data/lib/brute/truncation.rb +219 -0
  45. data/lib/brute/version.rb +1 -1
  46. data/lib/brute.rb +82 -45
  47. metadata +59 -46
  48. data/lib/brute/loop/agent_stream.rb +0 -118
  49. data/lib/brute/loop/agent_turn.rb +0 -520
  50. data/lib/brute/loop/compactor.rb +0 -107
  51. data/lib/brute/loop/doom_loop.rb +0 -86
  52. data/lib/brute/loop/step.rb +0 -332
  53. data/lib/brute/loop/tool_call_step.rb +0 -90
  54. data/lib/brute/middleware/base.rb +0 -27
  55. data/lib/brute/middleware/compaction_check.rb +0 -106
  56. data/lib/brute/middleware/doom_loop_detection.rb +0 -136
  57. data/lib/brute/middleware/llm_call.rb +0 -128
  58. data/lib/brute/middleware/message_tracking.rb +0 -339
  59. data/lib/brute/middleware/otel/span.rb +0 -105
  60. data/lib/brute/middleware/otel/token_usage.rb +0 -68
  61. data/lib/brute/middleware/otel/tool_calls.rb +0 -68
  62. data/lib/brute/middleware/otel/tool_results.rb +0 -65
  63. data/lib/brute/middleware/otel.rb +0 -34
  64. data/lib/brute/middleware/reasoning_normalizer.rb +0 -192
  65. data/lib/brute/middleware/retry.rb +0 -157
  66. data/lib/brute/middleware/session_persistence.rb +0 -72
  67. data/lib/brute/middleware/token_tracking.rb +0 -124
  68. data/lib/brute/middleware/tool_error_tracking.rb +0 -179
  69. data/lib/brute/middleware/tool_use_guard.rb +0 -133
  70. data/lib/brute/middleware/tracing.rb +0 -124
  71. data/lib/brute/middleware.rb +0 -18
  72. data/lib/brute/orchestrator/turn.rb +0 -105
  73. data/lib/brute/patches/anthropic_tool_role.rb +0 -35
  74. data/lib/brute/patches/buffer_nil_guard.rb +0 -26
  75. data/lib/brute/providers/models_dev.rb +0 -111
  76. data/lib/brute/providers/ollama.rb +0 -135
  77. data/lib/brute/providers/opencode_go.rb +0 -43
  78. data/lib/brute/providers/opencode_zen.rb +0 -87
  79. data/lib/brute/providers.rb +0 -62
  80. data/lib/brute/queue/base_queue.rb +0 -222
  81. data/lib/brute/queue/parallel_queue.rb +0 -66
  82. data/lib/brute/queue/sequential_queue.rb +0 -63
  83. data/lib/brute/store/message_store.rb +0 -362
  84. data/lib/brute/store/session.rb +0 -106
  85. /data/lib/brute/{diff.rb → utils/diff.rb} +0 -0
@@ -5,54 +5,66 @@ require "brute"
5
5
 
6
6
  module Brute
7
7
  module Tools
8
- class Delegate < LLM::Tool
9
- name "delegate"
8
+ class Delegate < RubyLLM::Tool
10
9
  description "Delegate a research or analysis task to a specialist sub-agent. " \
11
10
  "The sub-agent can read files and search but cannot write or execute commands. " \
12
11
  "Use for code analysis, understanding patterns, or gathering information."
13
12
 
14
- param :task, String, "A clear, detailed description of the research task", required: true
13
+ param :task, type: 'string', desc: "A clear, detailed description of the research task", required: true
15
14
 
16
- def call(task:)
17
- provider = Brute.provider
18
- sub = LLM::Context.new(provider, tools: [FSRead, FSSearch])
15
+ def name; "delegate"; end
19
16
 
20
- prompt = sub.prompt do
21
- system "You are a research agent. Analyze code, explain patterns, and answer questions. " \
22
- "You have read-only access to the filesystem. Be thorough and precise."
23
- user task
24
- end
17
+ MAX_ROUNDS = 10
25
18
 
26
- # Run a manual tool loop (max 10 rounds)
27
- res = sub.talk(prompt)
28
- rounds = 0
29
- while sub.functions.any? && rounds < 10
30
- res = sub.talk(sub.functions.map(&:call))
31
- rounds += 1
19
+ def execute(task:)
20
+ provider = Brute.provider
21
+ llm = provider.ruby_llm_provider
22
+ model_id = provider.default_model
23
+ model = Brute::Middleware::ModelRef.new(model_id, 16_384)
24
+
25
+ sub_tools = { read: FSRead.new, fs_search: FSSearch.new }
26
+
27
+ messages = [
28
+ RubyLLM::Message.new(
29
+ role: :system,
30
+ content: "You are a research agent. Analyze code, explain patterns, and answer questions. " \
31
+ "You have read-only access to the filesystem. Be thorough and precise."
32
+ ),
33
+ RubyLLM::Message.new(role: :user, content: task),
34
+ ]
35
+
36
+ response = nil
37
+ MAX_ROUNDS.times do
38
+ response = llm.complete(messages, tools: sub_tools, temperature: nil, model: model)
39
+ messages << response
40
+
41
+ break unless response.tool_call?
42
+
43
+ response.tool_calls.each_value do |tc|
44
+ tool = sub_tools[tc.name.to_sym]
45
+ result = if tool
46
+ tool.call(tc.arguments)
47
+ else
48
+ { error: "Unknown tool: #{tc.name}" }
49
+ end
50
+ content = result.is_a?(String) ? result : result.to_s
51
+ messages << RubyLLM::Message.new(role: :tool, content: content, tool_call_id: tc.id)
52
+ end
32
53
  end
33
54
 
34
- {result: extract_content(res, sub)}
55
+ { result: extract_content(response, messages) }
35
56
  end
36
57
 
37
58
  private
38
59
 
39
60
  # Safely extract text content from the sub-agent response.
40
- #
41
- # When the LLM returns only tool calls (no text content block),
42
- # res.content raises NoMethodError because the response adapter's
43
- # choices array is empty (it only maps over text blocks), or
44
- # returns nil when the response has no text. Fall back to the
45
- # last assistant text in the conversation history.
46
- def extract_content(res, context)
47
- text = begin
48
- res.content
49
- rescue NoMethodError
50
- nil
51
- end
61
+ def extract_content(response, messages)
62
+ text = response&.content
52
63
  return text if text.is_a?(::String) && !text.empty?
53
64
 
54
- last_assistant = context.messages.to_a
55
- .select(&:assistant?)
65
+ # Fall back to last assistant text in the conversation history
66
+ last_assistant = messages
67
+ .select { |m| m.role == :assistant }
56
68
  .reverse
57
69
  .find { |m| m.content.is_a?(::String) && !m.content.empty? }
58
70
  last_assistant&.content || "(sub-agent completed but produced no text response)"
@@ -65,54 +77,33 @@ test do
65
77
  require_relative "../../../spec/support/mock_provider"
66
78
  require_relative "../../../spec/support/mock_response"
67
79
 
68
- FakeMsg = Struct.new(:role, :content) do
69
- def assistant?; role == :assistant; end
70
- end
71
-
72
- def fake_context(messages)
73
- msgs_obj = Object.new
74
- msgs_obj.define_singleton_method(:to_a) { messages }
75
- ctx = Object.new
76
- ctx.define_singleton_method(:messages) { msgs_obj }
77
- ctx
78
- end
79
-
80
80
  delegate = Brute::Tools::Delegate.new
81
81
 
82
82
  it "returns content when response has text" do
83
- res = MockResponse.new(content: "analysis complete")
84
- delegate.send(:extract_content, res, fake_context([])).should == "analysis complete"
83
+ res = RubyLLM::Message.new(role: :assistant, content: "analysis complete")
84
+ delegate.send(:extract_content, res, []).should == "analysis complete"
85
85
  end
86
86
 
87
- it "falls back to last assistant text on NoMethodError" do
88
- bad_res = Object.new
89
- bad_res.define_singleton_method(:content) { raise NoMethodError }
90
- ctx = fake_context([FakeMsg.new(:user, "input"), FakeMsg.new(:assistant, "found the answer")])
91
- delegate.send(:extract_content, bad_res, ctx).should == "found the answer"
87
+ it "falls back to last assistant text on nil content" do
88
+ res = RubyLLM::Message.new(role: :assistant, content: "")
89
+ msgs = [
90
+ RubyLLM::Message.new(role: :user, content: "input"),
91
+ RubyLLM::Message.new(role: :assistant, content: "found the answer"),
92
+ ]
93
+ delegate.send(:extract_content, res, msgs).should == "found the answer"
92
94
  end
93
95
 
94
96
  it "returns fallback when no assistant messages exist" do
95
- bad_res = Object.new
96
- bad_res.define_singleton_method(:content) { raise NoMethodError }
97
- delegate.send(:extract_content, bad_res, fake_context([])).should == "(sub-agent completed but produced no text response)"
97
+ res = RubyLLM::Message.new(role: :assistant, content: "")
98
+ delegate.send(:extract_content, res, []).should == "(sub-agent completed but produced no text response)"
98
99
  end
99
100
 
100
101
  it "skips assistant messages with empty content" do
101
- bad_res = Object.new
102
- bad_res.define_singleton_method(:content) { raise NoMethodError }
103
- ctx = fake_context([FakeMsg.new(:assistant, "real answer"), FakeMsg.new(:assistant, "")])
104
- delegate.send(:extract_content, bad_res, ctx).should == "real answer"
105
- end
106
-
107
- it "falls back to last assistant on nil content" do
108
- nil_res = Struct.new(:content).new(nil)
109
- ctx = fake_context([FakeMsg.new(:assistant, "previous answer")])
110
- delegate.send(:extract_content, nil_res, ctx).should == "previous answer"
111
- end
112
-
113
- it "falls back to last assistant on empty string content" do
114
- empty_res = Struct.new(:content).new("")
115
- ctx = fake_context([FakeMsg.new(:assistant, "previous answer")])
116
- delegate.send(:extract_content, empty_res, ctx).should == "previous answer"
102
+ res = RubyLLM::Message.new(role: :assistant, content: "")
103
+ msgs = [
104
+ RubyLLM::Message.new(role: :assistant, content: "real answer"),
105
+ RubyLLM::Message.new(role: :assistant, content: ""),
106
+ ]
107
+ delegate.send(:extract_content, res, msgs).should == "real answer"
117
108
  end
118
109
  end
@@ -2,20 +2,22 @@
2
2
 
3
3
  require "bundler/setup"
4
4
  require "brute"
5
+ require "brute/tools"
5
6
 
6
7
  module Brute
7
8
  module Tools
8
- class FSPatch < LLM::Tool
9
- name 'patch'
9
+ class FSPatch < RubyLLM::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
 
13
- param :file_path, String, 'Path to the file to patch', required: true
14
- param :old_string, String, 'The exact text to find and replace', required: true
15
- param :new_string, String, 'The replacement text', required: true
16
- param :replace_all, Boolean, 'Replace all occurrences (default: false)'
13
+ param :file_path, type: 'string', desc: 'Path to the file to patch', required: true
14
+ param :old_string, type: 'string', desc: 'The exact text to find and replace', required: true
15
+ param :new_string, type: 'string', desc: 'The replacement text', required: true
16
+ param :replace_all, type: 'boolean', desc: 'Replace all occurrences (default: false)', required: false
17
17
 
18
- def call(file_path:, old_string:, new_string:, replace_all: false)
18
+ def name; "patch"; end
19
+
20
+ def execute(file_path:, old_string:, new_string:, replace_all: false)
19
21
  path = File.expand_path(file_path)
20
22
  Brute::Queue::FileMutationQueue.serialize(path) do
21
23
  raise "File not found: #{path}" unless File.exist?(path)
@@ -1,41 +1,254 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if __FILE__ == $0
4
- require "bundler/setup"
5
- require "brute"
6
- end
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/tools"
6
+ require "brute/truncation"
7
7
 
8
8
  module Brute
9
9
  module Tools
10
- class FSRead < LLM::Tool
11
- name "read"
10
+ # Existing features (ref: opencode read tool):
11
+ #
12
+ # 1. Default line limit — cap reads at 2000 lines when no start_line/end_line
13
+ # given, instead of reading the entire file.
14
+ # 2. Byte cap — stop reading when cumulative output exceeds 50 KB (MAX_BYTES).
15
+ # Whichever limit (lines or bytes) is hit first wins.
16
+ # 3. Per-line truncation — truncate individual lines longer than 2000 chars
17
+ # with a suffix like "... (line truncated to 2000 chars)".
18
+ # 4. Pagination hint — when output is truncated, append a hint:
19
+ # "(Showing lines 1-N of M. Use start_line=N+1 to continue.)"
20
+ # When reading completes, append "(End of file - total N lines)".
21
+ # 5. Binary file detection — read first 4 KB sample, check for null bytes
22
+ # and known binary extensions (.zip, .exe, .so, .pyc, etc.).
23
+ # Reject with "Cannot read binary file: <path>".
24
+ # 6. Directory listing — when file_path points to a directory, list entries
25
+ # (paginated, respecting limit) instead of raising an error.
26
+ # 7. File-not-found suggestions — on miss, scan the parent directory for
27
+ # similar names and suggest "Did you mean...?" candidates.
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.
30
+ #
31
+ class FSRead < RubyLLM::Tool
12
32
  description "Read the contents of a file. Returns file content with line numbers. " \
13
33
  "Use start_line/end_line for partial reads of large files."
14
34
 
15
- param :file_path, String, "Absolute or relative path to the file to read", required: true
16
- param :start_line, Integer, "Starting line number (1-indexed). Omit to read from beginning"
17
- param :end_line, Integer, "Ending line number (inclusive). Omit to read to end"
35
+ param :file_path, type: 'string', desc: "Absolute or relative path to the file to read", required: true
36
+ param :start_line, type: 'integer', desc: "Starting line number (1-indexed). Omit to read from beginning", required: false
37
+ param :end_line, type: 'integer', desc: "Ending line number (inclusive). Omit to read to end", required: false
38
+
39
+ def name; "read"; end
18
40
 
19
- def call(file_path:, start_line: nil, end_line: nil)
41
+ BINARY_EXTENSIONS = %w[.zip .exe .so .pyc .pyo .dll .dylib .bin .o .a .tar .gz .bz2 .xz .7z .rar .jar .war .class .png .jpg .jpeg .gif .bmp .ico .pdf .woff .woff2 .ttf .eot .mp3 .mp4 .avi .mov .flv .wmv .db .sqlite .sqlite3].freeze
42
+ DEFAULT_LINE_CAP = 2000
43
+ MAX_BYTES = Brute::Truncation::MAX_BYTES
44
+ MAX_LINE_LENGTH = Brute::Truncation::MAX_LINE_LENGTH
45
+
46
+ def execute(file_path:, start_line: nil, end_line: nil)
20
47
  path = File.expand_path(file_path)
21
- raise "File not found: #{path}" unless File.exist?(path)
48
+
49
+ # Directory listing
50
+ return list_directory(path) if File.directory?(path)
51
+
52
+ # File-not-found suggestions
53
+ unless File.exist?(path)
54
+ suggestions = find_similar(path)
55
+ msg = "File not found: #{path}"
56
+ msg += ". Did you mean: #{suggestions.join(', ')}?" if suggestions.any?
57
+ raise msg
58
+ end
59
+
22
60
  raise "Not a file: #{path}" unless File.file?(path)
23
61
 
62
+ # Binary file detection
63
+ ext = File.extname(path).downcase
64
+ raise "Cannot read binary file: #{path}" if BINARY_EXTENSIONS.include?(ext)
65
+
66
+ sample = File.binread(path, 4096) || ""
67
+ raise "Cannot read binary file: #{path}" if sample.include?("\x00")
68
+
24
69
  lines = File.readlines(path)
70
+ total = lines.size
25
71
  first = start_line ? [start_line - 1, 0].max : 0
26
- last = end_line ? [end_line - 1, lines.size - 1].min : lines.size - 1
72
+
73
+ # Apply default line cap when no explicit range given
74
+ default_last = end_line ? [end_line - 1, total - 1].min : [first + DEFAULT_LINE_CAP - 1, total - 1].min
75
+ last = default_last
27
76
 
28
77
  selected = lines[first..last] || []
29
- numbered = selected.each_with_index.map do |line, i|
30
- "#{first + i + 1}\t#{line}"
78
+
79
+ # Per-line truncation + byte cap
80
+ numbered = []
81
+ bytes = 0
82
+ selected.each_with_index do |line, i|
83
+ truncated_line = Brute::Truncation.truncate_line(line, max: MAX_LINE_LENGTH)
84
+ numbered_line = "#{first + i + 1}\t#{truncated_line}"
85
+ break if bytes + numbered_line.bytesize > MAX_BYTES
86
+ numbered << numbered_line
87
+ bytes += numbered_line.bytesize
88
+ end
89
+
90
+ actual_last = first + numbered.size - 1
91
+ content = numbered.join
92
+ truncated = (actual_last < total - 1) && end_line.nil?
93
+
94
+ if truncated
95
+ content + "\n(Showing lines #{first + 1}-#{actual_last + 1} of #{total}. Use start_line=#{actual_last + 2} to continue.)"
96
+ else
97
+ content
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def list_directory(path)
104
+ entries = Dir.entries(path).reject { |e| e.start_with?(".") }.sort
105
+ total = entries.size
106
+ capped = entries.first(DEFAULT_LINE_CAP)
107
+ result = capped.map do |entry|
108
+ full = File.join(path, entry)
109
+ type = File.directory?(full) ? "dir" : "file"
110
+ "#{entry} (#{type})"
111
+ end.join("\n")
112
+
113
+ if total > DEFAULT_LINE_CAP
114
+ result += "\n(Showing #{DEFAULT_LINE_CAP} of #{total} entries)"
115
+ end
116
+ result
117
+ end
118
+
119
+ def find_similar(path)
120
+ dir = File.dirname(path)
121
+ target = File.basename(path)
122
+ return [] unless File.directory?(dir)
123
+
124
+ entries = Dir.entries(dir).reject { |e| e.start_with?(".") }
125
+ entries.select { |e| levenshtein(e.downcase, target.downcase) <= 3 }
126
+ .sort_by { |e| levenshtein(e.downcase, target.downcase) }
127
+ .first(3)
128
+ end
129
+
130
+ def levenshtein(a, b)
131
+ m, n = a.length, b.length
132
+ d = Array.new(m + 1) { |i| i }
133
+ (1..n).each do |j|
134
+ prev = d[0]
135
+ d[0] = j
136
+ (1..m).each do |i|
137
+ cost = a[i - 1] == b[j - 1] ? 0 : 1
138
+ temp = d[i]
139
+ d[i] = [d[i] + 1, d[i - 1] + 1, prev + cost].min
140
+ prev = temp
141
+ end
31
142
  end
143
+ d[m]
144
+ end
145
+ end
146
+ end
147
+ end
148
+
149
+ test do
150
+ require "tmpdir"
151
+
152
+ it "reads a file without error" do
153
+ Dir.mktmpdir do |dir|
154
+ path = File.join(dir, "test.txt")
155
+ File.write(path, "line1\nline2\nline3\n")
156
+ result = Brute::Tools::FSRead.new.call(file_path: path)
157
+ result.should =~ /line1/
158
+ end
159
+ end
160
+
161
+ it "reads a range without error" do
162
+ Dir.mktmpdir do |dir|
163
+ path = File.join(dir, "test.txt")
164
+ File.write(path, "a\nb\nc\nd\ne\n")
165
+ result = Brute::Tools::FSRead.new.call(file_path: path, start_line: 2, end_line: 4)
166
+ result.should =~ /2\tb/
167
+ end
168
+ end
169
+
170
+ it "raises on missing file" do
171
+ lambda { Brute::Tools::FSRead.new.call(file_path: "/nonexistent/file.txt") }.should.raise
172
+ end
173
+
174
+ it "returns a String, not a Hash" do
175
+ Dir.mktmpdir do |dir|
176
+ path = File.join(dir, "test.txt")
177
+ File.write(path, "hello\n")
178
+ Brute::Tools::FSRead.new.call(file_path: path).should.be.kind_of(String)
179
+ end
180
+ end
181
+
182
+ it "caps output at 2000 lines by default" do
183
+ Dir.mktmpdir do |dir|
184
+ path = File.join(dir, "big.txt")
185
+ File.write(path, "x\n" * 3000)
186
+ result = Brute::Tools::FSRead.new.call(file_path: path)
187
+ result.lines.size.should.be < 2100
188
+ end
189
+ end
190
+
191
+ it "rejects binary files" do
192
+ Dir.mktmpdir do |dir|
193
+ path = File.join(dir, "binary.bin")
194
+ File.binwrite(path, "\x00\x01\x02\x03" * 1000)
195
+ lambda { Brute::Tools::FSRead.new.call(file_path: path) }.should.raise
196
+ end
197
+ end
198
+
199
+ it "includes a pagination hint when truncated" do
200
+ Dir.mktmpdir do |dir|
201
+ path = File.join(dir, "big.txt")
202
+ File.write(path, "x\n" * 3000)
203
+ result = Brute::Tools::FSRead.new.call(file_path: path)
204
+ result.should =~ /start_line/
205
+ end
206
+ end
207
+
208
+ # --- Byte cap ---
209
+
210
+ it "stops reading when output exceeds 50 KB" do
211
+ Dir.mktmpdir do |dir|
212
+ path = File.join(dir, "big.txt")
213
+ # Each line ~200 bytes, 500 lines = ~100 KB > 50 KB
214
+ File.write(path, ("z" * 200 + "\n") * 500)
215
+ result = Brute::Tools::FSRead.new.call(file_path: path)
216
+ result.bytesize.should.be < 55_000
217
+ end
218
+ end
219
+
220
+ # --- Per-line truncation ---
221
+
222
+ it "truncates lines longer than 2000 chars" do
223
+ Dir.mktmpdir do |dir|
224
+ path = File.join(dir, "longlines.txt")
225
+ File.write(path, "x" * 3000 + "\nshort\n")
226
+ result = Brute::Tools::FSRead.new.call(file_path: path)
227
+ result.lines.first.size.should.be < 2100
228
+ end
229
+ end
230
+
231
+ # --- Directory listing ---
232
+
233
+ it "lists directory entries instead of raising" do
234
+ Dir.mktmpdir do |dir|
235
+ File.write(File.join(dir, "a.txt"), "a")
236
+ File.write(File.join(dir, "b.rb"), "b")
237
+ result = Brute::Tools::FSRead.new.call(file_path: dir)
238
+ result.should =~ /a\.txt/
239
+ result.should =~ /b\.rb/
240
+ end
241
+ end
242
+
243
+ # --- File-not-found suggestions ---
32
244
 
33
- {
34
- file_path: path,
35
- total_lines: lines.size,
36
- showing: "#{first + 1}-#{last + 1}",
37
- content: numbered.join,
38
- }
245
+ it "suggests similar files on miss" do
246
+ Dir.mktmpdir do |dir|
247
+ File.write(File.join(dir, "config.yml"), "x")
248
+ begin
249
+ Brute::Tools::FSRead.new.call(file_path: File.join(dir, "conifg.yml"))
250
+ rescue => e
251
+ e.message.should =~ /did you mean/i
39
252
  end
40
253
  end
41
254
  end
@@ -1,21 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if __FILE__ == $0
4
- require "bundler/setup"
5
- require "brute"
6
- end
7
-
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/tools"
8
6
  require "fileutils"
9
7
 
10
8
  module Brute
11
9
  module Tools
12
- class FSRemove < LLM::Tool
13
- name "remove"
10
+ class FSRemove < RubyLLM::Tool
14
11
  description "Remove a file or empty directory."
15
12
 
16
- param :path, String, "Path to the file or directory to remove", required: true
13
+ param :path, type: 'string', desc: "Path to the file or directory to remove", required: true
14
+
15
+ def name; "remove"; end
17
16
 
18
- def call(path:)
17
+ def execute(path:)
19
18
  target = File.expand_path(path)
20
19
  Brute::Queue::FileMutationQueue.serialize(target) do
21
20
  raise "Path not found: #{target}" unless File.exist?(target)
@@ -1,31 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if __FILE__ == $0
4
- require "bundler/setup"
5
- require "brute"
6
- end
7
-
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/tools"
6
+ require "brute/truncation"
8
7
  require "open3"
9
8
 
10
9
  module Brute
11
10
  module Tools
12
- class FSSearch < LLM::Tool
13
- name "fs_search"
11
+ # Existing features (ref: opencode grep tool):
12
+ #
13
+ # 1. Global result cap — limit total matches to 100 across all files.
14
+ # 2. Per-line truncation — truncate individual match lines longer than
15
+ # 2000 chars via rg --max-columns with preview.
16
+ # 3. Structured truncation message — when results are capped, append:
17
+ # "(Results truncated: showing 100 of N matches. Consider a more
18
+ # specific path or pattern.)"
19
+ # 4. Sort results by file mtime — most-recently-modified files first,
20
+ # so the LLM sees the most relevant matches first.
21
+ # 5. Return a plain string instead of a Hash.
22
+ # 6. Align output cap with universal truncation (2000 lines / 50 KB).
23
+ #
24
+ class FSSearch < RubyLLM::Tool
14
25
  description "Search file contents using ripgrep (regex), or find files by glob pattern. " \
15
26
  "Returns matching lines with file paths and line numbers."
16
27
 
17
- param :pattern, String, "Regex pattern to search for in file contents", required: true
18
- param :path, String, "Directory to search in (defaults to current working directory)"
19
- param :glob, String, "File glob filter, e.g. '*.rb', '*.{js,ts}'"
20
- param :ignore_case, Boolean, "Case-insensitive search (default: false)"
28
+ param :pattern, type: 'string', desc: "Regex pattern to search for in file contents", required: true
29
+ param :path, type: 'string', desc: "Directory to search in (defaults to current working directory)", required: false
30
+ param :glob, type: 'string', desc: "File glob filter, e.g. '*.rb', '*.{js,ts}'", required: false
31
+ param :ignore_case, type: 'boolean', desc: "Case-insensitive search (default: false)", required: false
21
32
 
22
- MAX_OUTPUT = 40_000
33
+ def name; "fs_search"; end
23
34
 
24
- def call(pattern:, path: nil, glob: nil, ignore_case: false)
35
+ MAX_TOTAL_MATCHES = 100
36
+
37
+ def execute(pattern:, path: nil, glob: nil, ignore_case: false)
25
38
  dir = File.expand_path(path || Dir.pwd)
26
39
  raise "Directory not found: #{dir}" unless File.directory?(dir)
27
40
 
28
- cmd = ["rg", "--line-number", "--max-count=100", "--max-columns=200"]
41
+ cmd = ["rg", "--line-number", "--max-columns=2000", "--max-columns-preview", "--sortr=modified"]
29
42
  cmd << "--ignore-case" if ignore_case
30
43
  cmd += ["--glob", glob] if glob
31
44
  cmd << pattern
@@ -34,10 +47,79 @@ module Brute
34
47
  stdout, stderr, status = Open3.capture3(*cmd)
35
48
 
36
49
  output = stdout.empty? ? stderr : stdout
37
- output = output[0...MAX_OUTPUT] + "\n...(truncated)" if output.size > MAX_OUTPUT
38
50
 
39
- {results: output, exit_code: status.exitstatus, truncated: output.size > MAX_OUTPUT}
51
+ # Global cap at MAX_TOTAL_MATCHES lines
52
+ lines = output.lines
53
+ total_matches = lines.size
54
+ if total_matches > MAX_TOTAL_MATCHES
55
+ output = lines.first(MAX_TOTAL_MATCHES).join
56
+ output += "\n(Results truncated: showing 100 of #{total_matches} matches. Consider a more specific path or pattern.)"
57
+ end
58
+
59
+ Brute::Truncation.truncate(output)
40
60
  end
41
61
  end
42
62
  end
43
63
  end
64
+
65
+ test do
66
+ require "tmpdir"
67
+
68
+ it "searches the current directory without error" do
69
+ result = Brute::Tools::FSSearch.new.call(pattern: "class FSSearch", path: __dir__)
70
+ result.should =~ /class FSSearch/
71
+ end
72
+
73
+ it "returns non-zero for no matches" do
74
+ Dir.mktmpdir do |dir|
75
+ File.write(File.join(dir, "empty.txt"), "nothing here\n")
76
+ result = Brute::Tools::FSSearch.new.call(pattern: "zzz_no_match_zzz", path: dir)
77
+ result.should.be.kind_of(String)
78
+ end
79
+ end
80
+
81
+ it "returns a String, not a Hash" do
82
+ Dir.mktmpdir do |dir|
83
+ File.write(File.join(dir, "a.txt"), "hello world\n")
84
+ Brute::Tools::FSSearch.new.call(pattern: "hello", path: dir).should.be.kind_of(String)
85
+ end
86
+ end
87
+
88
+ it "caps total results at 100 matches" do
89
+ Dir.mktmpdir do |dir|
90
+ 150.times { |i| File.write(File.join(dir, "f#{i}.txt"), "match_me\n") }
91
+ result = Brute::Tools::FSSearch.new.call(pattern: "match_me", path: dir)
92
+ result.should =~ /showing.*100/i
93
+ end
94
+ end
95
+
96
+ # --- Per-line truncation ---
97
+
98
+ it "truncates long match lines with a preview" do
99
+ Dir.mktmpdir do |dir|
100
+ File.write(File.join(dir, "long.txt"), "x" * 3000 + "\n")
101
+ result = Brute::Tools::FSSearch.new.call(pattern: "x", path: dir)
102
+ # Each result line should be capped, not the full 3000 chars
103
+ result.lines.select { |l| l =~ /long\.txt/ }.each do |line|
104
+ line.size.should.be < 2200
105
+ end
106
+ end
107
+ end
108
+
109
+ # --- Sort by mtime ---
110
+
111
+ it "shows most recently modified files first" do
112
+ Dir.mktmpdir do |dir|
113
+ File.write(File.join(dir, "old.txt"), "findme\n")
114
+ old_time = Time.now - 1000
115
+ File.utime(old_time, old_time, File.join(dir, "old.txt"))
116
+ sleep 0.05
117
+ File.write(File.join(dir, "new.txt"), "findme\n")
118
+
119
+ result = Brute::Tools::FSSearch.new.call(pattern: "findme", path: dir)
120
+ old_pos = result.index("old.txt")
121
+ new_pos = result.index("new.txt")
122
+ new_pos.should.be < old_pos
123
+ end
124
+ end
125
+ end
@@ -1,20 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if __FILE__ == $0
4
- require "bundler/setup"
5
- require "brute"
6
- end
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/tools"
7
6
 
8
7
  module Brute
9
8
  module Tools
10
- class FSUndo < LLM::Tool
11
- name "undo"
9
+ class FSUndo < RubyLLM::Tool
12
10
  description "Undo the last write or patch operation on a file, restoring it to " \
13
11
  "its previous state."
14
12
 
15
- param :path, String, "Path to the file to undo", required: true
13
+ param :path, type: 'string', desc: "Path to the file to undo", required: true
14
+
15
+ def name; "undo"; end
16
16
 
17
- def call(path:)
17
+ def execute(path:)
18
18
  target = File.expand_path(path)
19
19
  Brute::Queue::FileMutationQueue.serialize(target) do
20
20
  snapshot = Brute::Store::SnapshotStore.pop(target)