rails_console_ai 0.31.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 +4 -4
- data/CHANGELOG.md +14 -26
- data/README.md +54 -10
- data/app/controllers/rails_console_ai/agent_versions_controller.rb +1 -1
- data/app/controllers/rails_console_ai/agents_controller.rb +1 -1
- data/app/controllers/rails_console_ai/memories_controller.rb +22 -2
- data/app/controllers/rails_console_ai/memory_versions_controller.rb +1 -1
- data/app/controllers/rails_console_ai/sessions_controller.rb +1 -1
- data/app/controllers/rails_console_ai/skill_versions_controller.rb +1 -1
- data/app/controllers/rails_console_ai/skills_controller.rb +1 -1
- data/app/models/rails_console_ai/memory.rb +40 -1
- data/app/views/rails_console_ai/memories/index.html.erb +13 -1
- data/app/views/rails_console_ai/memories/show.html.erb +23 -0
- data/config/routes.rb +3 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +5 -5
- data/lib/rails_console_ai/agent_runner.rb +12 -1
- data/lib/rails_console_ai/channel/console.rb +1 -1
- data/lib/rails_console_ai/configuration.rb +76 -33
- data/lib/rails_console_ai/conversation_engine.rb +74 -6
- data/lib/rails_console_ai/executor.rb +30 -1
- data/lib/rails_console_ai/safety_guards.rb +36 -0
- data/lib/rails_console_ai/slack_bot.rb +2 -2
- data/lib/rails_console_ai/sub_agent.rb +1 -1
- data/lib/rails_console_ai/tools/memory_tools.rb +29 -6
- data/lib/rails_console_ai/tools/registry.rb +23 -7
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +38 -1
- metadata +1 -1
|
@@ -1,39 +1,77 @@
|
|
|
1
|
-
require 'set'
|
|
2
|
-
|
|
3
1
|
module RailsConsoleAi
|
|
4
2
|
class Configuration
|
|
5
3
|
PROVIDERS = %i[anthropic openai local bedrock].freeze
|
|
6
4
|
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
# Per-family model attributes, matched by substring so one entry covers every
|
|
6
|
+
# ID variant of a family: bare Anthropic IDs (claude-sonnet-5), dated
|
|
7
|
+
# snapshots (claude-haiku-4-5-20251001), and Bedrock inference profiles
|
|
8
|
+
# (us.anthropic.claude-sonnet-5, global.anthropic.claude-opus-4-6-v1).
|
|
9
|
+
#
|
|
10
|
+
# input/output are $ per MTok (converted to per-token in .pricing_for).
|
|
11
|
+
# Cache pricing is derived: read = 0.1x input, write = 1.25x input.
|
|
12
|
+
# temperature: false marks families that reject the `temperature` parameter
|
|
13
|
+
# (removed on opus-4-7+, sonnet-5, and fable-5).
|
|
14
|
+
MODEL_FAMILIES = {
|
|
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 },
|
|
17
|
+
'claude-opus-4-8' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
|
|
18
|
+
'claude-opus-4-7' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
|
|
19
|
+
'claude-opus-4-6' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: true },
|
|
20
|
+
'claude-sonnet-5' => { input: 3.0, output: 15.0, max_tokens: 16_000, temperature: false },
|
|
21
|
+
'claude-sonnet-4-6' => { input: 3.0, output: 15.0, max_tokens: 16_000, temperature: true },
|
|
22
|
+
'claude-haiku-4-5' => { input: 1.0, output: 5.0, max_tokens: 16_000, temperature: true },
|
|
15
23
|
}.freeze
|
|
16
24
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
# Family keys sorted longest-first so a more specific family always wins
|
|
26
|
+
# if keys ever overlap (e.g. a future 'claude-sonnet-5-5' entry would match
|
|
27
|
+
# before 'claude-sonnet-5').
|
|
28
|
+
MODEL_FAMILY_KEYS = MODEL_FAMILIES.keys.sort_by { |k| -k.length }.freeze
|
|
29
|
+
|
|
30
|
+
# Returns the family attributes for a model ID, or nil for unknown models.
|
|
31
|
+
def self.model_family(model_id)
|
|
32
|
+
return nil unless model_id
|
|
33
|
+
key = MODEL_FAMILY_KEYS.find { |k| model_id.include?(k) }
|
|
34
|
+
key && MODEL_FAMILIES[key]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Per-token pricing for a model ID, matched by family. Returns
|
|
38
|
+
# { input:, output:, cache_read:, cache_write: } or nil for unknown models.
|
|
39
|
+
def self.pricing_for(model_id)
|
|
40
|
+
family = model_family(model_id)
|
|
41
|
+
return nil unless family
|
|
42
|
+
input = family[:input] / 1_000_000
|
|
43
|
+
{
|
|
44
|
+
input: input,
|
|
45
|
+
output: family[:output] / 1_000_000,
|
|
46
|
+
cache_read: input * 0.1,
|
|
47
|
+
cache_write: input * 1.25,
|
|
48
|
+
}
|
|
49
|
+
end
|
|
22
50
|
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
33
69
|
|
|
34
70
|
attr_accessor :provider, :api_key, :model, :thinking_model, :max_tokens,
|
|
35
71
|
:auto_execute, :temperature,
|
|
36
72
|
:timeout, :debug, :max_tool_rounds,
|
|
73
|
+
:error_hints,
|
|
74
|
+
:token_nudge_threshold, :token_stop_threshold,
|
|
37
75
|
:storage_adapter, :memories_enabled,
|
|
38
76
|
:session_logging, :connection_class,
|
|
39
77
|
:admin_username, :admin_password,
|
|
@@ -59,6 +97,9 @@ module RailsConsoleAi
|
|
|
59
97
|
@timeout = 30
|
|
60
98
|
@debug = false
|
|
61
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)
|
|
62
103
|
@storage_adapter = nil
|
|
63
104
|
@memories_enabled = true
|
|
64
105
|
@session_logging = true
|
|
@@ -176,26 +217,28 @@ module RailsConsoleAi
|
|
|
176
217
|
|
|
177
218
|
case @provider
|
|
178
219
|
when :anthropic
|
|
179
|
-
'claude-sonnet-
|
|
220
|
+
'claude-sonnet-5'
|
|
180
221
|
when :openai
|
|
181
222
|
'gpt-5.3-codex'
|
|
182
223
|
when :local
|
|
183
224
|
@local_model
|
|
184
225
|
when :bedrock
|
|
185
|
-
'us.anthropic.claude-sonnet-
|
|
226
|
+
'us.anthropic.claude-sonnet-5'
|
|
186
227
|
end
|
|
187
228
|
end
|
|
188
229
|
|
|
189
230
|
def resolved_max_tokens
|
|
190
231
|
return @max_tokens if @max_tokens
|
|
191
232
|
|
|
192
|
-
|
|
233
|
+
family = self.class.model_family(resolved_model)
|
|
234
|
+
family ? family[:max_tokens] : 4096
|
|
193
235
|
end
|
|
194
236
|
|
|
195
|
-
# Returns nil for
|
|
196
|
-
#
|
|
237
|
+
# Returns nil for model families that reject the `temperature` parameter
|
|
238
|
+
# (opus-4-7+, sonnet-5, fable-5) so providers omit the field from the request.
|
|
197
239
|
def resolved_temperature
|
|
198
|
-
|
|
240
|
+
family = self.class.model_family(resolved_model)
|
|
241
|
+
return nil if family && family[:temperature] == false
|
|
199
242
|
@temperature
|
|
200
243
|
end
|
|
201
244
|
|
|
@@ -204,13 +247,13 @@ module RailsConsoleAi
|
|
|
204
247
|
|
|
205
248
|
case @provider
|
|
206
249
|
when :anthropic
|
|
207
|
-
'claude-opus-
|
|
250
|
+
'claude-opus-5'
|
|
208
251
|
when :openai
|
|
209
252
|
'gpt-5.3-codex'
|
|
210
253
|
when :local
|
|
211
254
|
@local_model
|
|
212
255
|
when :bedrock
|
|
213
|
-
'us.anthropic.claude-opus-
|
|
256
|
+
'us.anthropic.claude-opus-5'
|
|
214
257
|
end
|
|
215
258
|
end
|
|
216
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
|
|
@@ -390,7 +394,7 @@ module RailsConsoleAi
|
|
|
390
394
|
$stdout.puts "\e[36m Cost estimate:\e[0m"
|
|
391
395
|
|
|
392
396
|
@token_usage.each do |model, usage|
|
|
393
|
-
pricing = Configuration
|
|
397
|
+
pricing = Configuration.pricing_for(model)
|
|
394
398
|
pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
|
|
395
399
|
input_str = "in: #{format_tokens(usage[:input])}"
|
|
396
400
|
output_str = "out: #{format_tokens(usage[:output])}"
|
|
@@ -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
|
-
|
|
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
|
-
|
|
961
|
-
|
|
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.
|
|
@@ -1386,7 +1454,7 @@ module RailsConsoleAi
|
|
|
1386
1454
|
cache_w = result.cache_write_input_tokens || 0
|
|
1387
1455
|
parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
|
|
1388
1456
|
model = effective_model
|
|
1389
|
-
pricing = Configuration
|
|
1457
|
+
pricing = Configuration.pricing_for(model)
|
|
1390
1458
|
if pricing
|
|
1391
1459
|
cost = ((result.input_tokens || 0) * pricing[:input]) + ((result.output_tokens || 0) * pricing[:output])
|
|
1392
1460
|
if (cache_r > 0 || cache_w > 0) && pricing[:cache_read]
|
|
@@ -1419,7 +1487,7 @@ module RailsConsoleAi
|
|
|
1419
1487
|
input_t = result.input_tokens || 0
|
|
1420
1488
|
output_t = result.output_tokens || 0
|
|
1421
1489
|
model = effective_model
|
|
1422
|
-
pricing = Configuration
|
|
1490
|
+
pricing = Configuration.pricing_for(model)
|
|
1423
1491
|
pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
|
|
1424
1492
|
|
|
1425
1493
|
cache_r = result.cache_read_input_tokens || 0
|
|
@@ -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
|
|
@@ -52,6 +52,22 @@ module RailsConsoleAi
|
|
|
52
52
|
Thread.current[:rails_console_ai_guards_disabled] = true
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# Hard sandbox: block ALL database access (reads and writes) for the duration of the
|
|
56
|
+
# block. Used to fence in sub-agents that must never touch the DB (e.g. the
|
|
57
|
+
# output-explorer, which only examines an in-memory string). Scoped via thread-local,
|
|
58
|
+
# so it covers synchronous work in the calling thread and is restored afterward —
|
|
59
|
+
# nestable, and independent of enable!/disable! and the registered guard set.
|
|
60
|
+
def with_database_blocked
|
|
61
|
+
BuiltinGuards.ensure_write_blocker_installed!
|
|
62
|
+
prev = Thread.current[:rails_console_ai_block_all_db]
|
|
63
|
+
Thread.current[:rails_console_ai_block_all_db] = true
|
|
64
|
+
begin
|
|
65
|
+
yield
|
|
66
|
+
ensure
|
|
67
|
+
Thread.current[:rails_console_ai_block_all_db] = prev
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
55
71
|
def empty?
|
|
56
72
|
@guards.empty?
|
|
57
73
|
end
|
|
@@ -213,6 +229,21 @@ module RailsConsoleAi
|
|
|
213
229
|
|
|
214
230
|
private
|
|
215
231
|
|
|
232
|
+
# Hard sandbox: when active, block ALL database access (reads and writes), not just
|
|
233
|
+
# mutations. Used to fence in sub-agents that must never touch the DB (e.g. the
|
|
234
|
+
# output-explorer, which only examines an in-memory string). Deliberately does NOT
|
|
235
|
+
# honor the bypass flag — it is a true wall, not a safe-mode toggle.
|
|
236
|
+
def rails_console_ai_check_db_blocked!(_sql)
|
|
237
|
+
return unless Thread.current[:rails_console_ai_block_all_db]
|
|
238
|
+
|
|
239
|
+
raise RailsConsoleAi::SafetyError.new(
|
|
240
|
+
"Database access is disabled here. The captured data is in the `output` " \
|
|
241
|
+
"variable — examine that instead of querying the database.",
|
|
242
|
+
guard: :database_access,
|
|
243
|
+
blocked_key: nil
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
|
|
216
247
|
def rails_console_ai_check_write!(sql)
|
|
217
248
|
return if Thread.current[:rails_console_ai_bypass_guards]
|
|
218
249
|
return unless Thread.current[:rails_console_ai_block_writes] && sql.match?(WRITE_PATTERN)
|
|
@@ -231,26 +262,31 @@ module RailsConsoleAi
|
|
|
231
262
|
public
|
|
232
263
|
|
|
233
264
|
def execute(sql, *args, **kwargs)
|
|
265
|
+
rails_console_ai_check_db_blocked!(sql)
|
|
234
266
|
rails_console_ai_check_write!(sql)
|
|
235
267
|
super
|
|
236
268
|
end
|
|
237
269
|
|
|
238
270
|
def exec_query(sql, *args, **kwargs)
|
|
271
|
+
rails_console_ai_check_db_blocked!(sql)
|
|
239
272
|
rails_console_ai_check_write!(sql)
|
|
240
273
|
super
|
|
241
274
|
end
|
|
242
275
|
|
|
243
276
|
def exec_insert(sql, *args, **kwargs)
|
|
277
|
+
rails_console_ai_check_db_blocked!(sql)
|
|
244
278
|
rails_console_ai_check_write!(sql)
|
|
245
279
|
super
|
|
246
280
|
end
|
|
247
281
|
|
|
248
282
|
def exec_delete(sql, *args, **kwargs)
|
|
283
|
+
rails_console_ai_check_db_blocked!(sql)
|
|
249
284
|
rails_console_ai_check_write!(sql)
|
|
250
285
|
super
|
|
251
286
|
end
|
|
252
287
|
|
|
253
288
|
def exec_update(sql, *args, **kwargs)
|
|
289
|
+
rails_console_ai_check_db_blocked!(sql)
|
|
254
290
|
rails_console_ai_check_write!(sql)
|
|
255
291
|
super
|
|
256
292
|
end
|
|
@@ -706,7 +706,7 @@ module RailsConsoleAi
|
|
|
706
706
|
config = RailsConsoleAi.configuration
|
|
707
707
|
model = engine ? engine.effective_model : config.resolved_model
|
|
708
708
|
thinking = config.resolved_thinking_model
|
|
709
|
-
pricing = Configuration
|
|
709
|
+
pricing = Configuration.pricing_for(model)
|
|
710
710
|
|
|
711
711
|
lines = ["*Model info:*"]
|
|
712
712
|
lines << " Provider: `#{config.provider}`"
|
|
@@ -739,7 +739,7 @@ module RailsConsoleAi
|
|
|
739
739
|
total_cost = 0.0
|
|
740
740
|
|
|
741
741
|
token_usage.each do |model, usage|
|
|
742
|
-
pricing = Configuration
|
|
742
|
+
pricing = Configuration.pricing_for(model)
|
|
743
743
|
pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
|
|
744
744
|
input_str = "in: #{usage[:input]}"
|
|
745
745
|
output_str = "out: #{usage[:output]}"
|
|
@@ -189,7 +189,7 @@ module RailsConsoleAi
|
|
|
189
189
|
|
|
190
190
|
def build_system_prompt
|
|
191
191
|
parts = []
|
|
192
|
-
parts << base_instructions
|
|
192
|
+
parts << base_instructions unless @agent_config['skip_base_instructions']
|
|
193
193
|
parts << guide_context
|
|
194
194
|
parts << pinned_memory_context
|
|
195
195
|
parts << @agent_config['body'] if @agent_config['body'] && !@agent_config['body'].strip.empty?
|
|
@@ -33,10 +33,15 @@ module RailsConsoleAi
|
|
|
33
33
|
name: name, description: description, tags: tags,
|
|
34
34
|
edited_by: edited_by || 'ai', change_note: change_note
|
|
35
35
|
)
|
|
36
|
+
status_note = if record.respond_to?(:proposed?) && record.proposed?
|
|
37
|
+
' — status: PROPOSED. A human must approve it at /rails_console_ai/memories before you can recall it.'
|
|
38
|
+
else
|
|
39
|
+
''
|
|
40
|
+
end
|
|
36
41
|
if was_new
|
|
37
|
-
"Memory saved (db): \"#{record.name}\" (id=#{record.id})"
|
|
42
|
+
"Memory saved (db): \"#{record.name}\" (id=#{record.id})#{status_note}"
|
|
38
43
|
else
|
|
39
|
-
"Memory updated (db): \"#{record.name}\" (id=#{record.id})"
|
|
44
|
+
"Memory updated (db): \"#{record.name}\" (id=#{record.id})#{status_note}"
|
|
40
45
|
end
|
|
41
46
|
end
|
|
42
47
|
rescue Storage::StorageError => e
|
|
@@ -72,8 +77,16 @@ module RailsConsoleAi
|
|
|
72
77
|
end
|
|
73
78
|
|
|
74
79
|
def recall_memory(name:)
|
|
75
|
-
memory =
|
|
76
|
-
|
|
80
|
+
memory = load_activatable_memories.find { |m| m['name'].to_s.downcase == name.to_s.downcase }
|
|
81
|
+
unless memory
|
|
82
|
+
# Distinguish "doesn't exist" from "exists but isn't approved yet".
|
|
83
|
+
proposed = load_all_memories.find { |m| m['name'].to_s.downcase == name.to_s.downcase }
|
|
84
|
+
if proposed && proposed['source'] == :db && proposed['status'] != 'approved'
|
|
85
|
+
return "Memory \"#{name}\" exists but is awaiting human approval and cannot be recalled yet. " \
|
|
86
|
+
"Ask the user to approve it in the web UI at /rails_console_ai/memories."
|
|
87
|
+
end
|
|
88
|
+
return "No memory found: \"#{name}\""
|
|
89
|
+
end
|
|
77
90
|
|
|
78
91
|
record_use(memory)
|
|
79
92
|
|
|
@@ -83,7 +96,7 @@ module RailsConsoleAi
|
|
|
83
96
|
end
|
|
84
97
|
|
|
85
98
|
def recall_memories(query: nil, tag: nil)
|
|
86
|
-
memories =
|
|
99
|
+
memories = load_activatable_memories
|
|
87
100
|
return "No memories stored yet." if memories.empty?
|
|
88
101
|
|
|
89
102
|
results = memories
|
|
@@ -117,7 +130,7 @@ module RailsConsoleAi
|
|
|
117
130
|
end
|
|
118
131
|
|
|
119
132
|
def memory_summaries
|
|
120
|
-
memories =
|
|
133
|
+
memories = load_activatable_memories
|
|
121
134
|
return nil if memories.empty?
|
|
122
135
|
|
|
123
136
|
memories.map { |m|
|
|
@@ -127,6 +140,10 @@ module RailsConsoleAi
|
|
|
127
140
|
}
|
|
128
141
|
end
|
|
129
142
|
|
|
143
|
+
# Includes proposed (unapproved) DB memories — they show up in the admin UI
|
|
144
|
+
# with a "PROPOSED" badge. The AI-facing surface (#memory_summaries,
|
|
145
|
+
# #recall_memory, #recall_memories) filters them out via
|
|
146
|
+
# #load_activatable_memories, so an unapproved memory can never be recalled.
|
|
130
147
|
def load_all_memories
|
|
131
148
|
db = Storage::DatabaseStorage.all_memories
|
|
132
149
|
file = load_all_file_memories
|
|
@@ -135,6 +152,12 @@ module RailsConsoleAi
|
|
|
135
152
|
(db + file).sort_by { |m| m['name'].to_s.downcase }
|
|
136
153
|
end
|
|
137
154
|
|
|
155
|
+
# Memories the AI is allowed to see / recall: approved DB memories + all file
|
|
156
|
+
# memories. File memories are considered pre-approved because they're git-tracked.
|
|
157
|
+
def load_activatable_memories
|
|
158
|
+
load_all_memories.reject { |m| m['source'] == :db && m['status'] != 'approved' }
|
|
159
|
+
end
|
|
160
|
+
|
|
138
161
|
private
|
|
139
162
|
|
|
140
163
|
# DB-backed memories only — file memories have no row to update.
|