rubycode 0.1.0 → 0.1.2
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 +8 -0
- data/README.md +82 -13
- data/USAGE.md +93 -0
- data/config/tools/bash.json +17 -0
- data/config/tools/done.json +17 -0
- data/config/tools/read.json +25 -0
- data/config/tools/search.json +29 -0
- data/lib/rubycode/adapters/base.rb +16 -0
- data/lib/rubycode/adapters/ollama.rb +65 -0
- data/lib/rubycode/agent_loop.rb +134 -0
- data/lib/rubycode/client/display_formatter.rb +53 -0
- data/lib/rubycode/client/response_handler.rb +55 -0
- data/lib/rubycode/client.rb +74 -0
- data/lib/rubycode/configuration.rb +21 -0
- data/lib/rubycode/context_builder.rb +21 -0
- data/lib/rubycode/errors.rb +21 -0
- data/lib/rubycode/history.rb +39 -0
- data/lib/rubycode/tools/base.rb +80 -0
- data/lib/rubycode/tools/bash.rb +66 -0
- data/lib/rubycode/tools/done.rb +14 -0
- data/lib/rubycode/tools/read.rb +60 -0
- data/lib/rubycode/tools/search.rb +64 -0
- data/lib/rubycode/tools.rb +37 -0
- data/lib/rubycode/value_objects.rb +102 -0
- data/lib/rubycode/version.rb +2 -2
- data/lib/rubycode.rb +24 -3
- data/rubycode_cli.rb +88 -0
- metadata +29 -3
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
# Main client that provides the public API for the agent
|
|
5
|
+
class Client
|
|
6
|
+
attr_reader :history
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@config = RubyCode.config
|
|
10
|
+
@adapter = build_adapter
|
|
11
|
+
@history = History.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def ask(prompt:)
|
|
15
|
+
@history.add_message(role: "user", content: prompt)
|
|
16
|
+
system_prompt = build_system_prompt
|
|
17
|
+
|
|
18
|
+
AgentLoop.new(
|
|
19
|
+
adapter: @adapter,
|
|
20
|
+
history: @history,
|
|
21
|
+
config: @config,
|
|
22
|
+
system_prompt: system_prompt
|
|
23
|
+
).run
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def clear_history
|
|
27
|
+
@history.clear
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def build_adapter
|
|
33
|
+
case @config.adapter
|
|
34
|
+
when :ollama
|
|
35
|
+
Adapters::Ollama.new(@config)
|
|
36
|
+
else
|
|
37
|
+
raise "Unknown Adapter"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def build_system_prompt
|
|
42
|
+
context = ContextBuilder.new(root_path: @config.root_path).environment_context
|
|
43
|
+
|
|
44
|
+
<<~PROMPT
|
|
45
|
+
You are a helpful Ruby on Rails coding assistant.
|
|
46
|
+
|
|
47
|
+
#{context}
|
|
48
|
+
|
|
49
|
+
# CRITICAL RULE
|
|
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
|
|
68
|
+
|
|
69
|
+
IMPORTANT: You cannot respond with plain text. You must ALWAYS call one of the tools.
|
|
70
|
+
When you're ready to provide your answer, call the "done" tool with your answer as the parameter.
|
|
71
|
+
PROMPT
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
# Configuration class for Rubycode settings
|
|
5
|
+
class Configuration
|
|
6
|
+
attr_accessor :adapter, :url, :model, :root_path, :debug, :enable_tool_injection_workaround
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@adapter = :ollama
|
|
10
|
+
@url = "http://localhost:11434"
|
|
11
|
+
@model = "deepseek-v3.1:671b-cloud"
|
|
12
|
+
@root_path = Dir.pwd
|
|
13
|
+
@debug = false # Set to true to see JSON requests/responses
|
|
14
|
+
|
|
15
|
+
# WORKAROUND for models that don't follow tool-calling instructions
|
|
16
|
+
# When enabled, injects reminder messages if model generates text instead of calling tools
|
|
17
|
+
# Enabled by default as most models need this nudge
|
|
18
|
+
@enable_tool_injection_workaround = true
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
# Builds environment context for the AI assistant
|
|
5
|
+
class ContextBuilder
|
|
6
|
+
def initialize(root_path:)
|
|
7
|
+
@root_path = root_path
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def environment_context
|
|
11
|
+
<<~CONTEXT
|
|
12
|
+
<env>
|
|
13
|
+
Working directory: #{@root_path}
|
|
14
|
+
Platform: #{RUBY_PLATFORM}
|
|
15
|
+
Ruby version: #{RUBY_VERSION}
|
|
16
|
+
Today's date: #{Time.now.strftime("%Y-%m-%d")}
|
|
17
|
+
</env>
|
|
18
|
+
CONTEXT
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
# Base error class for all RubyCode errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Base class for all tool-related errors
|
|
8
|
+
class ToolError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when attempting to execute an unsafe bash command
|
|
11
|
+
class UnsafeCommandError < ToolError; end
|
|
12
|
+
|
|
13
|
+
# Raised when a file is not found
|
|
14
|
+
class FileNotFoundError < ToolError; end
|
|
15
|
+
|
|
16
|
+
# Raised when a path is invalid or outside allowed directory
|
|
17
|
+
class PathError < ToolError; end
|
|
18
|
+
|
|
19
|
+
# Raised when command execution fails
|
|
20
|
+
class CommandExecutionError < ToolError; end
|
|
21
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
# Manages conversation history between user and assistant
|
|
5
|
+
class History
|
|
6
|
+
attr_reader :messages
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@messages = []
|
|
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
|
+
@messages << message
|
|
16
|
+
elsif role && content
|
|
17
|
+
@messages << Message.new(role: role, content: 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 to_llm_format
|
|
24
|
+
@messages.map(&:to_h)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def clear
|
|
28
|
+
@messages = []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def last_user_message
|
|
32
|
+
@messages.reverse.find { |msg| msg.role == "user" }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def last_assistant_message
|
|
36
|
+
@messages.reverse.find { |msg| msg.role == "assistant" }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RubyCode
|
|
6
|
+
module Tools
|
|
7
|
+
# Base class for all tools providing common functionality
|
|
8
|
+
class Base
|
|
9
|
+
attr_reader :context
|
|
10
|
+
|
|
11
|
+
def initialize(context:)
|
|
12
|
+
@context = context
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Public execute method that validates and calls perform
|
|
16
|
+
def execute(params)
|
|
17
|
+
validate_params!(params)
|
|
18
|
+
result = perform(params)
|
|
19
|
+
wrap_result(result)
|
|
20
|
+
rescue ToolError => e
|
|
21
|
+
# Re-raise tool errors as-is
|
|
22
|
+
raise e
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
# Wrap unexpected errors in ToolError
|
|
25
|
+
raise ToolError, "Error in #{self.class.name}: #{e.message}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Load tool schema from JSON file in config/tools/
|
|
29
|
+
def self.definition
|
|
30
|
+
@definition ||= load_schema
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Load schema from config/tools/{tool_name}.json
|
|
34
|
+
def self.load_schema
|
|
35
|
+
tool_name = name.split("::").last.downcase
|
|
36
|
+
schema_path = File.join(__dir__, "..", "..", "..", "config", "tools", "#{tool_name}.json")
|
|
37
|
+
|
|
38
|
+
raise ToolError, "Schema file not found: #{schema_path}" unless File.exist?(schema_path)
|
|
39
|
+
|
|
40
|
+
JSON.parse(File.read(schema_path), symbolize_names: true)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Must be implemented by subclasses to perform the actual work
|
|
46
|
+
def perform(params)
|
|
47
|
+
raise NotImplementedError, "#{self.class.name} must implement #perform"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Validates required parameters based on schema
|
|
51
|
+
def validate_params!(params)
|
|
52
|
+
return unless self.class.definition.dig(:function, :parameters, :required)
|
|
53
|
+
|
|
54
|
+
required_params = self.class.definition.dig(:function, :parameters, :required)
|
|
55
|
+
required_params.each do |param|
|
|
56
|
+
next if params.key?(param) || params.key?(param.to_s)
|
|
57
|
+
|
|
58
|
+
raise ToolError, "Missing required parameter: #{param}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Wraps string results in ToolResult, passes through ToolResult and CommandResult
|
|
63
|
+
def wrap_result(result)
|
|
64
|
+
case result
|
|
65
|
+
when ToolResult, CommandResult
|
|
66
|
+
result
|
|
67
|
+
when String
|
|
68
|
+
ToolResult.new(content: result)
|
|
69
|
+
else
|
|
70
|
+
ToolResult.new(content: result.to_s)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Helper to get root path from context
|
|
75
|
+
def root_path
|
|
76
|
+
context[:root_path]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "English"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module RubyCode
|
|
7
|
+
module Tools
|
|
8
|
+
# Tool for executing safe bash commands
|
|
9
|
+
class Bash < Base
|
|
10
|
+
# Whitelist of safe commands
|
|
11
|
+
SAFE_COMMANDS = %w[
|
|
12
|
+
ls
|
|
13
|
+
pwd
|
|
14
|
+
find
|
|
15
|
+
tree
|
|
16
|
+
cat
|
|
17
|
+
head
|
|
18
|
+
tail
|
|
19
|
+
wc
|
|
20
|
+
file
|
|
21
|
+
which
|
|
22
|
+
echo
|
|
23
|
+
grep
|
|
24
|
+
rg
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def perform(params)
|
|
30
|
+
command = params["command"].strip
|
|
31
|
+
base_command = command.split.first
|
|
32
|
+
|
|
33
|
+
raise UnsafeCommandError, safe_command_error(base_command) unless SAFE_COMMANDS.include?(base_command)
|
|
34
|
+
|
|
35
|
+
execute_command(command)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def safe_command_error(base_command)
|
|
39
|
+
"Command '#{base_command}' is not allowed. Safe commands: #{SAFE_COMMANDS.join(", ")}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def execute_command(command)
|
|
43
|
+
Dir.chdir(root_path) do
|
|
44
|
+
output = `#{command} 2>&1`
|
|
45
|
+
exit_code = $CHILD_STATUS.exitstatus
|
|
46
|
+
|
|
47
|
+
raise CommandExecutionError, "Command failed with exit code #{exit_code}:\n#{output}" unless exit_code.zero?
|
|
48
|
+
|
|
49
|
+
CommandResult.new(
|
|
50
|
+
stdout: truncate_output(output),
|
|
51
|
+
stderr: "",
|
|
52
|
+
exit_code: exit_code
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def truncate_output(output)
|
|
58
|
+
lines = output.split("\n")
|
|
59
|
+
return output if lines.length <= 200
|
|
60
|
+
|
|
61
|
+
truncated_output = lines[0..199].join("\n")
|
|
62
|
+
"#{truncated_output}\n\n... (#{lines.length - 200} more lines truncated)"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
module Tools
|
|
5
|
+
# Tool for reading files and directories
|
|
6
|
+
class Read < Base
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def perform(params)
|
|
10
|
+
file_path = params["file_path"]
|
|
11
|
+
offset = params["offset"] || 1
|
|
12
|
+
limit = params["limit"] || 2000
|
|
13
|
+
|
|
14
|
+
full_path = resolve_path(file_path)
|
|
15
|
+
raise FileNotFoundError, "File '#{file_path}' does not exist" unless File.exist?(full_path)
|
|
16
|
+
|
|
17
|
+
return list_directory(full_path, file_path) if File.directory?(full_path)
|
|
18
|
+
|
|
19
|
+
format_file_lines(full_path, offset, limit)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def resolve_path(file_path)
|
|
23
|
+
File.absolute_path?(file_path) ? file_path : File.join(root_path, file_path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def list_directory(full_path, file_path)
|
|
27
|
+
entries = Dir.entries(full_path).reject { |e| e.start_with?(".") }.sort
|
|
28
|
+
"Directory listing for '#{file_path}':\n" + entries.map { |e| " #{e}" }.join("\n")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def format_file_lines(full_path, offset, limit)
|
|
32
|
+
lines = File.readlines(full_path)
|
|
33
|
+
start_idx = [offset - 1, 0].max
|
|
34
|
+
end_idx = [start_idx + limit - 1, lines.length - 1].min
|
|
35
|
+
|
|
36
|
+
formatted_lines = (start_idx..end_idx).map do |i|
|
|
37
|
+
format_line(lines[i], i + 1)
|
|
38
|
+
end.join("\n")
|
|
39
|
+
|
|
40
|
+
build_tool_result(formatted_lines, start_idx, end_idx, lines.length)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def build_tool_result(content, start_idx, end_idx, total_lines)
|
|
44
|
+
ToolResult.new(
|
|
45
|
+
content: content,
|
|
46
|
+
metadata: {
|
|
47
|
+
line_count: end_idx - start_idx + 1,
|
|
48
|
+
truncated: end_idx < total_lines - 1
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def format_line(line, line_num)
|
|
54
|
+
content = line.chomp
|
|
55
|
+
content = "#{content[0..2000]}..." if content.length > 2000
|
|
56
|
+
"#{line_num}: #{content}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "English"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module RubyCode
|
|
7
|
+
module Tools
|
|
8
|
+
# Tool for searching file contents with grep
|
|
9
|
+
class Search < Base
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def perform(params)
|
|
13
|
+
pattern = params["pattern"]
|
|
14
|
+
path = params["path"] || "."
|
|
15
|
+
|
|
16
|
+
full_path = resolve_path(path)
|
|
17
|
+
raise PathError, "Path '#{path}' does not exist" unless File.exist?(full_path)
|
|
18
|
+
|
|
19
|
+
command = build_grep_command(pattern, full_path, params)
|
|
20
|
+
output, exit_code = execute_grep(command)
|
|
21
|
+
|
|
22
|
+
format_output(output, exit_code, pattern)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def resolve_path(path)
|
|
26
|
+
File.absolute_path?(path) ? path : File.join(root_path, path)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build_grep_command(pattern, full_path, params)
|
|
30
|
+
[
|
|
31
|
+
"grep", "-n", "-r",
|
|
32
|
+
("-i" if params["case_insensitive"]),
|
|
33
|
+
("--include=#{Shellwords.escape(params["include"])}" if params["include"]),
|
|
34
|
+
Shellwords.escape(pattern),
|
|
35
|
+
Shellwords.escape(full_path)
|
|
36
|
+
].compact.join(" ")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def execute_grep(command)
|
|
40
|
+
output = `#{command} 2>&1`
|
|
41
|
+
[output, $CHILD_STATUS.exitstatus]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def format_output(output, exit_code, pattern)
|
|
45
|
+
case exit_code
|
|
46
|
+
when 0
|
|
47
|
+
truncate_output(output)
|
|
48
|
+
when 1
|
|
49
|
+
"No matches found for pattern: #{pattern}"
|
|
50
|
+
else
|
|
51
|
+
raise CommandExecutionError, "Error running search: #{output}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def truncate_output(output)
|
|
56
|
+
lines = output.split("\n")
|
|
57
|
+
return output if lines.length <= 100
|
|
58
|
+
|
|
59
|
+
truncated = lines[0..99].join("\n")
|
|
60
|
+
"#{truncated}\n\n... (#{lines.length - 100} more matches truncated)"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tools/base"
|
|
4
|
+
require_relative "tools/bash"
|
|
5
|
+
require_relative "tools/read"
|
|
6
|
+
require_relative "tools/search"
|
|
7
|
+
require_relative "tools/done"
|
|
8
|
+
|
|
9
|
+
module RubyCode
|
|
10
|
+
# Collection of available tools for the AI agent
|
|
11
|
+
module Tools
|
|
12
|
+
# Registry of all available tools
|
|
13
|
+
TOOLS = [
|
|
14
|
+
Bash,
|
|
15
|
+
Read,
|
|
16
|
+
Search,
|
|
17
|
+
Done
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
def self.definitions
|
|
21
|
+
TOOLS.map(&:definition)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.execute(tool_name:, params:, context:)
|
|
25
|
+
tool_class = TOOLS.find { |t| t.definition[:function][:name] == tool_name }
|
|
26
|
+
|
|
27
|
+
raise ToolError, "Unknown tool '#{tool_name}'" unless tool_class
|
|
28
|
+
|
|
29
|
+
# Instantiate tool and call execute
|
|
30
|
+
tool_instance = tool_class.new(context: context)
|
|
31
|
+
result = tool_instance.execute(params)
|
|
32
|
+
|
|
33
|
+
# Convert result to string for history compatibility
|
|
34
|
+
result.respond_to?(:to_s) ? result.to_s : result
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
# Represents a conversation message
|
|
5
|
+
class Message
|
|
6
|
+
attr_reader :role, :content, :timestamp
|
|
7
|
+
|
|
8
|
+
def initialize(role:, content:)
|
|
9
|
+
@role = role
|
|
10
|
+
@content = content
|
|
11
|
+
@timestamp = Time.now
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_h
|
|
15
|
+
{ role: role, content: content }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def ==(other)
|
|
19
|
+
other.is_a?(Message) &&
|
|
20
|
+
role == other.role &&
|
|
21
|
+
content == other.content
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Represents a tool call from the LLM
|
|
26
|
+
class ToolCall
|
|
27
|
+
attr_reader :name, :arguments
|
|
28
|
+
|
|
29
|
+
def initialize(name:, arguments:)
|
|
30
|
+
@name = name
|
|
31
|
+
@arguments = arguments
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_h
|
|
35
|
+
{ name: name, arguments: arguments }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ==(other)
|
|
39
|
+
other.is_a?(ToolCall) &&
|
|
40
|
+
name == other.name &&
|
|
41
|
+
arguments == other.arguments
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Represents the result of a tool execution
|
|
46
|
+
class ToolResult
|
|
47
|
+
attr_reader :content, :metadata
|
|
48
|
+
|
|
49
|
+
def initialize(content:, metadata: {})
|
|
50
|
+
@content = content
|
|
51
|
+
@metadata = metadata
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def to_s
|
|
55
|
+
content
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def truncated?
|
|
59
|
+
metadata[:truncated] == true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def line_count
|
|
63
|
+
metadata[:line_count]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ==(other)
|
|
67
|
+
other.is_a?(ToolResult) &&
|
|
68
|
+
content == other.content &&
|
|
69
|
+
metadata == other.metadata
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Represents the result of a bash command execution
|
|
74
|
+
class CommandResult
|
|
75
|
+
attr_reader :stdout, :stderr, :exit_code
|
|
76
|
+
|
|
77
|
+
def initialize(stdout:, stderr: "", exit_code: 0)
|
|
78
|
+
@stdout = stdout
|
|
79
|
+
@stderr = stderr
|
|
80
|
+
@exit_code = exit_code
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def success?
|
|
84
|
+
exit_code.zero?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def output
|
|
88
|
+
success? ? stdout : stderr
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def to_s
|
|
92
|
+
output
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def ==(other)
|
|
96
|
+
other.is_a?(CommandResult) &&
|
|
97
|
+
stdout == other.stdout &&
|
|
98
|
+
stderr == other.stderr &&
|
|
99
|
+
exit_code == other.exit_code
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/rubycode/version.rb
CHANGED
data/lib/rubycode.rb
CHANGED
|
@@ -1,8 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "rubycode/version"
|
|
4
|
+
require_relative "rubycode/errors"
|
|
5
|
+
require_relative "rubycode/value_objects"
|
|
6
|
+
require_relative "rubycode/configuration"
|
|
7
|
+
require_relative "rubycode/history"
|
|
8
|
+
require_relative "rubycode/context_builder"
|
|
9
|
+
require_relative "rubycode/adapters/base"
|
|
10
|
+
require_relative "rubycode/adapters/ollama"
|
|
11
|
+
require_relative "rubycode/tools"
|
|
12
|
+
require_relative "rubycode/agent_loop"
|
|
13
|
+
require_relative "rubycode/client"
|
|
4
14
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
15
|
+
# Rubycode is a Ruby-native AI coding agent with pluggable LLM adapters
|
|
16
|
+
module RubyCode
|
|
17
|
+
class << self
|
|
18
|
+
attr_accessor :configuration
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.configure
|
|
22
|
+
self.configuration ||= Configuration.new
|
|
23
|
+
yield(configuration)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.config
|
|
27
|
+
self.configuration ||= Configuration.new
|
|
28
|
+
end
|
|
8
29
|
end
|