rubycode 0.1.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8da87e3aaf4fd3001f396d9394df720eff277f65a2074ab639f5ca881490fb5f
4
- data.tar.gz: b5ef26c288070a2b9a2a01914765b95e9778b71473a118e6d0cc7ec9084afafd
3
+ metadata.gz: 3de3303b47050cfd2d290e35fcbf834e57c5eeb53d85a6b697e0cd554a5b05c0
4
+ data.tar.gz: 3a60d16a0f7fc111e6ccf305b1d4d0427f8b9b42296e79455f9b3caae06ce2ee
5
5
  SHA512:
6
- metadata.gz: 24f9e8016eb87418822608cfd72cdc158c45c3712767b6b18da41b4be52fa58bf49f402e4e0b9c3a5d761a0625eec0e90fb41301c340e51987201d467d4b51fb
7
- data.tar.gz: 607b13e698d45693ef2041aa89095c91c9286e1939bbb800f823e1e1c64e9d2c8e538ddde11e9f41cbec56447ab12585ff2df5c0b0bc3df236ec0ce35654614c
6
+ metadata.gz: ca04ec8192747e4e97cef35c2a8ec6440e82b65a412e7c13a979f84a5d58de92c615e90e58cebe28b8761ae05d462f4a5e9a38bdeb0388005234b76bb02edc67
7
+ data.tar.gz: 84066bb9aa138c598eedad4a48472448d253f0eaeb1551ed1041a2bb4a3fc0087cabbc4c46dffeaf14ff83c1570096ff88db3c439537debbaea0f710eb01414c
data/.rubocop.yml CHANGED
@@ -1,8 +1,16 @@
1
1
  AllCops:
2
2
  TargetRubyVersion: 3.1
3
+ NewCops: enable
3
4
 
4
5
  Style/StringLiterals:
5
6
  EnforcedStyle: double_quotes
6
7
 
7
8
  Style/StringLiteralsInInterpolation:
8
9
  EnforcedStyle: double_quotes
10
+
11
+ # Metrics - reasonable limits for maintainability
12
+ Metrics/ClassLength:
13
+ Max: 200
14
+
15
+ Metrics/MethodLength:
16
+ Max: 20
data/README.md CHANGED
@@ -1,38 +1,107 @@
1
- # Rubycode
1
+ # RubyCode
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
3
+ [![Gem Version](https://badge.fury.io/rb/rubycode.svg)](https://badge.fury.io/rb/rubycode)
4
+ [![GitHub](https://img.shields.io/github/license/jonasmedeiros/rubycode)](https://github.com/jonasmedeiros/rubycode)
4
5
 
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/rubycode`. To experiment with that code, run `bin/console` for an interactive prompt.
6
+ A Ruby-native AI coding assistant with pluggable LLM adapters. RubyCode provides an agent-based system that can explore codebases, search files, execute commands, and assist with coding tasks.
6
7
 
7
- ## Installation
8
+ **GitHub Repository**: [github.com/jonasmedeiros/rubycode](https://github.com/jonasmedeiros/rubycode)
9
+
10
+ ## Features
8
11
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
12
+ - **AI Agent Loop**: Autonomous task execution with tool calling
13
+ - **Pluggable LLM Adapters**: Currently supports Ollama, easily extendable to other LLMs
14
+ - **Built-in Tools**:
15
+ - `bash`: Execute safe bash commands for filesystem exploration
16
+ - `search`: Search file contents using grep with regex support
17
+ - `read`: Read files and directories with line numbers
18
+ - `done`: Signal task completion with final answer
19
+ - **Conversation History**: Maintains context across interactions
20
+ - **Environment Context**: Automatically provides Ruby version, platform, and working directory info
10
21
 
11
- Install the gem and add to the application's Gemfile by executing:
22
+ ## Installation
23
+
24
+ Install the gem by executing:
12
25
 
13
26
  ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
27
+ gem install rubycode
28
+ ```
29
+
30
+ Or add it to your application's Gemfile:
31
+
32
+ ```ruby
33
+ gem "rubycode"
15
34
  ```
16
35
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
36
+ Then execute:
18
37
 
19
38
  ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
39
+ bundle install
21
40
  ```
22
41
 
23
42
  ## Usage
24
43
 
25
- TODO: Write usage instructions here
44
+ ### Basic Usage
45
+
46
+ ```ruby
47
+ require "rubycode"
48
+
49
+ # Configure the LLM adapter
50
+ RubyCode.configure do |config|
51
+ config.adapter = :ollama
52
+ config.url = "http://localhost:11434"
53
+ config.model = "deepseek-v3.1:671b-cloud"
54
+ config.root_path = Dir.pwd
55
+ config.debug = false
56
+ end
57
+
58
+ # Create a client and ask a question
59
+ client = RubyCode::Client.new
60
+ response = client.ask(prompt: "Find the User model in the codebase")
61
+ puts response
62
+ ```
63
+
64
+ ### Configuration Options
65
+
66
+ ```ruby
67
+ RubyCode.configure do |config|
68
+ config.adapter = :ollama # LLM adapter to use
69
+ config.url = "http://localhost:11434" # Ollama server URL
70
+ config.model = "deepseek-v3.1:671b-cloud" # Model name
71
+ config.root_path = Dir.pwd # Project root directory
72
+ config.debug = false # Enable debug output
73
+ config.enable_tool_injection_workaround = true # Force tool usage (enabled by default)
74
+ end
75
+ ```
76
+
77
+ ### Available Tools
78
+
79
+ The agent has access to four built-in tools:
80
+
81
+ 1. **bash**: Execute safe bash commands including:
82
+ - Directory exploration: `ls`, `pwd`, `find`, `tree`
83
+ - File inspection: `cat`, `head`, `tail`, `wc`, `file`
84
+ - Content search: `grep`, `rg` (ripgrep)
85
+ - Examples: `grep -rn "button" app/views`, `find . -name "*.rb"`
86
+ 2. **search**: Simplified search wrapper (use bash + grep for more control)
87
+ 3. **read**: Read files with line numbers or list directory contents
88
+ 4. **done**: Signal completion and provide the final answer
89
+
90
+ **Note**: Tool schemas are externalized in `config/tools/*.json` for easy customization.
26
91
 
27
92
  ## Development
28
93
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
94
+ After checking out the repo, run `bundle install` to install dependencies.
95
+
96
+ To install this gem onto your local machine, run:
30
97
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
98
+ ```bash
99
+ bundle exec rake install
100
+ ```
32
101
 
33
102
  ## Contributing
34
103
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/rubycode.
104
+ Bug reports and pull requests are welcome on GitHub at https://github.com/jonasmedeiros/rubycode.
36
105
 
37
106
  ## License
38
107
 
@@ -0,0 +1,17 @@
1
+ {
2
+ "type": "function",
3
+ "function": {
4
+ "name": "bash",
5
+ "description": "Execute safe bash commands for exploring the filesystem and terminal operations.\n\nUse this for any command-line operations including:\n- Directory exploration: ls, find, tree\n- File inspection: cat, head, tail, wc, file\n- Content search: grep, rg (ripgrep)\n\nExamples:\n- grep -rn 'button' app/views\n- find . -name '*.rb' -type f\n- ls -la app/\n\nWhitelisted commands: ls, pwd, find, tree, cat, head, tail, wc, file, which, echo, grep, rg",
6
+ "parameters": {
7
+ "type": "object",
8
+ "properties": {
9
+ "command": {
10
+ "type": "string",
11
+ "description": "The bash command to execute. Examples:\n- 'grep -rn \"button\" app/views' (search for text in files)\n- 'find . -name \"*.rb\"' (find files by name)\n- 'ls -la app/' (list directory contents)"
12
+ }
13
+ },
14
+ "required": ["command"]
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "type": "function",
3
+ "function": {
4
+ "name": "done",
5
+ "description": "Call this when you have found the code and are ready to provide your final answer. This signals you are finished exploring.",
6
+ "parameters": {
7
+ "type": "object",
8
+ "properties": {
9
+ "answer": {
10
+ "type": "string",
11
+ "description": "Your final answer showing the file, line number, current code, and suggested change"
12
+ }
13
+ },
14
+ "required": ["answer"]
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "type": "function",
3
+ "function": {
4
+ "name": "read",
5
+ "description": "Read a file or directory from the filesystem.\n\n- Use this when you know the file path and want to see its contents\n- Returns file contents with line numbers (format: 'line_number: content')\n- For directories, returns a listing of entries\n- Use the search tool to find specific content in files\n- Use bash with 'ls' or 'find' to discover what files exist",
6
+ "parameters": {
7
+ "type": "object",
8
+ "properties": {
9
+ "file_path": {
10
+ "type": "string",
11
+ "description": "Absolute or relative path to the file to read"
12
+ },
13
+ "offset": {
14
+ "type": "integer",
15
+ "description": "Line number to start reading from (1-indexed). Optional."
16
+ },
17
+ "limit": {
18
+ "type": "integer",
19
+ "description": "Maximum number of lines to read. Default 2000. Optional."
20
+ }
21
+ },
22
+ "required": ["file_path"]
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "type": "function",
3
+ "function": {
4
+ "name": "search",
5
+ "description": "Search INSIDE file contents for patterns using grep. Returns matching lines with file paths and line numbers.\n\n- Searches file CONTENTS using regular expressions\n- Use this when you need to find WHERE specific text/code appears inside files\n- Returns file paths, line numbers, and the matching content\n- Example: search for 'button' to find files containing that text",
6
+ "parameters": {
7
+ "type": "object",
8
+ "properties": {
9
+ "pattern": {
10
+ "type": "string",
11
+ "description": "The pattern to search for (supports regex)"
12
+ },
13
+ "path": {
14
+ "type": "string",
15
+ "description": "Directory or file to search in. Defaults to '.' (current directory). Optional."
16
+ },
17
+ "include": {
18
+ "type": "string",
19
+ "description": "File pattern to include (e.g., '*.rb', '*.js'). Optional."
20
+ },
21
+ "case_insensitive": {
22
+ "type": "boolean",
23
+ "description": "Perform case-insensitive search. Optional."
24
+ }
25
+ },
26
+ "required": ["pattern"]
27
+ }
28
+ }
29
+ }
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Rubycode
3
+ module RubyCode
4
4
  module Adapters
5
+ # Base adapter class for LLM integrations
5
6
  class Base
6
7
  def initialize(config)
7
8
  @config = config
@@ -3,55 +3,62 @@
3
3
  require "net/http"
4
4
  require "json"
5
5
 
6
- module Rubycode
6
+ module RubyCode
7
7
  module Adapters
8
+ # Ollama adapter for local LLM integration
8
9
  class Ollama < Base
9
10
  def generate(messages:, system: nil, tools: nil)
10
11
  uri = URI("#{@config.url}/api/chat")
12
+ payload = build_payload(messages, system, tools)
13
+ request = build_request(uri, payload)
11
14
 
12
- request = Net::HTTP::Post.new(uri)
13
- request["Content-Type"] = "application/json"
15
+ debug_request(uri, payload) if @config.debug
16
+
17
+ body = send_request(uri, request)
14
18
 
15
- payload = {
16
- model: @config.model,
17
- messages: messages,
18
- stream: false
19
- }
19
+ debug_response(body) if @config.debug
20
+
21
+ body
22
+ end
23
+
24
+ private
25
+
26
+ def build_payload(messages, system, tools)
27
+ payload = { model: @config.model, messages: messages, stream: false }
20
28
  payload[:system] = system if system
21
29
  payload[:tools] = tools if tools
30
+ payload
31
+ end
22
32
 
33
+ def build_request(uri, payload)
34
+ request = Net::HTTP::Post.new(uri)
35
+ request["Content-Type"] = "application/json"
23
36
  request.body = payload.to_json
37
+ request
38
+ end
24
39
 
25
- # DEBUG: Show request if debug mode enabled
26
- if @config.debug
27
- puts "\n" + "=" * 80
28
- puts "📤 REQUEST TO LLM"
29
- puts "=" * 80
30
- puts "URL: #{uri}"
31
- puts "Model: #{@config.model}"
32
- puts "\nPayload:"
33
- puts JSON.pretty_generate(payload)
34
- puts "=" * 80
35
- end
36
-
37
- response = Net::HTTP.start(uri.hostname, uri.port) do |http|
38
- http.request(request)
39
- end
40
-
41
- body = JSON.parse(response.body)
42
-
43
- # DEBUG: Show response if debug mode enabled
44
- if @config.debug
45
- puts "\n" + "=" * 80
46
- puts "📥 RESPONSE FROM LLM"
47
- puts "=" * 80
48
- puts JSON.pretty_generate(body)
49
- puts "=" * 80 + "\n"
50
- end
51
-
52
- # /api/chat always returns this structure:
53
- # { "message": { "role": "assistant", "content": "...", "tool_calls": [...] } }
54
- body
40
+ def send_request(uri, request)
41
+ response = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(request) }
42
+ JSON.parse(response.body)
43
+ end
44
+
45
+ def debug_request(uri, payload)
46
+ puts "\n#{"=" * 80}"
47
+ puts "📤 REQUEST TO LLM"
48
+ puts "=" * 80
49
+ puts "URL: #{uri}"
50
+ puts "Model: #{@config.model}"
51
+ puts "\nPayload:"
52
+ puts JSON.pretty_generate(payload)
53
+ puts "=" * 80
54
+ end
55
+
56
+ def debug_response(body)
57
+ puts "\n#{"=" * 80}"
58
+ puts "📥 RESPONSE FROM LLM"
59
+ puts "=" * 80
60
+ puts JSON.pretty_generate(body)
61
+ puts "#{"=" * 80}\n"
55
62
  end
56
63
  end
57
64
  end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client/response_handler"
4
+ require_relative "client/display_formatter"
5
+
6
+ module RubyCode
7
+ # Manages the agent loop - iterates until task completion or limits reached
8
+ class AgentLoop
9
+ MAX_ITERATIONS = 25
10
+ MAX_TOOL_CALLS = 50
11
+
12
+ def initialize(adapter:, history:, config:, system_prompt:)
13
+ @adapter = adapter
14
+ @history = history
15
+ @config = config
16
+ @system_prompt = system_prompt
17
+ @response_handler = Client::ResponseHandler.new(history: @history, config: @config)
18
+ @display_formatter = Client::DisplayFormatter.new(config: @config)
19
+ end
20
+
21
+ def run
22
+ iteration = 0
23
+ total_tool_calls = 0
24
+
25
+ loop do
26
+ iteration += 1
27
+
28
+ return @response_handler.handle_max_iterations(iteration) if iteration > MAX_ITERATIONS
29
+
30
+ content, tool_calls = llm_response
31
+
32
+ if tool_calls.empty?
33
+ result = @response_handler.handle_empty_tool_calls(content, iteration, total_tool_calls)
34
+ return result if result
35
+
36
+ next # Continue loop for workaround case
37
+ end
38
+
39
+ total_tool_calls += tool_calls.length
40
+ return @response_handler.handle_max_tool_calls(content, total_tool_calls) if total_tool_calls > MAX_TOOL_CALLS
41
+
42
+ done_result = execute_tool_calls(tool_calls, iteration)
43
+ return @response_handler.finalize_response(done_result, iteration, total_tool_calls) if done_result
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def llm_response
50
+ messages = @history.to_llm_format
51
+ response_body = @adapter.generate(
52
+ messages: messages,
53
+ system: @system_prompt,
54
+ tools: Tools.definitions
55
+ )
56
+
57
+ assistant_message = response_body["message"]
58
+ content = assistant_message["content"] || ""
59
+ tool_calls = assistant_message["tool_calls"] || []
60
+
61
+ @history.add_message(role: "assistant", content: content)
62
+ [content, tool_calls]
63
+ end
64
+
65
+ def execute_tool_calls(tool_calls, iteration)
66
+ puts "\n🤖 Iteration #{iteration}: Calling #{tool_calls.length} tool(s)..." unless @config.debug
67
+
68
+ done_result = nil
69
+ tool_calls.each do |tool_call|
70
+ result = execute_tool(tool_call)
71
+
72
+ if tool_call.dig("function", "name") == "done"
73
+ done_result = result
74
+ break
75
+ end
76
+ end
77
+ done_result
78
+ end
79
+
80
+ def execute_tool(tool_call)
81
+ tool_name = tool_call.dig("function", "name")
82
+ arguments = tool_call.dig("function", "arguments")
83
+
84
+ @display_formatter.display_tool_info(tool_name, arguments)
85
+
86
+ params = parse_arguments(arguments)
87
+ return nil unless params
88
+
89
+ result = run_tool(tool_name, params)
90
+ return nil unless result
91
+
92
+ @display_formatter.display_result(result)
93
+ add_tool_result_to_history(tool_name, result)
94
+ result
95
+ rescue ToolError, StandardError => e
96
+ # Handle all errors - add to history and continue
97
+ handle_tool_error(e)
98
+ end
99
+
100
+ def parse_arguments(arguments)
101
+ arguments.is_a?(Hash) ? arguments : JSON.parse(arguments)
102
+ rescue JSON::ParserError => e
103
+ error_msg = "Error parsing tool arguments: #{e.message}"
104
+ puts " ✗ #{error_msg}"
105
+ @history.add_message(role: "user", content: error_msg)
106
+ nil
107
+ end
108
+
109
+ def run_tool(tool_name, params)
110
+ context = { root_path: @config.root_path }
111
+ Tools.execute(tool_name: tool_name, params: params, context: context)
112
+ rescue ToolError => e
113
+ # Re-raise tool errors to be caught by execute_tool
114
+ raise e
115
+ rescue StandardError => e
116
+ # Wrap unexpected errors
117
+ error_msg = "Error executing tool: #{e.message}"
118
+ puts " ✗ #{error_msg}"
119
+ @history.add_message(role: "user", content: error_msg)
120
+ nil
121
+ end
122
+
123
+ def add_tool_result_to_history(tool_name, result)
124
+ @history.add_message(role: "user", content: "Tool '#{tool_name}' result:\n#{result}")
125
+ end
126
+
127
+ def handle_tool_error(error)
128
+ error_msg = "Error: #{error.message}"
129
+ puts " ✗ #{error_msg}"
130
+ @history.add_message(role: "user", content: error_msg)
131
+ nil
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RubyCode
6
+ class Client
7
+ # Handles formatting and display of tool information and results
8
+ class DisplayFormatter
9
+ TOOL_ICONS = {
10
+ "bash" => ["💻", "command"],
11
+ "read" => ["📖", "file_path"],
12
+ "search" => ["🔍", "pattern"]
13
+ }.freeze
14
+
15
+ def initialize(config:)
16
+ @config = config
17
+ end
18
+
19
+ def display_tool_info(tool_name, arguments)
20
+ if @config.debug
21
+ puts "\n🔧 Tool: #{tool_name}"
22
+ puts " Args: #{arguments.inspect}"
23
+ else
24
+ display_minimal_tool_info(tool_name, arguments)
25
+ end
26
+ end
27
+
28
+ def display_result(result)
29
+ return unless @config.debug
30
+
31
+ first_line = result.lines.first&.strip || "(empty)"
32
+ suffix = result.lines.count > 1 ? "... (#{result.lines.count} lines)" : ""
33
+ puts " ✓ Result: #{first_line}#{suffix}"
34
+ end
35
+
36
+ private
37
+
38
+ def display_minimal_tool_info(tool_name, arguments)
39
+ return unless TOOL_ICONS.key?(tool_name)
40
+
41
+ icon, key = TOOL_ICONS[tool_name]
42
+ value = extract_argument_value(arguments, key)
43
+ puts " #{icon} #{value}" if value
44
+ end
45
+
46
+ def extract_argument_value(arguments, key)
47
+ arguments.is_a?(Hash) ? arguments[key] : JSON.parse(arguments)[key]
48
+ rescue JSON::ParserError
49
+ nil
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCode
4
+ class Client
5
+ # Handles response scenarios (max iterations, empty tool calls, etc.)
6
+ class ResponseHandler
7
+ MAX_ITERATIONS = 25
8
+ MAX_TOOL_CALLS = 50
9
+
10
+ def initialize(history:, config:)
11
+ @history = history
12
+ @config = config
13
+ end
14
+
15
+ def handle_max_iterations(_iteration)
16
+ error_msg = "⚠️ Reached maximum iterations (#{MAX_ITERATIONS}). The agent may be stuck in a loop."
17
+ puts "\n#{error_msg}\n"
18
+ @history.add_message(role: "assistant", content: error_msg)
19
+ error_msg
20
+ end
21
+
22
+ def handle_empty_tool_calls(content, iteration, total_tool_calls)
23
+ if @config.enable_tool_injection_workaround && iteration < 10
24
+ inject_tool_reminder(iteration)
25
+ return nil
26
+ end
27
+
28
+ puts "\n✅ Agent finished (#{iteration} iterations, #{total_tool_calls} tool calls)\n" unless @config.debug
29
+ content
30
+ end
31
+
32
+ def handle_max_tool_calls(content, _total_tool_calls)
33
+ error_msg = "⚠️ Reached maximum tool calls (#{MAX_TOOL_CALLS}). Stopping to prevent excessive operations."
34
+ puts "\n#{error_msg}\n"
35
+ @history.add_message(role: "assistant", content: error_msg)
36
+ content.empty? ? error_msg : content
37
+ end
38
+
39
+ def finalize_response(done_result, iteration, total_tool_calls)
40
+ puts "\n✅ Agent finished (#{iteration} iterations, #{total_tool_calls + 1} tool calls)\n" unless @config.debug
41
+ done_result
42
+ end
43
+
44
+ private
45
+
46
+ def inject_tool_reminder(iteration)
47
+ puts " ⚠️ No tool calls - injecting reminder (iteration #{iteration})" unless @config.debug
48
+ @history.add_message(
49
+ role: "user",
50
+ content: "You MUST call a tool. Do not respond with text. Call search, read, bash, or done tool now."
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end