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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +8 -4
- data/lib/rails_console_ai/channel/base.rb +2 -1
- data/lib/rails_console_ai/channel/console.rb +5 -1
- data/lib/rails_console_ai/channel/slack.rb +16 -20
- data/lib/rails_console_ai/configuration.rb +23 -0
- data/lib/rails_console_ai/context_builder.rb +16 -8
- data/lib/rails_console_ai/conversation_engine.rb +340 -111
- data/lib/rails_console_ai/executor.rb +35 -13
- data/lib/rails_console_ai/providers/bedrock.rb +4 -2
- 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 +22 -18
- 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 +56 -9
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,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.
|
|
793
|
+
@channel.display_status(" Cancelled.")
|
|
776
794
|
break
|
|
777
795
|
end
|
|
778
796
|
|
|
779
797
|
if round == 0
|
|
780
|
-
@channel.
|
|
798
|
+
@channel.display_status(" Thinking...")
|
|
781
799
|
else
|
|
782
800
|
if last_thinking
|
|
783
801
|
last_thinking.split("\n").each do |line|
|
|
784
|
-
@channel.
|
|
802
|
+
@channel.display_thinking(" #{line}")
|
|
785
803
|
end
|
|
786
804
|
end
|
|
787
|
-
@channel.
|
|
805
|
+
@channel.display_status(" #{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
|
|
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
1189
|
+
if tool_result_block?(block)
|
|
1084
1190
|
tool_result_msgs += 1
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}"
|
|
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
|
|
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
|
|
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
|
|
1176
|
-
block.
|
|
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
|
-
|
|
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
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
|
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
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|