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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +8 -4
- data/lib/rails_console_ai/channel/slack.rb +2 -1
- data/lib/rails_console_ai/configuration.rb +23 -0
- data/lib/rails_console_ai/context_builder.rb +14 -7
- data/lib/rails_console_ai/conversation_engine.rb +327 -104
- data/lib/rails_console_ai/executor.rb +19 -10
- data/lib/rails_console_ai/providers/bedrock.rb +2 -0
- data/lib/rails_console_ai/repl.rb +2 -2
- data/lib/rails_console_ai/skill_loader.rb +49 -0
- data/lib/rails_console_ai/slack_bot.rb +20 -16
- data/lib/rails_console_ai/tools/memory_tools.rb +22 -5
- data/lib/rails_console_ai/tools/model_tools.rb +28 -0
- data/lib/rails_console_ai/tools/registry.rb +61 -3
- data/lib/rails_console_ai/version.rb +1 -1
- metadata +1 -1
|
@@ -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("
|
|
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("
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1183
|
+
if tool_result_block?(block)
|
|
1084
1184
|
tool_result_msgs += 1
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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
|
|
1176
|
-
block.
|
|
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
|
-
|
|
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
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
|
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
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|