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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +38 -0
  3. data/.rubocop.yml +4 -0
  4. data/CHANGELOG.md +52 -0
  5. data/README.md +101 -23
  6. data/USAGE.md +1 -23
  7. data/config/locales/en.yml +208 -1
  8. data/config/system_prompt.md +6 -1
  9. data/config/tools/bash.json +1 -1
  10. data/config/tools/fetch.json +22 -0
  11. data/config/tools/websearch.json +22 -0
  12. data/docs/images/demo.png +0 -0
  13. data/lib/rubycode/adapters/base.rb +92 -2
  14. data/lib/rubycode/adapters/concerns/debugging.rb +32 -0
  15. data/lib/rubycode/adapters/concerns/error_handling.rb +89 -0
  16. data/lib/rubycode/adapters/concerns/http_client.rb +67 -0
  17. data/lib/rubycode/adapters/deepseek.rb +97 -0
  18. data/lib/rubycode/adapters/gemini.rb +133 -0
  19. data/lib/rubycode/adapters/ollama.rb +114 -82
  20. data/lib/rubycode/adapters/openai.rb +97 -0
  21. data/lib/rubycode/adapters/openrouter.rb +102 -0
  22. data/lib/rubycode/agent_loop.rb +110 -18
  23. data/lib/rubycode/client/approval_handler.rb +14 -0
  24. data/lib/rubycode/client/display_formatter.rb +18 -10
  25. data/lib/rubycode/client/response_handler.rb +4 -23
  26. data/lib/rubycode/client.rb +9 -0
  27. data/lib/rubycode/config_manager.rb +81 -0
  28. data/lib/rubycode/configuration.rb +21 -10
  29. data/lib/rubycode/database.rb +19 -0
  30. data/lib/rubycode/errors.rb +12 -0
  31. data/lib/rubycode/models/api_key.rb +118 -0
  32. data/lib/rubycode/models/memory.rb +84 -10
  33. data/lib/rubycode/models.rb +1 -0
  34. data/lib/rubycode/pricing.rb +59 -0
  35. data/lib/rubycode/search_providers/base.rb +66 -0
  36. data/lib/rubycode/search_providers/brave_search.rb +60 -0
  37. data/lib/rubycode/search_providers/concerns/debugging.rb +37 -0
  38. data/lib/rubycode/search_providers/concerns/error_handling.rb +64 -0
  39. data/lib/rubycode/search_providers/concerns/http_client.rb +67 -0
  40. data/lib/rubycode/search_providers/duckduckgo_instant.rb +98 -0
  41. data/lib/rubycode/search_providers/exa_ai.rb +171 -0
  42. data/lib/rubycode/search_providers/multi_provider.rb +47 -0
  43. data/lib/rubycode/token_counter.rb +41 -0
  44. data/lib/rubycode/tools/bash.rb +38 -8
  45. data/lib/rubycode/tools/fetch.rb +120 -0
  46. data/lib/rubycode/tools/web_search.rb +122 -0
  47. data/lib/rubycode/tools.rb +5 -1
  48. data/lib/rubycode/value_objects.rb +8 -4
  49. data/lib/rubycode/version.rb +1 -1
  50. data/lib/rubycode/views/adapter/debug_delay.rb +20 -0
  51. data/lib/rubycode/views/adapter/debug_request.rb +33 -0
  52. data/lib/rubycode/views/adapter/debug_response.rb +31 -0
  53. data/lib/rubycode/views/agent_loop/token_summary.rb +54 -0
  54. data/lib/rubycode/views/agent_loop.rb +2 -0
  55. data/lib/rubycode/views/cli/api_key_missing.rb +37 -0
  56. data/lib/rubycode/views/cli/config_saved.rb +17 -0
  57. data/lib/rubycode/views/cli/configuration_table.rb +3 -3
  58. data/lib/rubycode/views/cli/first_time_setup.rb +17 -0
  59. data/lib/rubycode/views/cli/restart_message.rb +17 -0
  60. data/lib/rubycode/views/cli/setup_title.rb +17 -0
  61. data/lib/rubycode/views/cli.rb +5 -0
  62. data/lib/rubycode/views/formatter/fetch_summary.rb +54 -0
  63. data/lib/rubycode/views/formatter/web_search_summary.rb +53 -0
  64. data/lib/rubycode/views/formatter.rb +2 -0
  65. data/lib/rubycode/views/search_provider/debug_request.rb +30 -0
  66. data/lib/rubycode/views/search_provider/debug_response.rb +31 -0
  67. data/lib/rubycode/views/web_search_approval.rb +29 -0
  68. data/lib/rubycode/views.rb +5 -0
  69. data/lib/rubycode.rb +10 -0
  70. data/rubycode_cli.rb +228 -32
  71. metadata +81 -1
@@ -5,49 +5,49 @@ require "json"
5
5
 
6
6
  module RubyCode
7
7
  module Adapters
8
- # Ollama adapter for local LLM integration
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
- uri = URI("#{@config.url}/api/chat")
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
- debug_response(body) if @config.debug
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 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
40
+ def adapter_name
41
+ "Ollama"
42
42
  end
43
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
- )
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, "Invalid JSON response from server: #{e.message}"
85
+ raise AdapterError, I18n.t("rubycode.errors.adapter.invalid_json", error: e.message)
74
86
  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
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
- def handle_response(response)
103
- body = JSON.parse(response.body)
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
- # 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}"
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 debug_request(uri, payload)
119
- puts "\n#{"=" * 80}"
120
- puts "📤 REQUEST TO LLM"
121
- puts "=" * 80
122
- puts "URL: #{uri}"
123
- puts "Model: #{@config.model}"
124
- puts "\nPayload:"
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 debug_response(body)
130
- puts "\n#{"=" * 80}"
131
- puts "📥 RESPONSE FROM LLM"
132
- puts "=" * 80
133
- puts JSON.pretty_generate(body)
134
- puts "#{"=" * 80}\n"
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
@@ -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.empty?
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 unless @config.debug
78
+ puts Views::AgentLoop::ThinkingStatus.build
79
+
80
+ response_body = fetch_llm_response
81
+ display_response_info(response_body)
55
82
 
56
- messages = @memory.to_llm_format
57
- response_body = @adapter.generate(
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
- puts Views::AgentLoop::ResponseReceived.build unless @config.debug
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
- rescue AdapterError => e
72
- handle_adapter_error(e)
73
- [nil, []] # Return empty to continue loop
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) unless @config.debug
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 unless @config.debug
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)