agent_c 2.71828
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 +7 -0
- data/.rubocop.yml +10 -0
- data/.ruby-version +1 -0
- data/CLAUDE.md +21 -0
- data/README.md +360 -0
- data/Rakefile +16 -0
- data/TODO.md +12 -0
- data/agent_c.gemspec +38 -0
- data/docs/chat-methods.md +157 -0
- data/docs/cost-reporting.md +86 -0
- data/docs/pipeline-tips-and-tricks.md +71 -0
- data/docs/session-configuration.md +274 -0
- data/docs/testing.md +747 -0
- data/docs/tools.md +103 -0
- data/docs/versioned-store.md +840 -0
- data/lib/agent_c/agent/chat.rb +211 -0
- data/lib/agent_c/agent/chat_response.rb +32 -0
- data/lib/agent_c/agent/chats/anthropic_bedrock.rb +48 -0
- data/lib/agent_c/batch.rb +102 -0
- data/lib/agent_c/configs/repo.rb +90 -0
- data/lib/agent_c/context.rb +56 -0
- data/lib/agent_c/costs/data.rb +39 -0
- data/lib/agent_c/costs/report.rb +219 -0
- data/lib/agent_c/db/store.rb +162 -0
- data/lib/agent_c/errors.rb +19 -0
- data/lib/agent_c/pipeline.rb +188 -0
- data/lib/agent_c/processor.rb +98 -0
- data/lib/agent_c/prompts.yml +53 -0
- data/lib/agent_c/schema.rb +85 -0
- data/lib/agent_c/session.rb +207 -0
- data/lib/agent_c/store.rb +72 -0
- data/lib/agent_c/test_helpers.rb +173 -0
- data/lib/agent_c/tools/dir_glob.rb +46 -0
- data/lib/agent_c/tools/edit_file.rb +112 -0
- data/lib/agent_c/tools/file_metadata.rb +43 -0
- data/lib/agent_c/tools/grep.rb +119 -0
- data/lib/agent_c/tools/paths.rb +36 -0
- data/lib/agent_c/tools/read_file.rb +94 -0
- data/lib/agent_c/tools/run_rails_test.rb +87 -0
- data/lib/agent_c/tools.rb +60 -0
- data/lib/agent_c/utils/git.rb +75 -0
- data/lib/agent_c/utils/shell.rb +58 -0
- data/lib/agent_c/version.rb +5 -0
- data/lib/agent_c.rb +32 -0
- data/lib/versioned_store/base.rb +314 -0
- data/lib/versioned_store/config.rb +26 -0
- data/lib/versioned_store/stores/schema.rb +127 -0
- data/lib/versioned_store/version.rb +5 -0
- data/lib/versioned_store.rb +5 -0
- data/template/Gemfile +9 -0
- data/template/Gemfile.lock +152 -0
- data/template/README.md +61 -0
- data/template/Rakefile +50 -0
- data/template/bin/rake +27 -0
- data/template/lib/autoload.rb +10 -0
- data/template/lib/config.rb +59 -0
- data/template/lib/pipeline.rb +19 -0
- data/template/lib/prompts.yml +57 -0
- data/template/lib/store.rb +17 -0
- data/template/test/pipeline_test.rb +221 -0
- data/template/test/test_helper.rb +18 -0
- metadata +191 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentC
|
|
4
|
+
module Tools
|
|
5
|
+
class EditFile < RubyLLM::Tool
|
|
6
|
+
description "Edits a file with various operations: overwrite entire file, replace specific lines, insert at position, or append"
|
|
7
|
+
|
|
8
|
+
params do
|
|
9
|
+
string(
|
|
10
|
+
:path,
|
|
11
|
+
description: "Path to file. Must be a child of current directory.",
|
|
12
|
+
required: true
|
|
13
|
+
)
|
|
14
|
+
string(
|
|
15
|
+
:mode,
|
|
16
|
+
description: "Operation mode: 'overwrite' (replace entire file), 'replace_lines' (replace line range), 'insert_at_line' (insert before specified line), 'append' (add to end)",
|
|
17
|
+
enum: ["overwrite", "replace_lines", "insert_at_line", "append"],
|
|
18
|
+
required: true
|
|
19
|
+
)
|
|
20
|
+
string(
|
|
21
|
+
:content,
|
|
22
|
+
description: "The content to write, insert, or append. Can be multi-line.",
|
|
23
|
+
required: true
|
|
24
|
+
)
|
|
25
|
+
integer(
|
|
26
|
+
:start_line,
|
|
27
|
+
description: <<~TXT,
|
|
28
|
+
For replace_lines: first line to replace, inclusive (1-indexed).
|
|
29
|
+
For insert_at_line: line before which to insert (1=start of file, 999999=end of file).
|
|
30
|
+
TXT
|
|
31
|
+
required: false
|
|
32
|
+
)
|
|
33
|
+
integer(
|
|
34
|
+
:end_line,
|
|
35
|
+
description: "For replace_lines only: last line to replace, inclusive (1-indexed).",
|
|
36
|
+
required: false
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :workspace_dir
|
|
41
|
+
def initialize(workspace_dir: nil, **)
|
|
42
|
+
raise ArgumentError, "workspace_dir is required" unless workspace_dir
|
|
43
|
+
@workspace_dir = workspace_dir
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def execute(path:, mode:, content:, start_line: nil, end_line: nil, **params)
|
|
47
|
+
if params.any?
|
|
48
|
+
return "The following params were passed but are not allowed: #{params.keys.join(",")}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
unless Paths.allowed?(workspace_dir, path)
|
|
52
|
+
return "Path: #{path} not acceptable. Must be a child of directory: #{workspace_dir}."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
workspace_path = Paths.relative_to_dir(workspace_dir, path)
|
|
56
|
+
|
|
57
|
+
case mode
|
|
58
|
+
when "overwrite"
|
|
59
|
+
FileUtils.mkdir_p(File.dirname(workspace_path))
|
|
60
|
+
File.write(workspace_path, content)
|
|
61
|
+
|
|
62
|
+
when "replace_lines"
|
|
63
|
+
return "start_line and end_line required for replace_lines mode" unless start_line && end_line
|
|
64
|
+
return "start_line must be <= end_line" if start_line > end_line
|
|
65
|
+
|
|
66
|
+
lines = File.exist?(workspace_path) ? File.readlines(workspace_path, chomp: true) : []
|
|
67
|
+
|
|
68
|
+
# Convert to 0-indexed
|
|
69
|
+
start_idx = start_line - 1
|
|
70
|
+
end_idx = end_line - 1
|
|
71
|
+
|
|
72
|
+
# Replace the range with new content lines
|
|
73
|
+
new_lines = content.split("\n")
|
|
74
|
+
lines[start_idx..end_idx] = new_lines
|
|
75
|
+
|
|
76
|
+
File.write(workspace_path, lines.join("\n") + "\n")
|
|
77
|
+
|
|
78
|
+
when "insert_at_line"
|
|
79
|
+
return "start_line required for insert_at_line mode" unless start_line
|
|
80
|
+
|
|
81
|
+
lines = File.exist?(workspace_path) ? File.readlines(workspace_path, chomp: true) : []
|
|
82
|
+
|
|
83
|
+
# Insert before the specified line (1-indexed)
|
|
84
|
+
insert_idx = start_line - 1
|
|
85
|
+
insert_idx = [0, [insert_idx, lines.length].min].max # Clamp to valid range
|
|
86
|
+
|
|
87
|
+
new_lines = content.split("\n")
|
|
88
|
+
lines.insert(insert_idx, *new_lines)
|
|
89
|
+
|
|
90
|
+
File.write(workspace_path, lines.join("\n") + "\n")
|
|
91
|
+
|
|
92
|
+
when "append"
|
|
93
|
+
FileUtils.mkdir_p(File.dirname(workspace_path))
|
|
94
|
+
File.open(workspace_path, "a") do |f|
|
|
95
|
+
f.write(content)
|
|
96
|
+
f.write("\n") unless content.end_with?("\n")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Run syntax check for Ruby files
|
|
101
|
+
if workspace_path.end_with?(".rb")
|
|
102
|
+
syntax_check = `ruby -c #{workspace_path} 2>&1`
|
|
103
|
+
unless $?.success?
|
|
104
|
+
return "File successfully edited, but syntax errors were found:\n#{syntax_check}"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
true
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentC
|
|
4
|
+
module Tools
|
|
5
|
+
class FileMetadata < RubyLLM::Tool
|
|
6
|
+
description "Returns metadata of a file, including line-count, mtime"
|
|
7
|
+
|
|
8
|
+
params do
|
|
9
|
+
string(
|
|
10
|
+
:path,
|
|
11
|
+
description: "Path to file. Must be a child of current directory."
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :workspace_dir
|
|
16
|
+
def initialize(workspace_dir: nil, **)
|
|
17
|
+
raise ArgumentError, "workspace_dir is required" unless workspace_dir
|
|
18
|
+
@workspace_dir = workspace_dir
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def execute(path:, line_range_start: 0, line_range_end: nil, **params)
|
|
22
|
+
if params.any?
|
|
23
|
+
return "The following params were passed but are not allowed: #{params.keys.join(",")}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
unless Paths.allowed?(workspace_dir, path)
|
|
27
|
+
return "Path: #{path} not acceptable. Must be a child of directory: #{workspace_dir}."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
workspace_path = Paths.relative_to_dir(workspace_dir, path)
|
|
31
|
+
|
|
32
|
+
unless File.exist?(workspace_path)
|
|
33
|
+
return "File not found"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
mtime: File.mtime(workspace_path),
|
|
38
|
+
lines: File.foreach(workspace_path).count,
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module AgentC
|
|
6
|
+
module Tools
|
|
7
|
+
class Grep < RubyLLM::Tool
|
|
8
|
+
description <<~DESC
|
|
9
|
+
Searches for patterns in files using git grep. Returns matching lines
|
|
10
|
+
with file paths and line numbers.
|
|
11
|
+
DESC
|
|
12
|
+
|
|
13
|
+
params do
|
|
14
|
+
string(
|
|
15
|
+
:pattern,
|
|
16
|
+
description: <<~DESC,
|
|
17
|
+
The regex pattern to search for. Use standard regex syntax.
|
|
18
|
+
DESC
|
|
19
|
+
required: true
|
|
20
|
+
)
|
|
21
|
+
string(
|
|
22
|
+
:file_pattern,
|
|
23
|
+
description: <<~DESC,
|
|
24
|
+
Optional glob pattern to limit search to specific files
|
|
25
|
+
(e.g., '*.rb', 'app/**/*.js'). If omitted, searches all files.
|
|
26
|
+
DESC
|
|
27
|
+
required: false
|
|
28
|
+
)
|
|
29
|
+
boolean(
|
|
30
|
+
:ignore_case,
|
|
31
|
+
description: <<~DESC,
|
|
32
|
+
If true, performs case-insensitive search. Default is false.
|
|
33
|
+
DESC
|
|
34
|
+
required: false
|
|
35
|
+
)
|
|
36
|
+
integer(
|
|
37
|
+
:context_lines,
|
|
38
|
+
description: <<~DESC,
|
|
39
|
+
Number of context lines to show before and after each match.
|
|
40
|
+
Default is 0.
|
|
41
|
+
DESC
|
|
42
|
+
required: false
|
|
43
|
+
)
|
|
44
|
+
integer(
|
|
45
|
+
:line_range_start,
|
|
46
|
+
description: <<~DESC,
|
|
47
|
+
Return results starting from this line number (1-indexed).
|
|
48
|
+
Default is 1.
|
|
49
|
+
DESC
|
|
50
|
+
required: false
|
|
51
|
+
)
|
|
52
|
+
integer(
|
|
53
|
+
:line_range_end,
|
|
54
|
+
description: <<~DESC,
|
|
55
|
+
Return results up to and including this line number (1-indexed).
|
|
56
|
+
If omitted, returns to the end.
|
|
57
|
+
DESC
|
|
58
|
+
required: false
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
attr_reader :workspace_dir
|
|
63
|
+
def initialize(workspace_dir: nil, **)
|
|
64
|
+
raise ArgumentError, "workspace_dir is required" unless workspace_dir
|
|
65
|
+
@workspace_dir = workspace_dir
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def execute(pattern:, file_pattern: nil, ignore_case: false, context_lines: 0, line_range_start: 1, line_range_end: nil, **params)
|
|
69
|
+
if params.any?
|
|
70
|
+
return "The following params were passed but are not allowed: #{params.keys.join(",")}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
Dir.chdir(workspace_dir) do
|
|
74
|
+
|
|
75
|
+
cmd = ["git", "grep", "-n", "--extended-regexp"] # -n shows line numbers
|
|
76
|
+
|
|
77
|
+
cmd << "-i" if ignore_case
|
|
78
|
+
cmd << "-C" << context_lines.to_s if context_lines > 0
|
|
79
|
+
|
|
80
|
+
cmd << "-e" << pattern
|
|
81
|
+
|
|
82
|
+
if file_pattern
|
|
83
|
+
cmd << "--" << file_pattern
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
88
|
+
|
|
89
|
+
if status.success?
|
|
90
|
+
lines = stdout.force_encoding("UTF-8").split("\n")
|
|
91
|
+
|
|
92
|
+
# Apply line range (convert to 0-indexed)
|
|
93
|
+
start_idx = [line_range_start - 1, 0].max
|
|
94
|
+
end_idx = line_range_end ? line_range_end - 1 : -1
|
|
95
|
+
lines = lines[start_idx..end_idx] || []
|
|
96
|
+
|
|
97
|
+
lines = lines.map { |l| l[0..100]}
|
|
98
|
+
|
|
99
|
+
total_lines = lines.length
|
|
100
|
+
max_lines = 300
|
|
101
|
+
|
|
102
|
+
if total_lines > max_lines
|
|
103
|
+
limited_content = lines[0...max_lines].join("\n")
|
|
104
|
+
return "Returning #{max_lines} lines out of #{total_lines} total lines:\n\n#{limited_content}"
|
|
105
|
+
else
|
|
106
|
+
return lines.join("\n")
|
|
107
|
+
end
|
|
108
|
+
elsif status.exitstatus == 1
|
|
109
|
+
# Exit status 1 means no matches found
|
|
110
|
+
return "No matches found."
|
|
111
|
+
else
|
|
112
|
+
# Other error
|
|
113
|
+
return "Error: #{stderr}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module AgentC
|
|
6
|
+
module Tools
|
|
7
|
+
module Paths
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def relative_to_dir(dir, path)
|
|
11
|
+
if path.start_with?(dir)
|
|
12
|
+
path
|
|
13
|
+
else
|
|
14
|
+
File.join(dir, path)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def allowed?(dir, path)
|
|
19
|
+
child?(dir, path)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def child?(parent_dir, child_path)
|
|
23
|
+
|
|
24
|
+
unless child_path.start_with?("/")
|
|
25
|
+
child_path = File.join(parent_dir, child_path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
child = Pathname.new(child_path).expand_path
|
|
29
|
+
parent = Pathname.new(parent_dir).expand_path
|
|
30
|
+
|
|
31
|
+
relative = child.relative_path_from(parent)
|
|
32
|
+
!relative.to_s.start_with?('..')
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
raise "here" if defined?(AgentC::Tools::ReadFile)
|
|
4
|
+
|
|
5
|
+
module AgentC
|
|
6
|
+
module Tools
|
|
7
|
+
class ReadFile < RubyLLM::Tool
|
|
8
|
+
description "Reads the contents of a file"
|
|
9
|
+
|
|
10
|
+
params do
|
|
11
|
+
string(
|
|
12
|
+
:path,
|
|
13
|
+
description: <<~TXT
|
|
14
|
+
Path to file. Must be a child of current directory.
|
|
15
|
+
|
|
16
|
+
Only returns 300 lines at a time. Specify line range if possible
|
|
17
|
+
to avoid truncated responses.
|
|
18
|
+
TXT
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
number(
|
|
22
|
+
:line_range_start,
|
|
23
|
+
description: "Read lines on or after this line number.",
|
|
24
|
+
required: false
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
number(
|
|
28
|
+
:line_range_end,
|
|
29
|
+
description: "Read lines on or before this line number.",
|
|
30
|
+
required: false
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :workspace_dir
|
|
35
|
+
def initialize(workspace_dir: nil, **)
|
|
36
|
+
raise ArgumentError, "workspace_dir is required" unless workspace_dir
|
|
37
|
+
@workspace_dir = workspace_dir
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def execute(path:, line_range_start: 0, line_range_end: nil, **params)
|
|
42
|
+
if params.any?
|
|
43
|
+
return "The following params were passed but are not allowed: #{params.keys.join(",")}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
unless Paths.allowed?(workspace_dir, path)
|
|
47
|
+
return "Path: #{path} not acceptable. Must be a child of directory: #{workspace_dir}."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
workspace_path = Paths.relative_to_dir(workspace_dir, path)
|
|
51
|
+
|
|
52
|
+
unless File.exist?(workspace_path)
|
|
53
|
+
return "File not found"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
all_lines = File.read(workspace_path).split("\n")
|
|
57
|
+
lines = all_lines[line_range_start..line_range_end]
|
|
58
|
+
|
|
59
|
+
total_lines = lines.length
|
|
60
|
+
max_lines = 300
|
|
61
|
+
|
|
62
|
+
# Determine the actual line range being returned (1-indexed for display)
|
|
63
|
+
actual_start = line_range_start + 1
|
|
64
|
+
actual_end = line_range_end ? [line_range_end + 1, all_lines.length].min : all_lines.length
|
|
65
|
+
|
|
66
|
+
if total_lines > max_lines
|
|
67
|
+
limited_content = format_with_line_numbers(lines[0...max_lines], actual_start)
|
|
68
|
+
limited_end = actual_start + max_lines - 1
|
|
69
|
+
"Returning lines #{actual_start}-#{limited_end}, out of #{total_lines} total lines:\n\n#{limited_content}"
|
|
70
|
+
elsif line_range_start > 0 || line_range_end
|
|
71
|
+
# Line range was specified
|
|
72
|
+
formatted_content = format_with_line_numbers(lines, actual_start)
|
|
73
|
+
"Returning lines #{actual_start}-#{actual_end}, out of #{all_lines.length} total lines:\n\n#{formatted_content}"
|
|
74
|
+
else
|
|
75
|
+
format_with_line_numbers(lines, 1)
|
|
76
|
+
end
|
|
77
|
+
rescue => e
|
|
78
|
+
"File read error: #{e.class}:#{e.message}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Format lines with line numbers
|
|
84
|
+
def format_with_line_numbers(lines, starting_line_num)
|
|
85
|
+
max_line_num = starting_line_num + lines.length - 1
|
|
86
|
+
width = max_line_num.to_s.length
|
|
87
|
+
lines.map.with_index do |line, idx|
|
|
88
|
+
line_num = starting_line_num + idx
|
|
89
|
+
"%#{width}d: %s" % [line_num, line]
|
|
90
|
+
end.join("\n")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module AgentC
|
|
6
|
+
module Tools
|
|
7
|
+
class RunRailsTest < RubyLLM::Tool
|
|
8
|
+
description "Runs a minitest rails test using bin/rails test {path} --name={name_of_test_method}"
|
|
9
|
+
|
|
10
|
+
params do
|
|
11
|
+
string(
|
|
12
|
+
:path,
|
|
13
|
+
description: "Path to file. Must be a child of current directory.",
|
|
14
|
+
required: true
|
|
15
|
+
)
|
|
16
|
+
string(
|
|
17
|
+
:test_method_name,
|
|
18
|
+
description: "The name of the specific test method to run",
|
|
19
|
+
required: false
|
|
20
|
+
)
|
|
21
|
+
boolean(
|
|
22
|
+
:disable_spring,
|
|
23
|
+
description: <<~TXT,
|
|
24
|
+
Disable spring if the errors are weird and you want to make sure
|
|
25
|
+
it's not a spring issue. Prefer to use spring unless you are
|
|
26
|
+
encountering weird behavior.
|
|
27
|
+
TXT
|
|
28
|
+
required: false
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attr_reader :workspace_dir, :env
|
|
33
|
+
def initialize(
|
|
34
|
+
workspace_dir: nil,
|
|
35
|
+
env: {},
|
|
36
|
+
**
|
|
37
|
+
)
|
|
38
|
+
raise ArgumentError, "workspace_dir is required" unless workspace_dir
|
|
39
|
+
@env = env
|
|
40
|
+
@workspace_dir = workspace_dir
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def execute(path:, test_method_name: nil, disable_spring: false, **params)
|
|
44
|
+
|
|
45
|
+
# Spring hangs, need to timeout unresponsive shells
|
|
46
|
+
disable_spring = true
|
|
47
|
+
|
|
48
|
+
if params.any?
|
|
49
|
+
return "The following params were passed but are not allowed: #{params.keys.join(",")}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
unless Paths.allowed?(workspace_dir, path)
|
|
53
|
+
return "Path: #{path} not acceptable. Must be a child of directory: #{workspace_dir}."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
workspace_path = Paths.relative_to_dir(workspace_dir, path)
|
|
57
|
+
|
|
58
|
+
env_string = env.is_a?(Hash) ? env.map { |k, v| "#{k}=#{Shellwords.escape(v)}"}.join(" ") : env
|
|
59
|
+
|
|
60
|
+
env_string += " DISABLE_SPRING=1" if disable_spring
|
|
61
|
+
|
|
62
|
+
cmd = <<~TXT.chomp
|
|
63
|
+
cd #{workspace_dir} && \
|
|
64
|
+
#{env_string} bundle exec rails test #{path} #{test_method_name && "--name='#{test_method_name}'"}
|
|
65
|
+
TXT
|
|
66
|
+
|
|
67
|
+
lines = []
|
|
68
|
+
result = nil
|
|
69
|
+
Bundler.with_unbundled_env do
|
|
70
|
+
result = shell.run(cmd) do |stream, line|
|
|
71
|
+
lines << "[#{stream}] #{line}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
return <<~TXT
|
|
76
|
+
Command exited #{result.success? ? "successfully" : "with non-zero exit code"}
|
|
77
|
+
---
|
|
78
|
+
#{lines.join("\n")}
|
|
79
|
+
TXT
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def shell
|
|
83
|
+
AgentC::Utils::Shell
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentC
|
|
4
|
+
module Tools
|
|
5
|
+
NAMES = {
|
|
6
|
+
read_file: ReadFile,
|
|
7
|
+
edit_file: EditFile,
|
|
8
|
+
grep: Grep,
|
|
9
|
+
file_metadata: FileMetadata,
|
|
10
|
+
dir_glob: DirGlob,
|
|
11
|
+
run_rails_test: RunRailsTest,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
def self.all(**params)
|
|
15
|
+
NAMES.values.map { _1.new(**params) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.resolve(value:, available_tools:, args:, workspace_dir:)
|
|
19
|
+
|
|
20
|
+
# ensure any args passed have a
|
|
21
|
+
# workspace_dir.
|
|
22
|
+
resolved_args = (
|
|
23
|
+
if args.key?(:workspace_dir)
|
|
24
|
+
args
|
|
25
|
+
else
|
|
26
|
+
args.merge(workspace_dir:)
|
|
27
|
+
end
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# If they passed a tool instance, nothing to do
|
|
31
|
+
if value.is_a?(RubyLLM::Tool)
|
|
32
|
+
return value
|
|
33
|
+
elsif value.is_a?(Symbol) || value.is_a?(String)
|
|
34
|
+
# They passed the tool name
|
|
35
|
+
# we must initialize it with
|
|
36
|
+
# the standard args
|
|
37
|
+
tool_name = value.to_sym
|
|
38
|
+
unless available_tools.key?(tool_name)
|
|
39
|
+
raise ArgumentError, <<~TXT
|
|
40
|
+
Unknown tool name: #{value.inspect}.
|
|
41
|
+
If you wish to use a custom tool you must configure
|
|
42
|
+
it by passing `extra_tools` to the Session.
|
|
43
|
+
TXT
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
klass_or_instance = available_tools.fetch(tool_name)
|
|
47
|
+
|
|
48
|
+
if klass_or_instance.is_a?(RubyLLM::Tool)
|
|
49
|
+
klass_or_instance
|
|
50
|
+
else
|
|
51
|
+
klass_or_instance.new(**resolved_args)
|
|
52
|
+
end
|
|
53
|
+
elsif value.is_a?(Class) && value.ancestors.include?(RubyLLM::Tool)
|
|
54
|
+
value.new(**resolved_args)
|
|
55
|
+
else
|
|
56
|
+
raise ArgumentError, "unknown tool specified: #{value.inspect}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module AgentC
|
|
6
|
+
module Utils
|
|
7
|
+
# Git utility class for managing Git operations in worktrees
|
|
8
|
+
class Git
|
|
9
|
+
attr_reader :repo_path
|
|
10
|
+
|
|
11
|
+
def initialize(repo_path)
|
|
12
|
+
@repo_path = repo_path
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create_worktree(dir:, branch:, revision:)
|
|
16
|
+
# Prune any stale worktrees first
|
|
17
|
+
shell.run!("cd #{repo_path} && git worktree prune")
|
|
18
|
+
|
|
19
|
+
# Remove worktree at dir if it exists (don't fail if it doesn't exist)
|
|
20
|
+
shell.run!("cd #{repo_path} && (git worktree remove #{Shellwords.escape(dir)} --force 2>/dev/null || true)")
|
|
21
|
+
|
|
22
|
+
shell.run!(
|
|
23
|
+
<<~TXT
|
|
24
|
+
cd #{repo_path} && \
|
|
25
|
+
git worktree add \
|
|
26
|
+
-B #{Shellwords.escape(branch)} \
|
|
27
|
+
#{Shellwords.escape(dir)} \
|
|
28
|
+
#{Shellwords.escape(revision)}
|
|
29
|
+
TXT
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def diff
|
|
34
|
+
shell.run!("cd #{repo_path} && git diff")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def last_revision
|
|
38
|
+
shell.run!("cd #{repo_path} && git rev-parse @").strip
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def commit_all(message)
|
|
42
|
+
shell.run!("cd #{repo_path} && git add --all && git commit --no-gpg-sign -m #{Shellwords.escape(message)}")
|
|
43
|
+
last_revision
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def fixup_commit(revision)
|
|
47
|
+
shell.run!("cd #{repo_path} && git add --all && git commit --no-gpg-sign --fixup #{revision}")
|
|
48
|
+
last_revision
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def reset_hard_all
|
|
52
|
+
shell.run!("cd #{repo_path} && git add --all && git reset --hard")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def clean?
|
|
56
|
+
!uncommitted_changes?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def uncommitted_changes?
|
|
60
|
+
# Check for any changes including untracked files
|
|
61
|
+
# Returns true if there are uncommitted changes (staged, unstaged, or untracked)
|
|
62
|
+
status = shell.run!("cd #{repo_path} && git status --porcelain")
|
|
63
|
+
!status.strip.empty?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def shell
|
|
69
|
+
AgentC::Utils::Shell
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# TODO: Add more utility classes here as needed
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module AgentC
|
|
7
|
+
module Utils
|
|
8
|
+
class Shell
|
|
9
|
+
Error = Class.new(StandardError)
|
|
10
|
+
class << self
|
|
11
|
+
Result = Struct.new(:command, :success, :stdout, :stderr, keyword_init: true) do
|
|
12
|
+
def success?
|
|
13
|
+
success
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run!(...)
|
|
18
|
+
# only returns stdout just because.
|
|
19
|
+
run(...)
|
|
20
|
+
.tap { raise "command failed: \n#{_1.inspect}" unless _1.success? }
|
|
21
|
+
.stdout
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run(command)
|
|
25
|
+
Open3.popen3(command) do |_in, stdout, stderr, wait_thr|
|
|
26
|
+
process_stdout = []
|
|
27
|
+
stdout_thr = Thread.new do
|
|
28
|
+
while line = stdout.gets&.chomp
|
|
29
|
+
yield(:stdout, line) if block_given?
|
|
30
|
+
process_stdout << line
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
process_stderr = []
|
|
35
|
+
stderr_thr = Thread.new do
|
|
36
|
+
while line = stderr.gets&.chomp
|
|
37
|
+
yield(:stderr, line) if block_given?
|
|
38
|
+
process_stderr << line
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
[
|
|
43
|
+
stderr_thr,
|
|
44
|
+
stdout_thr,
|
|
45
|
+
].each(&:join)
|
|
46
|
+
|
|
47
|
+
Result.new(
|
|
48
|
+
command: command,
|
|
49
|
+
success: wait_thr.value.success?,
|
|
50
|
+
stdout: process_stdout.join("\n"),
|
|
51
|
+
stderr: process_stderr.join("\n"),
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|