rails_console_ai 0.23.0 → 0.25.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 +17 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +1 -1
- data/lib/rails_console_ai/channel/base.rb +2 -1
- data/lib/rails_console_ai/channel/console.rb +18 -4
- data/lib/rails_console_ai/channel/slack.rb +20 -21
- data/lib/rails_console_ai/configuration.rb +1 -1
- data/lib/rails_console_ai/context_builder.rb +2 -1
- data/lib/rails_console_ai/conversation_engine.rb +20 -14
- data/lib/rails_console_ai/executor.rb +44 -14
- data/lib/rails_console_ai/prefixed_io.rb +9 -0
- data/lib/rails_console_ai/providers/bedrock.rb +3 -3
- data/lib/rails_console_ai/safety_guards.rb +33 -14
- data/lib/rails_console_ai/slack_bot.rb +25 -15
- data/lib/rails_console_ai/tools/registry.rb +2 -13
- data/lib/rails_console_ai/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 89e0f9e402a40af4755fe61d2029f10a90c487c1d957e007032dcdd1f54a1c82
|
|
4
|
+
data.tar.gz: 7e68825010aed2bd0f7a07cdcd68a39d54edec2616913ab14c9a10aa972e6d4f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f0efcfa92b22f8cd4df14a643904aba9c0e3c05ac7873de6a303f72fd6cdd4df5c457cf1fdcafb120a02784c7247663e48141500f69214ac5bbfafa945ec9ce0
|
|
7
|
+
data.tar.gz: 2aed178645529500d0b1d72772e19307d4b42de2a0db586f928a46df2a6503d78f3693cde4222006ad0e4227ea840b4bd510a6ef5631e9395466cf9035db679a
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.25.0]
|
|
6
|
+
|
|
7
|
+
- Expand truncation limits
|
|
8
|
+
- Allow any user on allow list to interact with Slack bot in a thread
|
|
9
|
+
- Handle ctrl-c better in console
|
|
10
|
+
- Fix stdout capture in Slack sessions
|
|
11
|
+
- Improve Slack bot logging
|
|
12
|
+
- Fix thread safety issues in Slack bot
|
|
13
|
+
|
|
14
|
+
## [0.24.0]
|
|
15
|
+
|
|
16
|
+
- Refactor thinking text display and include in Slack with more technical detail
|
|
17
|
+
- Add `a` command to trigger auto-accept mode
|
|
18
|
+
- Include code output in Slack server logs
|
|
19
|
+
- Fix Bedrock issue after declining code execution
|
|
20
|
+
- Fix `allow_code_execution` configuration
|
|
21
|
+
|
|
5
22
|
## [0.23.0]
|
|
6
23
|
|
|
7
24
|
- Add `save_skill` tool
|
|
@@ -86,7 +86,7 @@ RailsConsoleAi.configure do |config|
|
|
|
86
86
|
# config.channels = {
|
|
87
87
|
# 'slack' => {
|
|
88
88
|
# 'allowed_usernames' => ['alice', 'bob'], # who can use the bot (or 'ALL')
|
|
89
|
-
# 'allow_code_execution' => ['alice'], # who can run code (nil = everyone)
|
|
89
|
+
# 'allow_code_execution' => ['alice'], # who can run code directly via ``` (nil = everyone)
|
|
90
90
|
# 'pinned_memory_tags' => ['sharding'],
|
|
91
91
|
# 'bypass_guards_for_methods' => ['ChangeApproval#approve_by!']
|
|
92
92
|
# },
|
|
@@ -2,7 +2,8 @@ module RailsConsoleAi
|
|
|
2
2
|
module Channel
|
|
3
3
|
class Base
|
|
4
4
|
def display(text); raise NotImplementedError; end
|
|
5
|
-
def
|
|
5
|
+
def display_thinking(text); raise NotImplementedError; end
|
|
6
|
+
def display_status(text); raise NotImplementedError; end
|
|
6
7
|
def display_warning(text); raise NotImplementedError; end
|
|
7
8
|
def display_error(text); raise NotImplementedError; end
|
|
8
9
|
def display_code(code); raise NotImplementedError; end
|
|
@@ -14,7 +14,11 @@ module RailsConsoleAi
|
|
|
14
14
|
$stdout.puts colorize(text, :cyan)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def
|
|
17
|
+
def display_thinking(text)
|
|
18
|
+
$stdout.puts "\e[2m#{text}\e[0m"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def display_status(text)
|
|
18
22
|
$stdout.puts "\e[2m#{text}\e[0m"
|
|
19
23
|
end
|
|
20
24
|
|
|
@@ -123,8 +127,8 @@ module RailsConsoleAi
|
|
|
123
127
|
|
|
124
128
|
# --- Omitted output tracking (shared with Executor) ---
|
|
125
129
|
|
|
126
|
-
MAX_DISPLAY_LINES =
|
|
127
|
-
MAX_DISPLAY_CHARS =
|
|
130
|
+
MAX_DISPLAY_LINES = 20
|
|
131
|
+
MAX_DISPLAY_CHARS = 4000
|
|
128
132
|
|
|
129
133
|
def init_omitted_tracking
|
|
130
134
|
@omitted_outputs = {}
|
|
@@ -226,7 +230,16 @@ module RailsConsoleAi
|
|
|
226
230
|
@interactive_console_capture.write("ai> #{input}\n")
|
|
227
231
|
@engine.log_interactive_turn
|
|
228
232
|
|
|
229
|
-
|
|
233
|
+
expected_stdout = $stdout
|
|
234
|
+
begin
|
|
235
|
+
status = @engine.send_and_execute
|
|
236
|
+
rescue Interrupt
|
|
237
|
+
$stdout = expected_stdout
|
|
238
|
+
$stdout.puts "\n\e[33m Cancelled.\e[0m"
|
|
239
|
+
@engine.pop_last_message
|
|
240
|
+
@engine.log_interactive_turn
|
|
241
|
+
next
|
|
242
|
+
end
|
|
230
243
|
if status == :interrupted
|
|
231
244
|
@engine.pop_last_message
|
|
232
245
|
@engine.log_interactive_turn
|
|
@@ -409,6 +422,7 @@ module RailsConsoleAi
|
|
|
409
422
|
@real_stdout.puts "\e[2m /debug Toggle debug summaries (context stats, cost per call)\e[0m"
|
|
410
423
|
@real_stdout.puts "\e[2m /retry Re-execute the last code block\e[0m"
|
|
411
424
|
@real_stdout.puts "\e[2m > code Execute Ruby directly (skip LLM)\e[0m"
|
|
425
|
+
@real_stdout.puts "\e[2m Ctrl-C Cancel the current operation\e[0m"
|
|
412
426
|
@real_stdout.puts "\e[2m exit/quit Leave interactive mode\e[0m"
|
|
413
427
|
end
|
|
414
428
|
|
|
@@ -28,23 +28,21 @@ module RailsConsoleAi
|
|
|
28
28
|
post(strip_ansi(text))
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
def display_thinking(text)
|
|
32
|
+
stripped = strip_ansi(text).strip
|
|
33
|
+
return if stripped.empty?
|
|
34
|
+
post(stripped)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def display_status(text)
|
|
38
|
+
stripped = strip_ansi(text).strip
|
|
39
|
+
return if stripped.empty?
|
|
34
40
|
|
|
35
41
|
if stripped =~ /\AThinking\.\.\.|\AAttempting to fix|\ACancelled|\A_session:/
|
|
36
42
|
post(stripped)
|
|
37
|
-
elsif stripped =~ /\ACalling LLM/
|
|
38
|
-
# Technical LLM round status — suppress in Slack
|
|
39
|
-
@output_log.write("#{stripped}\n")
|
|
40
|
-
STDOUT.puts "#{@log_prefix} (dim) #{stripped}"
|
|
41
|
-
elsif raw =~ /\A {2,4}\S/ && stripped.length > 10
|
|
42
|
-
# LLM thinking text (2-space indent from conversation engine) — show as status
|
|
43
|
-
post(stripped)
|
|
44
43
|
else
|
|
45
|
-
# Tool result previews (5+ space indent) and other technical noise — log only
|
|
46
44
|
@output_log.write("#{stripped}\n")
|
|
47
|
-
|
|
45
|
+
log_prefixed("(status)", stripped)
|
|
48
46
|
end
|
|
49
47
|
end
|
|
50
48
|
|
|
@@ -58,7 +56,7 @@ module RailsConsoleAi
|
|
|
58
56
|
|
|
59
57
|
def display_tool_call(text)
|
|
60
58
|
@output_log.write("-> #{text}\n")
|
|
61
|
-
|
|
59
|
+
log_prefixed("->", text)
|
|
62
60
|
end
|
|
63
61
|
|
|
64
62
|
def display_code(code)
|
|
@@ -112,9 +110,9 @@ module RailsConsoleAi
|
|
|
112
110
|
|
|
113
111
|
def system_instructions
|
|
114
112
|
<<~INSTRUCTIONS.strip
|
|
115
|
-
##
|
|
113
|
+
## Slack Channel
|
|
116
114
|
|
|
117
|
-
You are responding
|
|
115
|
+
You are responding in a Slack thread.
|
|
118
116
|
|
|
119
117
|
## Code Execution
|
|
120
118
|
- ALWAYS use the `execute_code` tool to run Ruby code. Do NOT put code in markdown
|
|
@@ -132,11 +130,8 @@ module RailsConsoleAi
|
|
|
132
130
|
123 John Smith john@example.com
|
|
133
131
|
456 Jane Doe jane@example.com
|
|
134
132
|
```
|
|
135
|
-
- Use `puts` with formatted output instead of returning arrays or hashes
|
|
136
|
-
-
|
|
137
|
-
- Do NOT show technical details like SQL queries, token counts, or class names
|
|
138
|
-
- Keep explanations simple and jargon-free
|
|
139
|
-
- Never return raw Ruby objects — always present data in a human-readable way
|
|
133
|
+
- Use `puts` with formatted output instead of returning arrays or hashes.
|
|
134
|
+
- Never return raw Ruby objects — always present data in a human-readable way.
|
|
140
135
|
- The output of `puts` in your code is automatically shown to the user. Do NOT
|
|
141
136
|
repeat or re-display data that your code already printed via `puts`.
|
|
142
137
|
Just add a brief summary after (e.g. "10 events found" or "Let me know if you need more detail").
|
|
@@ -166,7 +161,7 @@ module RailsConsoleAi
|
|
|
166
161
|
def post(text)
|
|
167
162
|
return if text.nil? || text.strip.empty?
|
|
168
163
|
@output_log.write("#{text}\n")
|
|
169
|
-
|
|
164
|
+
log_prefixed(">>", text)
|
|
170
165
|
@slack_bot.send(:post_message,
|
|
171
166
|
channel: @channel_id,
|
|
172
167
|
thread_ts: @thread_ts,
|
|
@@ -176,6 +171,10 @@ module RailsConsoleAi
|
|
|
176
171
|
RailsConsoleAi.logger.error("Slack post failed: #{e.message}")
|
|
177
172
|
end
|
|
178
173
|
|
|
174
|
+
def log_prefixed(tag, text)
|
|
175
|
+
text.each_line { |line| STDOUT.puts "#{@log_prefix} #{tag} #{line.rstrip}" }
|
|
176
|
+
end
|
|
177
|
+
|
|
179
178
|
def strip_ansi(text)
|
|
180
179
|
text.to_s.gsub(ANSI_REGEX, '')
|
|
181
180
|
end
|
|
@@ -97,7 +97,8 @@ module RailsConsoleAi
|
|
|
97
97
|
- Give ONE concise answer. Do not offer multiple alternatives or variations.
|
|
98
98
|
- For multi-step tasks, use execute_plan to break the work into small, clear steps.
|
|
99
99
|
- For simple queries, use the execute_code tool.
|
|
100
|
-
-
|
|
100
|
+
- Before calling tools, briefly state what you're about to do (e.g., "Let me check the
|
|
101
|
+
user's migration status." or "I'll look up the table structure."). Keep it to one sentence.
|
|
101
102
|
- Use the app's actual model names, associations, and schema.
|
|
102
103
|
- Prefer ActiveRecord query interface over raw SQL.
|
|
103
104
|
- For destructive operations, add a comment warning.
|
|
@@ -3,8 +3,8 @@ module RailsConsoleAi
|
|
|
3
3
|
attr_reader :history, :total_input_tokens, :total_output_tokens,
|
|
4
4
|
:interactive_session_id, :session_name
|
|
5
5
|
|
|
6
|
-
LARGE_OUTPUT_THRESHOLD =
|
|
7
|
-
LARGE_OUTPUT_PREVIEW_CHARS =
|
|
6
|
+
LARGE_OUTPUT_THRESHOLD = 20_000 # chars — truncate tool results larger than this immediately
|
|
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
10
|
|
|
@@ -49,7 +49,7 @@ module RailsConsoleAi
|
|
|
49
49
|
conversation << { role: :assistant, content: @_last_result_text }
|
|
50
50
|
conversation << { role: :user, content: error_msg }
|
|
51
51
|
|
|
52
|
-
@channel.
|
|
52
|
+
@channel.display_status(" Ran into an issue, trying a different approach...")
|
|
53
53
|
exec_result, code, executed = one_shot_round(conversation)
|
|
54
54
|
end
|
|
55
55
|
|
|
@@ -114,7 +114,7 @@ module RailsConsoleAi
|
|
|
114
114
|
|
|
115
115
|
status = send_and_execute
|
|
116
116
|
if status == :error
|
|
117
|
-
@channel.
|
|
117
|
+
@channel.display_status(" Ran into an issue, trying a different approach...")
|
|
118
118
|
send_and_execute
|
|
119
119
|
end
|
|
120
120
|
end
|
|
@@ -232,7 +232,7 @@ module RailsConsoleAi
|
|
|
232
232
|
@channel.display_warning("No code to retry.")
|
|
233
233
|
return
|
|
234
234
|
end
|
|
235
|
-
@channel.
|
|
235
|
+
@channel.display_status(" Retrying last code...")
|
|
236
236
|
execute_direct(code)
|
|
237
237
|
end
|
|
238
238
|
|
|
@@ -790,19 +790,19 @@ module RailsConsoleAi
|
|
|
790
790
|
|
|
791
791
|
max_rounds.times do |round|
|
|
792
792
|
if @channel.cancelled?
|
|
793
|
-
@channel.
|
|
793
|
+
@channel.display_status(" Cancelled.")
|
|
794
794
|
break
|
|
795
795
|
end
|
|
796
796
|
|
|
797
797
|
if round == 0
|
|
798
|
-
@channel.
|
|
798
|
+
@channel.display_status(" Thinking...")
|
|
799
799
|
else
|
|
800
800
|
if last_thinking
|
|
801
801
|
last_thinking.split("\n").each do |line|
|
|
802
|
-
@channel.
|
|
802
|
+
@channel.display_thinking(" #{line}")
|
|
803
803
|
end
|
|
804
804
|
end
|
|
805
|
-
@channel.
|
|
805
|
+
@channel.display_status(" #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}")
|
|
806
806
|
end
|
|
807
807
|
|
|
808
808
|
# Trim large tool outputs between rounds to prevent context explosion.
|
|
@@ -856,7 +856,7 @@ module RailsConsoleAi
|
|
|
856
856
|
else
|
|
857
857
|
"No matching outputs found with id(s) #{ids.join(', ')}."
|
|
858
858
|
end
|
|
859
|
-
@channel.
|
|
859
|
+
@channel.display_status(" #{tool_result}")
|
|
860
860
|
tool_msg = provider.format_tool_result(tc[:id], tool_result)
|
|
861
861
|
messages << tool_msg
|
|
862
862
|
new_messages << tool_msg
|
|
@@ -865,7 +865,7 @@ module RailsConsoleAi
|
|
|
865
865
|
|
|
866
866
|
# Display any pending LLM text before executing the tool
|
|
867
867
|
if last_thinking
|
|
868
|
-
last_thinking.split("\n").each { |line| @channel.
|
|
868
|
+
last_thinking.split("\n").each { |line| @channel.display_thinking(" #{line}") }
|
|
869
869
|
last_thinking = nil
|
|
870
870
|
end
|
|
871
871
|
|
|
@@ -879,7 +879,7 @@ module RailsConsoleAi
|
|
|
879
879
|
|
|
880
880
|
preview = compact_tool_result(tc[:name], tool_result)
|
|
881
881
|
cached_tag = tools.last_cached? ? " (cached)" : ""
|
|
882
|
-
@channel.
|
|
882
|
+
@channel.display_status(" #{preview}#{cached_tag}")
|
|
883
883
|
end
|
|
884
884
|
|
|
885
885
|
if RailsConsoleAi.configuration.debug
|
|
@@ -908,10 +908,10 @@ module RailsConsoleAi
|
|
|
908
908
|
tool_call_counts[key] += 1
|
|
909
909
|
|
|
910
910
|
if tool_call_counts[key] >= LOOP_BREAK_THRESHOLD
|
|
911
|
-
@channel.
|
|
911
|
+
@channel.display_status(" Loop detected: #{tc[:name]} called #{tool_call_counts[key]} times with same args — stopping.")
|
|
912
912
|
exhausted = true
|
|
913
913
|
elsif tool_call_counts[key] >= LOOP_WARN_THRESHOLD
|
|
914
|
-
@channel.
|
|
914
|
+
@channel.display_status(" Warning: #{tc[:name]} called #{tool_call_counts[key]} times with same args — consider a different approach.")
|
|
915
915
|
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." }
|
|
916
916
|
end
|
|
917
917
|
end
|
|
@@ -1095,6 +1095,12 @@ module RailsConsoleAi
|
|
|
1095
1095
|
else
|
|
1096
1096
|
truncate(result, 80)
|
|
1097
1097
|
end
|
|
1098
|
+
when 'execute_code'
|
|
1099
|
+
lines = result.split("\n").reject { |l| l.strip.empty? }
|
|
1100
|
+
output_lines = lines.select { |l| !l.start_with?('Output:') && !l.start_with?('Return value:') }
|
|
1101
|
+
summary = output_lines.first(2).map { |l| l.strip }.join('; ')
|
|
1102
|
+
summary = truncate(summary, 70) if summary.length > 70
|
|
1103
|
+
"#{output_lines.length} lines: #{summary}"
|
|
1098
1104
|
when 'execute_plan'
|
|
1099
1105
|
steps_done = result.scan(/^Step \d+/).length
|
|
1100
1106
|
steps_done > 0 ? "#{steps_done} steps executed" : truncate(result, 80)
|
|
@@ -100,13 +100,18 @@ module RailsConsoleAi
|
|
|
100
100
|
@last_safety_exception = nil
|
|
101
101
|
captured_output = StringIO.new
|
|
102
102
|
old_stdout = $stdout
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
103
|
+
# Three capture strategies:
|
|
104
|
+
# 1. Slack mode (PrefixedIO active): thread-local capture to avoid cross-thread pollution
|
|
105
|
+
# 2. Console mode (channel present): capture-only, channel.display_result_output shows it after
|
|
106
|
+
# 3. No channel (tests/one-shot): TeeIO so output appears live AND is captured
|
|
107
|
+
use_thread_local = defined?(RailsConsoleAi::PrefixedIO) && $stdout.is_a?(RailsConsoleAi::PrefixedIO)
|
|
108
|
+
if use_thread_local
|
|
109
|
+
Thread.current[:capture_io] = captured_output
|
|
110
|
+
elsif @channel
|
|
111
|
+
$stdout = captured_output
|
|
112
|
+
else
|
|
113
|
+
$stdout = TeeIO.new(old_stdout, captured_output)
|
|
114
|
+
end
|
|
110
115
|
|
|
111
116
|
RailsConsoleAi::SafetyError.clear!
|
|
112
117
|
|
|
@@ -114,7 +119,7 @@ module RailsConsoleAi
|
|
|
114
119
|
binding_context.eval(code, "(rails_console_ai)", 1)
|
|
115
120
|
end
|
|
116
121
|
|
|
117
|
-
|
|
122
|
+
restore_stdout(use_thread_local, old_stdout)
|
|
118
123
|
|
|
119
124
|
# Check if a SafetyError was raised but swallowed by a rescue inside the eval'd code
|
|
120
125
|
if (swallowed = RailsConsoleAi::SafetyError.last_raised)
|
|
@@ -136,8 +141,12 @@ module RailsConsoleAi
|
|
|
136
141
|
|
|
137
142
|
@last_output = captured_output.string
|
|
138
143
|
result
|
|
144
|
+
rescue Interrupt
|
|
145
|
+
restore_stdout(use_thread_local, old_stdout)
|
|
146
|
+
@last_output = captured_output&.string
|
|
147
|
+
raise
|
|
139
148
|
rescue RailsConsoleAi::SafetyError => e
|
|
140
|
-
|
|
149
|
+
restore_stdout(use_thread_local, old_stdout)
|
|
141
150
|
RailsConsoleAi::SafetyError.clear!
|
|
142
151
|
@last_error = "SafetyError: #{e.message}"
|
|
143
152
|
@last_safety_error = true
|
|
@@ -146,13 +155,13 @@ module RailsConsoleAi
|
|
|
146
155
|
@last_output = captured_output&.string
|
|
147
156
|
nil
|
|
148
157
|
rescue SyntaxError => e
|
|
149
|
-
|
|
158
|
+
restore_stdout(use_thread_local, old_stdout)
|
|
150
159
|
@last_error = "SyntaxError: #{e.message}"
|
|
151
160
|
log_execution_error(@last_error)
|
|
152
161
|
@last_output = nil
|
|
153
162
|
nil
|
|
154
163
|
rescue => e
|
|
155
|
-
|
|
164
|
+
restore_stdout(use_thread_local, old_stdout)
|
|
156
165
|
# Check if a SafetyError is wrapped (e.g. ActiveRecord::StatementInvalid wrapping our error)
|
|
157
166
|
if safety_error?(e)
|
|
158
167
|
safety_exc = extract_safety_exception(e)
|
|
@@ -216,7 +225,20 @@ module RailsConsoleAi
|
|
|
216
225
|
|
|
217
226
|
loop do
|
|
218
227
|
case answer
|
|
219
|
-
when '
|
|
228
|
+
when 'a', 'auto'
|
|
229
|
+
RailsConsoleAi.configuration.auto_execute = true
|
|
230
|
+
if @channel
|
|
231
|
+
@channel.display_status("Auto-execute: ON")
|
|
232
|
+
else
|
|
233
|
+
$stdout.puts colorize("Auto-execute: ON", :cyan)
|
|
234
|
+
end
|
|
235
|
+
result = execute(code)
|
|
236
|
+
if @last_safety_error
|
|
237
|
+
return nil unless danger_allowed?
|
|
238
|
+
return offer_danger_retry(code)
|
|
239
|
+
end
|
|
240
|
+
return result
|
|
241
|
+
when 'y', 'yes'
|
|
220
242
|
result = execute(code)
|
|
221
243
|
if @last_safety_error
|
|
222
244
|
return nil unless danger_allowed?
|
|
@@ -320,6 +342,14 @@ module RailsConsoleAi
|
|
|
320
342
|
|
|
321
343
|
private
|
|
322
344
|
|
|
345
|
+
def restore_stdout(use_thread_local, old_stdout)
|
|
346
|
+
if use_thread_local
|
|
347
|
+
Thread.current[:capture_io] = nil
|
|
348
|
+
else
|
|
349
|
+
$stdout = old_stdout if old_stdout
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
323
353
|
def danger_allowed?
|
|
324
354
|
@channel.nil? || @channel.supports_danger?
|
|
325
355
|
end
|
|
@@ -355,9 +385,9 @@ module RailsConsoleAi
|
|
|
355
385
|
def execute_prompt
|
|
356
386
|
guards = RailsConsoleAi.configuration.safety_guards
|
|
357
387
|
if !guards.empty? && guards.enabled? && danger_allowed?
|
|
358
|
-
"Execute? [y/N/danger] "
|
|
388
|
+
"Execute? [y/N/a/danger] "
|
|
359
389
|
else
|
|
360
|
-
"Execute? [y/N] "
|
|
390
|
+
"Execute? [y/N/a] "
|
|
361
391
|
end
|
|
362
392
|
end
|
|
363
393
|
|
|
@@ -5,6 +5,9 @@ module RailsConsoleAi
|
|
|
5
5
|
end
|
|
6
6
|
|
|
7
7
|
def write(str)
|
|
8
|
+
if (capture = Thread.current[:capture_io])
|
|
9
|
+
return capture.write(str)
|
|
10
|
+
end
|
|
8
11
|
prefix = Thread.current[:log_prefix]
|
|
9
12
|
if prefix && str.is_a?(String) && !str.strip.empty?
|
|
10
13
|
prefixed = str.gsub(/^(?=.)/, "#{prefix} ")
|
|
@@ -15,6 +18,9 @@ module RailsConsoleAi
|
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
def puts(*args)
|
|
21
|
+
if (capture = Thread.current[:capture_io])
|
|
22
|
+
return capture.puts(*args)
|
|
23
|
+
end
|
|
18
24
|
prefix = Thread.current[:log_prefix]
|
|
19
25
|
if prefix
|
|
20
26
|
args = [""] if args.empty?
|
|
@@ -32,6 +38,9 @@ module RailsConsoleAi
|
|
|
32
38
|
end
|
|
33
39
|
|
|
34
40
|
def print(*args)
|
|
41
|
+
if (capture = Thread.current[:capture_io])
|
|
42
|
+
return capture.print(*args)
|
|
43
|
+
end
|
|
35
44
|
@io.print(*args)
|
|
36
45
|
end
|
|
37
46
|
|
|
@@ -121,10 +121,10 @@ module RailsConsoleAi
|
|
|
121
121
|
else
|
|
122
122
|
[{ text: msg[:content].to_s }]
|
|
123
123
|
end
|
|
124
|
-
# Bedrock rejects empty text blocks in content arrays
|
|
125
|
-
content.reject! { |block| block.is_a?(Hash) && block.key?(:text) && !block.key?(:tool_use) && !block.key?(:tool_result) && block[:text].to_s.empty? }
|
|
124
|
+
# Bedrock rejects empty or whitespace-only text blocks in content arrays
|
|
125
|
+
content.reject! { |block| block.is_a?(Hash) && block.key?(:text) && !block.key?(:tool_use) && !block.key?(:tool_result) && block[:text].to_s.strip.empty? }
|
|
126
126
|
# Bedrock also rejects messages with completely empty content arrays
|
|
127
|
-
content << { text: '
|
|
127
|
+
content << { text: '.' } if content.empty?
|
|
128
128
|
{ role: msg[:role].to_s, content: content }
|
|
129
129
|
end
|
|
130
130
|
|
|
@@ -41,15 +41,15 @@ module RailsConsoleAi
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def enabled?
|
|
44
|
-
@enabled
|
|
44
|
+
@enabled && !Thread.current[:rails_console_ai_guards_disabled]
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def enable!
|
|
48
|
-
|
|
48
|
+
Thread.current[:rails_console_ai_guards_disabled] = nil
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def disable!
|
|
52
|
-
|
|
52
|
+
Thread.current[:rails_console_ai_guards_disabled] = true
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def empty?
|
|
@@ -60,33 +60,52 @@ module RailsConsoleAi
|
|
|
60
60
|
@guards.keys
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
# Add a permanent (config-time) allowlist entry visible to all threads.
|
|
64
|
+
def allow_global(guard_name, key)
|
|
64
65
|
guard_name = guard_name.to_sym
|
|
65
66
|
@allowlist[guard_name] ||= []
|
|
66
67
|
@allowlist[guard_name] << key unless @allowlist[guard_name].include?(key)
|
|
67
68
|
end
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
# Add a thread-local allowlist entry (runtime "allow for this session").
|
|
71
|
+
def allow(guard_name, key)
|
|
72
|
+
thread_list = Thread.current[:rails_console_ai_allowlist] ||= {}
|
|
73
|
+
guard_name = guard_name.to_sym
|
|
74
|
+
thread_list[guard_name] ||= []
|
|
75
|
+
thread_list[guard_name] << key unless thread_list[guard_name].include?(key)
|
|
76
|
+
end
|
|
72
77
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
def allowed?(guard_name, key)
|
|
79
|
+
guard_name = guard_name.to_sym
|
|
80
|
+
match = ->(entries) {
|
|
81
|
+
entries&.any? do |entry|
|
|
82
|
+
case entry
|
|
83
|
+
when Regexp then key.match?(entry)
|
|
84
|
+
else entry.to_s == key.to_s
|
|
85
|
+
end
|
|
77
86
|
end
|
|
78
|
-
|
|
87
|
+
}
|
|
88
|
+
# Check global (config-time) allowlist
|
|
89
|
+
return true if match.call(@allowlist[guard_name])
|
|
90
|
+
# Check thread-local (runtime session) allowlist
|
|
91
|
+
thread_list = Thread.current[:rails_console_ai_allowlist]
|
|
92
|
+
return true if thread_list && match.call(thread_list[guard_name])
|
|
93
|
+
false
|
|
79
94
|
end
|
|
80
95
|
|
|
81
96
|
def allowlist
|
|
82
|
-
|
|
97
|
+
thread_list = Thread.current[:rails_console_ai_allowlist]
|
|
98
|
+
return @allowlist unless thread_list
|
|
99
|
+
merged = @allowlist.dup
|
|
100
|
+
thread_list.each { |k, v| merged[k] = (merged[k] || []) + v }
|
|
101
|
+
merged
|
|
83
102
|
end
|
|
84
103
|
|
|
85
104
|
# Compose all guards around a block of code.
|
|
86
105
|
# Each guard is an around-block: guard.call { inner }
|
|
87
106
|
# Result: guard_1 { guard_2 { guard_3 { yield } } }
|
|
88
107
|
def wrap(channel_mode: nil, additional_bypass_methods: nil, &block)
|
|
89
|
-
return yield unless
|
|
108
|
+
return yield unless enabled? && !@guards.empty?
|
|
90
109
|
|
|
91
110
|
install_skills_once!
|
|
92
111
|
bypass_set = resolve_bypass_methods(channel_mode)
|
|
@@ -281,6 +281,19 @@ module RailsConsoleAi
|
|
|
281
281
|
slack_api("chat.postMessage", channel: channel, thread_ts: thread_ts, text: text)
|
|
282
282
|
end
|
|
283
283
|
|
|
284
|
+
# Cancel any in-progress thread on this session before spawning a new one.
|
|
285
|
+
# Prevents concurrent engine access when users send messages faster than
|
|
286
|
+
# the LLM can respond.
|
|
287
|
+
def replace_session_thread(session, &block)
|
|
288
|
+
old_thread = session[:thread]
|
|
289
|
+
if old_thread&.alive?
|
|
290
|
+
session[:channel].cancel!
|
|
291
|
+
old_thread.join(5) # wait up to 5s for graceful shutdown
|
|
292
|
+
session[:channel].instance_variable_set(:@cancelled, false)
|
|
293
|
+
end
|
|
294
|
+
session[:thread] = Thread.new(&block)
|
|
295
|
+
end
|
|
296
|
+
|
|
284
297
|
# --- Event handling ---
|
|
285
298
|
|
|
286
299
|
def handle_event(msg)
|
|
@@ -314,17 +327,14 @@ module RailsConsoleAi
|
|
|
314
327
|
|
|
315
328
|
session = @mutex.synchronize { @sessions[thread_ts] }
|
|
316
329
|
if session
|
|
317
|
-
#
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
chk_name
|
|
321
|
-
|
|
322
|
-
puts "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} @#{chk_name} << (ignored — not in allowed usernames)"
|
|
323
|
-
post_message(channel: channel_id, thread_ts: thread_ts, text: "Sorry, I don't recognize your username (@#{chk_name}). Ask an admin to add you to the allowed usernames list.")
|
|
324
|
-
end
|
|
330
|
+
# Any allowed user can interact with an existing session
|
|
331
|
+
chk_name = resolve_user_name(user_id)
|
|
332
|
+
unless RailsConsoleAi.configuration.username_allowed?('slack', 'allowed_usernames', chk_name)
|
|
333
|
+
puts "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} @#{chk_name} << (ignored — not in allowed usernames)"
|
|
334
|
+
post_message(channel: channel_id, thread_ts: thread_ts, text: "Sorry, I don't recognize your username (@#{chk_name}). Ask an admin to add you to the allowed usernames list.")
|
|
325
335
|
return
|
|
326
336
|
end
|
|
327
|
-
#
|
|
337
|
+
# Must @mention unless bot asked a question (waiting_for_reply?)
|
|
328
338
|
return unless mentioned || waiting_for_reply?(session[:channel])
|
|
329
339
|
# Log thread messages since last mention
|
|
330
340
|
thread_msgs = fetch_thread_messages(channel_id, thread_ts, since_ts: session[:last_seen_ts], exclude_ts: event[:ts])
|
|
@@ -443,10 +453,10 @@ module RailsConsoleAi
|
|
|
443
453
|
Thread.current.report_on_exception = false
|
|
444
454
|
Thread.current[:log_prefix] = "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} @#{user_name}"
|
|
445
455
|
begin
|
|
446
|
-
channel.
|
|
456
|
+
channel.display_status("_session: #{channel_id}/#{thread_ts}_")
|
|
447
457
|
if restored
|
|
448
458
|
puts "Restored session for thread #{thread_ts} (#{engine.history.length} messages)"
|
|
449
|
-
channel.
|
|
459
|
+
channel.display_status("_(session restored — continuing from previous conversation)_")
|
|
450
460
|
end
|
|
451
461
|
engine.process_message(text)
|
|
452
462
|
rescue => e
|
|
@@ -482,7 +492,7 @@ module RailsConsoleAi
|
|
|
482
492
|
end
|
|
483
493
|
|
|
484
494
|
# Otherwise treat as a new message in the conversation
|
|
485
|
-
session
|
|
495
|
+
replace_session_thread(session) do
|
|
486
496
|
Thread.current.report_on_exception = false
|
|
487
497
|
Thread.current[:log_prefix] = channel.instance_variable_get(:@log_prefix)
|
|
488
498
|
begin
|
|
@@ -514,7 +524,7 @@ module RailsConsoleAi
|
|
|
514
524
|
channel = session[:channel]
|
|
515
525
|
engine = session[:engine]
|
|
516
526
|
|
|
517
|
-
session
|
|
527
|
+
replace_session_thread(session) do
|
|
518
528
|
Thread.current.report_on_exception = false
|
|
519
529
|
Thread.current[:log_prefix] = channel.instance_variable_get(:@log_prefix)
|
|
520
530
|
begin
|
|
@@ -614,7 +624,7 @@ module RailsConsoleAi
|
|
|
614
624
|
summary = bang_cost(engine)
|
|
615
625
|
post_message(channel: channel_id, thread_ts: thread_ts, text: summary)
|
|
616
626
|
when 'compact'
|
|
617
|
-
session
|
|
627
|
+
replace_session_thread(session) do
|
|
618
628
|
Thread.current.report_on_exception = false
|
|
619
629
|
begin
|
|
620
630
|
before = engine.history.length
|
|
@@ -646,7 +656,7 @@ module RailsConsoleAi
|
|
|
646
656
|
summary = bang_context(engine)
|
|
647
657
|
post_message(channel: channel_id, thread_ts: thread_ts, text: summary)
|
|
648
658
|
when 'retry'
|
|
649
|
-
session
|
|
659
|
+
replace_session_thread(session) do
|
|
650
660
|
Thread.current.report_on_exception = false
|
|
651
661
|
Thread.current[:log_prefix] = session[:channel].instance_variable_get(:@log_prefix)
|
|
652
662
|
begin
|
|
@@ -368,13 +368,6 @@ module RailsConsoleAi
|
|
|
368
368
|
def register_execute_plan
|
|
369
369
|
return unless @executor
|
|
370
370
|
|
|
371
|
-
# Check per-channel code execution permission
|
|
372
|
-
if @channel
|
|
373
|
-
unless RailsConsoleAi.configuration.username_allowed?(@channel.mode, 'allow_code_execution', @channel.user_identity)
|
|
374
|
-
return
|
|
375
|
-
end
|
|
376
|
-
end
|
|
377
|
-
|
|
378
371
|
register(
|
|
379
372
|
name: 'execute_code',
|
|
380
373
|
description: 'Execute Ruby code in the Rails console and return the result. Use this for all code execution — simple queries, data lookups, reports, etc. The output of puts/print statements is automatically shown to the user. The return value is sent back to you so you can summarize the findings.',
|
|
@@ -419,12 +412,8 @@ module RailsConsoleAi
|
|
|
419
412
|
# Show the code to the user
|
|
420
413
|
@executor.display_code_block(code)
|
|
421
414
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
exec_result = if @channel&.mode == 'slack'
|
|
425
|
-
@executor.execute(code, display: false)
|
|
426
|
-
elsif RailsConsoleAi.configuration.auto_execute
|
|
427
|
-
@executor.execute(code, display: false)
|
|
415
|
+
exec_result = if @channel&.mode == 'slack' || RailsConsoleAi.configuration.auto_execute
|
|
416
|
+
@executor.execute(code)
|
|
428
417
|
else
|
|
429
418
|
@executor.confirm_and_execute(code)
|
|
430
419
|
end
|