rubycode 0.1.2 ā 0.1.3
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/.rubocop.yml +4 -0
- data/README.md +33 -4
- data/config/locales/en.yml +87 -0
- data/config/system_prompt.md +54 -0
- data/config/tools/done.json +2 -2
- data/config/tools/update.json +25 -0
- data/config/tools/write.json +21 -0
- data/docs/images/demo.png +0 -0
- data/lib/rubycode/adapters/ollama.rb +76 -3
- data/lib/rubycode/agent_loop.rb +41 -16
- data/lib/rubycode/client/approval_handler.rb +70 -0
- data/lib/rubycode/client/display_formatter.rb +32 -12
- data/lib/rubycode/client/response_handler.rb +20 -12
- data/lib/rubycode/client.rb +25 -36
- data/lib/rubycode/configuration.rb +8 -1
- data/lib/rubycode/database.rb +50 -0
- data/lib/rubycode/errors.rb +12 -0
- data/lib/rubycode/models/base.rb +68 -0
- data/lib/rubycode/models/memory.rb +57 -0
- data/lib/rubycode/models.rb +4 -0
- data/lib/rubycode/tools/base.rb +1 -10
- data/lib/rubycode/tools/bash.rb +10 -7
- data/lib/rubycode/tools/read.rb +3 -0
- data/lib/rubycode/tools/update.rb +80 -0
- data/lib/rubycode/tools/write.rb +57 -0
- data/lib/rubycode/tools.rb +4 -0
- data/lib/rubycode/version.rb +1 -1
- data/lib/rubycode/views/agent_loop/adapter_error.rb +14 -0
- data/lib/rubycode/views/agent_loop/iteration_footer.rb +17 -0
- data/lib/rubycode/views/agent_loop/iteration_header.rb +24 -0
- data/lib/rubycode/views/agent_loop/response_received.rb +17 -0
- data/lib/rubycode/views/agent_loop/retry_status.rb +14 -0
- data/lib/rubycode/views/agent_loop/thinking_status.rb +17 -0
- data/lib/rubycode/views/agent_loop/tool_error.rb +14 -0
- data/lib/rubycode/views/agent_loop.rb +8 -0
- data/lib/rubycode/views/bash_approval.rb +28 -0
- data/lib/rubycode/views/cli/configuration_table.rb +28 -0
- data/lib/rubycode/views/cli/error_display.rb +19 -0
- data/lib/rubycode/views/cli/error_message.rb +17 -0
- data/lib/rubycode/views/cli/exit_message.rb +17 -0
- data/lib/rubycode/views/cli/interrupt_message.rb +17 -0
- data/lib/rubycode/views/cli/memory_cleared_message.rb +17 -0
- data/lib/rubycode/views/cli/ready_message.rb +17 -0
- data/lib/rubycode/views/cli/response_box.rb +29 -0
- data/lib/rubycode/views/cli.rb +11 -0
- data/lib/rubycode/views/formatter/debug_tool_info.rb +17 -0
- data/lib/rubycode/views/formatter/info_message.rb +17 -0
- data/lib/rubycode/views/formatter/minimal_tool_info.rb +26 -0
- data/lib/rubycode/views/formatter/tool_result.rb +20 -0
- data/lib/rubycode/views/formatter.rb +7 -0
- data/lib/rubycode/views/response_handler/agent_finished.rb +31 -0
- data/lib/rubycode/views/response_handler/complete_message.rb +31 -0
- data/lib/rubycode/views/response_handler/max_iterations.rb +29 -0
- data/lib/rubycode/views/response_handler/max_tool_calls.rb +29 -0
- data/lib/rubycode/views/response_handler/tool_injection_warning.rb +17 -0
- data/lib/rubycode/views/response_handler.rb +8 -0
- data/lib/rubycode/views/skip_notification.rb +15 -0
- data/lib/rubycode/views/update_approval.rb +36 -0
- data/lib/rubycode/views/welcome.rb +27 -0
- data/lib/rubycode/views/write_approval.rb +42 -0
- data/lib/rubycode/views.rb +12 -0
- data/lib/rubycode.rb +9 -1
- data/rubycode_cli.rb +41 -51
- metadata +220 -5
- data/lib/rubycode/history.rb +0 -39
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "pastel"
|
|
4
5
|
|
|
5
6
|
module RubyCode
|
|
6
7
|
class Client
|
|
7
8
|
# Handles formatting and display of tool information and results
|
|
8
9
|
class DisplayFormatter
|
|
9
|
-
|
|
10
|
-
"bash" => ["
|
|
11
|
-
"read" => ["
|
|
12
|
-
"search" => ["
|
|
10
|
+
TOOL_LABELS = {
|
|
11
|
+
"bash" => ["[BASH]", "command"],
|
|
12
|
+
"read" => ["[READ]", "file_path"],
|
|
13
|
+
"search" => ["[SEARCH]", "pattern"],
|
|
14
|
+
"write" => ["[WRITE]", "file_path"],
|
|
15
|
+
"update" => ["[UPDATE]", "file_path"]
|
|
13
16
|
}.freeze
|
|
14
17
|
|
|
15
18
|
def initialize(config:)
|
|
16
19
|
@config = config
|
|
20
|
+
@pastel = Pastel.new
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
def display_tool_info(tool_name, arguments)
|
|
20
24
|
if @config.debug
|
|
21
|
-
puts
|
|
22
|
-
puts " Args: #{arguments.inspect}"
|
|
25
|
+
puts Views::Formatter::DebugToolInfo.build(tool_name: tool_name, arguments: arguments)
|
|
23
26
|
else
|
|
24
27
|
display_minimal_tool_info(tool_name, arguments)
|
|
25
28
|
end
|
|
@@ -28,19 +31,36 @@ module RubyCode
|
|
|
28
31
|
def display_result(result)
|
|
29
32
|
return unless @config.debug
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
puts Views::Formatter::ToolResult.build(result: result)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def display_info(message)
|
|
38
|
+
puts Views::Formatter::InfoMessage.build(message: message)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def display_skip_notification(_tool_name, detail)
|
|
42
|
+
puts @pastel.yellow(" ā Skipped: #{detail}")
|
|
34
43
|
end
|
|
35
44
|
|
|
36
45
|
private
|
|
37
46
|
|
|
38
47
|
def display_minimal_tool_info(tool_name, arguments)
|
|
39
|
-
return unless
|
|
48
|
+
return unless TOOL_LABELS.key?(tool_name)
|
|
40
49
|
|
|
41
|
-
|
|
50
|
+
label, key = TOOL_LABELS[tool_name]
|
|
42
51
|
value = extract_argument_value(arguments, key)
|
|
43
|
-
|
|
52
|
+
return unless value
|
|
53
|
+
|
|
54
|
+
puts Views::Formatter::MinimalToolInfo.build(
|
|
55
|
+
label: label,
|
|
56
|
+
value: truncate_value(value, 60)
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def truncate_value(value, max_length)
|
|
61
|
+
return value if value.length <= max_length
|
|
62
|
+
|
|
63
|
+
"#{value[0...(max_length - 3)]}..."
|
|
44
64
|
end
|
|
45
65
|
|
|
46
66
|
def extract_argument_value(arguments, key)
|
|
@@ -7,15 +7,16 @@ module RubyCode
|
|
|
7
7
|
MAX_ITERATIONS = 25
|
|
8
8
|
MAX_TOOL_CALLS = 50
|
|
9
9
|
|
|
10
|
-
def initialize(
|
|
11
|
-
@
|
|
10
|
+
def initialize(memory:, config:)
|
|
11
|
+
@memory = memory
|
|
12
12
|
@config = config
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def handle_max_iterations(_iteration)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
puts Views::ResponseHandler::MaxIterations.build(max_iterations: MAX_ITERATIONS)
|
|
17
|
+
|
|
18
|
+
error_msg = I18n.t("rubycode.errors.max_iterations_reached")
|
|
19
|
+
@memory.add_message(role: "assistant", content: error_msg)
|
|
19
20
|
error_msg
|
|
20
21
|
end
|
|
21
22
|
|
|
@@ -25,27 +26,34 @@ module RubyCode
|
|
|
25
26
|
return nil
|
|
26
27
|
end
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
unless @config.debug
|
|
30
|
+
puts Views::ResponseHandler::CompleteMessage.build(iteration: iteration,
|
|
31
|
+
total_tool_calls: total_tool_calls)
|
|
32
|
+
end
|
|
29
33
|
content
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
def handle_max_tool_calls(content, _total_tool_calls)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
puts Views::ResponseHandler::MaxToolCalls.build(max_tool_calls: MAX_TOOL_CALLS)
|
|
38
|
+
|
|
39
|
+
error_msg = I18n.t("rubycode.errors.max_tool_calls_reached")
|
|
40
|
+
@memory.add_message(role: "assistant", content: error_msg)
|
|
36
41
|
content.empty? ? error_msg : content
|
|
37
42
|
end
|
|
38
43
|
|
|
39
44
|
def finalize_response(done_result, iteration, total_tool_calls)
|
|
40
|
-
|
|
45
|
+
unless @config.debug
|
|
46
|
+
puts Views::ResponseHandler::AgentFinished.build(iteration: iteration,
|
|
47
|
+
total_tool_calls: total_tool_calls + 1)
|
|
48
|
+
end
|
|
41
49
|
done_result
|
|
42
50
|
end
|
|
43
51
|
|
|
44
52
|
private
|
|
45
53
|
|
|
46
54
|
def inject_tool_reminder(iteration)
|
|
47
|
-
puts
|
|
48
|
-
@
|
|
55
|
+
puts Views::ResponseHandler::ToolInjectionWarning.build(iteration: iteration) unless @config.debug
|
|
56
|
+
@memory.add_message(
|
|
49
57
|
role: "user",
|
|
50
58
|
content: "You MUST call a tool. Do not respond with text. Call search, read, bash, or done tool now."
|
|
51
59
|
)
|
data/lib/rubycode/client.rb
CHANGED
|
@@ -1,30 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
3
5
|
module RubyCode
|
|
4
6
|
# Main client that provides the public API for the agent
|
|
5
7
|
class Client
|
|
6
|
-
attr_reader :
|
|
8
|
+
attr_reader :memory
|
|
7
9
|
|
|
8
|
-
def initialize
|
|
10
|
+
def initialize(tty_prompt: nil)
|
|
9
11
|
@config = RubyCode.config
|
|
10
12
|
@adapter = build_adapter
|
|
11
|
-
@
|
|
13
|
+
@memory = Memory.new
|
|
14
|
+
@read_files = Set.new
|
|
15
|
+
@tty_prompt = tty_prompt
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
def ask(prompt:)
|
|
15
|
-
@
|
|
19
|
+
@memory.add_message(role: "user", content: prompt)
|
|
16
20
|
system_prompt = build_system_prompt
|
|
17
21
|
|
|
18
22
|
AgentLoop.new(
|
|
19
23
|
adapter: @adapter,
|
|
20
|
-
|
|
24
|
+
memory: @memory,
|
|
21
25
|
config: @config,
|
|
22
|
-
system_prompt: system_prompt
|
|
26
|
+
system_prompt: system_prompt,
|
|
27
|
+
options: { read_files: @read_files, tty_prompt: @tty_prompt }
|
|
23
28
|
).run
|
|
24
29
|
end
|
|
25
30
|
|
|
26
|
-
def
|
|
27
|
-
@
|
|
31
|
+
def clear_memory
|
|
32
|
+
@memory.clear
|
|
33
|
+
@read_files.clear
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
private
|
|
@@ -34,41 +40,24 @@ module RubyCode
|
|
|
34
40
|
when :ollama
|
|
35
41
|
Adapters::Ollama.new(@config)
|
|
36
42
|
else
|
|
37
|
-
raise "
|
|
43
|
+
raise I18n.t("rubycode.errors.unknown_adapter")
|
|
38
44
|
end
|
|
39
45
|
end
|
|
40
46
|
|
|
41
47
|
def build_system_prompt
|
|
42
48
|
context = ContextBuilder.new(root_path: @config.root_path).environment_context
|
|
49
|
+
instructions = load_system_prompt
|
|
43
50
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
You MUST call a tool in EVERY response. You MUST NEVER respond with just text.
|
|
51
|
-
|
|
52
|
-
# Available tools
|
|
53
|
-
- bash: run any safe command (ls, find, grep, cat, etc.)
|
|
54
|
-
- search: simplified search (use bash + grep for more control)
|
|
55
|
-
- read: view file contents with line numbers
|
|
56
|
-
- done: call when you have the answer
|
|
57
|
-
|
|
58
|
-
# Recommended workflow
|
|
59
|
-
1. Use bash with grep to search: `grep -rn "pattern" directory/`
|
|
60
|
-
2. Use bash with find to locate files: `find . -name "*.rb"`
|
|
61
|
-
3. Once found ā use read to see the file
|
|
62
|
-
4. When ready ā call done with your final answer
|
|
63
|
-
|
|
64
|
-
# Example searches
|
|
65
|
-
- `grep -rn "button" app/views` - search for "button" in views
|
|
66
|
-
- `grep -ri "new product" .` - case-insensitive search
|
|
67
|
-
- `find . -name "*product*"` - find files with "product" in name
|
|
51
|
+
[
|
|
52
|
+
instructions,
|
|
53
|
+
"",
|
|
54
|
+
context
|
|
55
|
+
].join("\n")
|
|
56
|
+
end
|
|
68
57
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
58
|
+
def load_system_prompt
|
|
59
|
+
prompt_path = File.join(__dir__, "..", "..", "config", "system_prompt.md")
|
|
60
|
+
File.read(prompt_path)
|
|
72
61
|
end
|
|
73
62
|
end
|
|
74
63
|
end
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module RubyCode
|
|
4
4
|
# Configuration class for Rubycode settings
|
|
5
5
|
class Configuration
|
|
6
|
-
attr_accessor :adapter, :url, :model, :root_path, :debug, :enable_tool_injection_workaround
|
|
6
|
+
attr_accessor :adapter, :url, :model, :root_path, :debug, :enable_tool_injection_workaround,
|
|
7
|
+
:http_read_timeout, :http_open_timeout, :max_retries, :retry_base_delay
|
|
7
8
|
|
|
8
9
|
def initialize
|
|
9
10
|
@adapter = :ollama
|
|
@@ -16,6 +17,12 @@ module RubyCode
|
|
|
16
17
|
# When enabled, injects reminder messages if model generates text instead of calling tools
|
|
17
18
|
# Enabled by default as most models need this nudge
|
|
18
19
|
@enable_tool_injection_workaround = true
|
|
20
|
+
|
|
21
|
+
# HTTP timeout and retry configuration
|
|
22
|
+
@http_read_timeout = 120 # 2 minutes for LLM inference
|
|
23
|
+
@http_open_timeout = 10 # 10 seconds for connection
|
|
24
|
+
@max_retries = 3 # Number of retries (4 total attempts)
|
|
25
|
+
@retry_base_delay = 2.0 # Base delay for exponential backoff
|
|
19
26
|
end
|
|
20
27
|
end
|
|
21
28
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sequel"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module RubyCode
|
|
7
|
+
# Database connection manager for all models
|
|
8
|
+
class Database
|
|
9
|
+
class << self
|
|
10
|
+
attr_accessor :db
|
|
11
|
+
|
|
12
|
+
def connect(db_path: nil)
|
|
13
|
+
path = db_path || default_db_path
|
|
14
|
+
@db = Sequel.sqlite(path)
|
|
15
|
+
run_migrations
|
|
16
|
+
@db
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def disconnect
|
|
20
|
+
@db&.disconnect
|
|
21
|
+
@db = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def connection
|
|
25
|
+
@db || connect
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def default_db_path
|
|
31
|
+
dir = File.join(Dir.home, ".rubycode")
|
|
32
|
+
FileUtils.mkdir_p(dir)
|
|
33
|
+
File.join(dir, "memory.db")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run_migrations
|
|
37
|
+
create_messages_table
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def create_messages_table
|
|
41
|
+
@db.create_table?(:messages) do
|
|
42
|
+
primary_key :id
|
|
43
|
+
String :role, null: false
|
|
44
|
+
String :content, null: false, text: true
|
|
45
|
+
DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/rubycode/errors.rb
CHANGED
|
@@ -18,4 +18,16 @@ module RubyCode
|
|
|
18
18
|
|
|
19
19
|
# Raised when command execution fails
|
|
20
20
|
class CommandExecutionError < ToolError; end
|
|
21
|
+
|
|
22
|
+
# Base class for all adapter-related errors
|
|
23
|
+
class AdapterError < Error; end
|
|
24
|
+
|
|
25
|
+
# Raised when adapter request times out
|
|
26
|
+
class AdapterTimeoutError < AdapterError; end
|
|
27
|
+
|
|
28
|
+
# Raised when adapter cannot connect to server
|
|
29
|
+
class AdapterConnectionError < AdapterError; end
|
|
30
|
+
|
|
31
|
+
# Raised when all retry attempts are exhausted
|
|
32
|
+
class AdapterRetryExhaustedError < AdapterError; end
|
|
21
33
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
module Models
|
|
5
|
+
# Base class for all models that use the database
|
|
6
|
+
class Base
|
|
7
|
+
class << self
|
|
8
|
+
# Expose Sequel dataset for direct querying
|
|
9
|
+
# Subclasses must implement table_name
|
|
10
|
+
def dataset
|
|
11
|
+
Database.connection[table_name]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Delegate common Sequel methods to dataset
|
|
15
|
+
def where(*args)
|
|
16
|
+
dataset.where(*args)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def order(field, direction = :asc)
|
|
20
|
+
if direction == :desc
|
|
21
|
+
dataset.order(Sequel.desc(field))
|
|
22
|
+
else
|
|
23
|
+
dataset.order(field)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def latest(field = :id)
|
|
28
|
+
order(field, :desc)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def oldest(field = :id)
|
|
32
|
+
order(field, :asc)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def delete
|
|
36
|
+
dataset.delete
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def all
|
|
40
|
+
dataset.all
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def first
|
|
44
|
+
dataset.first
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def last
|
|
48
|
+
dataset.order(:id).last
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def count
|
|
52
|
+
dataset.count
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Subclasses must define their table name
|
|
56
|
+
def table_name
|
|
57
|
+
raise NotImplementedError, "Subclasses must define table_name"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def db
|
|
64
|
+
Database.connection
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
# Manages conversation memory using shared database connection
|
|
5
|
+
class Memory < Models::Base
|
|
6
|
+
class << self
|
|
7
|
+
def table_name
|
|
8
|
+
:messages
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Accept either Message objects or keyword arguments for backwards compatibility
|
|
13
|
+
def add_message(message = nil, role: nil, content: nil)
|
|
14
|
+
if message.is_a?(Message)
|
|
15
|
+
insert_message(message.role, message.content)
|
|
16
|
+
elsif role && content
|
|
17
|
+
insert_message(role, content)
|
|
18
|
+
else
|
|
19
|
+
raise ArgumentError, "Must provide either a Message object or role: and content: keyword arguments"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def messages
|
|
24
|
+
db[:messages].order(:id).map do |row|
|
|
25
|
+
Message.new(role: row[:role], content: row[:content])
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_llm_format
|
|
30
|
+
messages.map(&:to_h)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def clear
|
|
34
|
+
db[:messages].delete
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def last_user_message
|
|
38
|
+
row = self.class.latest.where(role: "user").first
|
|
39
|
+
return nil unless row
|
|
40
|
+
|
|
41
|
+
Message.new(role: row[:role], content: row[:content])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def last_assistant_message
|
|
45
|
+
row = self.class.latest.where(role: "assistant").first
|
|
46
|
+
return nil unless row
|
|
47
|
+
|
|
48
|
+
Message.new(role: row[:role], content: row[:content])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def insert_message(role, content)
|
|
54
|
+
db[:messages].insert(role: role, content: content)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/rubycode/tools/base.rb
CHANGED
|
@@ -4,7 +4,7 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module RubyCode
|
|
6
6
|
module Tools
|
|
7
|
-
# Base class for all tools
|
|
7
|
+
# Base class for all tools that can be executed by the AI agent
|
|
8
8
|
class Base
|
|
9
9
|
attr_reader :context
|
|
10
10
|
|
|
@@ -12,25 +12,20 @@ module RubyCode
|
|
|
12
12
|
@context = context
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# Public execute method that validates and calls perform
|
|
16
15
|
def execute(params)
|
|
17
16
|
validate_params!(params)
|
|
18
17
|
result = perform(params)
|
|
19
18
|
wrap_result(result)
|
|
20
19
|
rescue ToolError => e
|
|
21
|
-
# Re-raise tool errors as-is
|
|
22
20
|
raise e
|
|
23
21
|
rescue StandardError => e
|
|
24
|
-
# Wrap unexpected errors in ToolError
|
|
25
22
|
raise ToolError, "Error in #{self.class.name}: #{e.message}"
|
|
26
23
|
end
|
|
27
24
|
|
|
28
|
-
# Load tool schema from JSON file in config/tools/
|
|
29
25
|
def self.definition
|
|
30
26
|
@definition ||= load_schema
|
|
31
27
|
end
|
|
32
28
|
|
|
33
|
-
# Load schema from config/tools/{tool_name}.json
|
|
34
29
|
def self.load_schema
|
|
35
30
|
tool_name = name.split("::").last.downcase
|
|
36
31
|
schema_path = File.join(__dir__, "..", "..", "..", "config", "tools", "#{tool_name}.json")
|
|
@@ -42,12 +37,10 @@ module RubyCode
|
|
|
42
37
|
|
|
43
38
|
private
|
|
44
39
|
|
|
45
|
-
# Must be implemented by subclasses to perform the actual work
|
|
46
40
|
def perform(params)
|
|
47
41
|
raise NotImplementedError, "#{self.class.name} must implement #perform"
|
|
48
42
|
end
|
|
49
43
|
|
|
50
|
-
# Validates required parameters based on schema
|
|
51
44
|
def validate_params!(params)
|
|
52
45
|
return unless self.class.definition.dig(:function, :parameters, :required)
|
|
53
46
|
|
|
@@ -59,7 +52,6 @@ module RubyCode
|
|
|
59
52
|
end
|
|
60
53
|
end
|
|
61
54
|
|
|
62
|
-
# Wraps string results in ToolResult, passes through ToolResult and CommandResult
|
|
63
55
|
def wrap_result(result)
|
|
64
56
|
case result
|
|
65
57
|
when ToolResult, CommandResult
|
|
@@ -71,7 +63,6 @@ module RubyCode
|
|
|
71
63
|
end
|
|
72
64
|
end
|
|
73
65
|
|
|
74
|
-
# Helper to get root path from context
|
|
75
66
|
def root_path
|
|
76
67
|
context[:root_path]
|
|
77
68
|
end
|
data/lib/rubycode/tools/bash.rb
CHANGED
|
@@ -5,9 +5,8 @@ require "shellwords"
|
|
|
5
5
|
|
|
6
6
|
module RubyCode
|
|
7
7
|
module Tools
|
|
8
|
-
# Tool for executing
|
|
8
|
+
# Tool for executing bash commands with safety checks and approval workflow
|
|
9
9
|
class Bash < Base
|
|
10
|
-
# Whitelist of safe commands
|
|
11
10
|
SAFE_COMMANDS = %w[
|
|
12
11
|
ls
|
|
13
12
|
pwd
|
|
@@ -30,15 +29,19 @@ module RubyCode
|
|
|
30
29
|
command = params["command"].strip
|
|
31
30
|
base_command = command.split.first
|
|
32
31
|
|
|
33
|
-
|
|
32
|
+
unless SAFE_COMMANDS.include?(base_command)
|
|
33
|
+
approval_handler = context[:approval_handler]
|
|
34
|
+
unless approval_handler.request_bash_approval(command, base_command, SAFE_COMMANDS)
|
|
35
|
+
message = I18n.t("rubycode.errors.user_cancelled_bash",
|
|
36
|
+
command: base_command,
|
|
37
|
+
safe_commands: SAFE_COMMANDS.join(", "))
|
|
38
|
+
raise ToolError, message
|
|
39
|
+
end
|
|
40
|
+
end
|
|
34
41
|
|
|
35
42
|
execute_command(command)
|
|
36
43
|
end
|
|
37
44
|
|
|
38
|
-
def safe_command_error(base_command)
|
|
39
|
-
"Command '#{base_command}' is not allowed. Safe commands: #{SAFE_COMMANDS.join(", ")}"
|
|
40
|
-
end
|
|
41
|
-
|
|
42
45
|
def execute_command(command)
|
|
43
46
|
Dir.chdir(root_path) do
|
|
44
47
|
output = `#{command} 2>&1`
|
data/lib/rubycode/tools/read.rb
CHANGED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
module Tools
|
|
5
|
+
# Tool for updating existing files
|
|
6
|
+
class Update < Base
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def perform(params)
|
|
10
|
+
file_path = params["file_path"]
|
|
11
|
+
old_string = params["old_string"]
|
|
12
|
+
new_string = params["new_string"]
|
|
13
|
+
|
|
14
|
+
full_path = resolve_path(file_path)
|
|
15
|
+
validate_file_exists(file_path, full_path)
|
|
16
|
+
auto_read_file_if_needed(full_path)
|
|
17
|
+
|
|
18
|
+
content = File.read(full_path)
|
|
19
|
+
validate_string_match(content, old_string)
|
|
20
|
+
validate_uniqueness(content, old_string)
|
|
21
|
+
request_approval(file_path, old_string, new_string)
|
|
22
|
+
|
|
23
|
+
update_file(full_path, content, old_string, new_string)
|
|
24
|
+
build_result(file_path, old_string, new_string)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def validate_file_exists(file_path, full_path)
|
|
28
|
+
raise FileNotFoundError, I18n.t("rubycode.errors.file_not_found", path: file_path) unless File.exist?(full_path)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def auto_read_file_if_needed(full_path)
|
|
32
|
+
read_files = context[:read_files]
|
|
33
|
+
return if read_files&.include?(full_path)
|
|
34
|
+
|
|
35
|
+
context[:display_formatter].display_info("Auto-reading file before update...")
|
|
36
|
+
read_files&.add(full_path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate_string_match(content, old_string)
|
|
40
|
+
return if content.include?(old_string)
|
|
41
|
+
|
|
42
|
+
raise ToolError, I18n.t("rubycode.errors.string_not_found", string: old_string[0..100])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_uniqueness(content, old_string)
|
|
46
|
+
occurrences = content.scan(old_string).count
|
|
47
|
+
return if occurrences == 1
|
|
48
|
+
|
|
49
|
+
raise ToolError, I18n.t("rubycode.errors.string_not_unique", count: occurrences)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def request_approval(file_path, old_string, new_string)
|
|
53
|
+
approval_handler = context[:approval_handler]
|
|
54
|
+
return if approval_handler.request_update_approval(file_path, old_string, new_string)
|
|
55
|
+
|
|
56
|
+
raise ToolError, I18n.t("rubycode.errors.user_cancelled_update")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def update_file(full_path, content, old_string, new_string)
|
|
60
|
+
new_content = content.sub(old_string, new_string)
|
|
61
|
+
File.write(full_path, new_content)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_result(file_path, old_string, new_string)
|
|
65
|
+
ToolResult.new(
|
|
66
|
+
content: "Updated '#{file_path}' (replaced #{old_string.lines.count} lines)",
|
|
67
|
+
metadata: {
|
|
68
|
+
file_path: file_path,
|
|
69
|
+
old_lines: old_string.lines.count,
|
|
70
|
+
new_lines: new_string.lines.count
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resolve_path(file_path)
|
|
76
|
+
File.absolute_path?(file_path) ? file_path : File.join(root_path, file_path)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|