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.
@@ -1,186 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Rubycode
3
+ module RubyCode
4
+ # Main client that provides the public API for the agent
4
5
  class Client
5
6
  attr_reader :history
6
7
 
7
8
  def initialize
8
- @config = Rubycode.config
9
+ @config = RubyCode.config
9
10
  @adapter = build_adapter
10
11
  @history = History.new
11
12
  end
12
13
 
13
- MAX_ITERATIONS = 25 # Maximum number of LLM calls per request
14
- MAX_TOOL_CALLS = 50 # Maximum total tool calls
15
-
16
14
  def ask(prompt:)
17
- # Add user message to history
18
15
  @history.add_message(role: "user", content: prompt)
19
-
20
- # Build system prompt with environment context
21
16
  system_prompt = build_system_prompt
22
17
 
23
- iteration = 0
24
- total_tool_calls = 0
25
-
26
- # Agent loop: keep calling LLM until no more tool calls
27
- loop do
28
- iteration += 1
29
-
30
- # Check iteration limit
31
- if iteration > MAX_ITERATIONS
32
- error_msg = "⚠️ Reached maximum iterations (#{MAX_ITERATIONS}). The agent may be stuck in a loop."
33
- puts "\n#{error_msg}\n"
34
- @history.add_message(role: "assistant", content: error_msg)
35
- return error_msg
36
- end
37
-
38
- # Get messages in LLM format
39
- messages = @history.to_llm_format
40
-
41
- # Get response from LLM with tools
42
- response_body = @adapter.generate(
43
- messages: messages,
44
- system: system_prompt,
45
- tools: Tools.definitions
46
- )
47
-
48
- # Extract assistant message
49
- assistant_message = response_body["message"]
50
- content = assistant_message["content"] || ""
51
- tool_calls = assistant_message["tool_calls"] || []
52
-
53
- # Add assistant response to history
54
- @history.add_message(role: "assistant", content: content)
55
-
56
- # If no tool calls, we're done
57
- if tool_calls.empty?
58
- # ============================================================================
59
- # WORKAROUND FOR WEAK TOOL-CALLING MODELS (e.g., qwen3-coder)
60
- # This is ONLY for testing with models that have poor tool-calling capabilities.
61
- # OpenCode does NOT do this - they rely on good models (Claude, GPT-4).
62
- # Enable with: config.enable_tool_injection_workaround = true
63
- # ============================================================================
64
- if @config.enable_tool_injection_workaround && iteration < 10
65
- puts " ⚠️ No tool calls - injecting reminder (iteration #{iteration})" unless @config.debug
66
-
67
- @history.add_message(
68
- role: "user",
69
- content: "You MUST call a tool. Do not respond with text. Call search, read, bash, or done tool now."
70
- )
71
- next # Continue the loop
72
- end
73
- # ============================================================================
74
- # END WORKAROUND
75
- # ============================================================================
76
-
77
- puts "\n✅ Agent finished (#{iteration} iterations, #{total_tool_calls} tool calls)\n" unless @config.debug
78
- return content
79
- end
80
-
81
- # Check tool call limit
82
- total_tool_calls += tool_calls.length
83
- if total_tool_calls > MAX_TOOL_CALLS
84
- error_msg = "⚠️ Reached maximum tool calls (#{MAX_TOOL_CALLS}). Stopping to prevent excessive operations."
85
- puts "\n#{error_msg}\n"
86
- @history.add_message(role: "assistant", content: error_msg)
87
- return content.empty? ? error_msg : content
88
- end
89
-
90
- # Execute each tool call
91
- unless @config.debug
92
- puts "\n🤖 Iteration #{iteration}: Calling #{tool_calls.length} tool(s)..."
93
- end
94
-
95
- done_result = nil
96
- tool_calls.each do |tool_call|
97
- result = execute_tool(tool_call)
98
-
99
- # Check if this was the "done" tool
100
- if tool_call.dig("function", "name") == "done"
101
- done_result = result
102
- break
103
- end
104
- end
105
-
106
- # If done was called, return the result
107
- if done_result
108
- puts "\n✅ Agent finished (#{iteration} iterations, #{total_tool_calls + 1} tool calls)\n" unless @config.debug
109
- return done_result
110
- end
111
-
112
- # Loop continues - send tool results back to LLM
113
- end
18
+ AgentLoop.new(
19
+ adapter: @adapter,
20
+ history: @history,
21
+ config: @config,
22
+ system_prompt: system_prompt
23
+ ).run
114
24
  end
115
25
 
116
- private
117
-
118
26
  def clear_history
119
27
  @history.clear
120
28
  end
121
29
 
122
30
  private
123
31
 
124
- def execute_tool(tool_call)
125
- tool_name = tool_call.dig("function", "name")
126
- arguments = tool_call.dig("function", "arguments")
127
-
128
- if @config.debug
129
- puts "\n🔧 Tool: #{tool_name}"
130
- puts " Args: #{arguments.inspect}"
131
- else
132
- # Show minimal output
133
- case tool_name
134
- when "bash"
135
- cmd = arguments.is_a?(Hash) ? arguments["command"] : JSON.parse(arguments)["command"]
136
- puts " 💻 #{cmd}"
137
- when "read"
138
- file = arguments.is_a?(Hash) ? arguments["file_path"] : JSON.parse(arguments)["file_path"]
139
- puts " 📖 #{file}"
140
- when "search"
141
- pattern = arguments.is_a?(Hash) ? arguments["pattern"] : JSON.parse(arguments)["pattern"]
142
- puts " 🔍 #{pattern}"
143
- end
144
- end
145
-
146
- begin
147
- # Arguments might be Hash or JSON string
148
- params = arguments.is_a?(Hash) ? arguments : JSON.parse(arguments)
149
-
150
- # Execute the tool
151
- context = { root_path: @config.root_path }
152
- result = Tools.execute(
153
- tool_name: tool_name,
154
- params: params,
155
- context: context
156
- )
157
-
158
- if @config.debug
159
- puts " ✓ Result: #{result.lines.first&.strip || '(empty)'}#{result.lines.count > 1 ? "... (#{result.lines.count} lines)" : ""}"
160
- end
161
-
162
- # Add tool result to history
163
- @history.add_message(
164
- role: "user",
165
- content: "Tool '#{tool_name}' result:\n#{result}"
166
- )
167
-
168
- # Return the result so caller can check if it was "done"
169
- result
170
-
171
- rescue JSON::ParserError => e
172
- error_msg = "Error parsing tool arguments: #{e.message}"
173
- puts " ✗ #{error_msg}"
174
- @history.add_message(role: "user", content: error_msg)
175
- nil
176
- rescue => e
177
- error_msg = "Error executing tool: #{e.message}"
178
- puts " ✗ #{error_msg}"
179
- @history.add_message(role: "user", content: error_msg)
180
- nil
181
- end
182
- end
183
-
184
32
  def build_adapter
185
33
  case @config.adapter
186
34
  when :ollama
@@ -193,7 +41,7 @@ module Rubycode
193
41
  def build_system_prompt
194
42
  context = ContextBuilder.new(root_path: @config.root_path).environment_context
195
43
 
196
- <<~PROMPT.strip
44
+ <<~PROMPT
197
45
  You are a helpful Ruby on Rails coding assistant.
198
46
 
199
47
  #{context}
@@ -202,17 +50,21 @@ module Rubycode
202
50
  You MUST call a tool in EVERY response. You MUST NEVER respond with just text.
203
51
 
204
52
  # Available tools
205
- - bash: explore directories (ls, find)
206
- - search: find text inside files (supports case_insensitive parameter)
53
+ - bash: run any safe command (ls, find, grep, cat, etc.)
54
+ - search: simplified search (use bash + grep for more control)
207
55
  - read: view file contents with line numbers
208
- - done: call this when you have the answer (with your final answer as the parameter)
209
-
210
- # Required workflow
211
- 1. Call search with the pattern
212
- 2. If "No matches found" call search again with case_insensitive: true
213
- 3. If still no matches call search with simpler pattern
214
- 4. Once found → call read to see the file
215
- 5. Once you have the answer → call done with your final answer
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
216
68
 
217
69
  IMPORTANT: You cannot respond with plain text. You must ALWAYS call one of the tools.
218
70
  When you're ready to provide your answer, call the "done" tool with your answer as the parameter.
@@ -1,22 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Rubycode
3
+ module RubyCode
4
+ # Configuration class for Rubycode settings
4
5
  class Configuration
5
-
6
6
  attr_accessor :adapter, :url, :model, :root_path, :debug, :enable_tool_injection_workaround
7
7
 
8
8
  def initialize
9
9
  @adapter = :ollama
10
10
  @url = "http://localhost:11434"
11
- @model = "qwen3-coder:480b-cloud"
11
+ @model = "deepseek-v3.1:671b-cloud"
12
12
  @root_path = Dir.pwd
13
- @debug = false # Set to true to see JSON requests/responses
13
+ @debug = false # Set to true to see JSON requests/responses
14
14
 
15
- # WORKAROUND for weak tool-calling models (qwen3-coder, etc.)
15
+ # WORKAROUND for models that don't follow tool-calling instructions
16
16
  # When enabled, injects reminder messages if model generates text instead of calling tools
17
- # OpenCode does NOT use this - they rely on strong tool-calling models (Claude, GPT-4)
18
- # Set to true ONLY for testing with weak models
19
- @enable_tool_injection_workaround = false
17
+ # Enabled by default as most models need this nudge
18
+ @enable_tool_injection_workaround = true
20
19
  end
21
20
  end
22
21
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Rubycode
3
+ module RubyCode
4
+ # Builds environment context for the AI assistant
4
5
  class ContextBuilder
5
6
  def initialize(root_path:)
6
7
  @root_path = root_path
@@ -12,7 +13,7 @@ module Rubycode
12
13
  Working directory: #{@root_path}
13
14
  Platform: #{RUBY_PLATFORM}
14
15
  Ruby version: #{RUBY_VERSION}
15
- Today's date: #{Time.now.strftime('%Y-%m-%d')}
16
+ Today's date: #{Time.now.strftime("%Y-%m-%d")}
16
17
  </env>
17
18
  CONTEXT
18
19
  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
@@ -1,37 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Rubycode
3
+ module RubyCode
4
+ # Manages conversation history between user and assistant
4
5
  class History
6
+ attr_reader :messages
7
+
5
8
  def initialize
6
9
  @messages = []
7
10
  end
8
11
 
9
- def add_message(role:, content:)
10
- @messages << {
11
- role: role,
12
- content: content,
13
- timestamp: Time.now
14
- }
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
15
21
  end
16
22
 
17
23
  def to_llm_format
18
- @messages.map { |msg| { role: msg[:role], content: msg[:content] } }
24
+ @messages.map(&:to_h)
19
25
  end
20
26
 
21
27
  def clear
22
28
  @messages = []
23
29
  end
24
30
 
25
- def messages
26
- @messages
27
- end
28
-
29
31
  def last_user_message
30
- @messages.reverse.find { |msg| msg[:role] == "user" }
32
+ @messages.reverse.find { |msg| msg.role == "user" }
31
33
  end
32
34
 
33
35
  def last_assistant_message
34
- @messages.reverse.find { |msg| msg[:role] == "assistant" }
36
+ @messages.reverse.find { |msg| msg.role == "assistant" }
35
37
  end
36
38
  end
37
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
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "English"
3
4
  require "shellwords"
4
5
 
5
- module Rubycode
6
+ module RubyCode
6
7
  module Tools
7
- class Bash
8
+ # Tool for executing safe bash commands
9
+ class Bash < Base
8
10
  # Whitelist of safe commands
9
11
  SAFE_COMMANDS = %w[
10
12
  ls
@@ -18,57 +20,46 @@ module Rubycode
18
20
  file
19
21
  which
20
22
  echo
23
+ grep
24
+ rg
21
25
  ].freeze
22
26
 
23
- def self.definition
24
- {
25
- type: "function",
26
- function: {
27
- name: "bash",
28
- description: "Execute safe bash commands for exploring the filesystem and terminal operations.\n\nIMPORTANT: This tool is for terminal operations and directory exploration (ls, find, tree, etc.). DO NOT use it for file operations (reading, searching file contents) - use the specialized tools instead.\n\nWhitelisted commands: #{SAFE_COMMANDS.join(', ')}",
29
- parameters: {
30
- type: "object",
31
- properties: {
32
- command: {
33
- type: "string",
34
- description: "The bash command to execute (e.g., 'ls -la', 'find . -name \"*.rb\"', 'tree app')"
35
- }
36
- },
37
- required: ["command"]
38
- }
39
- }
40
- }
41
- end
27
+ private
42
28
 
43
- def self.execute(params:, context:)
29
+ def perform(params)
44
30
  command = params["command"].strip
45
-
46
- # Extract the base command (first word)
47
31
  base_command = command.split.first
48
32
 
49
- unless SAFE_COMMANDS.include?(base_command)
50
- return "Error: Command '#{base_command}' is not allowed. Safe commands: #{SAFE_COMMANDS.join(', ')}"
51
- end
33
+ raise UnsafeCommandError, safe_command_error(base_command) unless SAFE_COMMANDS.include?(base_command)
52
34
 
53
- # Execute in the project's root directory
54
- Dir.chdir(context[:root_path]) do
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
55
44
  output = `#{command} 2>&1`
56
- exit_code = $?.exitstatus
45
+ exit_code = $CHILD_STATUS.exitstatus
46
+
47
+ raise CommandExecutionError, "Command failed with exit code #{exit_code}:\n#{output}" unless exit_code.zero?
57
48
 
58
- if exit_code == 0
59
- # Limit output length
60
- lines = output.split("\n")
61
- if lines.length > 200
62
- lines[0..199].join("\n") + "\n\n... (#{lines.length - 200} more lines truncated)"
63
- else
64
- output
65
- end
66
- else
67
- "Command failed with exit code #{exit_code}:\n#{output}"
68
- end
49
+ CommandResult.new(
50
+ stdout: truncate_output(output),
51
+ stderr: "",
52
+ exit_code: exit_code
53
+ )
69
54
  end
70
- rescue => e
71
- "Error executing command: #{e.message}"
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)"
72
63
  end
73
64
  end
74
65
  end
@@ -1,30 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Rubycode
3
+ module RubyCode
4
4
  module Tools
5
- class Done
6
- def self.definition
7
- {
8
- type: "function",
9
- function: {
10
- name: "done",
11
- description: "Call this when you have found the code and are ready to provide your final answer. This signals you are finished exploring.",
12
- parameters: {
13
- type: "object",
14
- properties: {
15
- answer: {
16
- type: "string",
17
- description: "Your final answer showing the file, line number, current code, and suggested change"
18
- }
19
- },
20
- required: ["answer"]
21
- }
22
- }
23
- }
24
- end
5
+ # Tool for signaling task completion
6
+ class Done < Base
7
+ private
25
8
 
26
- def self.execute(params:, context:)
27
- # Just return the answer - this is the final response
9
+ def perform(params)
28
10
  params["answer"]
29
11
  end
30
12
  end