agent_c 2.9
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 +105 -0
- data/agent_c.gemspec +38 -0
- data/docs/batch.md +503 -0
- data/docs/chat-methods.md +156 -0
- data/docs/cost-reporting.md +86 -0
- data/docs/pipeline-tips-and-tricks.md +453 -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 +38 -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 +152 -0
- data/lib/agent_c/pipelines/agent.rb +219 -0
- data/lib/agent_c/processor.rb +98 -0
- data/lib/agent_c/prompts.yml +53 -0
- data/lib/agent_c/schema.rb +71 -0
- data/lib/agent_c/session.rb +206 -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 +114 -0
- data/lib/agent_c/tools/file_metadata.rb +43 -0
- data/lib/agent_c/tools/git_status.rb +30 -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 +61 -0
- data/lib/agent_c/utils/git.rb +87 -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 +194 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ostruct"
|
|
4
|
+
require "json-schema"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module AgentC
|
|
8
|
+
module TestHelpers
|
|
9
|
+
# Helper to create a test session with minimal required parameters
|
|
10
|
+
def test_session(
|
|
11
|
+
agent_db_path: File.join(Dir.mktmpdir, "db.sqlite"),
|
|
12
|
+
**overrides
|
|
13
|
+
)
|
|
14
|
+
Session.new(
|
|
15
|
+
agent_db_path:,
|
|
16
|
+
project: "test_project",
|
|
17
|
+
**overrides
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# DummyChat that maps input_text => output_text for testing
|
|
22
|
+
# Use this with Session.new() by passing it as a chat_provider or record parameter
|
|
23
|
+
class DummyChat
|
|
24
|
+
attr_reader :id, :messages_history, :tools_received, :prompts_received, :invocations
|
|
25
|
+
|
|
26
|
+
def initialize(
|
|
27
|
+
responses: {},
|
|
28
|
+
prompts: [],
|
|
29
|
+
tools: [],
|
|
30
|
+
cached_prompts: [],
|
|
31
|
+
workspace_dir: nil,
|
|
32
|
+
record: nil,
|
|
33
|
+
session: nil,
|
|
34
|
+
**_options
|
|
35
|
+
)
|
|
36
|
+
@responses = responses
|
|
37
|
+
@id = "test-chat-#{rand(1000)}"
|
|
38
|
+
@messages_history = []
|
|
39
|
+
@prompts_received = prompts
|
|
40
|
+
@tools_received = tools
|
|
41
|
+
@on_end_message_blocks = []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ask(input_text)
|
|
45
|
+
# Try to find a matching response
|
|
46
|
+
_, output = (
|
|
47
|
+
@responses.find do |key, value|
|
|
48
|
+
(key.is_a?(Regexp) && input_text.match?(key)) ||
|
|
49
|
+
(key.is_a?(Proc) && key.call(input_text)) ||
|
|
50
|
+
(key == input_text)
|
|
51
|
+
end
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
output_text = (
|
|
55
|
+
if output.respond_to?(:call)
|
|
56
|
+
output.call
|
|
57
|
+
else
|
|
58
|
+
output
|
|
59
|
+
end
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
raise "No response configured for: #{input_text.inspect}" if output_text.nil?
|
|
63
|
+
|
|
64
|
+
# Create a mock message with the input
|
|
65
|
+
user_message = OpenStruct.new(
|
|
66
|
+
role: :user,
|
|
67
|
+
content: input_text,
|
|
68
|
+
to_llm: OpenStruct.new(to_h: { role: :user, content: input_text })
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Create a mock response message
|
|
72
|
+
response_message = OpenStruct.new(
|
|
73
|
+
role: :assistant,
|
|
74
|
+
content: output_text,
|
|
75
|
+
to_llm: OpenStruct.new(to_h: { role: :assistant, content: output_text })
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@messages_history << user_message
|
|
79
|
+
@messages_history << response_message
|
|
80
|
+
|
|
81
|
+
# Call all on_end_message hooks
|
|
82
|
+
@on_end_message_blocks.each { |block| block.call(response_message) }
|
|
83
|
+
|
|
84
|
+
response_message
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def get(input_text, schema: nil, **options)
|
|
88
|
+
# Similar to ask, but returns parsed JSON as a Hash for structured responses
|
|
89
|
+
response_message = ask(input_text)
|
|
90
|
+
|
|
91
|
+
json_schema = schema&.to_json_schema&.fetch(:schema)
|
|
92
|
+
|
|
93
|
+
# Parse the response content as JSON
|
|
94
|
+
begin
|
|
95
|
+
result = JSON.parse(response_message.content)
|
|
96
|
+
if json_schema.nil? || JSON::Validator.validate(json_schema, result)
|
|
97
|
+
result
|
|
98
|
+
else
|
|
99
|
+
raise "Failed to get valid response"
|
|
100
|
+
end
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
# If not valid JSON, wrap in a hash
|
|
103
|
+
{ "result" => response_message.content }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def messages(...)
|
|
108
|
+
@messages_history
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def with_tools(*tools)
|
|
112
|
+
@tools_received = tools.flatten
|
|
113
|
+
self
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def on_new_message(&block)
|
|
117
|
+
self
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def on_end_message(&block)
|
|
121
|
+
@on_end_message_blocks << block
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def on_tool_call(&block)
|
|
126
|
+
self
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def on_tool_result(&block)
|
|
130
|
+
self
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# DummyGit for testing git operations without actual git commands
|
|
135
|
+
# Use this by passing it as the git parameter to Pipeline.call
|
|
136
|
+
class DummyGit
|
|
137
|
+
attr_reader :invocations
|
|
138
|
+
|
|
139
|
+
def initialize(workspace_dir)
|
|
140
|
+
@workspace_dir = workspace_dir
|
|
141
|
+
@invocations = []
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def uncommitted_changes?
|
|
145
|
+
@has_changes ||= false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def commit_all(message)
|
|
149
|
+
@invocations << {
|
|
150
|
+
method: :commit_all,
|
|
151
|
+
args: [message],
|
|
152
|
+
params: {}
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def simulate_file_created!
|
|
157
|
+
@has_changes = true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def method_missing(method, *args, **params)
|
|
161
|
+
@invocations << {
|
|
162
|
+
method:,
|
|
163
|
+
args:,
|
|
164
|
+
params:,
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def respond_to_missing?(method, include_private = false)
|
|
169
|
+
true
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentC
|
|
4
|
+
module Tools
|
|
5
|
+
class DirGlob < RubyLLM::Tool
|
|
6
|
+
description("Find files in a directory using a ruby-compatible glob pattern.")
|
|
7
|
+
|
|
8
|
+
params do
|
|
9
|
+
string(
|
|
10
|
+
:glob_pattern,
|
|
11
|
+
description: "Only returns children paths of the 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(glob_pattern:, **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, glob_pattern)
|
|
27
|
+
return "Path: #{glob_pattern} not acceptable. Must be a child of directory: #{workspace_dir}."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
results = (
|
|
31
|
+
Dir
|
|
32
|
+
.glob(File.join(workspace_dir, glob_pattern))
|
|
33
|
+
.select { Paths.allowed?(workspace_dir, _1) }
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
warning = (
|
|
37
|
+
if results.count > 30
|
|
38
|
+
"Returning 30 of #{results.count} results"
|
|
39
|
+
end
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
[warning, results.take(30).to_json].compact.join("\n")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
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", "delete"],
|
|
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 "delete"
|
|
59
|
+
FileUtils.rm_f(path)
|
|
60
|
+
when "overwrite"
|
|
61
|
+
FileUtils.mkdir_p(File.dirname(workspace_path))
|
|
62
|
+
File.write(workspace_path, content)
|
|
63
|
+
|
|
64
|
+
when "replace_lines"
|
|
65
|
+
return "start_line and end_line required for replace_lines mode" unless start_line && end_line
|
|
66
|
+
return "start_line must be <= end_line" if start_line > end_line
|
|
67
|
+
|
|
68
|
+
lines = File.exist?(workspace_path) ? File.readlines(workspace_path, chomp: true) : []
|
|
69
|
+
|
|
70
|
+
# Convert to 0-indexed
|
|
71
|
+
start_idx = start_line - 1
|
|
72
|
+
end_idx = end_line - 1
|
|
73
|
+
|
|
74
|
+
# Replace the range with new content lines
|
|
75
|
+
new_lines = content.split("\n")
|
|
76
|
+
lines[start_idx..end_idx] = new_lines
|
|
77
|
+
|
|
78
|
+
File.write(workspace_path, lines.join("\n") + "\n")
|
|
79
|
+
|
|
80
|
+
when "insert_at_line"
|
|
81
|
+
return "start_line required for insert_at_line mode" unless start_line
|
|
82
|
+
|
|
83
|
+
lines = File.exist?(workspace_path) ? File.readlines(workspace_path, chomp: true) : []
|
|
84
|
+
|
|
85
|
+
# Insert before the specified line (1-indexed)
|
|
86
|
+
insert_idx = start_line - 1
|
|
87
|
+
insert_idx = [0, [insert_idx, lines.length].min].max # Clamp to valid range
|
|
88
|
+
|
|
89
|
+
new_lines = content.split("\n")
|
|
90
|
+
lines.insert(insert_idx, *new_lines)
|
|
91
|
+
|
|
92
|
+
File.write(workspace_path, lines.join("\n") + "\n")
|
|
93
|
+
|
|
94
|
+
when "append"
|
|
95
|
+
FileUtils.mkdir_p(File.dirname(workspace_path))
|
|
96
|
+
File.open(workspace_path, "a") do |f|
|
|
97
|
+
f.write(content)
|
|
98
|
+
f.write("\n") unless content.end_with?("\n")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Run syntax check for Ruby files
|
|
103
|
+
if workspace_path.end_with?(".rb")
|
|
104
|
+
syntax_check = `ruby -c #{workspace_path} 2>&1`
|
|
105
|
+
unless $?.success?
|
|
106
|
+
return "File successfully edited, but syntax errors were found:\n#{syntax_check}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
true
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
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,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module AgentC
|
|
6
|
+
module Tools
|
|
7
|
+
class GitStatus < RubyLLM::Tool
|
|
8
|
+
description <<~DESC
|
|
9
|
+
Return the current git status
|
|
10
|
+
DESC
|
|
11
|
+
|
|
12
|
+
params do
|
|
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(**params)
|
|
22
|
+
if params.any?
|
|
23
|
+
return "The following params were passed but are not allowed: #{params.keys.join(",")}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Utils::Git.new(workspace_dir).status
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
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
|