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.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -0
- data/README.md +33 -4
- data/config/locales/en.yml +87 -0
- data/config/system_prompt.md +54 -0
- data/config/tools/done.json +2 -2
- data/config/tools/update.json +25 -0
- data/config/tools/write.json +21 -0
- data/docs/images/demo.png +0 -0
- data/lib/rubycode/adapters/ollama.rb +76 -3
- data/lib/rubycode/agent_loop.rb +41 -16
- data/lib/rubycode/client/approval_handler.rb +70 -0
- data/lib/rubycode/client/display_formatter.rb +32 -12
- data/lib/rubycode/client/response_handler.rb +20 -12
- data/lib/rubycode/client.rb +25 -36
- data/lib/rubycode/configuration.rb +8 -1
- data/lib/rubycode/database.rb +50 -0
- data/lib/rubycode/errors.rb +12 -0
- data/lib/rubycode/models/base.rb +68 -0
- data/lib/rubycode/models/memory.rb +57 -0
- data/lib/rubycode/models.rb +4 -0
- data/lib/rubycode/tools/base.rb +1 -10
- data/lib/rubycode/tools/bash.rb +10 -7
- data/lib/rubycode/tools/read.rb +3 -0
- data/lib/rubycode/tools/update.rb +80 -0
- data/lib/rubycode/tools/write.rb +57 -0
- data/lib/rubycode/tools.rb +4 -0
- data/lib/rubycode/version.rb +1 -1
- data/lib/rubycode/views/agent_loop/adapter_error.rb +14 -0
- data/lib/rubycode/views/agent_loop/iteration_footer.rb +17 -0
- data/lib/rubycode/views/agent_loop/iteration_header.rb +24 -0
- data/lib/rubycode/views/agent_loop/response_received.rb +17 -0
- data/lib/rubycode/views/agent_loop/retry_status.rb +14 -0
- data/lib/rubycode/views/agent_loop/thinking_status.rb +17 -0
- data/lib/rubycode/views/agent_loop/tool_error.rb +14 -0
- data/lib/rubycode/views/agent_loop.rb +8 -0
- data/lib/rubycode/views/bash_approval.rb +28 -0
- data/lib/rubycode/views/cli/configuration_table.rb +28 -0
- data/lib/rubycode/views/cli/error_display.rb +19 -0
- data/lib/rubycode/views/cli/error_message.rb +17 -0
- data/lib/rubycode/views/cli/exit_message.rb +17 -0
- data/lib/rubycode/views/cli/interrupt_message.rb +17 -0
- data/lib/rubycode/views/cli/memory_cleared_message.rb +17 -0
- data/lib/rubycode/views/cli/ready_message.rb +17 -0
- data/lib/rubycode/views/cli/response_box.rb +29 -0
- data/lib/rubycode/views/cli.rb +11 -0
- data/lib/rubycode/views/formatter/debug_tool_info.rb +17 -0
- data/lib/rubycode/views/formatter/info_message.rb +17 -0
- data/lib/rubycode/views/formatter/minimal_tool_info.rb +26 -0
- data/lib/rubycode/views/formatter/tool_result.rb +20 -0
- data/lib/rubycode/views/formatter.rb +7 -0
- data/lib/rubycode/views/response_handler/agent_finished.rb +31 -0
- data/lib/rubycode/views/response_handler/complete_message.rb +31 -0
- data/lib/rubycode/views/response_handler/max_iterations.rb +29 -0
- data/lib/rubycode/views/response_handler/max_tool_calls.rb +29 -0
- data/lib/rubycode/views/response_handler/tool_injection_warning.rb +17 -0
- data/lib/rubycode/views/response_handler.rb +8 -0
- data/lib/rubycode/views/skip_notification.rb +15 -0
- data/lib/rubycode/views/update_approval.rb +36 -0
- data/lib/rubycode/views/welcome.rb +27 -0
- data/lib/rubycode/views/write_approval.rb +42 -0
- data/lib/rubycode/views.rb +12 -0
- data/lib/rubycode.rb +9 -1
- data/rubycode_cli.rb +41 -51
- metadata +220 -5
- data/lib/rubycode/history.rb +0 -39
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b77afd8d1e1731bea1936ffad3ed3e572ce8d14088ebb8fd49570b11c1c7b0b5
|
|
4
|
+
data.tar.gz: 2c5abe4b384a4f1f0ef5a26ee4c173eb41124235cd3337147d3fc04a291043b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7400478e459c00e8e6ce7889f4eb58667cbe203a30fe4bd64568b122b1d323acb7f08dea92be7b725e4c41547d420fb81c9520071b1c89be3b1a3b6048c4fd1f
|
|
7
|
+
data.tar.gz: a6559e94215bf89b6c6cc0ed8a065dce7bae23232e7d3bd7c203ea274644a045a8e003c8e922772575c86f1ea86e0ce56b6fdaed0603535d958c15cd3d35a464
|
data/.rubocop.yml
CHANGED
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
|
+

|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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. **
|
|
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.
|
data/config/tools/done.json
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
"type": "function",
|
|
3
3
|
"function": {
|
|
4
4
|
"name": "done",
|
|
5
|
-
"description": "Call this when you have
|
|
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
|
|
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 =
|
|
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 =
|
|
42
|
-
|
|
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)
|
data/lib/rubycode/agent_loop.rb
CHANGED
|
@@ -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:,
|
|
13
|
+
def initialize(adapter:, memory:, config:, system_prompt:, options: {})
|
|
13
14
|
@adapter = adapter
|
|
14
|
-
@
|
|
15
|
+
@memory = memory
|
|
15
16
|
@config = config
|
|
16
17
|
@system_prompt = system_prompt
|
|
17
|
-
@
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
|
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
|
-
|
|
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
|
|
105
|
-
@
|
|
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 = {
|
|
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
|
|
119
|
-
@
|
|
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
|
|
124
|
-
@
|
|
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
|
|
130
|
-
@
|
|
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
|