rails_console_ai 0.32.0 → 0.33.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7eab1fd8d3a5c8c7a228206f0c723bfa845fa2d344471e0251659658c0c5e571
4
- data.tar.gz: c607b6cfa9e6834612513f0c75dc1303386f79bcdd96bba203a4fb5d0549f32b
3
+ metadata.gz: 8af25f720aae1fe703b947bdc860dcace9a13e2e1db61d9d698634fa46838e59
4
+ data.tar.gz: efc3b4c00ea66d941e5fb03c61543398fed5ffdde700c44c080ee5e8c8e09ad6
5
5
  SHA512:
6
- metadata.gz: 1acd49dcf3370eaab8a8fb8ef77c7dad4746fcb0e0eb2f78935b53c870228a9b53a2cef8dc82608ed94f5f2642de905070fe9e241e9a98d8f408653535ce30d7
7
- data.tar.gz: bd7ec6fcf50788b45f47f6a7b1948a74fb83cf4ff7fba62687eb2d6e8c75743972f4e83525d87f57096c1a6cd114611a69c5b6e2299a88dea6b02063cdf09adc
6
+ metadata.gz: 0b8de788b6391a303815cd160fb57ce4300c33dfd3f66645ce31f9bb256de0922b7ce8d26fd05c23dc8802c80da6201ffb9935c89cfd82b94ebde609b1631733
7
+ data.tar.gz: 5c73d678909c5e950cff5191b506a72570c2eb50bd2d6f01602bfe63ec2fade56c94b88d91f6d487803b073abd7b3565c9b92c355f90f7e32be82e465b72b7f9
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.33.0]
6
+
7
+ - Support Claude Opus 5 and make it the default model for the Anthropic and Bedrock providers
8
+ - Add runaway-loop circuit breakers to the tool loop — break when the same error signature repeats (warn at 3 occurrences, stop at 5) or when a token budget is exceeded (nudge at 500K input tokens, force a final answer at 1M), with a tailored wrap-up prompt for each break reason
9
+ - Fail fast on known environment errors: a built-in hint recognizes decryption key misconfiguration (`bad decrypt` / `ActiveRecord::Encryption`) and tells the model not to retry, and apps can register their own hints via `config.error_hints`
10
+
5
11
  ## [0.32.0]
6
12
 
7
13
  - Switch default models to Claude Sonnet 5 and Opus 4.8, and rework pricing to match models by family so dated snapshots and Bedrock IDs are covered
data/README.md CHANGED
@@ -291,7 +291,7 @@ RailsConsoleAi.configure do |config|
291
291
  end
292
292
  ```
293
293
 
294
- Default model: `claude-sonnet-5`. Thinking model: `claude-opus-4-8`. Prompt caching is enabled automatically.
294
+ Default model: `claude-sonnet-5`. Thinking model: `claude-opus-5`. Prompt caching is enabled automatically.
295
295
 
296
296
  ### OpenAI
297
297
 
@@ -318,7 +318,7 @@ RailsConsoleAi.configure do |config|
318
318
  config.provider = :bedrock
319
319
  config.bedrock_region = 'us-east-1'
320
320
  # config.model = 'us.anthropic.claude-sonnet-5' # default
321
- # config.thinking_model = 'us.anthropic.claude-opus-4-8' # default
321
+ # config.thinking_model = 'us.anthropic.claude-opus-5' # default
322
322
  end
323
323
  ```
324
324
 
@@ -396,9 +396,48 @@ RailsConsoleAi.configure do |config|
396
396
  config.timeout = 30 # HTTP timeout in seconds
397
397
  config.max_tool_rounds = 200 # safety cap on tool-use loops
398
398
  config.code_search_paths = %w[app] # directories for list_files / search_code
399
+
400
+ # Runaway-loop circuit breakers (see "Runaway sessions" below)
401
+ config.token_nudge_threshold = 500_000 # input tokens in one tool loop → nudge model to wrap up (nil disables)
402
+ config.token_stop_threshold = 1_000_000 # input tokens in one tool loop → force a final answer (nil disables)
399
403
  end
400
404
  ```
401
405
 
406
+ ### Runaway sessions & known-issue hints
407
+
408
+ Two mechanisms stop the LLM from burning tokens on a dead end:
409
+
410
+ **Known-issue hints** — when executed code fails (or prints a rescued error) matching a
411
+ known environment-level problem, the tool result includes explicit guidance telling the
412
+ model not to retry. The built-in hint recognizes decryption failures
413
+ (`OpenSSL::Cipher::CipherError` / "bad decrypt" / `ActiveRecord::Encryption` errors),
414
+ which indicate a missing or placeholder encryption key in the console process — an
415
+ environment issue no amount of retrying can fix. Repeat occurrences escalate the message.
416
+ Apps can add their own:
417
+
418
+ ```ruby
419
+ RailsConsoleAi.configure do |config|
420
+ config.error_hints << {
421
+ name: :vpn_required,
422
+ pattern: /Errno::ECONNREFUSED.*10\.8\./,
423
+ hint: "This host is only reachable over the VPN, which this console does not have. " \
424
+ "Do not retry; report the limitation to the user."
425
+ }
426
+ end
427
+ ```
428
+
429
+ **Circuit breakers** — inside a single tool loop, the engine tracks:
430
+
431
+ - *Identical tool calls* (same tool + same args): warns at 3, breaks at 5.
432
+ - *Repeated error signatures* (same normalized error from **different** code): warns at 3,
433
+ breaks at 5. This catches "new code, same dead end" loops that identical-call detection
434
+ misses. Digits are normalized so varying record IDs don't disguise the same failure.
435
+ - *Token budget*: at `token_nudge_threshold` cumulative input tokens the model is told to
436
+ wrap up; at `token_stop_threshold` the loop is stopped and a final answer is forced.
437
+
438
+ When any breaker trips, the model is asked to summarize what it established, what it
439
+ could not determine and why, and what a human should do next — instead of iterating.
440
+
402
441
  ### Code Search Paths
403
442
 
404
443
  By default, `list_files` and `search_code` only look in `app/`. If your project has code in other directories (e.g. a frontend in `public/portal`, or shared code in `lib`), add them:
@@ -13,6 +13,7 @@ module RailsConsoleAi
13
13
  # (removed on opus-4-7+, sonnet-5, and fable-5).
14
14
  MODEL_FAMILIES = {
15
15
  'claude-fable-5' => { input: 10.0, output: 50.0, max_tokens: 16_000, temperature: false },
16
+ 'claude-opus-5' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
16
17
  'claude-opus-4-8' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
17
18
  'claude-opus-4-7' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
18
19
  'claude-opus-4-6' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: true },
@@ -47,9 +48,30 @@ module RailsConsoleAi
47
48
  }
48
49
  end
49
50
 
51
+ # Known environment-level failures the executor recognizes and explains to the
52
+ # LLM on the FIRST occurrence, so it doesn't burn rounds rediscovering them
53
+ # through trial and error. Each entry: { name:, pattern:, hint: }.
54
+ # The pattern is matched against the execution error AND the captured output
55
+ # (to catch errors rescued and printed by the generated code itself).
56
+ DEFAULT_ERROR_HINTS = [
57
+ {
58
+ name: :decryption_failure,
59
+ pattern: /OpenSSL::Cipher::CipherError|bad decrypt|ActiveRecord::Encryption::Errors/i,
60
+ hint: "Decryption failed — this console process's encryption key (e.g. ENV['ENCRYPTION_KEY']) " \
61
+ "appears to be missing, invalid, or a placeholder for the data you are reading. This is an " \
62
+ "environment configuration issue, not a data issue, and it affects EVERY encrypted field in " \
63
+ "this session. Retrying with different code (reloading records, toggling encryption flags, " \
64
+ "decrypting other fields or records) will fail the same way. Do NOT retry. Report this " \
65
+ "limitation to the user, tell them the encryption key needs to be configured for this " \
66
+ "environment, and answer using only what you can determine without decrypting."
67
+ }
68
+ ].freeze
69
+
50
70
  attr_accessor :provider, :api_key, :model, :thinking_model, :max_tokens,
51
71
  :auto_execute, :temperature,
52
72
  :timeout, :debug, :max_tool_rounds,
73
+ :error_hints,
74
+ :token_nudge_threshold, :token_stop_threshold,
53
75
  :storage_adapter, :memories_enabled,
54
76
  :session_logging, :connection_class,
55
77
  :admin_username, :admin_password,
@@ -75,6 +97,9 @@ module RailsConsoleAi
75
97
  @timeout = 30
76
98
  @debug = false
77
99
  @max_tool_rounds = 200
100
+ @error_hints = DEFAULT_ERROR_HINTS.dup
101
+ @token_nudge_threshold = 500_000 # input tokens in one tool loop → nudge model to wrap up (nil disables)
102
+ @token_stop_threshold = 1_000_000 # input tokens in one tool loop → force a final answer (nil disables)
78
103
  @storage_adapter = nil
79
104
  @memories_enabled = true
80
105
  @session_logging = true
@@ -222,13 +247,13 @@ module RailsConsoleAi
222
247
 
223
248
  case @provider
224
249
  when :anthropic
225
- 'claude-opus-4-8'
250
+ 'claude-opus-5'
226
251
  when :openai
227
252
  'gpt-5.3-codex'
228
253
  when :local
229
254
  @local_model
230
255
  when :bedrock
231
- 'us.anthropic.claude-opus-4-8'
256
+ 'us.anthropic.claude-opus-5'
232
257
  end
233
258
  end
234
259
 
@@ -7,6 +7,8 @@ module RailsConsoleAi
7
7
  LARGE_OUTPUT_PREVIEW_CHARS = 16_000 # chars — how much of the output the LLM sees upfront
8
8
  LOOP_WARN_THRESHOLD = 3 # same tool+args repeated → inject warning
9
9
  LOOP_BREAK_THRESHOLD = 5 # same tool+args repeated → break loop
10
+ REPEAT_ERROR_WARN_THRESHOLD = 3 # same error signature (any args) → inject warning
11
+ REPEAT_ERROR_BREAK_THRESHOLD = 5 # same error signature (any args) → force wrap-up
10
12
 
11
13
  def initialize(binding_context:, channel:, slack_thread_ts: nil, slack_channel_name: nil)
12
14
  @binding_context = binding_context
@@ -46,6 +48,7 @@ module RailsConsoleAi
46
48
  if executed && @executor.last_error && !@executor.last_safety_error
47
49
  error_msg = "Code execution failed with error: #{@executor.last_error}"
48
50
  error_msg = error_msg[0..1000] + '...' if error_msg.length > 1000
51
+ error_msg += "\n\n#{@executor.last_error_hint}" if @executor.last_error_hint
49
52
  conversation << { role: :assistant, content: @_last_result_text }
50
53
  conversation << { role: :user, content: error_msg }
51
54
 
@@ -347,6 +350,7 @@ module RailsConsoleAi
347
350
  elsif @executor.last_error
348
351
  error_msg = "Code execution failed with error: #{@executor.last_error}"
349
352
  error_msg = error_msg[0..1000] + '...' if error_msg.length > 1000
353
+ error_msg += "\n\n#{@executor.last_error_hint}" if @executor.last_error_hint
350
354
  @history << { role: :user, content: error_msg }
351
355
  :error
352
356
  else
@@ -796,7 +800,11 @@ module RailsConsoleAi
796
800
  last_tool_names = []
797
801
 
798
802
  exhausted = false
803
+ wrap_up_reason = nil
799
804
  tool_call_counts = Hash.new(0)
805
+ error_sig_counts = Hash.new(0)
806
+ warned_error_sigs = Set.new
807
+ token_nudge_sent = false
800
808
 
801
809
  max_rounds.times do |round|
802
810
  if @channel.cancelled?
@@ -918,6 +926,12 @@ module RailsConsoleAi
918
926
  $stderr.puts "\e[35m[debug] tool result (#{tool_result.to_s.length} chars)\e[0m"
919
927
  end
920
928
 
929
+ # Track repeated error signatures across DIFFERENT tool calls — the
930
+ # identical-call loop detection below misses "same dead end, new code".
931
+ if (sig = error_signature(tool_result))
932
+ error_sig_counts[sig] += 1
933
+ end
934
+
921
935
  tool_msg = provider.format_tool_result(tc[:id], tool_result)
922
936
  full_text = tool_result.to_s
923
937
  output_id = @executor.store_output(full_text)
@@ -942,23 +956,68 @@ module RailsConsoleAi
942
956
  if tool_call_counts[key] >= LOOP_BREAK_THRESHOLD
943
957
  @channel.display_status(" Loop detected: #{tc[:name]} called #{tool_call_counts[key]} times with same args — stopping.")
944
958
  exhausted = true
959
+ wrap_up_reason ||= :tool_loop
945
960
  elsif tool_call_counts[key] >= LOOP_WARN_THRESHOLD
946
961
  @channel.display_status(" Warning: #{tc[:name]} called #{tool_call_counts[key]} times with same args — consider a different approach.")
947
962
  messages << { role: :user, content: "You are repeating the same tool call (#{tc[:name]}) with the same arguments. This is not making progress. Try a different approach or provide your answer now." }
948
963
  end
949
964
  end
965
+
966
+ # Circuit breaker: the same error signature recurring across different
967
+ # tool calls means no new information is being gained (e.g. every
968
+ # decryption attempt failing with "bad decrypt" regardless of the code).
969
+ error_sig_counts.each do |sig, count|
970
+ if count >= REPEAT_ERROR_BREAK_THRESHOLD
971
+ @channel.display_status(" Circuit breaker: same error hit #{count} times (#{sig}) — stopping.")
972
+ exhausted = true
973
+ wrap_up_reason ||= :repeated_errors
974
+ elsif count >= REPEAT_ERROR_WARN_THRESHOLD && !warned_error_sigs.include?(sig)
975
+ warned_error_sigs << sig
976
+ @channel.display_status(" Warning: same error hit #{count} times — nudging model to change strategy.")
977
+ messages << { role: :user, content: "You have now hit the same error #{count} times (#{sig}). Trying variations of the same approach is not producing new information. If this error cannot be resolved from this session, stop investigating it: summarize what you have established, state what you could not determine and why, and give the user your best answer." }
978
+ end
979
+ end
980
+
981
+ # Circuit breaker: token budget for a single tool loop.
982
+ config = RailsConsoleAi.configuration
983
+ if config.token_stop_threshold && total_input >= config.token_stop_threshold
984
+ @channel.display_status(" Token budget exceeded (#{format_tokens(total_input)} input tokens this request) — forcing wrap-up.")
985
+ exhausted = true
986
+ wrap_up_reason ||= :token_budget
987
+ elsif config.token_nudge_threshold && total_input >= config.token_nudge_threshold && !token_nudge_sent
988
+ token_nudge_sent = true
989
+ @channel.display_status(" High token usage (#{format_tokens(total_input)} input tokens this request) — nudging model to wrap up.")
990
+ messages << { role: :user, content: "This investigation has consumed #{format_tokens(total_input)} input tokens without reaching a conclusion. Wrap up now: stop opening new lines of investigation, summarize what you have established, state what you could not determine and why, and give the user your best answer. Only make another tool call if you are confident a single call will resolve the question." }
991
+ end
992
+
950
993
  break if exhausted
951
994
 
952
995
  # If the user declined execution, don't call the LLM again —
953
996
  # just return to the prompt so they can correct their request.
954
997
  break if @executor.last_cancelled?
955
998
 
956
- exhausted = true if round == max_rounds - 1
999
+ if round == max_rounds - 1
1000
+ exhausted = true
1001
+ wrap_up_reason ||= :round_cap
1002
+ end
957
1003
  end
958
1004
 
959
1005
  if exhausted
960
- $stdout.puts "\e[33m Hit tool round limit (#{max_rounds}). Forcing final answer. Increase with: RailsConsoleAi.configure { |c| c.max_tool_rounds = 200 }\e[0m"
961
- messages << { role: :user, content: "You've used all available tool rounds. Please provide your best answer now based on what you've learned so far." }
1006
+ final_nudge =
1007
+ case wrap_up_reason
1008
+ when :repeated_errors
1009
+ "You have hit the same error repeatedly without gaining new information. Do not attempt any further tool calls. Give the user a final answer now: (1) what you established, (2) what you could not determine and the blocking error, and (3) what a human should do next."
1010
+ when :token_budget
1011
+ "This session has exceeded its token budget. Do not attempt any further tool calls. Give the user a final answer now: (1) what you established, (2) what you could not determine and why, and (3) what a human should do next."
1012
+ when :tool_loop
1013
+ "You kept repeating the same tool call with the same arguments. Do not attempt any further tool calls. Provide your best answer now based on what you've learned so far."
1014
+ else
1015
+ "You've used all available tool rounds. Please provide your best answer now based on what you've learned so far."
1016
+ end
1017
+ if wrap_up_reason.nil? || wrap_up_reason == :round_cap
1018
+ $stdout.puts "\e[33m Hit tool round limit (#{max_rounds}). Forcing final answer. Increase with: RailsConsoleAi.configure { |c| c.max_tool_rounds = 200 }\e[0m"
1019
+ end
1020
+ messages << { role: :user, content: final_nudge }
962
1021
  result = provider.chat(messages, system_prompt: active_system_prompt)
963
1022
  total_input += result.input_tokens || 0
964
1023
  total_output += result.output_tokens || 0
@@ -1167,6 +1226,15 @@ module RailsConsoleAi
1167
1226
  str.length > max ? str[0..max] + '...' : str
1168
1227
  end
1169
1228
 
1229
+ # Extract a normalized error signature from a tool result so we can detect
1230
+ # "same failure, different code" loops. Digits are collapsed so record IDs,
1231
+ # counts, and offsets don't make identical failures look distinct.
1232
+ def error_signature(tool_result)
1233
+ line = tool_result.to_s[/^ERROR: [^\n]+/]
1234
+ return nil unless line
1235
+ line.gsub(/\d+/, 'N')[0, 160]
1236
+ end
1237
+
1170
1238
  # Wraps mid-task user messages with explicit framing so the model treats them
1171
1239
  # as a real-time interruption that supersedes the prior task, rather than as
1172
1240
  # a reply to the most recent tool result.
@@ -44,7 +44,8 @@ module RailsConsoleAi
44
44
  class Executor
45
45
  CODE_REGEX = /```ruby\s*\n(.*?)```/m
46
46
 
47
- attr_reader :binding_context, :last_error, :last_safety_error, :last_safety_exception
47
+ attr_reader :binding_context, :last_error, :last_safety_error, :last_safety_exception,
48
+ :last_error_hint
48
49
  attr_accessor :on_prompt
49
50
 
50
51
  def initialize(binding_context, channel: nil)
@@ -55,6 +56,7 @@ module RailsConsoleAi
55
56
  @output_store = {}
56
57
  @output_counter = 0
57
58
  @active_skill_bypass_methods = Set.new
59
+ @error_hint_counts = Hash.new(0)
58
60
  end
59
61
 
60
62
  def extract_code(response)
@@ -98,6 +100,7 @@ module RailsConsoleAi
98
100
  @last_error = nil
99
101
  @last_safety_error = false
100
102
  @last_safety_exception = nil
103
+ @last_error_hint = nil
101
104
  captured_output = StringIO.new
102
105
  old_stdout = $stdout
103
106
  # Three capture strategies:
@@ -140,6 +143,8 @@ module RailsConsoleAi
140
143
  display_result(result) if display
141
144
 
142
145
  @last_output = captured_output.string
146
+ # Code may have rescued a known failure and printed it — still surface the hint.
147
+ @last_error_hint = build_error_hint(@last_output)
143
148
  result
144
149
  rescue Interrupt
145
150
  restore_stdout(use_thread_local, old_stdout)
@@ -174,6 +179,7 @@ module RailsConsoleAi
174
179
  return nil
175
180
  end
176
181
  @last_error = "#{e.class}: #{e.message}"
182
+ @last_error_hint = build_error_hint(@last_error, captured_output&.string)
177
183
  backtrace = e.backtrace.first(3).map { |line| " #{line}" }.join("\n")
178
184
  log_execution_error("Error: #{@last_error}\n#{backtrace}")
179
185
  @last_output = captured_output&.string
@@ -346,6 +352,29 @@ module RailsConsoleAi
346
352
 
347
353
  private
348
354
 
355
+ # Match the error/output against configured known-issue patterns
356
+ # (Configuration#error_hints). Returns a guidance string for the LLM, or nil.
357
+ # The first occurrence explains the issue; repeats escalate so the model
358
+ # stops probing the same dead end.
359
+ def build_error_hint(*texts)
360
+ text = texts.compact.join("\n")
361
+ return nil if text.strip.empty?
362
+
363
+ hint_def = Array(RailsConsoleAi.configuration.error_hints).find do |h|
364
+ h[:pattern] && text.match?(h[:pattern])
365
+ end
366
+ return nil unless hint_def
367
+
368
+ count = (@error_hint_counts[hint_def[:name]] += 1)
369
+ if count == 1
370
+ "KNOWN ISSUE DETECTED (#{hint_def[:name]}): #{hint_def[:hint]}"
371
+ else
372
+ "KNOWN ISSUE (#{hint_def[:name]}) — this is failure ##{count} for the same underlying problem. " \
373
+ "You were already told this cannot succeed from this session. Stop retrying now and report the " \
374
+ "limitation to the user. Reminder: #{hint_def[:hint]}"
375
+ end
376
+ end
377
+
349
378
  def restore_stdout(use_thread_local, old_stdout)
350
379
  if use_thread_local
351
380
  Thread.current[:capture_io] = nil
@@ -698,13 +698,16 @@ module RailsConsoleAi
698
698
  end
699
699
 
700
700
  if @executor.last_error
701
- return "ERROR: #{@executor.last_error}"
701
+ result = "ERROR: #{@executor.last_error}"
702
+ result += "\n\n#{@executor.last_error_hint}" if @executor.last_error_hint
703
+ return result
702
704
  end
703
705
 
704
706
  output = @executor.last_output
705
707
  parts = []
706
708
  parts << "Output:\n#{output.strip}" if output && !output.strip.empty?
707
709
  parts << "Return value: #{exec_result.inspect}"
710
+ parts << @executor.last_error_hint if @executor.last_error_hint
708
711
  parts.join("\n\n")
709
712
  end
710
713
 
@@ -808,6 +811,7 @@ module RailsConsoleAi
808
811
  step_report = "Step #{i + 1} (#{step['description']}):\n"
809
812
  if error
810
813
  step_report += "ERROR: #{error}\n"
814
+ step_report += "#{@executor.last_error_hint}\n" if @executor.last_error_hint
811
815
  end
812
816
  if output && !output.strip.empty?
813
817
  step_report += "Output: #{output.strip}\n"
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.32.0'.freeze
2
+ VERSION = '0.33.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_console_ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.32.0
4
+ version: 0.33.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr