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.
- checksums.yaml +4 -4
- data/lib/brute/agent.rb +72 -6
- data/lib/brute/events/handler.rb +69 -0
- data/lib/brute/events/prefixed_terminal_output.rb +72 -0
- data/lib/brute/events/terminal_output_handler.rb +68 -0
- data/lib/brute/middleware/001_otel_span.rb +77 -0
- data/lib/brute/middleware/003_tool_result_loop.rb +103 -0
- data/lib/brute/middleware/004_summarize.rb +139 -0
- data/lib/brute/middleware/005_tracing.rb +86 -0
- data/lib/brute/middleware/010_max_iterations.rb +73 -0
- data/lib/brute/middleware/015_otel_token_usage.rb +42 -0
- data/lib/brute/middleware/020_system_prompt.rb +128 -0
- data/lib/brute/middleware/040_compaction_check.rb +155 -0
- data/lib/brute/middleware/060_questions.rb +41 -0
- data/lib/brute/middleware/070_tool_call.rb +247 -0
- data/lib/brute/middleware/073_otel_tool_call.rb +49 -0
- data/lib/brute/middleware/075_otel_tool_results.rb +46 -0
- data/lib/brute/middleware/100_llm_call.rb +62 -0
- data/lib/brute/middleware/event_handler.rb +25 -0
- data/lib/brute/middleware/user_queue.rb +35 -0
- data/lib/brute/pipeline.rb +44 -107
- data/lib/brute/prompts/skills.rb +2 -2
- data/lib/brute/prompts.rb +23 -23
- data/lib/brute/providers/shell.rb +6 -19
- data/lib/brute/providers/shell_response.rb +22 -30
- data/lib/brute/session.rb +52 -0
- data/lib/brute/store/snapshot_store.rb +21 -37
- data/lib/brute/sub_agent.rb +106 -0
- data/lib/brute/system_prompt.rb +1 -83
- data/lib/brute/tool.rb +107 -0
- data/lib/brute/tools/delegate.rb +61 -70
- data/lib/brute/tools/fs_patch.rb +9 -7
- data/lib/brute/tools/fs_read.rb +233 -20
- data/lib/brute/tools/fs_remove.rb +8 -9
- data/lib/brute/tools/fs_search.rb +98 -16
- data/lib/brute/tools/fs_undo.rb +8 -8
- data/lib/brute/tools/fs_write.rb +7 -5
- data/lib/brute/tools/net_fetch.rb +8 -8
- data/lib/brute/tools/question.rb +36 -24
- data/lib/brute/tools/shell.rb +74 -16
- data/lib/brute/tools/todo_read.rb +8 -8
- data/lib/brute/tools/todo_write.rb +25 -18
- data/lib/brute/tools.rb +8 -12
- data/lib/brute/truncation.rb +219 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +82 -45
- metadata +59 -46
- data/lib/brute/loop/agent_stream.rb +0 -118
- data/lib/brute/loop/agent_turn.rb +0 -520
- data/lib/brute/loop/compactor.rb +0 -107
- data/lib/brute/loop/doom_loop.rb +0 -86
- data/lib/brute/loop/step.rb +0 -332
- data/lib/brute/loop/tool_call_step.rb +0 -90
- data/lib/brute/middleware/base.rb +0 -27
- data/lib/brute/middleware/compaction_check.rb +0 -106
- data/lib/brute/middleware/doom_loop_detection.rb +0 -136
- data/lib/brute/middleware/llm_call.rb +0 -128
- data/lib/brute/middleware/message_tracking.rb +0 -339
- data/lib/brute/middleware/otel/span.rb +0 -105
- data/lib/brute/middleware/otel/token_usage.rb +0 -68
- data/lib/brute/middleware/otel/tool_calls.rb +0 -68
- data/lib/brute/middleware/otel/tool_results.rb +0 -65
- data/lib/brute/middleware/otel.rb +0 -34
- data/lib/brute/middleware/reasoning_normalizer.rb +0 -192
- data/lib/brute/middleware/retry.rb +0 -157
- data/lib/brute/middleware/session_persistence.rb +0 -72
- data/lib/brute/middleware/token_tracking.rb +0 -124
- data/lib/brute/middleware/tool_error_tracking.rb +0 -179
- data/lib/brute/middleware/tool_use_guard.rb +0 -133
- data/lib/brute/middleware/tracing.rb +0 -124
- data/lib/brute/middleware.rb +0 -18
- data/lib/brute/orchestrator/turn.rb +0 -105
- data/lib/brute/patches/anthropic_tool_role.rb +0 -35
- data/lib/brute/patches/buffer_nil_guard.rb +0 -26
- data/lib/brute/providers/models_dev.rb +0 -111
- data/lib/brute/providers/ollama.rb +0 -135
- data/lib/brute/providers/opencode_go.rb +0 -43
- data/lib/brute/providers/opencode_zen.rb +0 -87
- data/lib/brute/providers.rb +0 -62
- data/lib/brute/queue/base_queue.rb +0 -222
- data/lib/brute/queue/parallel_queue.rb +0 -66
- data/lib/brute/queue/sequential_queue.rb +0 -63
- data/lib/brute/store/message_store.rb +0 -362
- data/lib/brute/store/session.rb +0 -106
- /data/lib/brute/{diff.rb → utils/diff.rb} +0 -0
data/lib/brute/tools/delegate.rb
CHANGED
|
@@ -5,54 +5,66 @@ require "brute"
|
|
|
5
5
|
|
|
6
6
|
module Brute
|
|
7
7
|
module Tools
|
|
8
|
-
class Delegate <
|
|
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,
|
|
13
|
+
param :task, type: 'string', desc: "A clear, detailed description of the research task", required: true
|
|
15
14
|
|
|
16
|
-
def
|
|
17
|
-
provider = Brute.provider
|
|
18
|
-
sub = LLM::Context.new(provider, tools: [FSRead, FSSearch])
|
|
15
|
+
def name; "delegate"; end
|
|
19
16
|
|
|
20
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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 =
|
|
84
|
-
delegate.send(:extract_content, res,
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
data/lib/brute/tools/fs_patch.rb
CHANGED
|
@@ -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 <
|
|
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,
|
|
14
|
-
param :old_string,
|
|
15
|
-
param :new_string,
|
|
16
|
-
param :replace_all,
|
|
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
|
|
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)
|
data/lib/brute/tools/fs_read.rb
CHANGED
|
@@ -1,41 +1,254 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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,
|
|
16
|
-
param :start_line,
|
|
17
|
-
param :end_line,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 <
|
|
13
|
-
name "remove"
|
|
10
|
+
class FSRemove < RubyLLM::Tool
|
|
14
11
|
description "Remove a file or empty directory."
|
|
15
12
|
|
|
16
|
-
param :path,
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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,
|
|
18
|
-
param :path,
|
|
19
|
-
param :glob,
|
|
20
|
-
param :ignore_case,
|
|
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
|
-
|
|
33
|
+
def name; "fs_search"; end
|
|
23
34
|
|
|
24
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
data/lib/brute/tools/fs_undo.rb
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 <
|
|
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,
|
|
13
|
+
param :path, type: 'string', desc: "Path to the file to undo", required: true
|
|
14
|
+
|
|
15
|
+
def name; "undo"; end
|
|
16
16
|
|
|
17
|
-
def
|
|
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)
|