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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +56 -2
- data/app/helpers/rails_console_ai/sessions_helper.rb +11 -4
- data/app/views/rails_console_ai/sessions/index.html.erb +1 -1
- data/app/views/rails_console_ai/sessions/show.html.erb +6 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +12 -3
- data/lib/rails_console_ai/channel/console.rb +120 -15
- data/lib/rails_console_ai/configuration.rb +97 -19
- data/lib/rails_console_ai/conversation_engine.rb +241 -63
- data/lib/rails_console_ai/line_editor.rb +142 -0
- data/lib/rails_console_ai/providers/anthropic.rb +53 -9
- data/lib/rails_console_ai/providers/base.rb +74 -4
- data/lib/rails_console_ai/providers/bedrock.rb +47 -3
- data/lib/rails_console_ai/providers/local.rb +16 -36
- data/lib/rails_console_ai/providers/openai.rb +30 -9
- data/lib/rails_console_ai/providers/openrouter.rb +100 -0
- data/lib/rails_console_ai/session_logger.rb +4 -0
- data/lib/rails_console_ai/slack_bot.rb +19 -10
- data/lib/rails_console_ai/slash_commands.rb +149 -0
- data/lib/rails_console_ai/sub_agent.rb +15 -5
- data/lib/rails_console_ai/tools/registry.rb +2 -2
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +15 -0
- metadata +4 -1
|
@@ -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
|
-
|
|
422
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
703
|
+
return if @compact_warned
|
|
581
704
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
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 &&
|
|
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(
|
|
1010
|
-
|
|
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
|
-
|
|
1041
|
-
|
|
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
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
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}"
|