rails_console_ai 0.22.0 → 0.23.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_dim(" 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_dim(" Ran into an issue, trying a different approach...")
116
118
  send_and_execute
117
119
  end
118
120
  end
@@ -235,7 +237,7 @@ module RailsConsoleAi
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,6 +786,7 @@ 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?
@@ -787,9 +805,9 @@ module RailsConsoleAi
787
805
  @channel.display_dim(" #{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
 
@@ -844,12 +863,13 @@ module RailsConsoleAi
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_dim(" #{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])
@@ -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_dim(" 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_dim(" 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,27 @@ 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
1040
1098
  when 'execute_plan'
1041
1099
  steps_done = result.scan(/^Step \d+/).length
1042
1100
  steps_done > 0 ? "#{steps_done} steps executed" : truncate(result, 80)
@@ -1062,36 +1120,103 @@ module RailsConsoleAi
1062
1120
  status
1063
1121
  end
1064
1122
 
1123
+ # Provider-agnostic block detection helpers.
1124
+ # Anthropic uses string keys: { 'type' => 'tool_result', ... }
1125
+ # Bedrock uses symbol keys: { tool_result: { ... } }
1126
+ def tool_result_block?(block)
1127
+ return false unless block.is_a?(Hash)
1128
+ block['type'] == 'tool_result' || block.key?(:tool_result)
1129
+ end
1130
+
1131
+ def tool_use_block?(block)
1132
+ return false unless block.is_a?(Hash)
1133
+ block['type'] == 'tool_use' || block.key?(:tool_use)
1134
+ end
1135
+
1136
+ def tool_result_content(block)
1137
+ if block['type'] == 'tool_result'
1138
+ block['content'].to_s
1139
+ elsif block.key?(:tool_result)
1140
+ content = block[:tool_result][:content]
1141
+ content.is_a?(Array) ? content.map { |c| c[:text].to_s }.join : content.to_s
1142
+ else
1143
+ ''
1144
+ end
1145
+ end
1146
+
1147
+ def tool_use_name(block)
1148
+ if block['type'] == 'tool_use'
1149
+ block['name']
1150
+ elsif block.key?(:tool_use)
1151
+ block[:tool_use][:name]
1152
+ end
1153
+ end
1154
+
1065
1155
  def debug_pre_call(round, messages, system_prompt, tools, total_input, total_output)
1066
1156
  d = "\e[35m"
1067
1157
  r = "\e[0m"
1158
+ $stderr.puts "#{d}[debug] ── LLM call ##{round + 1} ──#{r}"
1159
+ conversation_summary(messages, system_prompt, tools, io: $stderr, prefix: "[debug] ", d: d, r: r)
1160
+ if total_input > 0 || total_output > 0
1161
+ $stderr.puts "#{d}[debug] tokens so far: in: #{format_tokens(total_input)} | out: #{format_tokens(total_output)}#{r}"
1162
+ end
1163
+ conversation_messages(messages, io: $stderr, prefix: "[debug] ", d: d, r: r, show_pending: true)
1164
+ end
1068
1165
 
1166
+ def conversation_summary(messages, system_prompt, tools, io:, prefix:, d:, r:)
1069
1167
  user_msgs = 0; assistant_msgs = 0; tool_result_msgs = 0; tool_use_msgs = 0
1070
- output_msgs = 0; omitted_msgs = 0
1168
+ output_msgs = 0; omitted_msgs = 0; expanded_msgs = 0
1071
1169
  total_content_chars = system_prompt.to_s.length
1072
1170
 
1073
1171
  messages.each do |msg|
1074
1172
  content_str = msg[:content].is_a?(Array) ? msg[:content].to_s : msg[:content].to_s
1075
1173
  total_content_chars += content_str.length
1174
+ is_expanded = msg[:expanded] || (msg[:output_id] && @expanded_output_ids.include?(msg[:output_id]))
1076
1175
 
1077
1176
  role = msg[:role].to_s
1078
1177
  if role == 'tool'
1079
1178
  tool_result_msgs += 1
1080
1179
  elsif msg[:content].is_a?(Array)
1180
+ has_tool_block = false
1081
1181
  msg[:content].each do |block|
1082
1182
  next unless block.is_a?(Hash)
1083
- if block['type'] == 'tool_result'
1183
+ if tool_result_block?(block)
1084
1184
  tool_result_msgs += 1
1085
- omitted_msgs += 1 if block['content'].to_s.include?('Output omitted')
1086
- elsif block['type'] == 'tool_use'
1185
+ has_tool_block = true
1186
+ if is_expanded
1187
+ expanded_msgs += 1
1188
+ elsif tool_result_content(block).include?('Output omitted')
1189
+ omitted_msgs += 1
1190
+ end
1191
+ elsif tool_use_block?(block)
1087
1192
  tool_use_msgs += 1
1193
+ has_tool_block = true
1194
+ end
1195
+ end
1196
+ unless has_tool_block
1197
+ if role == 'user'
1198
+ user_msgs += 1
1199
+ if content_str.include?('Code was executed') || content_str.include?('directly executed code')
1200
+ output_msgs += 1
1201
+ if is_expanded
1202
+ expanded_msgs += 1
1203
+ elsif content_str.include?('Output omitted')
1204
+ omitted_msgs += 1
1205
+ end
1206
+ end
1207
+ elsif role == 'assistant'
1208
+ assistant_msgs += 1
1088
1209
  end
1089
1210
  end
1090
1211
  elsif role == 'user'
1091
1212
  user_msgs += 1
1092
1213
  if content_str.include?('Code was executed') || content_str.include?('directly executed code')
1093
1214
  output_msgs += 1
1094
- omitted_msgs += 1 if content_str.include?('Output omitted')
1215
+ if is_expanded
1216
+ expanded_msgs += 1
1217
+ elsif content_str.include?('Output omitted')
1218
+ omitted_msgs += 1
1219
+ end
1095
1220
  end
1096
1221
  elsif role == 'assistant'
1097
1222
  assistant_msgs += 1
@@ -1100,15 +1225,108 @@ module RailsConsoleAi
1100
1225
 
1101
1226
  tool_count = tools.respond_to?(:definitions) ? tools.definitions.length : 0
1102
1227
 
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}"
1228
+ io.puts "#{d}#{prefix}system prompt: #{format_tokens(system_prompt.to_s.length)} chars#{r}"
1229
+ io.puts "#{d}#{prefix}messages: #{messages.length} (#{user_msgs} user, #{assistant_msgs} assistant, #{tool_result_msgs} tool results, #{tool_use_msgs} tool calls)#{r}"
1230
+ if output_msgs > 0 || omitted_msgs > 0 || expanded_msgs > 0
1231
+ detail_parts = []
1232
+ detail_parts << "#{omitted_msgs} omitted" if omitted_msgs > 0
1233
+ detail_parts << "#{expanded_msgs} expanded" if expanded_msgs > 0
1234
+ io.puts "#{d}#{prefix}execution outputs: #{output_msgs} (#{detail_parts.join(', ')})#{r}"
1111
1235
  end
1236
+ io.puts "#{d}#{prefix}tools provided: #{tool_count}#{r}"
1237
+ io.puts "#{d}#{prefix}est. content size: #{format_tokens(total_content_chars)} chars#{r}"
1238
+ end
1239
+
1240
+ def conversation_messages(messages, io:, prefix:, d:, r:, show_pending: false)
1241
+ io.puts "#{d}#{prefix}conversation:#{r}"
1242
+ llm_call = 0
1243
+ messages.each_with_index do |msg, i|
1244
+ role = msg[:role].to_s
1245
+ parts = []
1246
+ display_role = role
1247
+ is_assistant = role == 'assistant' || (msg[:content].is_a?(Array) && msg[:content].any? { |b| b.is_a?(Hash) && tool_use_block?(b) })
1248
+
1249
+ if is_assistant
1250
+ llm_call += 1
1251
+ stats = msg[:llm_stats] ? " → #{msg[:llm_stats]}" : ""
1252
+ io.puts "#{d}#{prefix} ── LLM call ##{llm_call}#{stats} ──#{r}"
1253
+ end
1254
+
1255
+ if role == 'tool'
1256
+ display_role = 'tool_result'
1257
+ text = msg[:content].to_s
1258
+ flags = debug_output_flags(text, msg)
1259
+ flag_str = flags.any? ? ", #{flags.join(', ')}" : ""
1260
+ parts << "#{text.length} chars#{flag_str}"
1261
+ elsif msg[:content].is_a?(Array)
1262
+ has_tool_result = false
1263
+ has_tool_use = false
1264
+ msg[:content].each do |block|
1265
+ next unless block.is_a?(Hash)
1266
+ if tool_result_block?(block)
1267
+ has_tool_result = true
1268
+ content = tool_result_content(block)
1269
+ flags = debug_output_flags(content, msg)
1270
+ flag_str = flags.any? ? ", #{flags.join(', ')}" : ""
1271
+ parts << "#{content.length} chars#{flag_str}"
1272
+ elsif tool_use_block?(block)
1273
+ has_tool_use = true
1274
+ parts << "tool_use: #{tool_use_name(block)}"
1275
+ elsif block['type'] == 'text' || block.key?(:text)
1276
+ text = block['text'] || block[:text]
1277
+ parts << "text(#{text.to_s.length} chars)" if text.to_s.length > 0
1278
+ end
1279
+ end
1280
+ display_role = 'tool_result' if has_tool_result && !has_tool_use
1281
+ display_role = 'assistant' if has_tool_use && !has_tool_result
1282
+ else
1283
+ text = msg[:content].to_s
1284
+ preview = text.length > 60 ? text[0, 57] + "..." : text
1285
+ preview = preview.gsub("\n", "\\n")
1286
+ flags = debug_output_flags(text, msg)
1287
+ flag_str = flags.any? ? " (#{flags.join(', ')})" : ""
1288
+ parts << "\"#{preview}\" #{text.length} chars#{flag_str}"
1289
+ end
1290
+
1291
+ io.puts "#{d}#{prefix} ##{i} #{display_role}: [#{parts.join(', ')}]#{r}"
1292
+ end
1293
+ if show_pending
1294
+ llm_call += 1
1295
+ io.puts "#{d}#{prefix} ── LLM call ##{llm_call} (pending) ──#{r}"
1296
+ end
1297
+ end
1298
+
1299
+ def format_llm_stats(result)
1300
+ parts = ["in: #{format_tokens(result.input_tokens || 0)}"]
1301
+ parts << "out: #{format_tokens(result.output_tokens || 0)}"
1302
+ cache_r = result.cache_read_input_tokens || 0
1303
+ cache_w = result.cache_write_input_tokens || 0
1304
+ parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
1305
+ model = effective_model
1306
+ pricing = Configuration::PRICING[model]
1307
+ if pricing
1308
+ cost = ((result.input_tokens || 0) * pricing[:input]) + ((result.output_tokens || 0) * pricing[:output])
1309
+ if (cache_r > 0 || cache_w > 0) && pricing[:cache_read]
1310
+ cost -= cache_r * pricing[:input]
1311
+ cost += cache_r * pricing[:cache_read]
1312
+ cost += cache_w * (pricing[:cache_write] - pricing[:input])
1313
+ end
1314
+ parts << "~$#{'%.4f' % cost}"
1315
+ end
1316
+ parts.join(' | ')
1317
+ end
1318
+
1319
+ def debug_output_flags(content_text, msg)
1320
+ flags = []
1321
+ flags << "output ##{msg[:output_id]}" if msg[:output_id]
1322
+ if msg[:expanded] || (msg[:output_id] && @expanded_output_ids.include?(msg[:output_id]))
1323
+ flags << "expanded"
1324
+ elsif content_text.include?('Output omitted')
1325
+ flags << "omitted"
1326
+ elsif (m = content_text.match(/Output truncated at (\S+) of (\S+) chars/))
1327
+ flags << "truncated #{m[2]}→#{m[1]}"
1328
+ end
1329
+ flags
1112
1330
  end
1113
1331
 
1114
1332
  def debug_post_call(round, result, total_input, total_output)
@@ -1135,9 +1353,9 @@ module RailsConsoleAi
1135
1353
  end
1136
1354
  session_cost = (total_input * pricing[:input]) + (total_output * pricing[:output])
1137
1355
  parts << "~$#{'%.4f' % cost}"
1138
- $stderr.puts "#{d}[debug] ← response: #{parts.join(' | ')} (session: ~$#{'%.4f' % session_cost})#{r}"
1356
+ $stderr.puts "\n#{d}[debug] ← response: #{parts.join(' | ')} (session: ~$#{'%.4f' % session_cost})#{r}"
1139
1357
  else
1140
- $stderr.puts "#{d}[debug] ← response: #{parts.join(' | ')}#{r}"
1358
+ $stderr.puts "\n#{d}[debug] ← response: #{parts.join(' | ')}#{r}"
1141
1359
  end
1142
1360
 
1143
1361
  if result.tool_use?
@@ -1150,20 +1368,18 @@ module RailsConsoleAi
1150
1368
 
1151
1369
  # --- Conversation context management ---
1152
1370
 
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
1371
+ def trim_large_outputs(messages)
1372
+ messages.map do |msg|
1373
+ next msg unless msg[:output_id] && !msg[:do_not_trim]
1374
+ # Re-expand messages that were expanded in a prior turn but lost content
1375
+ # (because trim_message creates new hashes, disconnecting from @history)
1376
+ if @expanded_output_ids.include?(msg[:output_id]) && !msg[:expanded]
1377
+ expand_message_in_place(msg)
1166
1378
  end
1379
+ next msg if msg[:expanded]
1380
+ stored = @executor.recall_output(msg[:output_id])
1381
+ next msg unless stored && stored.length > LARGE_OUTPUT_THRESHOLD
1382
+ trim_message(msg)
1167
1383
  end
1168
1384
  end
1169
1385
 
@@ -1172,8 +1388,14 @@ module RailsConsoleAi
1172
1388
 
1173
1389
  if msg[:content].is_a?(Array)
1174
1390
  trimmed_content = msg[:content].map do |block|
1175
- if block.is_a?(Hash) && block['type'] == 'tool_result'
1176
- block.merge('content' => ref)
1391
+ if block.is_a?(Hash) && tool_result_block?(block)
1392
+ if block.key?(:tool_result)
1393
+ # Bedrock format
1394
+ block.merge(tool_result: block[:tool_result].merge(content: [{ text: ref }]))
1395
+ else
1396
+ # Anthropic format
1397
+ block.merge('content' => ref)
1398
+ end
1177
1399
  else
1178
1400
  block
1179
1401
  end
@@ -1191,40 +1413,40 @@ module RailsConsoleAi
1191
1413
  expanded = []
1192
1414
  messages.each do |msg|
1193
1415
  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
1416
+ next unless expand_message_in_place(msg)
1215
1417
  expanded << msg[:output_id]
1216
1418
  end
1217
1419
  expanded
1218
1420
  end
1219
1421
 
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)
1422
+ def expand_message_in_place(msg)
1423
+ full_output = @executor.recall_output(msg[:output_id])
1424
+ return false unless full_output
1425
+ # Replace content with full output (handle Anthropic, Bedrock, and user message formats)
1426
+ if msg[:content].is_a?(Array)
1427
+ msg[:content] = msg[:content].map do |block|
1428
+ if block.is_a?(Hash) && tool_result_block?(block)
1429
+ if block.key?(:tool_result)
1430
+ # Bedrock format
1431
+ block.merge(tool_result: block[:tool_result].merge(content: [{ text: full_output }]))
1432
+ else
1433
+ # Anthropic format
1434
+ block.merge('content' => full_output)
1435
+ end
1436
+ else
1437
+ block
1438
+ end
1439
+ end
1440
+ elsif msg[:role].to_s == 'tool'
1441
+ msg[:content] = full_output
1442
+ else
1443
+ # User messages (e.g., direct execution) — preserve first line, replace rest
1444
+ first_line = msg[:content].to_s.lines.first&.chomp || ''
1445
+ msg[:content] = "#{first_line}\n#{full_output}"
1227
1446
  end
1447
+ msg[:expanded] = true
1448
+ @expanded_output_ids.add(msg[:output_id])
1449
+ true
1228
1450
  end
1229
1451
 
1230
1452
  def extract_executed_code(history)
@@ -1246,11 +1468,11 @@ module RailsConsoleAi
1246
1468
 
1247
1469
  if msg[:role].to_s == 'assistant' && msg[:content].is_a?(Array)
1248
1470
  msg[:content].each do |block|
1249
- next unless block.is_a?(Hash) && block['type'] == 'tool_use' && block['name'] == 'execute_plan'
1250
- input = block['input'] || {}
1471
+ next unless block.is_a?(Hash) && tool_use_block?(block) && tool_use_name(block) == 'execute_plan'
1472
+ input = block['input'] || block.dig(:tool_use, :input) || {}
1251
1473
  steps = input['steps'] || []
1252
1474
 
1253
- tool_id = block['id']
1475
+ tool_id = block['id'] || block.dig(:tool_use, :tool_use_id)
1254
1476
  result_msg = find_tool_result(history, tool_id)
1255
1477
  next unless result_msg
1256
1478
 
@@ -1274,14 +1496,15 @@ module RailsConsoleAi
1274
1496
 
1275
1497
  def find_tool_result(history, tool_id)
1276
1498
  history.each do |msg|
1499
+ if msg[:role].to_s == 'tool' && msg[:tool_call_id] == tool_id
1500
+ return msg[:content]
1501
+ end
1277
1502
  next unless msg[:content].is_a?(Array)
1278
1503
  msg[:content].each do |block|
1279
1504
  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]
1505
+ if tool_result_block?(block)
1506
+ block_tool_id = block['tool_use_id'] || block.dig(:tool_result, :tool_use_id)
1507
+ return tool_result_content(block) if block_tool_id == tool_id
1285
1508
  end
1286
1509
  end
1287
1510
  end