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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -0
  3. data/README.md +33 -4
  4. data/config/locales/en.yml +87 -0
  5. data/config/system_prompt.md +54 -0
  6. data/config/tools/done.json +2 -2
  7. data/config/tools/update.json +25 -0
  8. data/config/tools/write.json +21 -0
  9. data/docs/images/demo.png +0 -0
  10. data/lib/rubycode/adapters/ollama.rb +76 -3
  11. data/lib/rubycode/agent_loop.rb +41 -16
  12. data/lib/rubycode/client/approval_handler.rb +70 -0
  13. data/lib/rubycode/client/display_formatter.rb +32 -12
  14. data/lib/rubycode/client/response_handler.rb +20 -12
  15. data/lib/rubycode/client.rb +25 -36
  16. data/lib/rubycode/configuration.rb +8 -1
  17. data/lib/rubycode/database.rb +50 -0
  18. data/lib/rubycode/errors.rb +12 -0
  19. data/lib/rubycode/models/base.rb +68 -0
  20. data/lib/rubycode/models/memory.rb +57 -0
  21. data/lib/rubycode/models.rb +4 -0
  22. data/lib/rubycode/tools/base.rb +1 -10
  23. data/lib/rubycode/tools/bash.rb +10 -7
  24. data/lib/rubycode/tools/read.rb +3 -0
  25. data/lib/rubycode/tools/update.rb +80 -0
  26. data/lib/rubycode/tools/write.rb +57 -0
  27. data/lib/rubycode/tools.rb +4 -0
  28. data/lib/rubycode/version.rb +1 -1
  29. data/lib/rubycode/views/agent_loop/adapter_error.rb +14 -0
  30. data/lib/rubycode/views/agent_loop/iteration_footer.rb +17 -0
  31. data/lib/rubycode/views/agent_loop/iteration_header.rb +24 -0
  32. data/lib/rubycode/views/agent_loop/response_received.rb +17 -0
  33. data/lib/rubycode/views/agent_loop/retry_status.rb +14 -0
  34. data/lib/rubycode/views/agent_loop/thinking_status.rb +17 -0
  35. data/lib/rubycode/views/agent_loop/tool_error.rb +14 -0
  36. data/lib/rubycode/views/agent_loop.rb +8 -0
  37. data/lib/rubycode/views/bash_approval.rb +28 -0
  38. data/lib/rubycode/views/cli/configuration_table.rb +28 -0
  39. data/lib/rubycode/views/cli/error_display.rb +19 -0
  40. data/lib/rubycode/views/cli/error_message.rb +17 -0
  41. data/lib/rubycode/views/cli/exit_message.rb +17 -0
  42. data/lib/rubycode/views/cli/interrupt_message.rb +17 -0
  43. data/lib/rubycode/views/cli/memory_cleared_message.rb +17 -0
  44. data/lib/rubycode/views/cli/ready_message.rb +17 -0
  45. data/lib/rubycode/views/cli/response_box.rb +29 -0
  46. data/lib/rubycode/views/cli.rb +11 -0
  47. data/lib/rubycode/views/formatter/debug_tool_info.rb +17 -0
  48. data/lib/rubycode/views/formatter/info_message.rb +17 -0
  49. data/lib/rubycode/views/formatter/minimal_tool_info.rb +26 -0
  50. data/lib/rubycode/views/formatter/tool_result.rb +20 -0
  51. data/lib/rubycode/views/formatter.rb +7 -0
  52. data/lib/rubycode/views/response_handler/agent_finished.rb +31 -0
  53. data/lib/rubycode/views/response_handler/complete_message.rb +31 -0
  54. data/lib/rubycode/views/response_handler/max_iterations.rb +29 -0
  55. data/lib/rubycode/views/response_handler/max_tool_calls.rb +29 -0
  56. data/lib/rubycode/views/response_handler/tool_injection_warning.rb +17 -0
  57. data/lib/rubycode/views/response_handler.rb +8 -0
  58. data/lib/rubycode/views/skip_notification.rb +15 -0
  59. data/lib/rubycode/views/update_approval.rb +36 -0
  60. data/lib/rubycode/views/welcome.rb +27 -0
  61. data/lib/rubycode/views/write_approval.rb +42 -0
  62. data/lib/rubycode/views.rb +12 -0
  63. data/lib/rubycode.rb +9 -1
  64. data/rubycode_cli.rb +41 -51
  65. metadata +220 -5
  66. data/lib/rubycode/history.rb +0 -39
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3de3303b47050cfd2d290e35fcbf834e57c5eeb53d85a6b697e0cd554a5b05c0
4
- data.tar.gz: 3a60d16a0f7fc111e6ccf305b1d4d0427f8b9b42296e79455f9b3caae06ce2ee
3
+ metadata.gz: b77afd8d1e1731bea1936ffad3ed3e572ce8d14088ebb8fd49570b11c1c7b0b5
4
+ data.tar.gz: 2c5abe4b384a4f1f0ef5a26ee4c173eb41124235cd3337147d3fc04a291043b5
5
5
  SHA512:
6
- metadata.gz: ca04ec8192747e4e97cef35c2a8ec6440e82b65a412e7c13a979f84a5d58de92c615e90e58cebe28b8761ae05d462f4a5e9a38bdeb0388005234b76bb02edc67
7
- data.tar.gz: 84066bb9aa138c598eedad4a48472448d253f0eaeb1551ed1041a2bb4a3fc0087cabbc4c46dffeaf14ff83c1570096ff88db3c439537debbaea0f710eb01414c
6
+ metadata.gz: 7400478e459c00e8e6ce7889f4eb58667cbe203a30fe4bd64568b122b1d323acb7f08dea92be7b725e4c41547d420fb81c9520071b1c89be3b1a3b6048c4fd1f
7
+ data.tar.gz: a6559e94215bf89b6c6cc0ed8a065dce7bae23232e7d3bd7c203ea274644a045a8e003c8e922772575c86f1ea86e0ce56b6fdaed0603535d958c15cd3d35a464
data/.rubocop.yml CHANGED
@@ -8,6 +8,10 @@ Style/StringLiterals:
8
8
  Style/StringLiteralsInInterpolation:
9
9
  EnforcedStyle: double_quotes
10
10
 
11
+ Style/OneClassPerFile:
12
+ Exclude:
13
+ - "test/**/*"
14
+
11
15
  # Metrics - reasonable limits for maintainability
12
16
  Metrics/ClassLength:
13
17
  Max: 200
data/README.md CHANGED
@@ -7,16 +7,27 @@ A Ruby-native AI coding assistant with pluggable LLM adapters. RubyCode provides
7
7
 
8
8
  **GitHub Repository**: [github.com/jonasmedeiros/rubycode](https://github.com/jonasmedeiros/rubycode)
9
9
 
10
+ ## Demo
11
+
12
+ ![RubyCode in action](docs/images/demo.png)
13
+
14
+ *RubyCode autonomously exploring a Rails codebase, finding the right file, and suggesting code changes.*
15
+
10
16
  ## Features
11
17
 
12
18
  - **AI Agent Loop**: Autonomous task execution with tool calling
13
- - **Pluggable LLM Adapters**: Currently supports Ollama, easily extendable to other LLMs
19
+ - **Pluggable LLM Adapters**: Currently supports Ollama with configurable timeouts and retry logic
14
20
  - **Built-in Tools**:
15
21
  - `bash`: Execute safe bash commands for filesystem exploration
16
22
  - `search`: Search file contents using grep with regex support
17
23
  - `read`: Read files and directories with line numbers
24
+ - `write`: Create new files with user approval
25
+ - `update`: Edit existing files with user approval
18
26
  - `done`: Signal task completion with final answer
19
- - **Conversation History**: Maintains context across interactions
27
+ - **Persistent Memory**: SQLite-backed conversation history with Sequel ORM
28
+ - **Enhanced CLI**: TTY-based interface with formatted output, progress indicators, and approval workflows
29
+ - **Resilient Network**: Automatic retry with exponential backoff for LLM requests
30
+ - **I18n Support**: Internationalized error messages and UI text
20
31
  - **Environment Context**: Automatically provides Ruby version, platform, and working directory info
21
32
 
22
33
  ## Installation
@@ -71,12 +82,18 @@ RubyCode.configure do |config|
71
82
  config.root_path = Dir.pwd # Project root directory
72
83
  config.debug = false # Enable debug output
73
84
  config.enable_tool_injection_workaround = true # Force tool usage (enabled by default)
85
+
86
+ # HTTP timeout and retry settings (new in 0.1.3)
87
+ config.http_read_timeout = 120 # Request timeout in seconds (default: 120)
88
+ config.http_open_timeout = 10 # Connection timeout in seconds (default: 10)
89
+ config.max_retries = 3 # Number of retry attempts (default: 3)
90
+ config.retry_base_delay = 2.0 # Exponential backoff base delay (default: 2.0)
74
91
  end
75
92
  ```
76
93
 
77
94
  ### Available Tools
78
95
 
79
- The agent has access to four built-in tools:
96
+ The agent has access to six built-in tools:
80
97
 
81
98
  1. **bash**: Execute safe bash commands including:
82
99
  - Directory exploration: `ls`, `pwd`, `find`, `tree`
@@ -85,10 +102,22 @@ The agent has access to four built-in tools:
85
102
  - Examples: `grep -rn "button" app/views`, `find . -name "*.rb"`
86
103
  2. **search**: Simplified search wrapper (use bash + grep for more control)
87
104
  3. **read**: Read files with line numbers or list directory contents
88
- 4. **done**: Signal completion and provide the final answer
105
+ 4. **write**: Create new files (requires user approval)
106
+ 5. **update**: Edit existing files with exact string replacement (requires user approval)
107
+ 6. **done**: Signal completion and provide the final answer
89
108
 
90
109
  **Note**: Tool schemas are externalized in `config/tools/*.json` for easy customization.
91
110
 
111
+ ### New in 0.1.3
112
+
113
+ - **Persistent Memory**: Conversation history now stored in SQLite database using Sequel ORM
114
+ - **Write & Update Tools**: Create and modify files with user approval workflow
115
+ - **Network Resilience**: Automatic retry with exponential backoff for failed LLM requests
116
+ - **Enhanced CLI**: Improved UI with TTY toolkit, formatted output, and progress indicators
117
+ - **I18n Support**: Internationalized error messages and system prompts
118
+ - **Improved Architecture**: Separated database connection management, view classes for all UI components
119
+ - **Cross-platform Support**: Added Linux platform support for CI/CD environments
120
+
92
121
  ## Development
93
122
 
94
123
  After checking out the repo, run `bundle install` to install dependencies.
@@ -0,0 +1,87 @@
1
+ en:
2
+ rubycode:
3
+ cli:
4
+ ready: "Ready! You can now ask questions or request code changes."
5
+ exit: "Goodbye!"
6
+ memory_cleared: "Memory cleared"
7
+ interrupted: "Operation cancelled by user"
8
+
9
+ welcome:
10
+ title: "RubyCode v%{version}"
11
+ subtitle: "AI Ruby/Rails Code Assistant"
12
+ built_with: "Built with: %{stack}"
13
+ stack: "Ollama + DeepSeek + TTY Toolkit"
14
+ commands: "Commands: exit, quit, clear"
15
+
16
+ approval:
17
+ bash:
18
+ title: "Bash Command - Approval Required"
19
+ command: "Command"
20
+ base_command: "Base Command"
21
+ safe_commands: "Safe Commands (no approval needed)"
22
+ prompt: "Execute this command?"
23
+
24
+ write:
25
+ title: "Write Operation - Approval Required"
26
+ file: "File"
27
+ lines: "Lines"
28
+ prompt: "Create this file?"
29
+ truncated: "... (%{count} more lines)"
30
+
31
+ update:
32
+ title: "Update Operation - Approval Required"
33
+ file: "File"
34
+ remove_label: "- REMOVE:"
35
+ add_label: "+ ADD:"
36
+ prompt: "Apply this update?"
37
+
38
+ skip:
39
+ message: "Skipping approval (auto-approve enabled)"
40
+
41
+ response_handler:
42
+ max_iterations:
43
+ title: "⚠ WARNING"
44
+ message: "Reached maximum iterations (%{max})\nThe agent may be stuck in a loop."
45
+
46
+ max_tool_calls:
47
+ title: "⚠ WARNING"
48
+ message: "Reached maximum tool calls (%{max})\nStopping to prevent excessive operations."
49
+
50
+ complete:
51
+ title: "āœ“ SUCCESS"
52
+ message: "Task completed\n%{iterations} iterations, %{tool_calls} tool calls"
53
+
54
+ agent_finished:
55
+ title: "āœ“ SUCCESS"
56
+ message: "Agent finished\n%{iterations} iterations, %{tool_calls} tool calls"
57
+
58
+ tool_injection_warning: "No tool calls - injecting reminder (iteration %{iteration})"
59
+
60
+ agent_loop:
61
+ thinking: "Thinking..."
62
+ response_received: "Response received"
63
+ iteration: "Iteration %{number}"
64
+ tool_error: "Tool error: %{error}"
65
+
66
+ formatter:
67
+ tool_result: "Result"
68
+ info_prefix: "ℹ"
69
+ debug_prefix: "[DEBUG]"
70
+ error_prefix: "[ERROR]"
71
+
72
+ errors:
73
+ unknown_adapter: "Unknown Adapter"
74
+ file_not_found: "File '%{path}' not found"
75
+ file_exists: "File '%{path}' already exists. Use 'update' tool to modify it."
76
+ string_not_found: "String not found in file. Searched for:\n%{string}"
77
+ string_not_unique: "String appears %{count} times. Must be unique for safe replacement."
78
+ command_failed: "Command failed with exit code %{code}:\n%{output}"
79
+ user_cancelled_bash: "USER CANCELLED: The user declined to execute '%{command}'. Do not retry this command. Either use a whitelisted command (%{safe_commands}) or call 'done' to finish."
80
+ user_cancelled_update: "USER CANCELLED: The user declined this change. Do not retry this exact update. Either move to the next change or call 'done' to finish."
81
+ user_cancelled_write: "USER CANCELLED: The user declined to create this file. Do not retry this operation. Ask the user if they want to make a different change or call 'done' to finish."
82
+ max_iterations_reached: "Reached maximum iterations"
83
+ max_tool_calls_reached: "Reached maximum tool calls"
84
+ adapter_timeout: "LLM request timed out after %{timeout}s"
85
+ adapter_connection: "Cannot connect to LLM at %{url}: %{error}"
86
+ adapter_retry: "Request failed: %{error}. Retrying in %{delay}s... (attempt %{attempt}/%{max_retries})"
87
+ adapter_failed: "LLM adapter failed: %{error}"
@@ -0,0 +1,54 @@
1
+ # Ruby on Rails Coding Assistant
2
+
3
+ You are a helpful Ruby on Rails coding assistant.
4
+
5
+ ## CRITICAL RULE
6
+
7
+ You MUST call a tool in EVERY response. You MUST NEVER respond with just text.
8
+
9
+ ## Available tools
10
+
11
+ - **bash**: run commands (whitelisted commands run directly, others require user approval)
12
+ - Whitelisted: ls, pwd, find, tree, cat, head, tail, wc, file, which, echo, grep, rg
13
+ - **search**: simplified search (use bash + grep for more control)
14
+ - **read**: view file contents with line numbers
15
+ - **write**: create new files (requires approval, errors if file exists)
16
+ - **update**: modify existing files (auto-reads if needed, requires approval)
17
+ - **done**: MUST call when task is complete (see below)
18
+
19
+ ## Recommended workflow
20
+
21
+ 1. Use bash with grep to search: `grep -rn "pattern" directory/`
22
+ 2. Use bash with find to locate files: `find . -name "*.rb"`
23
+ 3. Once found → use read to see the file
24
+ 4. Make changes with write/update if needed
25
+ 5. IMMEDIATELY call done when finished - do not continue exploring
26
+
27
+ ## CRITICAL: When to call 'done'
28
+
29
+ You MUST call 'done' immediately after:
30
+ - Completing file changes (write/update operations succeeded)
31
+ - Answering a user's question
32
+ - Finding the information the user requested
33
+ - Unable to proceed (errors, file not found, etc.)
34
+
35
+ Do NOT keep exploring after the task is done. Call 'done' right away.
36
+
37
+ ## CRITICAL: Handling user cancellations
38
+
39
+ If you see "USER CANCELLED" in an error message:
40
+ - The user explicitly declined that specific operation
41
+ - Do NOT retry the exact same operation - the user has rejected it
42
+ - Move on to other changes, or call 'done' if there's nothing else to do
43
+ - Never get stuck in a loop retrying cancelled operations
44
+
45
+ ## Example searches
46
+
47
+ - `grep -rn "button" app/views` - search for "button" in views
48
+ - `grep -ri "new product" .` - case-insensitive search
49
+ - `find . -name "*product*"` - find files with "product" in name
50
+
51
+ ## Final reminder
52
+
53
+ IMPORTANT: You cannot respond with plain text. You must ALWAYS call one of the tools.
54
+ When you're ready to provide your answer, call the "done" tool with your answer as the parameter.
@@ -2,13 +2,13 @@
2
2
  "type": "function",
3
3
  "function": {
4
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.",
5
+ "description": "CRITICAL: Call this tool when you have completed the user's request and are ready to finish.\n\nYou MUST call 'done' after:\n- Successfully making code changes (write/update operations)\n- Answering a question or providing information\n- Completing any task the user requested\n- Unable to proceed further\n\nDo NOT continue calling other tools after the task is complete - call 'done' immediately.\n\nThe 'answer' parameter should contain:\n- For code changes: Summary of what was changed and where\n- For questions: Direct answer to the user's question\n- For searches: What you found and relevant file locations",
6
6
  "parameters": {
7
7
  "type": "object",
8
8
  "properties": {
9
9
  "answer": {
10
10
  "type": "string",
11
- "description": "Your final answer showing the file, line number, current code, and suggested change"
11
+ "description": "Your final response to the user summarizing what was accomplished or found"
12
12
  }
13
13
  },
14
14
  "required": ["answer"]
@@ -0,0 +1,25 @@
1
+ {
2
+ "type": "function",
3
+ "function": {
4
+ "name": "update",
5
+ "description": "Update existing file by replacing old_string with new_string. Auto-reads file if not already read.\n\n- Use this to modify existing files only\n- Will error if file doesn't exist (use 'write' tool instead)\n- Automatically reads file if not already read in this conversation\n- Requires exact unique string match for safe replacement\n- Requires user approval before updating\n- Shows colored diff (red for removed, green for added) before applying\n- old_string must appear exactly once in the file",
6
+ "parameters": {
7
+ "type": "object",
8
+ "properties": {
9
+ "file_path": {
10
+ "type": "string",
11
+ "description": "Path to file to update (relative or absolute)"
12
+ },
13
+ "old_string": {
14
+ "type": "string",
15
+ "description": "Exact string to replace (including whitespace)"
16
+ },
17
+ "new_string": {
18
+ "type": "string",
19
+ "description": "Replacement string"
20
+ }
21
+ },
22
+ "required": ["file_path", "old_string", "new_string"]
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "type": "function",
3
+ "function": {
4
+ "name": "write",
5
+ "description": "Create a new file with content. File must not exist. Use 'update' for existing files.\n\n- Use this to create brand new files only\n- Will error if file already exists (use 'update' tool instead)\n- Requires user approval before writing\n- Shows preview of content before creating file\n- Automatically creates parent directories if needed",
6
+ "parameters": {
7
+ "type": "object",
8
+ "properties": {
9
+ "file_path": {
10
+ "type": "string",
11
+ "description": "Path to new file (relative or absolute)"
12
+ },
13
+ "content": {
14
+ "type": "string",
15
+ "description": "Complete file content to write"
16
+ }
17
+ },
18
+ "required": ["file_path", "content"]
19
+ }
20
+ }
21
+ }
Binary file
@@ -14,7 +14,7 @@ module RubyCode
14
14
 
15
15
  debug_request(uri, payload) if @config.debug
16
16
 
17
- body = send_request(uri, request)
17
+ body = send_request_with_retry(uri, request)
18
18
 
19
19
  debug_response(body) if @config.debug
20
20
 
@@ -23,6 +23,33 @@ module RubyCode
23
23
 
24
24
  private
25
25
 
26
+ def send_request_with_retry(uri, request)
27
+ attempt = 0
28
+
29
+ begin
30
+ attempt += 1
31
+ send_request(uri, request)
32
+ rescue AdapterTimeoutError, AdapterConnectionError => e
33
+ unless attempt <= @config.max_retries
34
+ raise AdapterRetryExhaustedError, "Failed after #{@config.max_retries} retries: #{e.message}"
35
+ end
36
+
37
+ delay = @config.retry_base_delay * (2**(attempt - 1))
38
+ display_retry_status(attempt, @config.max_retries, delay, e)
39
+ sleep(delay)
40
+ retry
41
+ end
42
+ end
43
+
44
+ def display_retry_status(attempt, max_retries, delay, error)
45
+ puts Views::AgentLoop::RetryStatus.build(
46
+ attempt: attempt,
47
+ max_retries: max_retries,
48
+ delay: delay,
49
+ error: error.message
50
+ )
51
+ end
52
+
26
53
  def build_payload(messages, system, tools)
27
54
  payload = { model: @config.model, messages: messages, stream: false }
28
55
  payload[:system] = system if system
@@ -38,8 +65,54 @@ module RubyCode
38
65
  end
39
66
 
40
67
  def send_request(uri, request)
41
- response = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(request) }
42
- JSON.parse(response.body)
68
+ response = perform_http_request(uri, request)
69
+ handle_response(response)
70
+ rescue Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError => e
71
+ handle_network_error(e, uri)
72
+ rescue JSON::ParserError => e
73
+ raise AdapterError, "Invalid JSON response from server: #{e.message}"
74
+ rescue StandardError => e
75
+ raise AdapterError, "Unexpected error: #{e.message}"
76
+ end
77
+
78
+ def perform_http_request(uri, request)
79
+ Net::HTTP.start(
80
+ uri.hostname,
81
+ uri.port,
82
+ read_timeout: @config.http_read_timeout,
83
+ open_timeout: @config.http_open_timeout
84
+ ) { |http| http.request(request) }
85
+ end
86
+
87
+ def handle_network_error(error, uri)
88
+ case error
89
+ when Net::ReadTimeout
90
+ raise AdapterTimeoutError, "Request timed out after #{@config.http_read_timeout}s: #{error.message}"
91
+ when Net::OpenTimeout
92
+ raise AdapterTimeoutError, "Connection timed out after #{@config.http_open_timeout}s: #{error.message}"
93
+ when Errno::ECONNREFUSED
94
+ raise AdapterConnectionError, "Connection refused to #{uri}: #{error.message}"
95
+ when Errno::ETIMEDOUT
96
+ raise AdapterConnectionError, "Connection timed out to #{uri}: #{error.message}"
97
+ when SocketError
98
+ raise AdapterConnectionError, "Cannot resolve host #{uri.hostname}: #{error.message}"
99
+ end
100
+ end
101
+
102
+ def handle_response(response)
103
+ body = JSON.parse(response.body)
104
+
105
+ # Check for HTTP errors
106
+ case response.code.to_i
107
+ when 200..299
108
+ body
109
+ when 500..599
110
+ # Server errors are retriable
111
+ raise AdapterConnectionError, "Server error (#{response.code}): #{response.message}"
112
+ else
113
+ # Client errors are not retriable
114
+ raise AdapterError, "HTTP error (#{response.code}): #{response.message}"
115
+ end
43
116
  end
44
117
 
45
118
  def debug_request(uri, payload)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "client/response_handler"
4
4
  require_relative "client/display_formatter"
5
+ require_relative "client/approval_handler"
5
6
 
6
7
  module RubyCode
7
8
  # Manages the agent loop - iterates until task completion or limits reached
@@ -9,13 +10,16 @@ module RubyCode
9
10
  MAX_ITERATIONS = 25
10
11
  MAX_TOOL_CALLS = 50
11
12
 
12
- def initialize(adapter:, history:, config:, system_prompt:)
13
+ def initialize(adapter:, memory:, config:, system_prompt:, options: {})
13
14
  @adapter = adapter
14
- @history = history
15
+ @memory = memory
15
16
  @config = config
16
17
  @system_prompt = system_prompt
17
- @response_handler = Client::ResponseHandler.new(history: @history, config: @config)
18
+ @read_files = options[:read_files]
19
+ @tty_prompt = options[:tty_prompt]
20
+ @response_handler = Client::ResponseHandler.new(memory: @memory, config: @config)
18
21
  @display_formatter = Client::DisplayFormatter.new(config: @config)
22
+ @approval_handler = Client::ApprovalHandler.new(tty_prompt: @tty_prompt, config: @config)
19
23
  end
20
24
 
21
25
  def run
@@ -47,23 +51,36 @@ module RubyCode
47
51
  private
48
52
 
49
53
  def llm_response
50
- messages = @history.to_llm_format
54
+ puts Views::AgentLoop::ThinkingStatus.build unless @config.debug
55
+
56
+ messages = @memory.to_llm_format
51
57
  response_body = @adapter.generate(
52
58
  messages: messages,
53
59
  system: @system_prompt,
54
60
  tools: Tools.definitions
55
61
  )
56
62
 
63
+ puts Views::AgentLoop::ResponseReceived.build unless @config.debug
64
+
57
65
  assistant_message = response_body["message"]
58
66
  content = assistant_message["content"] || ""
59
67
  tool_calls = assistant_message["tool_calls"] || []
60
68
 
61
- @history.add_message(role: "assistant", content: content)
69
+ @memory.add_message(role: "assistant", content: content)
62
70
  [content, tool_calls]
71
+ rescue AdapterError => e
72
+ handle_adapter_error(e)
73
+ [nil, []] # Return empty to continue loop
74
+ end
75
+
76
+ def handle_adapter_error(error)
77
+ error_msg = I18n.t("rubycode.errors.adapter_failed", error: error.message)
78
+ puts Views::AgentLoop::AdapterError.build(message: error_msg)
79
+ @memory.add_message(role: "user", content: error_msg)
63
80
  end
64
81
 
65
82
  def execute_tool_calls(tool_calls, iteration)
66
- puts "\nšŸ¤– Iteration #{iteration}: Calling #{tool_calls.length} tool(s)..." unless @config.debug
83
+ puts Views::AgentLoop::IterationHeader.build(iteration: iteration, tool_calls: tool_calls) unless @config.debug
67
84
 
68
85
  done_result = nil
69
86
  tool_calls.each do |tool_call|
@@ -74,6 +91,8 @@ module RubyCode
74
91
  break
75
92
  end
76
93
  end
94
+
95
+ puts Views::AgentLoop::IterationFooter.build unless @config.debug
77
96
  done_result
78
97
  end
79
98
 
@@ -90,7 +109,7 @@ module RubyCode
90
109
  return nil unless result
91
110
 
92
111
  @display_formatter.display_result(result)
93
- add_tool_result_to_history(tool_name, result)
112
+ add_tool_result_to_memory(tool_name, result)
94
113
  result
95
114
  rescue ToolError, StandardError => e
96
115
  # Handle all errors - add to history and continue
@@ -101,13 +120,19 @@ module RubyCode
101
120
  arguments.is_a?(Hash) ? arguments : JSON.parse(arguments)
102
121
  rescue JSON::ParserError => e
103
122
  error_msg = "Error parsing tool arguments: #{e.message}"
104
- puts " āœ— #{error_msg}"
105
- @history.add_message(role: "user", content: error_msg)
123
+ puts Views::AgentLoop::ToolError.build(message: error_msg)
124
+ @memory.add_message(role: "user", content: error_msg)
106
125
  nil
107
126
  end
108
127
 
109
128
  def run_tool(tool_name, params)
110
- context = { root_path: @config.root_path }
129
+ context = {
130
+ root_path: @config.root_path,
131
+ read_files: @read_files,
132
+ tty_prompt: @tty_prompt,
133
+ approval_handler: @approval_handler,
134
+ display_formatter: @display_formatter
135
+ }
111
136
  Tools.execute(tool_name: tool_name, params: params, context: context)
112
137
  rescue ToolError => e
113
138
  # Re-raise tool errors to be caught by execute_tool
@@ -115,19 +140,19 @@ module RubyCode
115
140
  rescue StandardError => e
116
141
  # Wrap unexpected errors
117
142
  error_msg = "Error executing tool: #{e.message}"
118
- puts " āœ— #{error_msg}"
119
- @history.add_message(role: "user", content: error_msg)
143
+ puts Views::AgentLoop::ToolError.build(message: error_msg)
144
+ @memory.add_message(role: "user", content: error_msg)
120
145
  nil
121
146
  end
122
147
 
123
- def add_tool_result_to_history(tool_name, result)
124
- @history.add_message(role: "user", content: "Tool '#{tool_name}' result:\n#{result}")
148
+ def add_tool_result_to_memory(tool_name, result)
149
+ @memory.add_message(role: "user", content: "Tool '#{tool_name}' result:\n#{result}")
125
150
  end
126
151
 
127
152
  def handle_tool_error(error)
128
153
  error_msg = "Error: #{error.message}"
129
- puts " āœ— #{error_msg}"
130
- @history.add_message(role: "user", content: error_msg)
154
+ puts Views::AgentLoop::ToolError.build(message: error_msg)
155
+ @memory.add_message(role: "user", content: error_msg)
131
156
  nil
132
157
  end
133
158
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCode
4
+ class Client
5
+ # Handles user approval prompts for tools
6
+ class ApprovalHandler
7
+ def initialize(tty_prompt:, config:)
8
+ @prompt = tty_prompt
9
+ @config = config
10
+ end
11
+
12
+ def request_bash_approval(command, base_command, safe_commands)
13
+ display = Views::BashApproval.build(
14
+ command: command,
15
+ base_command: base_command,
16
+ safe_commands: safe_commands
17
+ )
18
+ puts display
19
+
20
+ approved = request_approval("Execute this command?")
21
+
22
+ puts Views::SkipNotification.build(message: "User declined to execute '#{base_command}'") unless approved
23
+
24
+ approved
25
+ end
26
+
27
+ def request_write_approval(file_path, content)
28
+ display = Views::WriteApproval.build(
29
+ file_path: file_path,
30
+ content: content
31
+ )
32
+ puts display
33
+
34
+ approved = request_approval("Create this file?")
35
+
36
+ puts Views::SkipNotification.build(message: "User declined to create #{file_path}") unless approved
37
+
38
+ approved
39
+ end
40
+
41
+ def request_update_approval(file_path, old_string, new_string)
42
+ display = Views::UpdateApproval.build(
43
+ file_path: file_path,
44
+ old_string: old_string,
45
+ new_string: new_string
46
+ )
47
+ puts display
48
+
49
+ approved = request_approval("Apply this update?")
50
+
51
+ puts Views::SkipNotification.build(message: "User declined to update #{file_path}") unless approved
52
+
53
+ approved
54
+ end
55
+
56
+ private
57
+
58
+ def request_approval(question)
59
+ # Flush output to ensure prompt is visible
60
+ $stdout.flush
61
+
62
+ # Always create a fresh prompt instance to ensure stdin is available
63
+ prompt = TTY::Prompt.new(input: $stdin, output: $stdout, interrupt: :exit)
64
+ prompt.yes?(question) do |q|
65
+ q.default false
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end