rails_console_ai 0.34.0 → 0.36.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.
@@ -1,6 +1,9 @@
1
+ require 'rails_console_ai/slash_commands'
2
+
1
3
  module RailsConsoleAi
2
4
  class ConversationEngine
3
5
  attr_reader :history, :total_input_tokens, :total_output_tokens,
6
+ :total_cache_read_tokens, :total_cache_write_tokens,
4
7
  :interactive_session_id, :session_name
5
8
 
6
9
  LARGE_OUTPUT_THRESHOLD = 20_000 # chars — truncate tool results larger than this immediately
@@ -9,6 +12,7 @@ module RailsConsoleAi
9
12
  LOOP_BREAK_THRESHOLD = 5 # same tool+args repeated → break loop
10
13
  REPEAT_ERROR_WARN_THRESHOLD = 3 # same error signature (any args) → inject warning
11
14
  REPEAT_ERROR_BREAK_THRESHOLD = 5 # same error signature (any args) → force wrap-up
15
+ CONTEXT_WARN_FRACTION = 0.7 # share of the model's context window → suggest /compact
12
16
 
13
17
  def initialize(binding_context:, channel:, slack_thread_ts: nil, slack_channel_name: nil)
14
18
  @binding_context = binding_context
@@ -17,11 +21,14 @@ module RailsConsoleAi
17
21
  @slack_channel_name = slack_channel_name
18
22
  @executor = Executor.new(binding_context, channel: channel)
19
23
  @provider = nil
24
+ @routing_session_id = SecureRandom.hex(8)
20
25
  @context_builder = nil
21
26
  @context = nil
22
27
  @history = []
23
28
  @total_input_tokens = 0
24
29
  @total_output_tokens = 0
30
+ @total_cache_read_tokens = 0
31
+ @total_cache_write_tokens = 0
25
32
  @token_usage = Hash.new { |h, k| h[k] = { input: 0, output: 0 } }
26
33
  @interactive_session_id = nil
27
34
  @session_name = nil
@@ -42,7 +49,7 @@ module RailsConsoleAi
42
49
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
43
50
  console_capture = StringIO.new
44
51
  exec_result = with_console_capture(console_capture) do
45
- conversation = [{ role: :user, content: query }]
52
+ conversation = [{ role: :user, content: user_turn(query) }]
46
53
  exec_result, code, executed = one_shot_round(conversation)
47
54
 
48
55
  if executed && @executor.last_error && !@executor.last_safety_error
@@ -85,7 +92,7 @@ module RailsConsoleAi
85
92
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
86
93
  console_capture = StringIO.new
87
94
  with_console_capture(console_capture) do
88
- result, _ = send_query(query)
95
+ result, _ = send_query(user_turn(query))
89
96
  track_usage(result)
90
97
  @executor.display_response(result.text)
91
98
  display_usage(result)
@@ -115,7 +122,7 @@ module RailsConsoleAi
115
122
  @channel.log_input(text) if @channel.respond_to?(:log_input)
116
123
  @interactive_query ||= text
117
124
  maybe_auto_upgrade_thinking(text)
118
- @history << { role: :user, content: text }
125
+ @history << { role: :user, content: user_turn(text) }
119
126
 
120
127
  status = send_and_execute
121
128
  if status == :error
@@ -144,9 +151,6 @@ module RailsConsoleAi
144
151
  sys_prompt = init_system_prompt(existing_guide)
145
152
  messages = [{ role: :user, content: "Explore this Rails application and generate the application guide." }]
146
153
 
147
- original_timeout = RailsConsoleAi.configuration.timeout
148
- RailsConsoleAi.configuration.timeout = [original_timeout, 120].max
149
-
150
154
  result, _ = send_query_with_tools(messages, system_prompt: sys_prompt, tools_override: init_tools)
151
155
 
152
156
  guide_text = result.text.to_s.strip
@@ -172,8 +176,6 @@ module RailsConsoleAi
172
176
  rescue => e
173
177
  @channel.display_error("RailsConsoleAi Error: #{e.class}: #{e.message}")
174
178
  nil
175
- ensure
176
- RailsConsoleAi.configuration.timeout = original_timeout if original_timeout
177
179
  end
178
180
 
179
181
  # --- Interactive session management ---
@@ -184,6 +186,8 @@ module RailsConsoleAi
184
186
  @history = []
185
187
  @total_input_tokens = 0
186
188
  @total_output_tokens = 0
189
+ @total_cache_read_tokens = 0
190
+ @total_cache_write_tokens = 0
187
191
  @token_usage = Hash.new { |h, k| h[k] = { input: 0, output: 0 } }
188
192
  @interactive_query = nil
189
193
  @interactive_session_id = nil
@@ -203,20 +207,33 @@ module RailsConsoleAi
203
207
  @session_name = session.name
204
208
  @total_input_tokens = session.input_tokens || 0
205
209
  @total_output_tokens = session.output_tokens || 0
210
+ # respond_to? rather than #try: the columns are only present after
211
+ # RailsConsoleAi.migrate! has run, and this path must not depend on
212
+ # ActiveSupport being loaded.
213
+ @total_cache_read_tokens = session_column(session, :cache_read_tokens)
214
+ @total_cache_write_tokens = session_column(session, :cache_write_tokens)
206
215
  @prior_duration_ms = session.duration_ms || 0
207
216
 
208
217
  if session.model && (session.input_tokens.to_i > 0 || session.output_tokens.to_i > 0)
209
218
  @token_usage[session.model][:input] = session.input_tokens.to_i
210
219
  @token_usage[session.model][:output] = session.output_tokens.to_i
220
+ @token_usage[session.model][:cache_read] = @total_cache_read_tokens
221
+ @token_usage[session.model][:cache_write] = @total_cache_write_tokens
211
222
  end
212
223
  end
213
224
 
225
+ # Reads a column that may not exist yet on this install (added by migrate!).
226
+ def session_column(session, name)
227
+ return 0 unless session.respond_to?(name)
228
+ session.public_send(name).to_i
229
+ end
230
+
214
231
  def set_interactive_query(text)
215
232
  @interactive_query ||= text
216
233
  end
217
234
 
218
235
  def add_user_message(text)
219
- @history << { role: :user, content: text }
236
+ @history << { role: :user, content: user_turn(text) }
220
237
  end
221
238
 
222
239
  def pop_last_message
@@ -241,6 +258,80 @@ module RailsConsoleAi
241
258
  execute_direct(code)
242
259
  end
243
260
 
261
+ # --- Slash-command invocation of skills and agents ---
262
+
263
+ # A skill is a recipe for *this* assistant, and its guard bypasses have to
264
+ # land on the live executor — so it runs as a normal turn with the recipe
265
+ # prepended, not in a sub-agent. Returns the user-turn text to send.
266
+ def skill_command_prompt(skill, request)
267
+ bypass_methods = Array(skill['bypass_guards_for_methods'])
268
+ @executor.activate_skill_bypasses(bypass_methods) unless bypass_methods.empty?
269
+
270
+ if skill['source'] == :db && skill['id']
271
+ RailsConsoleAi::Skill.record_use!(skill['id'])
272
+ end
273
+
274
+ @channel.display_status(" Activated skill: #{skill['name']}")
275
+
276
+ request = request.to_s.strip
277
+ task = request.empty? ? "Follow this skill now." : request
278
+
279
+ "The user invoked the \"#{skill['name']}\" skill. Follow its procedure.\n\n" \
280
+ "--- SKILL: #{skill['name']} ---\n#{skill['body']}\n--- END SKILL ---\n\n" \
281
+ "Their request: #{task}"
282
+ end
283
+
284
+ # An agent is a separate context by definition, so this mirrors delegate_task:
285
+ # run it, then fold only its summary back into the conversation.
286
+ def run_agent_command(agent, task)
287
+ require 'rails_console_ai/sub_agent'
288
+
289
+ task = task.to_s.strip
290
+ if task.empty?
291
+ @channel.display_warning(" /#{SlashCommands.slugify(agent['name'])} needs a task. Try: /#{SlashCommands.slugify(agent['name'])} <what to investigate>")
292
+ return
293
+ end
294
+
295
+ @interactive_query ||= "/#{SlashCommands.slugify(agent['name'])} #{task}"
296
+ log_interactive_turn(status: 'running')
297
+
298
+ if agent['source'] == :db && agent['id']
299
+ RailsConsoleAi::Agent.record_use!(agent['id'])
300
+ end
301
+
302
+ sub = SubAgent.new(
303
+ task: task,
304
+ agent_config: agent,
305
+ binding_context: @executor.binding_context,
306
+ parent_channel: @channel,
307
+ executor: @executor
308
+ )
309
+
310
+ begin
311
+ # Deliberately NOT wrapped in @channel.wrap_llm_call. That wrapper puts the
312
+ # terminal in raw mode to watch for Esc, which clears ONLCR — so anything
313
+ # printed inside it staircases down the screen. Everywhere else it wraps
314
+ # only the provider HTTP call, where nothing prints; a sub-agent run prints
315
+ # throughout. Ctrl-C still lands here as an Interrupt.
316
+ result = sub.run
317
+ rescue Interrupt
318
+ @channel.display_warning(" Cancelled.")
319
+ log_interactive_turn(status: 'ready')
320
+ return
321
+ end
322
+
323
+ record_sub_agent_usage(sub)
324
+
325
+ @history << {
326
+ role: :user,
327
+ content: "The user ran the \"#{agent['name']}\" agent with the task: #{task}\n\n" \
328
+ "The agent reported back:\n#{result}"
329
+ }
330
+
331
+ @channel.display(result.to_s)
332
+ log_interactive_turn(status: 'ready')
333
+ end
334
+
244
335
  def execute_direct(raw_code)
245
336
  @interactive_query ||= "> #{raw_code}"
246
337
  log_interactive_turn(status: 'running')
@@ -410,26 +501,34 @@ module RailsConsoleAi
410
501
  end
411
502
 
412
503
  total_cost = 0.0
504
+ has_reported_cost = false
413
505
  $stdout.puts "\e[36m Cost estimate:\e[0m"
414
506
 
415
507
  @token_usage.each do |model, usage|
416
- pricing = Configuration.pricing_for(model)
508
+ pricing = Configuration.pricing_for(model, cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
417
509
  pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
418
510
  input_str = "in: #{format_tokens(usage[:input])}"
419
511
  output_str = "out: #{format_tokens(usage[:output])}"
420
512
 
421
- if pricing
422
- cost = (usage[:input] * pricing[:input]) + (usage[:output] * pricing[:output])
513
+ # A provider that reports real dollars (OpenRouter) beats any estimate.
514
+ reported_cost = usage[:cost]
515
+ if reported_cost && reported_cost > 0
516
+ has_reported_cost = true
517
+ total_cost += reported_cost
518
+ cache_str = ""
423
519
  cache_read = usage[:cache_read] || 0
424
520
  cache_write = usage[:cache_write] || 0
425
- if (cache_read > 0 || cache_write > 0) && pricing[:cache_read]
426
- # Subtract cached tokens from full-price input, add at cache rates
427
- cost -= cache_read * pricing[:input]
428
- cost += cache_read * pricing[:cache_read]
429
- cost += cache_write * (pricing[:cache_write] - pricing[:input])
430
- end
521
+ cache_str = " cache r: #{format_tokens(cache_read)} w: #{format_tokens(cache_write)}" if cache_read > 0 || cache_write > 0
522
+ $stdout.puts "\e[2m #{model}: #{input_str} #{output_str}#{cache_str} $#{'%.4f' % reported_cost}\e[0m"
523
+ elsif pricing
524
+ cost = Configuration.estimate_cost(model,
525
+ input: usage[:input], output: usage[:output],
526
+ cache_read: usage[:cache_read] || 0, cache_write: usage[:cache_write] || 0,
527
+ cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
431
528
  total_cost += cost
432
529
  cache_str = ""
530
+ cache_read = usage[:cache_read] || 0
531
+ cache_write = usage[:cache_write] || 0
433
532
  cache_str = " cache r: #{format_tokens(cache_read)} w: #{format_tokens(cache_write)}" if cache_read > 0 || cache_write > 0
434
533
  $stdout.puts "\e[2m #{model}: #{input_str} #{output_str}#{cache_str} ~$#{'%.2f' % cost}\e[0m"
435
534
  else
@@ -437,7 +536,8 @@ module RailsConsoleAi
437
536
  end
438
537
  end
439
538
 
440
- $stdout.puts "\e[36m Total: ~$#{'%.2f' % total_cost}\e[0m"
539
+ label = has_reported_cost ? "Total:" : "Total: ~"
540
+ $stdout.puts "\e[36m #{label}$#{'%.2f' % total_cost}\e[0m"
441
541
  end
442
542
 
443
543
  def display_conversation
@@ -466,15 +566,29 @@ module RailsConsoleAi
466
566
  conversation_messages(messages, **opts)
467
567
  end
468
568
 
569
+ # The system prompt must stay byte-identical for the life of a session: it
570
+ # renders ahead of the entire conversation, so any change to it invalidates
571
+ # the system cache AND every cached message after it. Everything here is
572
+ # fixed for the session — the binding's variable list, which changes whenever
573
+ # the console (or generated code) assigns a local, rides along with the user
574
+ # turn instead. See #user_turn.
469
575
  def context
470
576
  base = @context_base ||= context_builder.build
471
577
  parts = [base]
472
578
  parts << safety_context
473
579
  parts << @channel.system_instructions
474
- parts << binding_variable_summary
475
580
  parts.compact.join("\n\n")
476
581
  end
477
582
 
583
+ # Composes a user turn with the console binding's current variables appended.
584
+ # This belongs in `messages`, not in the system prompt: a message at turn 5
585
+ # invalidates nothing before turn 5, and because it is persisted into history
586
+ # rather than injected per-request, the prefix stays append-only.
587
+ def user_turn(text)
588
+ summary = binding_variable_summary
589
+ summary ? "#{text}\n\n#{summary}" : text
590
+ end
591
+
478
592
  AUTO_THINK_PATTERN = /\bthink\s+(harder|deeper|hard|carefully|more\s+carefully)\b/i
479
593
 
480
594
  def maybe_auto_upgrade_thinking(text)
@@ -576,13 +690,27 @@ module RailsConsoleAi
576
690
  end
577
691
  end
578
692
 
693
+ # Warn when the conversation is closing in on the model's context window — the
694
+ # one thing a long conversation still costs. It used to warn at 50K characters
695
+ # (~12K tokens) on the theory that a big conversation is an expensive one; that
696
+ # was true when every round re-sent the whole history at full input price, but
697
+ # the history is cached now and a warm 15K-token prefix is unremarkable. Warning
698
+ # there just nags, and the advice actively costs money: /compact rewrites the
699
+ # prefix, throwing away the cache, and spends a summarization call doing it.
700
+ #
701
+ # So this fires on headroom instead, and says what compacting costs.
579
702
  def warn_if_history_large
580
- chars = @history.sum { |m| m[:content].to_s.length }
703
+ return if @compact_warned
581
704
 
582
- if chars > 50_000 && !@compact_warned
583
- @compact_warned = true
584
- $stdout.puts "\e[33m Conversation is getting large (~#{format_tokens(chars)} chars). Consider running /compact to reduce context size.\e[0m"
585
- end
705
+ tokens = estimate_request_tokens(@history)
706
+ window = Configuration.context_window_for(effective_model)
707
+ return if tokens < window * CONTEXT_WARN_FRACTION
708
+
709
+ @compact_warned = true
710
+ pct = ((tokens.to_f / window) * 100).round
711
+ $stdout.puts "\e[33m Conversation is using ~#{format_tokens(tokens)} of the #{format_tokens(window)} " \
712
+ "context window (~#{pct}%). /compact will summarize it to free room — it also resets the " \
713
+ "prompt cache, so only run it when you need the headroom.\e[0m"
586
714
  end
587
715
 
588
716
  # --- Session logging ---
@@ -658,6 +786,19 @@ module RailsConsoleAi
658
786
 
659
787
  private
660
788
 
789
+ def record_sub_agent_usage(sub)
790
+ absorb_sub_agent_usage(input: sub.input_tokens, output: sub.output_tokens, model: sub.model_used, cost: sub.cost)
791
+ end
792
+
793
+ def absorb_sub_agent_usage(usage)
794
+ @total_input_tokens += usage[:input] || 0
795
+ @total_output_tokens += usage[:output] || 0
796
+ return unless usage[:model]
797
+ @token_usage[usage[:model]][:input] += usage[:input] || 0
798
+ @token_usage[usage[:model]][:output] += usage[:output] || 0
799
+ @token_usage[usage[:model]][:cost] = (@token_usage[usage[:model]][:cost] || 0) + usage[:cost] if usage[:cost]
800
+ end
801
+
661
802
  def safety_context
662
803
  guards = RailsConsoleAi.configuration.safety_guards
663
804
  return nil if guards.empty?
@@ -713,13 +854,15 @@ module RailsConsoleAi
713
854
 
714
855
  def provider
715
856
  @provider ||= begin
716
- if @model_override
857
+ p = if @model_override
717
858
  config = RailsConsoleAi.configuration.dup
718
859
  config.model = @model_override
719
860
  Providers.build(config)
720
861
  else
721
862
  Providers.build
722
863
  end
864
+ p.routing_session_id = @routing_session_id if p.respond_to?(:routing_session_id=)
865
+ p
723
866
  end
724
867
  end
725
868
 
@@ -814,11 +957,33 @@ module RailsConsoleAi
814
957
  max_rounds = RailsConsoleAi.configuration.max_tool_rounds
815
958
  total_input = 0
816
959
  total_output = 0
960
+ # Cache activity has to be summed across rounds and reported out with the
961
+ # rest of the usage: it is the only evidence that caching is working, and
962
+ # `input_tokens` alone can't show it (the API reports only the UNCACHED
963
+ # remainder there — total prompt size is input + cache_read + cache_write).
964
+ total_cache_read = 0
965
+ total_cache_write = 0
966
+ # Prompt volume actually sent this loop. `total_input` alone is NOT it: the
967
+ # API reports only the uncached remainder there, so once caching is working
968
+ # it stays near zero no matter how large the conversation grows. The token
969
+ # budget below has to be measured against the full prompt or it never fires.
970
+ total_prompt = -> { total_input + total_cache_read + total_cache_write }
817
971
  result = nil
818
972
  new_messages = []
819
973
  last_thinking = nil
820
974
  last_tool_names = []
821
975
 
976
+ # Steering messages go into BOTH the request and the persisted history.
977
+ # Injecting a message for one request and dropping it from history rewrites
978
+ # the prefix the next turn sends, so every cached block from that point on
979
+ # misses — and the model also loses the fact that it was already nudged.
980
+ add_nudge = lambda do |text|
981
+ msg = { role: :user, content: text }
982
+ messages << msg
983
+ new_messages << msg
984
+ msg
985
+ end
986
+
822
987
  exhausted = false
823
988
  wrap_up_reason = nil
824
989
  tool_call_counts = Hash.new(0)
@@ -855,7 +1020,7 @@ module RailsConsoleAi
855
1020
 
856
1021
  if round > 0
857
1022
  req_tokens = estimate_request_tokens(messages)
858
- @channel.display_status(" #{llm_status(round, messages, req_tokens, total_input, last_thinking, last_tool_names)}")
1023
+ @channel.display_status(" #{llm_status(round, messages, req_tokens, total_prompt.call, last_thinking, last_tool_names)}")
859
1024
  end
860
1025
 
861
1026
  if RailsConsoleAi.configuration.debug
@@ -871,6 +1036,8 @@ module RailsConsoleAi
871
1036
  end
872
1037
  total_input += result.input_tokens || 0
873
1038
  total_output += result.output_tokens || 0
1039
+ total_cache_read += result.cache_read_input_tokens || 0
1040
+ total_cache_write += result.cache_write_input_tokens || 0
874
1041
 
875
1042
  break if @channel.cancelled?
876
1043
 
@@ -933,13 +1100,7 @@ module RailsConsoleAi
933
1100
 
934
1101
  # Aggregate sub-agent token usage into parent's cost tracking
935
1102
  if tc[:name] == 'delegate_task' && tools.last_sub_agent_usage
936
- sa = tools.last_sub_agent_usage
937
- @total_input_tokens += sa[:input] || 0
938
- @total_output_tokens += sa[:output] || 0
939
- if sa[:model]
940
- @token_usage[sa[:model]][:input] += sa[:input] || 0
941
- @token_usage[sa[:model]][:output] += sa[:output] || 0
942
- end
1103
+ absorb_sub_agent_usage(tools.last_sub_agent_usage)
943
1104
  end
944
1105
 
945
1106
  if RailsConsoleAi.configuration.debug
@@ -979,7 +1140,7 @@ module RailsConsoleAi
979
1140
  wrap_up_reason ||= :tool_loop
980
1141
  elsif tool_call_counts[key] >= LOOP_WARN_THRESHOLD
981
1142
  @channel.display_status(" Warning: #{tc[:name]} called #{tool_call_counts[key]} times with same args — consider a different approach.")
982
- 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." }
1143
+ add_nudge.call("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.")
983
1144
  end
984
1145
  end
985
1146
 
@@ -994,20 +1155,21 @@ module RailsConsoleAi
994
1155
  elsif count >= REPEAT_ERROR_WARN_THRESHOLD && !warned_error_sigs.include?(sig)
995
1156
  warned_error_sigs << sig
996
1157
  @channel.display_status(" Warning: same error hit #{count} times — nudging model to change strategy.")
997
- messages << { role: :user, content: "You have now hit the same error #{count} times (#{sig}). Trying variations of the same approach is not producing new information. If this error cannot be resolved from this session, stop investigating it: summarize what you have established, state what you could not determine and why, and give the user your best answer." }
1158
+ add_nudge.call("You have now hit the same error #{count} times (#{sig}). Trying variations of the same approach is not producing new information. If this error cannot be resolved from this session, stop investigating it: summarize what you have established, state what you could not determine and why, and give the user your best answer.")
998
1159
  end
999
1160
  end
1000
1161
 
1001
1162
  # Circuit breaker: token budget for a single tool loop.
1002
1163
  config = RailsConsoleAi.configuration
1003
- if config.token_stop_threshold && total_input >= config.token_stop_threshold
1004
- @channel.display_status(" Token budget exceeded (#{format_tokens(total_input)} input tokens this request) — forcing wrap-up.")
1164
+ prompt_tokens = total_prompt.call
1165
+ if config.token_stop_threshold && prompt_tokens >= config.token_stop_threshold
1166
+ @channel.display_status(" Token budget exceeded (#{format_tokens(prompt_tokens)} prompt tokens this request) — forcing wrap-up.")
1005
1167
  exhausted = true
1006
1168
  wrap_up_reason ||= :token_budget
1007
- elsif config.token_nudge_threshold && total_input >= config.token_nudge_threshold && !token_nudge_sent
1169
+ elsif config.token_nudge_threshold && prompt_tokens >= config.token_nudge_threshold && !token_nudge_sent
1008
1170
  token_nudge_sent = true
1009
- @channel.display_status(" High token usage (#{format_tokens(total_input)} input tokens this request) — nudging model to wrap up.")
1010
- messages << { role: :user, content: "This investigation has consumed #{format_tokens(total_input)} input tokens without reaching a conclusion. Wrap up now: stop opening new lines of investigation, summarize what you have established, state what you could not determine and why, and give the user your best answer. Only make another tool call if you are confident a single call will resolve the question." }
1171
+ @channel.display_status(" High token usage (#{format_tokens(prompt_tokens)} prompt tokens this request) — nudging model to wrap up.")
1172
+ add_nudge.call("This investigation has consumed #{format_tokens(prompt_tokens)} prompt tokens without reaching a conclusion. Wrap up now: stop opening new lines of investigation, summarize what you have established, state what you could not determine and why, and give the user your best answer. Only make another tool call if you are confident a single call will resolve the question.")
1011
1173
  end
1012
1174
 
1013
1175
  break if exhausted
@@ -1037,10 +1199,16 @@ module RailsConsoleAi
1037
1199
  if wrap_up_reason.nil? || wrap_up_reason == :round_cap
1038
1200
  $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"
1039
1201
  end
1040
- messages << { role: :user, content: final_nudge }
1041
- result = provider.chat(messages, system_prompt: active_system_prompt)
1202
+ add_nudge.call(final_nudge)
1203
+ # Must be chat_with_tools, not chat: the transcript contains
1204
+ # tool_use/tool_result blocks, and Bedrock/Anthropic reject those unless
1205
+ # the request also defines tools. Any tool calls in the response are
1206
+ # ignored — only the text is used.
1207
+ result = provider.chat_with_tools(messages, tools: tools, system_prompt: active_system_prompt)
1042
1208
  total_input += result.input_tokens || 0
1043
1209
  total_output += result.output_tokens || 0
1210
+ total_cache_read += result.cache_read_input_tokens || 0
1211
+ total_cache_write += result.cache_write_input_tokens || 0
1044
1212
  end
1045
1213
 
1046
1214
  last_llm_stats = result ? format_llm_stats(result) : nil
@@ -1048,6 +1216,8 @@ module RailsConsoleAi
1048
1216
  text: result ? result.text : '',
1049
1217
  input_tokens: total_input,
1050
1218
  output_tokens: total_output,
1219
+ cache_read_input_tokens: total_cache_read,
1220
+ cache_write_input_tokens: total_cache_write,
1051
1221
  stop_reason: result ? result.stop_reason : :end_turn
1052
1222
  )
1053
1223
  [final_result, new_messages, last_llm_stats]
@@ -1056,12 +1226,15 @@ module RailsConsoleAi
1056
1226
  def track_usage(result)
1057
1227
  @total_input_tokens += result.input_tokens || 0
1058
1228
  @total_output_tokens += result.output_tokens || 0
1229
+ @total_cache_read_tokens += result.cache_read_input_tokens || 0
1230
+ @total_cache_write_tokens += result.cache_write_input_tokens || 0
1059
1231
 
1060
1232
  model = effective_model
1061
1233
  @token_usage[model][:input] += result.input_tokens || 0
1062
1234
  @token_usage[model][:output] += result.output_tokens || 0
1063
1235
  @token_usage[model][:cache_read] = (@token_usage[model][:cache_read] || 0) + (result.cache_read_input_tokens || 0)
1064
1236
  @token_usage[model][:cache_write] = (@token_usage[model][:cache_write] || 0) + (result.cache_write_input_tokens || 0)
1237
+ @token_usage[model][:cost] = (@token_usage[model][:cost] || 0) + (result.cost || 0) if result.cost
1065
1238
  end
1066
1239
 
1067
1240
  def display_usage(result, show_session: false)
@@ -1104,6 +1277,8 @@ module RailsConsoleAi
1104
1277
  merged = attrs.merge(
1105
1278
  input_tokens: @total_input_tokens,
1106
1279
  output_tokens: @total_output_tokens,
1280
+ cache_read_tokens: @total_cache_read_tokens,
1281
+ cache_write_tokens: @total_cache_write_tokens,
1107
1282
  duration_ms: duration_ms,
1108
1283
  model: effective_model
1109
1284
  )
@@ -1473,17 +1648,17 @@ module RailsConsoleAi
1473
1648
  cache_r = result.cache_read_input_tokens || 0
1474
1649
  cache_w = result.cache_write_input_tokens || 0
1475
1650
  parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
1476
- model = effective_model
1477
- pricing = Configuration.pricing_for(model)
1478
- if pricing
1479
- cost = ((result.input_tokens || 0) * pricing[:input]) + ((result.output_tokens || 0) * pricing[:output])
1480
- if (cache_r > 0 || cache_w > 0) && pricing[:cache_read]
1481
- cost -= cache_r * pricing[:input]
1482
- cost += cache_r * pricing[:cache_read]
1483
- cost += cache_w * (pricing[:cache_write] - pricing[:input])
1484
- end
1485
- parts << "~$#{'%.4f' % cost}"
1651
+
1652
+ if result.cost
1653
+ parts << "$#{'%.4f' % result.cost}"
1654
+ else
1655
+ cost = Configuration.estimate_cost(effective_model,
1656
+ input: result.input_tokens || 0, output: result.output_tokens || 0,
1657
+ cache_read: cache_r, cache_write: cache_w,
1658
+ cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
1659
+ parts << "~$#{'%.4f' % cost}" if cost
1486
1660
  end
1661
+
1487
1662
  parts.join(' | ')
1488
1663
  end
1489
1664
 
@@ -1507,7 +1682,7 @@ module RailsConsoleAi
1507
1682
  input_t = result.input_tokens || 0
1508
1683
  output_t = result.output_tokens || 0
1509
1684
  model = effective_model
1510
- pricing = Configuration.pricing_for(model)
1685
+ pricing = Configuration.pricing_for(model, cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
1511
1686
  pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
1512
1687
 
1513
1688
  cache_r = result.cache_read_input_tokens || 0
@@ -1515,15 +1690,18 @@ module RailsConsoleAi
1515
1690
  parts = ["in: #{format_tokens(input_t)}", "out: #{format_tokens(output_t)}"]
1516
1691
  parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
1517
1692
 
1518
- if pricing
1519
- cost = (input_t * pricing[:input]) + (output_t * pricing[:output])
1520
- if (cache_r > 0 || cache_w > 0) && pricing[:cache_read]
1521
- cost -= cache_r * pricing[:input]
1522
- cost += cache_r * pricing[:cache_read]
1523
- cost += cache_w * (pricing[:cache_write] - pricing[:input])
1524
- end
1525
- session_cost = (total_input * pricing[:input]) + (total_output * pricing[:output])
1526
- parts << "~$#{'%.4f' % cost}"
1693
+ ttl = RailsConsoleAi.configuration.resolved_cache_ttl
1694
+ if result.cost
1695
+ parts << "$#{'%.4f' % result.cost}"
1696
+ session_has_reported = @token_usage.any? { |_, u| u[:cost] && u[:cost] > 0 }
1697
+ session_cost = session_has_reported ? @token_usage.sum { |_, u| u[:cost] || 0 } :
1698
+ Configuration.estimate_cost(model, input: total_input, output: total_output, cache_ttl: ttl) || 0
1699
+ $stderr.puts "\n#{d}[debug] ← response: #{parts.join(' | ')} (session: $#{'%.4f' % session_cost})#{r}"
1700
+ elsif pricing
1701
+ cost = Configuration.estimate_cost(model, input: input_t, output: output_t,
1702
+ cache_read: cache_r, cache_write: cache_w, cache_ttl: ttl)
1703
+ parts << "~$#{'%.4f' % cost}" if cost
1704
+ session_cost = Configuration.estimate_cost(model, input: total_input, output: total_output, cache_ttl: ttl) || 0
1527
1705
  $stderr.puts "\n#{d}[debug] ← response: #{parts.join(' | ')} (session: ~$#{'%.4f' % session_cost})#{r}"
1528
1706
  else
1529
1707
  $stderr.puts "\n#{d}[debug] ← response: #{parts.join(' | ')}#{r}"