rubycode 0.1.3 → 0.1.4
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/.env.example +38 -0
- data/.rubocop.yml +4 -0
- data/CHANGELOG.md +52 -0
- data/README.md +101 -23
- data/USAGE.md +1 -23
- data/config/locales/en.yml +208 -1
- data/config/system_prompt.md +6 -1
- data/config/tools/bash.json +1 -1
- data/config/tools/fetch.json +22 -0
- data/config/tools/websearch.json +22 -0
- data/docs/images/demo.png +0 -0
- data/lib/rubycode/adapters/base.rb +92 -2
- data/lib/rubycode/adapters/concerns/debugging.rb +32 -0
- data/lib/rubycode/adapters/concerns/error_handling.rb +89 -0
- data/lib/rubycode/adapters/concerns/http_client.rb +67 -0
- data/lib/rubycode/adapters/deepseek.rb +97 -0
- data/lib/rubycode/adapters/gemini.rb +133 -0
- data/lib/rubycode/adapters/ollama.rb +114 -82
- data/lib/rubycode/adapters/openai.rb +97 -0
- data/lib/rubycode/adapters/openrouter.rb +102 -0
- data/lib/rubycode/agent_loop.rb +110 -18
- data/lib/rubycode/client/approval_handler.rb +14 -0
- data/lib/rubycode/client/display_formatter.rb +18 -10
- data/lib/rubycode/client/response_handler.rb +4 -23
- data/lib/rubycode/client.rb +9 -0
- data/lib/rubycode/config_manager.rb +81 -0
- data/lib/rubycode/configuration.rb +21 -10
- data/lib/rubycode/database.rb +19 -0
- data/lib/rubycode/errors.rb +12 -0
- data/lib/rubycode/models/api_key.rb +118 -0
- data/lib/rubycode/models/memory.rb +84 -10
- data/lib/rubycode/models.rb +1 -0
- data/lib/rubycode/pricing.rb +59 -0
- data/lib/rubycode/search_providers/base.rb +66 -0
- data/lib/rubycode/search_providers/brave_search.rb +60 -0
- data/lib/rubycode/search_providers/concerns/debugging.rb +37 -0
- data/lib/rubycode/search_providers/concerns/error_handling.rb +64 -0
- data/lib/rubycode/search_providers/concerns/http_client.rb +67 -0
- data/lib/rubycode/search_providers/duckduckgo_instant.rb +98 -0
- data/lib/rubycode/search_providers/exa_ai.rb +171 -0
- data/lib/rubycode/search_providers/multi_provider.rb +47 -0
- data/lib/rubycode/token_counter.rb +41 -0
- data/lib/rubycode/tools/bash.rb +38 -8
- data/lib/rubycode/tools/fetch.rb +120 -0
- data/lib/rubycode/tools/web_search.rb +122 -0
- data/lib/rubycode/tools.rb +5 -1
- data/lib/rubycode/value_objects.rb +8 -4
- data/lib/rubycode/version.rb +1 -1
- data/lib/rubycode/views/adapter/debug_delay.rb +20 -0
- data/lib/rubycode/views/adapter/debug_request.rb +33 -0
- data/lib/rubycode/views/adapter/debug_response.rb +31 -0
- data/lib/rubycode/views/agent_loop/token_summary.rb +54 -0
- data/lib/rubycode/views/agent_loop.rb +2 -0
- data/lib/rubycode/views/cli/api_key_missing.rb +37 -0
- data/lib/rubycode/views/cli/config_saved.rb +17 -0
- data/lib/rubycode/views/cli/configuration_table.rb +3 -3
- data/lib/rubycode/views/cli/first_time_setup.rb +17 -0
- data/lib/rubycode/views/cli/restart_message.rb +17 -0
- data/lib/rubycode/views/cli/setup_title.rb +17 -0
- data/lib/rubycode/views/cli.rb +5 -0
- data/lib/rubycode/views/formatter/fetch_summary.rb +54 -0
- data/lib/rubycode/views/formatter/web_search_summary.rb +53 -0
- data/lib/rubycode/views/formatter.rb +2 -0
- data/lib/rubycode/views/search_provider/debug_request.rb +30 -0
- data/lib/rubycode/views/search_provider/debug_response.rb +31 -0
- data/lib/rubycode/views/web_search_approval.rb +29 -0
- data/lib/rubycode/views.rb +5 -0
- data/lib/rubycode.rb +10 -0
- data/rubycode_cli.rb +228 -32
- metadata +81 -1
|
@@ -5,49 +5,49 @@ require "json"
|
|
|
5
5
|
|
|
6
6
|
module RubyCode
|
|
7
7
|
module Adapters
|
|
8
|
-
# Ollama adapter for
|
|
8
|
+
# Ollama adapter for cloud LLM integration
|
|
9
9
|
class Ollama < Base
|
|
10
|
+
def initialize(config)
|
|
11
|
+
super
|
|
12
|
+
validate_api_key!
|
|
13
|
+
end
|
|
14
|
+
|
|
10
15
|
def generate(messages:, system: nil, tools: nil)
|
|
11
|
-
|
|
16
|
+
enforce_rate_limit_delay
|
|
17
|
+
|
|
18
|
+
uri = build_uri
|
|
12
19
|
payload = build_payload(messages, system, tools)
|
|
13
20
|
request = build_request(uri, payload)
|
|
14
21
|
|
|
15
|
-
debug_request(uri, payload) if @config.debug
|
|
16
|
-
|
|
17
22
|
body = send_request_with_retry(uri, request)
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
@last_request_time = Time.now
|
|
25
|
+
|
|
26
|
+
# Extract and track tokens
|
|
27
|
+
@current_request_tokens = extract_tokens(body)
|
|
28
|
+
@total_tokens_counter += @current_request_tokens
|
|
20
29
|
|
|
21
30
|
body
|
|
31
|
+
rescue AdapterError => e
|
|
32
|
+
# If model doesn't support tools, provide helpful error message
|
|
33
|
+
raise AdapterError, build_tools_error_message if tools && e.message.include?("does not support tools")
|
|
34
|
+
|
|
35
|
+
raise e
|
|
22
36
|
end
|
|
23
37
|
|
|
24
38
|
private
|
|
25
39
|
|
|
26
|
-
def
|
|
27
|
-
|
|
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
|
|
40
|
+
def adapter_name
|
|
41
|
+
"Ollama"
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
)
|
|
44
|
+
def api_endpoint
|
|
45
|
+
# Not used - Ollama uses build_uri override
|
|
46
|
+
"#{@config.url}/api/chat"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_uri
|
|
50
|
+
URI("#{@config.url}/api/chat")
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def build_payload(messages, system, tools)
|
|
@@ -60,78 +60,110 @@ module RubyCode
|
|
|
60
60
|
def build_request(uri, payload)
|
|
61
61
|
request = Net::HTTP::Post.new(uri)
|
|
62
62
|
request["Content-Type"] = "application/json"
|
|
63
|
+
request["Authorization"] = "Bearer #{api_key}"
|
|
63
64
|
request.body = payload.to_json
|
|
64
65
|
request
|
|
65
66
|
end
|
|
66
67
|
|
|
68
|
+
def convert_response(_raw_response)
|
|
69
|
+
# Ollama returns the body directly in the right format
|
|
70
|
+
# No conversion needed
|
|
71
|
+
raise NotImplementedError, "Ollama doesn't use convert_response"
|
|
72
|
+
end
|
|
73
|
+
|
|
67
74
|
def send_request(uri, request)
|
|
68
75
|
response = perform_http_request(uri, request)
|
|
69
|
-
handle_response(response)
|
|
76
|
+
body = handle_response(response)
|
|
77
|
+
|
|
78
|
+
# Parse tool calls from content for models that use XML format (like Qwen)
|
|
79
|
+
parse_tool_calls_from_content(body) if body["message"]
|
|
80
|
+
|
|
81
|
+
body
|
|
70
82
|
rescue Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError => e
|
|
71
83
|
handle_network_error(e, uri)
|
|
72
84
|
rescue JSON::ParserError => e
|
|
73
|
-
raise AdapterError, "
|
|
85
|
+
raise AdapterError, I18n.t("rubycode.errors.adapter.invalid_json", error: e.message)
|
|
74
86
|
rescue StandardError => e
|
|
75
|
-
raise AdapterError, "
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def
|
|
79
|
-
|
|
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
|
|
87
|
+
raise AdapterError, I18n.t("rubycode.errors.adapter.unexpected_error", error: e.message)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_tools_error_message
|
|
91
|
+
I18n.t("rubycode.errors.tools_not_supported", model: @config.model)
|
|
100
92
|
end
|
|
101
93
|
|
|
102
|
-
|
|
103
|
-
|
|
94
|
+
# Parse <tool_call> XML tags or plain JSON from message content
|
|
95
|
+
# Some models (like Qwen) return tool calls as XML or JSON in content instead of structured format
|
|
96
|
+
def parse_tool_calls_from_content(body)
|
|
97
|
+
message = body["message"]
|
|
98
|
+
content = message["content"] || ""
|
|
104
99
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
100
|
+
return if content.empty?
|
|
101
|
+
|
|
102
|
+
tool_calls, clean_content = extract_tool_calls_and_content(content)
|
|
103
|
+
|
|
104
|
+
# Update message with parsed tool calls and cleaned content
|
|
105
|
+
return unless tool_calls.any?
|
|
106
|
+
|
|
107
|
+
message["tool_calls"] = tool_calls
|
|
108
|
+
message["content"] = clean_content
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def extract_tool_calls_and_content(content)
|
|
112
|
+
# Try XML-wrapped tool calls first
|
|
113
|
+
xml_result = parse_xml_tool_calls(content)
|
|
114
|
+
return xml_result if xml_result[0].any?
|
|
115
|
+
|
|
116
|
+
# Try plain JSON tool call
|
|
117
|
+
json_result = parse_json_tool_call(content)
|
|
118
|
+
return json_result if json_result[0].any?
|
|
119
|
+
|
|
120
|
+
[[], content]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def parse_xml_tool_calls(content)
|
|
124
|
+
return [[], content] unless content.include?("<tool_call>")
|
|
125
|
+
|
|
126
|
+
tool_calls = []
|
|
127
|
+
content.scan(%r{<tool_call>(.*?)</tool_call>}m) do |match|
|
|
128
|
+
tool_call_json = match[0].strip
|
|
129
|
+
tool_data = parse_tool_json(tool_call_json)
|
|
130
|
+
tool_calls << convert_to_openai_format(tool_data) if tool_data
|
|
115
131
|
end
|
|
132
|
+
|
|
133
|
+
clean_content = tool_calls.any? ? content.gsub(%r{<tool_call>.*?</tool_call>}m, "").strip : content
|
|
134
|
+
[tool_calls, clean_content]
|
|
116
135
|
end
|
|
117
136
|
|
|
118
|
-
def
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
puts JSON.pretty_generate(payload)
|
|
126
|
-
puts "=" * 80
|
|
137
|
+
def parse_json_tool_call(content)
|
|
138
|
+
return [[], content] unless content.strip.start_with?("{")
|
|
139
|
+
|
|
140
|
+
tool_data = parse_tool_json(content)
|
|
141
|
+
return [[], content] unless tool_data
|
|
142
|
+
|
|
143
|
+
[[convert_to_openai_format(tool_data)], ""]
|
|
127
144
|
end
|
|
128
145
|
|
|
129
|
-
def
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
146
|
+
def parse_tool_json(json_string)
|
|
147
|
+
JSON.parse(json_string)
|
|
148
|
+
rescue JSON::ParserError
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def convert_to_openai_format(tool_data)
|
|
153
|
+
{
|
|
154
|
+
"function" => {
|
|
155
|
+
"name" => tool_data["name"],
|
|
156
|
+
"arguments" => tool_data["arguments"]
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def extract_tokens(response_body)
|
|
162
|
+
# Ollama returns tokens in different format
|
|
163
|
+
TokenCounter.new(
|
|
164
|
+
input: response_body["prompt_eval_count"],
|
|
165
|
+
output: response_body["eval_count"]
|
|
166
|
+
)
|
|
135
167
|
end
|
|
136
168
|
end
|
|
137
169
|
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RubyCode
|
|
7
|
+
module Adapters
|
|
8
|
+
# OpenAI adapter for cloud LLM integration
|
|
9
|
+
# OpenAI provides industry-leading models including GPT-4o
|
|
10
|
+
class Openai < Base
|
|
11
|
+
API_ENDPOINT = "https://api.openai.com/v1/chat/completions"
|
|
12
|
+
|
|
13
|
+
def initialize(config)
|
|
14
|
+
super
|
|
15
|
+
validate_api_key!
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def adapter_name
|
|
21
|
+
"OpenAI"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def api_endpoint
|
|
25
|
+
API_ENDPOINT
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def build_payload(messages, system, tools)
|
|
29
|
+
# OpenAI format: system message goes in messages array
|
|
30
|
+
formatted_messages = messages.dup
|
|
31
|
+
|
|
32
|
+
formatted_messages.unshift({ role: "system", content: system }) if system
|
|
33
|
+
|
|
34
|
+
payload = {
|
|
35
|
+
model: @config.model,
|
|
36
|
+
messages: formatted_messages,
|
|
37
|
+
temperature: 0.7
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# OpenAI supports function calling format
|
|
41
|
+
payload[:tools] = tools if tools
|
|
42
|
+
|
|
43
|
+
payload
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_request(uri, payload)
|
|
47
|
+
request = Net::HTTP::Post.new(uri)
|
|
48
|
+
request["Content-Type"] = "application/json"
|
|
49
|
+
request["Authorization"] = "Bearer #{api_key}"
|
|
50
|
+
request.body = payload.to_json
|
|
51
|
+
request
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def convert_response(openai_response)
|
|
55
|
+
# OpenAI format: { choices: [{ message: { role, content, tool_calls } }] }
|
|
56
|
+
# Our format: { "message" => { "content" => ..., "tool_calls" => [...] } }
|
|
57
|
+
|
|
58
|
+
choice = openai_response["choices"]&.first
|
|
59
|
+
raise AdapterError, I18n.t("rubycode.errors.adapter.no_choices") unless choice
|
|
60
|
+
|
|
61
|
+
message = choice["message"]
|
|
62
|
+
raise AdapterError, I18n.t("rubycode.errors.adapter.no_message") unless message
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
"message" => {
|
|
66
|
+
"content" => message["content"] || "",
|
|
67
|
+
"tool_calls" => format_tool_calls(message["tool_calls"])
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def format_tool_calls(openai_tool_calls)
|
|
73
|
+
return [] unless openai_tool_calls
|
|
74
|
+
|
|
75
|
+
openai_tool_calls.map do |tool_call|
|
|
76
|
+
{
|
|
77
|
+
"function" => {
|
|
78
|
+
"name" => tool_call.dig("function", "name"),
|
|
79
|
+
"arguments" => tool_call.dig("function", "arguments")
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def extract_tokens(response_body)
|
|
86
|
+
usage = response_body["usage"] || {}
|
|
87
|
+
|
|
88
|
+
TokenCounter.new(
|
|
89
|
+
input: usage["prompt_tokens"],
|
|
90
|
+
output: usage["completion_tokens"],
|
|
91
|
+
cached: usage.dig("prompt_tokens_details", "cached_tokens"),
|
|
92
|
+
thinking: usage.dig("completion_tokens_details", "reasoning_tokens")
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RubyCode
|
|
7
|
+
module Adapters
|
|
8
|
+
# OpenRouter adapter for cloud LLM integration (OpenAI-compatible API)
|
|
9
|
+
# Provides access to multiple LLM providers through a unified API
|
|
10
|
+
class Openrouter < Base
|
|
11
|
+
API_ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
|
|
12
|
+
|
|
13
|
+
def initialize(config)
|
|
14
|
+
super
|
|
15
|
+
validate_api_key!
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def adapter_name
|
|
21
|
+
"OpenRouter"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def api_endpoint
|
|
25
|
+
API_ENDPOINT
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def build_payload(messages, system, tools)
|
|
29
|
+
# OpenAI-compatible format: system message goes in messages array
|
|
30
|
+
formatted_messages = messages.dup
|
|
31
|
+
|
|
32
|
+
formatted_messages.unshift({ role: "system", content: system }) if system
|
|
33
|
+
|
|
34
|
+
payload = {
|
|
35
|
+
model: @config.model,
|
|
36
|
+
messages: formatted_messages,
|
|
37
|
+
temperature: 0.7
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# OpenRouter supports OpenAI function calling format
|
|
41
|
+
payload[:tools] = tools if tools
|
|
42
|
+
|
|
43
|
+
payload
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_request(uri, payload)
|
|
47
|
+
request = Net::HTTP::Post.new(uri)
|
|
48
|
+
request["Content-Type"] = "application/json"
|
|
49
|
+
request["Authorization"] = "Bearer #{api_key}"
|
|
50
|
+
|
|
51
|
+
# Optional but recommended headers for OpenRouter
|
|
52
|
+
request["HTTP-Referer"] = "https://github.com/jonasmedeiros/rubycode"
|
|
53
|
+
request["X-Title"] = "RubyCode"
|
|
54
|
+
|
|
55
|
+
request.body = payload.to_json
|
|
56
|
+
request
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def convert_response(openrouter_response)
|
|
60
|
+
# OpenRouter uses OpenAI-compatible format
|
|
61
|
+
# Format: { choices: [{ message: { role, content, tool_calls } }] }
|
|
62
|
+
# Our format: { "message" => { "content" => ..., "tool_calls" => [...] } }
|
|
63
|
+
|
|
64
|
+
choice = openrouter_response["choices"]&.first
|
|
65
|
+
raise AdapterError, I18n.t("rubycode.errors.adapter.no_choices") unless choice
|
|
66
|
+
|
|
67
|
+
message = choice["message"]
|
|
68
|
+
raise AdapterError, I18n.t("rubycode.errors.adapter.no_message") unless message
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
"message" => {
|
|
72
|
+
"content" => message["content"] || "",
|
|
73
|
+
"tool_calls" => format_tool_calls(message["tool_calls"])
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def format_tool_calls(tool_calls)
|
|
79
|
+
return [] unless tool_calls
|
|
80
|
+
|
|
81
|
+
tool_calls.map do |tool_call|
|
|
82
|
+
{
|
|
83
|
+
"function" => {
|
|
84
|
+
"name" => tool_call.dig("function", "name"),
|
|
85
|
+
"arguments" => tool_call.dig("function", "arguments")
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def extract_tokens(response_body)
|
|
92
|
+
usage = response_body["usage"] || {}
|
|
93
|
+
|
|
94
|
+
TokenCounter.new(
|
|
95
|
+
input: usage["prompt_tokens"],
|
|
96
|
+
output: usage["completion_tokens"],
|
|
97
|
+
cached: usage.dig("prompt_tokens_details", "cached_tokens")
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/rubycode/agent_loop.rb
CHANGED
|
@@ -9,6 +9,7 @@ module RubyCode
|
|
|
9
9
|
class AgentLoop
|
|
10
10
|
MAX_ITERATIONS = 25
|
|
11
11
|
MAX_TOOL_CALLS = 50
|
|
12
|
+
MAX_CONSECUTIVE_RATE_LIMIT_ERRORS = 3
|
|
12
13
|
|
|
13
14
|
def initialize(adapter:, memory:, config:, system_prompt:, options: {})
|
|
14
15
|
@adapter = adapter
|
|
@@ -20,11 +21,13 @@ module RubyCode
|
|
|
20
21
|
@response_handler = Client::ResponseHandler.new(memory: @memory, config: @config)
|
|
21
22
|
@display_formatter = Client::DisplayFormatter.new(config: @config)
|
|
22
23
|
@approval_handler = Client::ApprovalHandler.new(tty_prompt: @tty_prompt, config: @config)
|
|
24
|
+
@consecutive_rate_limit_errors = 0
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def run
|
|
26
28
|
iteration = 0
|
|
27
29
|
total_tool_calls = 0
|
|
30
|
+
@last_response_was_error = false
|
|
28
31
|
|
|
29
32
|
loop do
|
|
30
33
|
iteration += 1
|
|
@@ -33,12 +36,7 @@ module RubyCode
|
|
|
33
36
|
|
|
34
37
|
content, tool_calls = llm_response
|
|
35
38
|
|
|
36
|
-
if tool_calls
|
|
37
|
-
result = @response_handler.handle_empty_tool_calls(content, iteration, total_tool_calls)
|
|
38
|
-
return result if result
|
|
39
|
-
|
|
40
|
-
next # Continue loop for workaround case
|
|
41
|
-
end
|
|
39
|
+
next if handle_empty_tool_calls_case(content, tool_calls, iteration, total_tool_calls)
|
|
42
40
|
|
|
43
41
|
total_tool_calls += tool_calls.length
|
|
44
42
|
return @response_handler.handle_max_tool_calls(content, total_tool_calls) if total_tool_calls > MAX_TOOL_CALLS
|
|
@@ -46,31 +44,115 @@ module RubyCode
|
|
|
46
44
|
done_result = execute_tool_calls(tool_calls, iteration)
|
|
47
45
|
return @response_handler.finalize_response(done_result, iteration, total_tool_calls) if done_result
|
|
48
46
|
end
|
|
47
|
+
rescue RubyCode::AdapterRetryExhaustedError => e
|
|
48
|
+
build_retry_exhausted_message(e)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def handle_empty_tool_calls_case(content, tool_calls, iteration, total_tool_calls)
|
|
52
|
+
return false unless tool_calls.empty?
|
|
53
|
+
|
|
54
|
+
# Skip empty tool call handling if last response was an adapter error
|
|
55
|
+
if @last_response_was_error
|
|
56
|
+
@last_response_was_error = false # Reset flag
|
|
57
|
+
return true # Continue loop
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
result = @response_handler.handle_empty_tool_calls(content, iteration, total_tool_calls)
|
|
61
|
+
return result if result
|
|
62
|
+
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_retry_exhausted_message(error)
|
|
67
|
+
"\n❌ Unable to reach LLM server after multiple retries.\n\n" \
|
|
68
|
+
"Error: #{error.message}\n\n" \
|
|
69
|
+
"Please check:\n " \
|
|
70
|
+
"• Is your LLM server running?\n " \
|
|
71
|
+
"• Are you being rate limited? (wait a few minutes)\n " \
|
|
72
|
+
"• Is the server URL correct in your config?\n"
|
|
49
73
|
end
|
|
50
74
|
|
|
51
75
|
private
|
|
52
76
|
|
|
53
77
|
def llm_response
|
|
54
|
-
puts Views::AgentLoop::ThinkingStatus.build
|
|
78
|
+
puts Views::AgentLoop::ThinkingStatus.build
|
|
79
|
+
|
|
80
|
+
response_body = fetch_llm_response
|
|
81
|
+
display_response_info(response_body)
|
|
55
82
|
|
|
56
|
-
|
|
57
|
-
|
|
83
|
+
content, tool_calls = extract_message_parts(response_body)
|
|
84
|
+
|
|
85
|
+
reset_error_tracking
|
|
86
|
+
@memory.add_message(role: "assistant", content: content, tool_calls: tool_calls)
|
|
87
|
+
|
|
88
|
+
[content, tool_calls]
|
|
89
|
+
rescue RubyCode::AdapterRetryExhaustedError => e
|
|
90
|
+
# Stop the agent loop when retries are exhausted
|
|
91
|
+
handle_retry_exhausted(e)
|
|
92
|
+
raise e # Re-raise to stop the loop
|
|
93
|
+
rescue RubyCode::AdapterError => e
|
|
94
|
+
handle_adapter_error_with_rate_limiting(e)
|
|
95
|
+
@last_response_was_error = true # Mark as error to skip injection reminder
|
|
96
|
+
[nil, []] # Return empty to continue loop
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def fetch_llm_response
|
|
100
|
+
messages = @memory.to_llm_format(
|
|
101
|
+
window_size: @config.memory_window,
|
|
102
|
+
prune_tool_results: @config.prune_tool_results
|
|
103
|
+
)
|
|
104
|
+
@adapter.generate(
|
|
58
105
|
messages: messages,
|
|
59
106
|
system: @system_prompt,
|
|
60
107
|
tools: Tools.definitions
|
|
61
108
|
)
|
|
109
|
+
end
|
|
62
110
|
|
|
63
|
-
|
|
111
|
+
def display_response_info(_response_body)
|
|
112
|
+
puts Views::AgentLoop::ResponseReceived.build
|
|
64
113
|
|
|
114
|
+
tokens = @adapter.current_request_tokens
|
|
115
|
+
cumulative = @adapter.total_tokens_counter
|
|
116
|
+
puts Views::AgentLoop::TokenSummary.build(
|
|
117
|
+
tokens: tokens,
|
|
118
|
+
adapter: @config.adapter,
|
|
119
|
+
model: @config.model,
|
|
120
|
+
cumulative: cumulative
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def extract_message_parts(response_body)
|
|
65
125
|
assistant_message = response_body["message"]
|
|
66
126
|
content = assistant_message["content"] || ""
|
|
67
127
|
tool_calls = assistant_message["tool_calls"] || []
|
|
68
|
-
|
|
69
|
-
@memory.add_message(role: "assistant", content: content)
|
|
70
128
|
[content, tool_calls]
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def reset_error_tracking
|
|
132
|
+
@consecutive_rate_limit_errors = 0
|
|
133
|
+
@last_response_was_error = false
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def handle_adapter_error_with_rate_limiting(error)
|
|
137
|
+
if rate_limit_error?(error)
|
|
138
|
+
@consecutive_rate_limit_errors += 1
|
|
139
|
+
if @consecutive_rate_limit_errors >= MAX_CONSECUTIVE_RATE_LIMIT_ERRORS
|
|
140
|
+
handle_rate_limit_exhausted(error)
|
|
141
|
+
raise AdapterRetryExhaustedError,
|
|
142
|
+
"Rate limit exceeded after #{MAX_CONSECUTIVE_RATE_LIMIT_ERRORS} consecutive attempts"
|
|
143
|
+
end
|
|
144
|
+
else
|
|
145
|
+
# Reset counter for non-rate-limit errors
|
|
146
|
+
@consecutive_rate_limit_errors = 0
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
handle_adapter_error(error)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def handle_retry_exhausted(error)
|
|
153
|
+
error_msg = I18n.t("rubycode.errors.adapter_retry_exhausted", error: error.message)
|
|
154
|
+
puts Views::AgentLoop::AdapterError.build(message: error_msg)
|
|
155
|
+
@memory.add_message(role: "user", content: error_msg)
|
|
74
156
|
end
|
|
75
157
|
|
|
76
158
|
def handle_adapter_error(error)
|
|
@@ -79,8 +161,18 @@ module RubyCode
|
|
|
79
161
|
@memory.add_message(role: "user", content: error_msg)
|
|
80
162
|
end
|
|
81
163
|
|
|
164
|
+
def rate_limit_error?(error)
|
|
165
|
+
error.message.include?("Rate limited") || error.message.include?("429")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def handle_rate_limit_exhausted(_error)
|
|
169
|
+
error_msg = I18n.t("rubycode.errors.rate_limit_exhausted", max_attempts: MAX_CONSECUTIVE_RATE_LIMIT_ERRORS)
|
|
170
|
+
puts Views::AgentLoop::AdapterError.build(message: error_msg)
|
|
171
|
+
@memory.add_message(role: "user", content: error_msg)
|
|
172
|
+
end
|
|
173
|
+
|
|
82
174
|
def execute_tool_calls(tool_calls, iteration)
|
|
83
|
-
puts Views::AgentLoop::IterationHeader.build(iteration: iteration, tool_calls: tool_calls)
|
|
175
|
+
puts Views::AgentLoop::IterationHeader.build(iteration: iteration, tool_calls: tool_calls)
|
|
84
176
|
|
|
85
177
|
done_result = nil
|
|
86
178
|
tool_calls.each do |tool_call|
|
|
@@ -92,7 +184,7 @@ module RubyCode
|
|
|
92
184
|
end
|
|
93
185
|
end
|
|
94
186
|
|
|
95
|
-
puts Views::AgentLoop::IterationFooter.build
|
|
187
|
+
puts Views::AgentLoop::IterationFooter.build
|
|
96
188
|
done_result
|
|
97
189
|
end
|
|
98
190
|
|
|
@@ -108,7 +200,7 @@ module RubyCode
|
|
|
108
200
|
result = run_tool(tool_name, params)
|
|
109
201
|
return nil unless result
|
|
110
202
|
|
|
111
|
-
@display_formatter.display_result(result)
|
|
203
|
+
@display_formatter.display_result(result, tool_name: tool_name)
|
|
112
204
|
add_tool_result_to_memory(tool_name, result)
|
|
113
205
|
result
|
|
114
206
|
rescue ToolError, StandardError => e
|
|
@@ -53,6 +53,20 @@ module RubyCode
|
|
|
53
53
|
approved
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
def request_web_search_approval(query, max_results)
|
|
57
|
+
display = Views::WebSearchApproval.build(
|
|
58
|
+
query: query,
|
|
59
|
+
max_results: max_results
|
|
60
|
+
)
|
|
61
|
+
puts display
|
|
62
|
+
|
|
63
|
+
approved = request_approval("Perform this web search?")
|
|
64
|
+
|
|
65
|
+
puts Views::SkipNotification.build(message: "User declined web search for '#{query}'") unless approved
|
|
66
|
+
|
|
67
|
+
approved
|
|
68
|
+
end
|
|
69
|
+
|
|
56
70
|
private
|
|
57
71
|
|
|
58
72
|
def request_approval(question)
|