rails_console_ai 0.22.0 → 0.24.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.
@@ -3,9 +3,10 @@ module RailsConsoleAi
3
3
  attr_reader :history, :total_input_tokens, :total_output_tokens,
4
4
  :interactive_session_id, :session_name
5
5
 
6
- RECENT_OUTPUTS_TO_KEEP = 2
7
6
  LARGE_OUTPUT_THRESHOLD = 10_000 # chars — truncate tool results larger than this immediately
8
7
  LARGE_OUTPUT_PREVIEW_CHARS = 8_000 # chars — how much of the output the LLM sees upfront
8
+ LOOP_WARN_THRESHOLD = 3 # same tool+args repeated → inject warning
9
+ LOOP_BREAK_THRESHOLD = 5 # same tool+args repeated → break loop
9
10
 
10
11
  def initialize(binding_context:, channel:, slack_thread_ts: nil, slack_channel_name: nil)
11
12
  @binding_context = binding_context
@@ -30,6 +31,7 @@ module RailsConsoleAi
30
31
  @last_interactive_executed = false
31
32
  @compact_warned = false
32
33
  @prior_duration_ms = 0
34
+ @expanded_output_ids = Set.new
33
35
  end
34
36
 
35
37
  # --- Public API for channels ---
@@ -47,7 +49,7 @@ module RailsConsoleAi
47
49
  conversation << { role: :assistant, content: @_last_result_text }
48
50
  conversation << { role: :user, content: error_msg }
49
51
 
50
- @channel.display_dim(" Attempting to fix...")
52
+ @channel.display_status(" Ran into an issue, trying a different approach...")
51
53
  exec_result, code, executed = one_shot_round(conversation)
52
54
  end
53
55
 
@@ -112,7 +114,7 @@ module RailsConsoleAi
112
114
 
113
115
  status = send_and_execute
114
116
  if status == :error
115
- @channel.display_dim(" Attempting to fix...")
117
+ @channel.display_status(" Ran into an issue, trying a different approach...")
116
118
  send_and_execute
117
119
  end
118
120
  end
@@ -230,12 +232,12 @@ module RailsConsoleAi
230
232
  @channel.display_warning("No code to retry.")
231
233
  return
232
234
  end
233
- @channel.display_dim(" Retrying last code...")
235
+ @channel.display_status(" Retrying last code...")
234
236
  execute_direct(code)
235
237
  end
236
238
 
237
239
  def execute_direct(raw_code)
238
- exec_result = @executor.execute(raw_code)
240
+ exec_result = @executor.execute_unsafe(raw_code)
239
241
 
240
242
  output_parts = []
241
243
  output_parts << "Output:\n#{@executor.last_output.strip}" if @executor.last_output && !@executor.last_output.strip.empty?
@@ -244,12 +246,11 @@ module RailsConsoleAi
244
246
  result_str = output_parts.join("\n\n")
245
247
 
246
248
  context_msg = "User directly executed code: `#{raw_code}`"
249
+ output_id = @executor.store_output(result_str)
247
250
  if result_str.length > LARGE_OUTPUT_THRESHOLD
248
- output_id = @executor.store_output(result_str)
249
251
  preview = result_str[0, LARGE_OUTPUT_PREVIEW_CHARS]
250
252
  context_msg += "\n#{preview}\n\n[Output truncated at #{LARGE_OUTPUT_PREVIEW_CHARS} of #{result_str.length} chars — use recall_output tool with id #{output_id} to retrieve the full output]"
251
253
  elsif !output_parts.empty?
252
- output_id = @executor.store_output(result_str)
253
254
  context_msg += "\n#{result_str}"
254
255
  end
255
256
  @history << { role: :user, content: context_msg, output_id: output_id }
@@ -263,7 +264,7 @@ module RailsConsoleAi
263
264
 
264
265
  def send_and_execute
265
266
  begin
266
- result, tool_messages = send_query(nil, conversation: @history)
267
+ result, tool_messages, last_llm_stats = send_query(nil, conversation: @history)
267
268
  rescue Providers::ProviderError => e
268
269
  if e.message.include?("prompt is too long") && @history.length >= 6
269
270
  @channel.display_warning(" Context limit reached. Run /compact to reduce context size, then try again.")
@@ -284,7 +285,15 @@ module RailsConsoleAi
284
285
  log_interactive_turn
285
286
 
286
287
  @history.concat(tool_messages) if tool_messages && !tool_messages.empty?
287
- @history << { role: :assistant, content: result.text }
288
+ # Only add the final assistant text when the LLM gave a final response (end_turn).
289
+ # For tool_use results, the assistant message is already in tool_messages via
290
+ # format_assistant_message, so adding result.text again would duplicate it —
291
+ # and if the text is empty, Bedrock rejects the empty content array.
292
+ unless result.tool_use?
293
+ entry = { role: :assistant, content: result.text }
294
+ entry[:llm_stats] = last_llm_stats if last_llm_stats
295
+ @history << entry
296
+ end
288
297
 
289
298
  return :no_code unless code && !code.strip.empty?
290
299
  return :cancelled if @channel.cancelled?
@@ -413,15 +422,23 @@ module RailsConsoleAi
413
422
  return
414
423
  end
415
424
 
416
- trimmed = trim_old_outputs(@history)
417
- stdout.puts "\e[36m Conversation (#{trimmed.length} messages, as sent to LLM):\e[0m"
418
- trimmed.each_with_index do |msg, i|
419
- role = msg[:role].to_s
420
- content = msg[:content].to_s
421
- label = role == 'user' ? "\e[33m[user]\e[0m" : "\e[36m[assistant]\e[0m"
422
- stdout.puts "#{label} #{content}"
423
- stdout.puts if i < trimmed.length - 1
424
- end
425
+ messages = trim_large_outputs(@history)
426
+ system_prompt = context
427
+ require 'rails_console_ai/tools/registry'
428
+ tools = Tools::Registry.new(executor: @executor, channel: @channel) rescue nil
429
+ opts = { io: stdout, prefix: " ", d: "\e[2m", r: "\e[0m" }
430
+ conversation_summary(messages, system_prompt, tools, **opts)
431
+ conversation_messages(messages, **opts)
432
+ end
433
+
434
+ def display_conversation_to(io)
435
+ messages = trim_large_outputs(@history)
436
+ system_prompt = context
437
+ require 'rails_console_ai/tools/registry'
438
+ tools = Tools::Registry.new(executor: @executor, channel: @channel) rescue nil
439
+ opts = { io: io, prefix: "", d: "", r: "" }
440
+ conversation_summary(messages, system_prompt, tools, **opts)
441
+ conversation_messages(messages, **opts)
425
442
  end
426
443
 
427
444
  def context
@@ -710,8 +727,8 @@ module RailsConsoleAi
710
727
 
711
728
  EXPLORATION STRATEGY — be efficient to avoid timeouts:
712
729
  1. Start with list_models to see all models and their associations
713
- 2. Pick the 5-8 CORE models and call describe_model on those only
714
- 3. Call describe_table on only 3-5 key tables (skip tables whose models already told you enough)
730
+ 2. Pick the 5-8 CORE models and call describe_model on those only (it includes columns, indexes, associations, validations)
731
+ 3. Call describe_table only for tables that have NO corresponding model (join tables, legacy tables, etc.)
715
732
  4. Use search_code sparingly — only for specific patterns you suspect (sharding, STI, concerns)
716
733
  5. Use read_file only when you need to understand a specific pattern (read small sections, not whole files)
717
734
  6. Do NOT exhaustively describe every table or model — focus on what's important
@@ -751,7 +768,7 @@ module RailsConsoleAi
751
768
  [{ role: :user, content: query }]
752
769
  end
753
770
 
754
- messages = trim_old_outputs(messages) if conversation
771
+ messages = trim_large_outputs(messages) if conversation
755
772
 
756
773
  send_query_with_tools(messages)
757
774
  end
@@ -769,27 +786,28 @@ module RailsConsoleAi
769
786
  last_tool_names = []
770
787
 
771
788
  exhausted = false
789
+ tool_call_counts = Hash.new(0)
772
790
 
773
791
  max_rounds.times do |round|
774
792
  if @channel.cancelled?
775
- @channel.display_dim(" Cancelled.")
793
+ @channel.display_status(" Cancelled.")
776
794
  break
777
795
  end
778
796
 
779
797
  if round == 0
780
- @channel.display_dim(" Thinking...")
798
+ @channel.display_status(" Thinking...")
781
799
  else
782
800
  if last_thinking
783
801
  last_thinking.split("\n").each do |line|
784
- @channel.display_dim(" #{line}")
802
+ @channel.display_thinking(" #{line}")
785
803
  end
786
804
  end
787
- @channel.display_dim(" #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}")
805
+ @channel.display_status(" #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}")
788
806
  end
789
807
 
790
- # Trim old tool outputs between rounds to prevent context explosion.
808
+ # Trim large tool outputs between rounds to prevent context explosion.
791
809
  # The LLM can still retrieve omitted outputs via recall_output.
792
- messages = trim_old_outputs(messages) if round > 0
810
+ messages = trim_large_outputs(messages) if round > 0
793
811
 
794
812
  if RailsConsoleAi.configuration.debug
795
813
  debug_pre_call(round, messages, active_system_prompt, tools, total_input, total_output)
@@ -816,6 +834,7 @@ module RailsConsoleAi
816
834
  last_thinking = (result.text && !result.text.strip.empty?) ? result.text.strip : nil
817
835
 
818
836
  assistant_msg = provider.format_assistant_message(result)
837
+ assistant_msg[:llm_stats] = format_llm_stats(result)
819
838
  messages << assistant_msg
820
839
  new_messages << assistant_msg
821
840
 
@@ -837,19 +856,20 @@ module RailsConsoleAi
837
856
  else
838
857
  "No matching outputs found with id(s) #{ids.join(', ')}."
839
858
  end
840
- @channel.display_dim(" #{tool_result}")
859
+ @channel.display_status(" #{tool_result}")
841
860
  tool_msg = provider.format_tool_result(tc[:id], tool_result)
842
861
  messages << tool_msg
843
862
  new_messages << tool_msg
844
863
  next
845
864
  end
846
865
 
866
+ # Display any pending LLM text before executing the tool
867
+ if last_thinking
868
+ last_thinking.split("\n").each { |line| @channel.display_thinking(" #{line}") }
869
+ last_thinking = nil
870
+ end
871
+
847
872
  if tc[:name] == 'ask_user' || tc[:name] == 'execute_plan'
848
- # Display any pending LLM text before prompting the user
849
- if last_thinking
850
- last_thinking.split("\n").each { |line| @channel.display_dim(" #{line}") }
851
- last_thinking = nil
852
- end
853
873
  tool_result = tools.execute(tc[:name], tc[:arguments])
854
874
  else
855
875
  args_display = format_tool_args(tc[:name], tc[:arguments])
@@ -859,7 +879,7 @@ module RailsConsoleAi
859
879
 
860
880
  preview = compact_tool_result(tc[:name], tool_result)
861
881
  cached_tag = tools.last_cached? ? " (cached)" : ""
862
- @channel.display_dim(" #{preview}#{cached_tag}")
882
+ @channel.display_status(" #{preview}#{cached_tag}")
863
883
  end
864
884
 
865
885
  if RailsConsoleAi.configuration.debug
@@ -868,20 +888,35 @@ module RailsConsoleAi
868
888
 
869
889
  tool_msg = provider.format_tool_result(tc[:id], tool_result)
870
890
  full_text = tool_result.to_s
891
+ output_id = @executor.store_output(full_text)
892
+ tool_msg[:output_id] = output_id
871
893
  if full_text.length > LARGE_OUTPUT_THRESHOLD
872
- output_id = @executor.store_output(full_text)
873
- tool_msg[:output_id] = output_id
874
894
  truncated = full_text[0, LARGE_OUTPUT_PREVIEW_CHARS]
875
895
  truncated += "\n\n[Output truncated at #{LARGE_OUTPUT_PREVIEW_CHARS} of #{full_text.length} chars — use recall_output tool with id #{output_id} to retrieve the full output]"
876
896
  tool_msg = provider.format_tool_result(tc[:id], truncated)
877
897
  tool_msg[:output_id] = output_id
878
- elsif full_text.length > 200
879
- tool_msg[:output_id] = @executor.store_output(full_text)
880
898
  end
899
+ tool_msg[:do_not_trim] = true if %w[recall_memory recall_memories activate_skill
900
+ describe_model describe_table list_models list_tables].include?(tc[:name])
881
901
  messages << tool_msg
882
902
  new_messages << tool_msg
883
903
  end
884
904
 
905
+ # Loop detection: track repeated identical tool calls
906
+ result.tool_calls.each do |tc|
907
+ key = "#{tc[:name]}:#{tc[:arguments].to_json}"
908
+ tool_call_counts[key] += 1
909
+
910
+ if tool_call_counts[key] >= LOOP_BREAK_THRESHOLD
911
+ @channel.display_status(" Loop detected: #{tc[:name]} called #{tool_call_counts[key]} times with same args — stopping.")
912
+ exhausted = true
913
+ elsif tool_call_counts[key] >= LOOP_WARN_THRESHOLD
914
+ @channel.display_status(" Warning: #{tc[:name]} called #{tool_call_counts[key]} times with same args — consider a different approach.")
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
+ end
917
+ end
918
+ break if exhausted
919
+
885
920
  # If the user declined execution, don't call the LLM again —
886
921
  # just return to the prompt so they can correct their request.
887
922
  break if @executor.last_cancelled?
@@ -889,10 +924,6 @@ module RailsConsoleAi
889
924
  exhausted = true if round == max_rounds - 1
890
925
  end
891
926
 
892
- # Re-truncate any outputs that were expanded for the LLM — the LLM has
893
- # seen them and responded, so collapse back to save context on future calls.
894
- re_truncate_expanded(messages)
895
-
896
927
  if exhausted
897
928
  $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"
898
929
  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." }
@@ -901,13 +932,14 @@ module RailsConsoleAi
901
932
  total_output += result.output_tokens || 0
902
933
  end
903
934
 
935
+ last_llm_stats = result ? format_llm_stats(result) : nil
904
936
  final_result = Providers::ChatResult.new(
905
937
  text: result ? result.text : '',
906
938
  input_tokens: total_input,
907
939
  output_tokens: total_output,
908
940
  stop_reason: result ? result.stop_reason : :end_turn
909
941
  )
910
- [final_result, new_messages]
942
+ [final_result, new_messages, last_llm_stats]
911
943
  end
912
944
 
913
945
  def track_usage(result)
@@ -928,6 +960,9 @@ module RailsConsoleAi
928
960
 
929
961
  parts = []
930
962
  parts << "in: #{input}" if input
963
+ cache_r = result.cache_read_input_tokens || 0
964
+ cache_w = result.cache_write_input_tokens || 0
965
+ parts << "cache r: #{cache_r} w: #{cache_w}" if cache_r > 0 || cache_w > 0
931
966
  parts << "out: #{output}" if output
932
967
  parts << "total: #{result.total_tokens}"
933
968
 
@@ -989,8 +1024,11 @@ module RailsConsoleAi
989
1024
  when 'list_files' then args['directory'] ? "(\"#{args['directory']}\")" : ''
990
1025
  when 'save_memory' then "(\"#{args['name']}\")"
991
1026
  when 'delete_memory' then "(\"#{args['name']}\")"
1027
+ when 'recall_memory' then "(\"#{args['name']}\")"
992
1028
  when 'recall_memories' then args['query'] ? "(\"#{args['query']}\")" : ''
993
1029
  when 'activate_skill' then "(\"#{args['name']}\")"
1030
+ when 'save_skill' then "(\"#{args['name']}\")"
1031
+ when 'delete_skill' then "(\"#{args['name']}\")"
994
1032
  when 'recall_output' then "(#{args['id']})"
995
1033
  when 'execute_plan'
996
1034
  steps = args['steps']
@@ -1013,8 +1051,10 @@ module RailsConsoleAi
1013
1051
  "#{result.scan(/^\s{2}\S/).length} columns"
1014
1052
  when 'describe_model'
1015
1053
  parts = []
1054
+ col_count = result.scan(/^\s{2}\S+:\S+/).length
1016
1055
  assoc_count = result.scan(/^\s{2}(has_many|has_one|belongs_to|has_and_belongs_to_many)/).length
1017
1056
  val_count = result.scan(/^\s{2}(presence|uniqueness|format|length|numericality|inclusion|exclusion|confirmation|acceptance)/).length
1057
+ parts << "#{col_count} columns" if col_count > 0
1018
1058
  parts << "#{assoc_count} associations" if assoc_count > 0
1019
1059
  parts << "#{val_count} validations" if val_count > 0
1020
1060
  parts.empty? ? truncate(result, 80) : parts.join(', ')
@@ -1034,9 +1074,33 @@ module RailsConsoleAi
1034
1074
  (result.start_with?('Memory saved') || result.start_with?('Memory updated')) ? result : truncate(result, 80)
1035
1075
  when 'delete_memory'
1036
1076
  result.start_with?('Memory deleted') ? result : truncate(result, 80)
1077
+ when 'save_skill'
1078
+ (result.start_with?('Skill created') || result.start_with?('Skill updated')) ? result : truncate(result, 80)
1079
+ when 'delete_skill'
1080
+ result.start_with?('Skill deleted') ? result : truncate(result, 80)
1081
+ when 'recall_memory'
1082
+ if result.start_with?('No memory found')
1083
+ result
1084
+ else
1085
+ name = result[/\A\*\*(.+?)\*\*/, 1]
1086
+ name ? "loaded: #{name}" : truncate(result, 80)
1087
+ end
1037
1088
  when 'recall_memories'
1038
- chunks = result.split("\n\n")
1039
- chunks.length > 1 ? "#{chunks.length} memories found" : truncate(result, 80)
1089
+ chunks = result.split("\n\n---\n\n")
1090
+ names = chunks.map { |c| c[/\A\*\*(.+?)\*\*/, 1] }.compact
1091
+ if names.length > 1
1092
+ "#{names.length} memories found: #{names.join(', ')}"
1093
+ elsif names.length == 1
1094
+ "1 memory found: #{names.first}"
1095
+ else
1096
+ truncate(result, 80)
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}"
1040
1104
  when 'execute_plan'
1041
1105
  steps_done = result.scan(/^Step \d+/).length
1042
1106
  steps_done > 0 ? "#{steps_done} steps executed" : truncate(result, 80)
@@ -1062,36 +1126,103 @@ module RailsConsoleAi
1062
1126
  status
1063
1127
  end
1064
1128
 
1129
+ # Provider-agnostic block detection helpers.
1130
+ # Anthropic uses string keys: { 'type' => 'tool_result', ... }
1131
+ # Bedrock uses symbol keys: { tool_result: { ... } }
1132
+ def tool_result_block?(block)
1133
+ return false unless block.is_a?(Hash)
1134
+ block['type'] == 'tool_result' || block.key?(:tool_result)
1135
+ end
1136
+
1137
+ def tool_use_block?(block)
1138
+ return false unless block.is_a?(Hash)
1139
+ block['type'] == 'tool_use' || block.key?(:tool_use)
1140
+ end
1141
+
1142
+ def tool_result_content(block)
1143
+ if block['type'] == 'tool_result'
1144
+ block['content'].to_s
1145
+ elsif block.key?(:tool_result)
1146
+ content = block[:tool_result][:content]
1147
+ content.is_a?(Array) ? content.map { |c| c[:text].to_s }.join : content.to_s
1148
+ else
1149
+ ''
1150
+ end
1151
+ end
1152
+
1153
+ def tool_use_name(block)
1154
+ if block['type'] == 'tool_use'
1155
+ block['name']
1156
+ elsif block.key?(:tool_use)
1157
+ block[:tool_use][:name]
1158
+ end
1159
+ end
1160
+
1065
1161
  def debug_pre_call(round, messages, system_prompt, tools, total_input, total_output)
1066
1162
  d = "\e[35m"
1067
1163
  r = "\e[0m"
1164
+ $stderr.puts "#{d}[debug] ── LLM call ##{round + 1} ──#{r}"
1165
+ conversation_summary(messages, system_prompt, tools, io: $stderr, prefix: "[debug] ", d: d, r: r)
1166
+ if total_input > 0 || total_output > 0
1167
+ $stderr.puts "#{d}[debug] tokens so far: in: #{format_tokens(total_input)} | out: #{format_tokens(total_output)}#{r}"
1168
+ end
1169
+ conversation_messages(messages, io: $stderr, prefix: "[debug] ", d: d, r: r, show_pending: true)
1170
+ end
1068
1171
 
1172
+ def conversation_summary(messages, system_prompt, tools, io:, prefix:, d:, r:)
1069
1173
  user_msgs = 0; assistant_msgs = 0; tool_result_msgs = 0; tool_use_msgs = 0
1070
- output_msgs = 0; omitted_msgs = 0
1174
+ output_msgs = 0; omitted_msgs = 0; expanded_msgs = 0
1071
1175
  total_content_chars = system_prompt.to_s.length
1072
1176
 
1073
1177
  messages.each do |msg|
1074
1178
  content_str = msg[:content].is_a?(Array) ? msg[:content].to_s : msg[:content].to_s
1075
1179
  total_content_chars += content_str.length
1180
+ is_expanded = msg[:expanded] || (msg[:output_id] && @expanded_output_ids.include?(msg[:output_id]))
1076
1181
 
1077
1182
  role = msg[:role].to_s
1078
1183
  if role == 'tool'
1079
1184
  tool_result_msgs += 1
1080
1185
  elsif msg[:content].is_a?(Array)
1186
+ has_tool_block = false
1081
1187
  msg[:content].each do |block|
1082
1188
  next unless block.is_a?(Hash)
1083
- if block['type'] == 'tool_result'
1189
+ if tool_result_block?(block)
1084
1190
  tool_result_msgs += 1
1085
- omitted_msgs += 1 if block['content'].to_s.include?('Output omitted')
1086
- elsif block['type'] == 'tool_use'
1191
+ has_tool_block = true
1192
+ if is_expanded
1193
+ expanded_msgs += 1
1194
+ elsif tool_result_content(block).include?('Output omitted')
1195
+ omitted_msgs += 1
1196
+ end
1197
+ elsif tool_use_block?(block)
1087
1198
  tool_use_msgs += 1
1199
+ has_tool_block = true
1200
+ end
1201
+ end
1202
+ unless has_tool_block
1203
+ if role == 'user'
1204
+ user_msgs += 1
1205
+ if content_str.include?('Code was executed') || content_str.include?('directly executed code')
1206
+ output_msgs += 1
1207
+ if is_expanded
1208
+ expanded_msgs += 1
1209
+ elsif content_str.include?('Output omitted')
1210
+ omitted_msgs += 1
1211
+ end
1212
+ end
1213
+ elsif role == 'assistant'
1214
+ assistant_msgs += 1
1088
1215
  end
1089
1216
  end
1090
1217
  elsif role == 'user'
1091
1218
  user_msgs += 1
1092
1219
  if content_str.include?('Code was executed') || content_str.include?('directly executed code')
1093
1220
  output_msgs += 1
1094
- omitted_msgs += 1 if content_str.include?('Output omitted')
1221
+ if is_expanded
1222
+ expanded_msgs += 1
1223
+ elsif content_str.include?('Output omitted')
1224
+ omitted_msgs += 1
1225
+ end
1095
1226
  end
1096
1227
  elsif role == 'assistant'
1097
1228
  assistant_msgs += 1
@@ -1100,15 +1231,108 @@ module RailsConsoleAi
1100
1231
 
1101
1232
  tool_count = tools.respond_to?(:definitions) ? tools.definitions.length : 0
1102
1233
 
1103
- $stderr.puts "#{d}[debug] ── LLM call ##{round + 1} ──#{r}"
1104
- $stderr.puts "#{d}[debug] system prompt: #{format_tokens(system_prompt.to_s.length)} chars#{r}"
1105
- $stderr.puts "#{d}[debug] messages: #{messages.length} (#{user_msgs} user, #{assistant_msgs} assistant, #{tool_result_msgs} tool results, #{tool_use_msgs} tool calls)#{r}"
1106
- $stderr.puts "#{d}[debug] execution outputs: #{output_msgs} (#{omitted_msgs} omitted)#{r}" if output_msgs > 0 || omitted_msgs > 0
1107
- $stderr.puts "#{d}[debug] tools provided: #{tool_count}#{r}"
1108
- $stderr.puts "#{d}[debug] est. content size: #{format_tokens(total_content_chars)} chars#{r}"
1109
- if total_input > 0 || total_output > 0
1110
- $stderr.puts "#{d}[debug] tokens so far: in: #{format_tokens(total_input)} | out: #{format_tokens(total_output)}#{r}"
1234
+ io.puts "#{d}#{prefix}system prompt: #{format_tokens(system_prompt.to_s.length)} chars#{r}"
1235
+ io.puts "#{d}#{prefix}messages: #{messages.length} (#{user_msgs} user, #{assistant_msgs} assistant, #{tool_result_msgs} tool results, #{tool_use_msgs} tool calls)#{r}"
1236
+ if output_msgs > 0 || omitted_msgs > 0 || expanded_msgs > 0
1237
+ detail_parts = []
1238
+ detail_parts << "#{omitted_msgs} omitted" if omitted_msgs > 0
1239
+ detail_parts << "#{expanded_msgs} expanded" if expanded_msgs > 0
1240
+ io.puts "#{d}#{prefix}execution outputs: #{output_msgs} (#{detail_parts.join(', ')})#{r}"
1111
1241
  end
1242
+ io.puts "#{d}#{prefix}tools provided: #{tool_count}#{r}"
1243
+ io.puts "#{d}#{prefix}est. content size: #{format_tokens(total_content_chars)} chars#{r}"
1244
+ end
1245
+
1246
+ def conversation_messages(messages, io:, prefix:, d:, r:, show_pending: false)
1247
+ io.puts "#{d}#{prefix}conversation:#{r}"
1248
+ llm_call = 0
1249
+ messages.each_with_index do |msg, i|
1250
+ role = msg[:role].to_s
1251
+ parts = []
1252
+ display_role = role
1253
+ is_assistant = role == 'assistant' || (msg[:content].is_a?(Array) && msg[:content].any? { |b| b.is_a?(Hash) && tool_use_block?(b) })
1254
+
1255
+ if is_assistant
1256
+ llm_call += 1
1257
+ stats = msg[:llm_stats] ? " → #{msg[:llm_stats]}" : ""
1258
+ io.puts "#{d}#{prefix} ── LLM call ##{llm_call}#{stats} ──#{r}"
1259
+ end
1260
+
1261
+ if role == 'tool'
1262
+ display_role = 'tool_result'
1263
+ text = msg[:content].to_s
1264
+ flags = debug_output_flags(text, msg)
1265
+ flag_str = flags.any? ? ", #{flags.join(', ')}" : ""
1266
+ parts << "#{text.length} chars#{flag_str}"
1267
+ elsif msg[:content].is_a?(Array)
1268
+ has_tool_result = false
1269
+ has_tool_use = false
1270
+ msg[:content].each do |block|
1271
+ next unless block.is_a?(Hash)
1272
+ if tool_result_block?(block)
1273
+ has_tool_result = true
1274
+ content = tool_result_content(block)
1275
+ flags = debug_output_flags(content, msg)
1276
+ flag_str = flags.any? ? ", #{flags.join(', ')}" : ""
1277
+ parts << "#{content.length} chars#{flag_str}"
1278
+ elsif tool_use_block?(block)
1279
+ has_tool_use = true
1280
+ parts << "tool_use: #{tool_use_name(block)}"
1281
+ elsif block['type'] == 'text' || block.key?(:text)
1282
+ text = block['text'] || block[:text]
1283
+ parts << "text(#{text.to_s.length} chars)" if text.to_s.length > 0
1284
+ end
1285
+ end
1286
+ display_role = 'tool_result' if has_tool_result && !has_tool_use
1287
+ display_role = 'assistant' if has_tool_use && !has_tool_result
1288
+ else
1289
+ text = msg[:content].to_s
1290
+ preview = text.length > 60 ? text[0, 57] + "..." : text
1291
+ preview = preview.gsub("\n", "\\n")
1292
+ flags = debug_output_flags(text, msg)
1293
+ flag_str = flags.any? ? " (#{flags.join(', ')})" : ""
1294
+ parts << "\"#{preview}\" #{text.length} chars#{flag_str}"
1295
+ end
1296
+
1297
+ io.puts "#{d}#{prefix} ##{i} #{display_role}: [#{parts.join(', ')}]#{r}"
1298
+ end
1299
+ if show_pending
1300
+ llm_call += 1
1301
+ io.puts "#{d}#{prefix} ── LLM call ##{llm_call} (pending) ──#{r}"
1302
+ end
1303
+ end
1304
+
1305
+ def format_llm_stats(result)
1306
+ parts = ["in: #{format_tokens(result.input_tokens || 0)}"]
1307
+ parts << "out: #{format_tokens(result.output_tokens || 0)}"
1308
+ cache_r = result.cache_read_input_tokens || 0
1309
+ cache_w = result.cache_write_input_tokens || 0
1310
+ parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
1311
+ model = effective_model
1312
+ pricing = Configuration::PRICING[model]
1313
+ if pricing
1314
+ cost = ((result.input_tokens || 0) * pricing[:input]) + ((result.output_tokens || 0) * pricing[:output])
1315
+ if (cache_r > 0 || cache_w > 0) && pricing[:cache_read]
1316
+ cost -= cache_r * pricing[:input]
1317
+ cost += cache_r * pricing[:cache_read]
1318
+ cost += cache_w * (pricing[:cache_write] - pricing[:input])
1319
+ end
1320
+ parts << "~$#{'%.4f' % cost}"
1321
+ end
1322
+ parts.join(' | ')
1323
+ end
1324
+
1325
+ def debug_output_flags(content_text, msg)
1326
+ flags = []
1327
+ flags << "output ##{msg[:output_id]}" if msg[:output_id]
1328
+ if msg[:expanded] || (msg[:output_id] && @expanded_output_ids.include?(msg[:output_id]))
1329
+ flags << "expanded"
1330
+ elsif content_text.include?('Output omitted')
1331
+ flags << "omitted"
1332
+ elsif (m = content_text.match(/Output truncated at (\S+) of (\S+) chars/))
1333
+ flags << "truncated #{m[2]}→#{m[1]}"
1334
+ end
1335
+ flags
1112
1336
  end
1113
1337
 
1114
1338
  def debug_post_call(round, result, total_input, total_output)
@@ -1135,9 +1359,9 @@ module RailsConsoleAi
1135
1359
  end
1136
1360
  session_cost = (total_input * pricing[:input]) + (total_output * pricing[:output])
1137
1361
  parts << "~$#{'%.4f' % cost}"
1138
- $stderr.puts "#{d}[debug] ← response: #{parts.join(' | ')} (session: ~$#{'%.4f' % session_cost})#{r}"
1362
+ $stderr.puts "\n#{d}[debug] ← response: #{parts.join(' | ')} (session: ~$#{'%.4f' % session_cost})#{r}"
1139
1363
  else
1140
- $stderr.puts "#{d}[debug] ← response: #{parts.join(' | ')}#{r}"
1364
+ $stderr.puts "\n#{d}[debug] ← response: #{parts.join(' | ')}#{r}"
1141
1365
  end
1142
1366
 
1143
1367
  if result.tool_use?
@@ -1150,20 +1374,18 @@ module RailsConsoleAi
1150
1374
 
1151
1375
  # --- Conversation context management ---
1152
1376
 
1153
- def trim_old_outputs(messages)
1154
- output_indices = messages.each_with_index
1155
- .select { |m, _| m[:output_id] }
1156
- .map { |_, i| i }
1157
-
1158
- return messages if output_indices.length <= RECENT_OUTPUTS_TO_KEEP
1159
-
1160
- trim_indices = output_indices[0..-(RECENT_OUTPUTS_TO_KEEP + 1)]
1161
- messages.each_with_index.map do |msg, i|
1162
- if trim_indices.include?(i)
1163
- trim_message(msg)
1164
- else
1165
- msg
1377
+ def trim_large_outputs(messages)
1378
+ messages.map do |msg|
1379
+ next msg unless msg[:output_id] && !msg[:do_not_trim]
1380
+ # Re-expand messages that were expanded in a prior turn but lost content
1381
+ # (because trim_message creates new hashes, disconnecting from @history)
1382
+ if @expanded_output_ids.include?(msg[:output_id]) && !msg[:expanded]
1383
+ expand_message_in_place(msg)
1166
1384
  end
1385
+ next msg if msg[:expanded]
1386
+ stored = @executor.recall_output(msg[:output_id])
1387
+ next msg unless stored && stored.length > LARGE_OUTPUT_THRESHOLD
1388
+ trim_message(msg)
1167
1389
  end
1168
1390
  end
1169
1391
 
@@ -1172,8 +1394,14 @@ module RailsConsoleAi
1172
1394
 
1173
1395
  if msg[:content].is_a?(Array)
1174
1396
  trimmed_content = msg[:content].map do |block|
1175
- if block.is_a?(Hash) && block['type'] == 'tool_result'
1176
- block.merge('content' => ref)
1397
+ if block.is_a?(Hash) && tool_result_block?(block)
1398
+ if block.key?(:tool_result)
1399
+ # Bedrock format
1400
+ block.merge(tool_result: block[:tool_result].merge(content: [{ text: ref }]))
1401
+ else
1402
+ # Anthropic format
1403
+ block.merge('content' => ref)
1404
+ end
1177
1405
  else
1178
1406
  block
1179
1407
  end
@@ -1191,40 +1419,40 @@ module RailsConsoleAi
1191
1419
  expanded = []
1192
1420
  messages.each do |msg|
1193
1421
  next unless msg[:output_id] && ids.include?(msg[:output_id])
1194
- full_output = @executor.recall_output(msg[:output_id])
1195
- next unless full_output
1196
- # Save original content so re_truncate_expanded can restore it
1197
- msg[:pre_expand_content] = msg[:content]
1198
- # Replace content with full output (handle Anthropic, OpenAI, and user message formats)
1199
- if msg[:content].is_a?(Array)
1200
- msg[:content] = msg[:content].map do |block|
1201
- if block.is_a?(Hash) && block['type'] == 'tool_result'
1202
- block.merge('content' => full_output)
1203
- else
1204
- block
1205
- end
1206
- end
1207
- elsif msg[:role].to_s == 'tool'
1208
- msg[:content] = full_output
1209
- else
1210
- # User messages (e.g., direct execution) — preserve first line, replace rest
1211
- first_line = msg[:content].to_s.lines.first&.chomp || ''
1212
- msg[:content] = "#{first_line}\n#{full_output}"
1213
- end
1214
- msg[:expanded] = true
1422
+ next unless expand_message_in_place(msg)
1215
1423
  expanded << msg[:output_id]
1216
1424
  end
1217
1425
  expanded
1218
1426
  end
1219
1427
 
1220
- # Restore messages that were temporarily expanded back to their original
1221
- # (preview/truncated) content. Called after the LLM has seen the expanded
1222
- # content and responded.
1223
- def re_truncate_expanded(messages)
1224
- messages.each do |msg|
1225
- next unless msg.delete(:expanded)
1226
- msg[:content] = msg.delete(:pre_expand_content)
1428
+ def expand_message_in_place(msg)
1429
+ full_output = @executor.recall_output(msg[:output_id])
1430
+ return false unless full_output
1431
+ # Replace content with full output (handle Anthropic, Bedrock, and user message formats)
1432
+ if msg[:content].is_a?(Array)
1433
+ msg[:content] = msg[:content].map do |block|
1434
+ if block.is_a?(Hash) && tool_result_block?(block)
1435
+ if block.key?(:tool_result)
1436
+ # Bedrock format
1437
+ block.merge(tool_result: block[:tool_result].merge(content: [{ text: full_output }]))
1438
+ else
1439
+ # Anthropic format
1440
+ block.merge('content' => full_output)
1441
+ end
1442
+ else
1443
+ block
1444
+ end
1445
+ end
1446
+ elsif msg[:role].to_s == 'tool'
1447
+ msg[:content] = full_output
1448
+ else
1449
+ # User messages (e.g., direct execution) — preserve first line, replace rest
1450
+ first_line = msg[:content].to_s.lines.first&.chomp || ''
1451
+ msg[:content] = "#{first_line}\n#{full_output}"
1227
1452
  end
1453
+ msg[:expanded] = true
1454
+ @expanded_output_ids.add(msg[:output_id])
1455
+ true
1228
1456
  end
1229
1457
 
1230
1458
  def extract_executed_code(history)
@@ -1246,11 +1474,11 @@ module RailsConsoleAi
1246
1474
 
1247
1475
  if msg[:role].to_s == 'assistant' && msg[:content].is_a?(Array)
1248
1476
  msg[:content].each do |block|
1249
- next unless block.is_a?(Hash) && block['type'] == 'tool_use' && block['name'] == 'execute_plan'
1250
- input = block['input'] || {}
1477
+ next unless block.is_a?(Hash) && tool_use_block?(block) && tool_use_name(block) == 'execute_plan'
1478
+ input = block['input'] || block.dig(:tool_use, :input) || {}
1251
1479
  steps = input['steps'] || []
1252
1480
 
1253
- tool_id = block['id']
1481
+ tool_id = block['id'] || block.dig(:tool_use, :tool_use_id)
1254
1482
  result_msg = find_tool_result(history, tool_id)
1255
1483
  next unless result_msg
1256
1484
 
@@ -1274,14 +1502,15 @@ module RailsConsoleAi
1274
1502
 
1275
1503
  def find_tool_result(history, tool_id)
1276
1504
  history.each do |msg|
1505
+ if msg[:role].to_s == 'tool' && msg[:tool_call_id] == tool_id
1506
+ return msg[:content]
1507
+ end
1277
1508
  next unless msg[:content].is_a?(Array)
1278
1509
  msg[:content].each do |block|
1279
1510
  next unless block.is_a?(Hash)
1280
- if block['type'] == 'tool_result' && block['tool_use_id'] == tool_id
1281
- return block['content']
1282
- end
1283
- if msg[:role].to_s == 'tool' && msg[:tool_call_id] == tool_id
1284
- return msg[:content]
1511
+ if tool_result_block?(block)
1512
+ block_tool_id = block['tool_use_id'] || block.dig(:tool_result, :tool_use_id)
1513
+ return tool_result_content(block) if block_tool_id == tool_id
1285
1514
  end
1286
1515
  end
1287
1516
  end