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.
@@ -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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCode
4
+ module Tools
5
+ # Tool for signaling task completion
6
+ class Done < Base
7
+ private
8
+
9
+ def perform(params)
10
+ params["answer"]
11
+ end
12
+ end
13
+ end
14
+ 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Rubycode
4
- VERSION = "0.1.0"
3
+ module RubyCode
4
+ VERSION = "0.1.2"
5
5
  end
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
- module Rubycode
6
- class Error < StandardError; end
7
- # Your code goes here...
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