rails_console_ai 0.36.0 → 0.38.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.
@@ -36,6 +36,12 @@
36
36
  <span><%= @session.slack_channel_name %></span>
37
37
  </div>
38
38
  <% end %>
39
+ <% if @session.try(:slack_thread_ts).present? %>
40
+ <div class="meta-item">
41
+ <label>Slack Thread</label>
42
+ <span class="mono"><%= @session.slack_thread_ts %></span>
43
+ </div>
44
+ <% end %>
39
45
  <div class="meta-item">
40
46
  <label>Provider / Model</label>
41
47
  <span><%= @session.provider %> / <%= @session.model %></span>
data/config/routes.rb CHANGED
@@ -2,6 +2,14 @@ RailsConsoleAi::Engine.routes.draw do
2
2
  root to: 'sessions#index'
3
3
  resources :sessions, only: [:index, :show]
4
4
 
5
+ # Addressed by token, not id — see ArtifactsController#load_artifact.
6
+ resources :artifacts, only: [:index, :show, :destroy] do
7
+ member do
8
+ get :raw
9
+ get :download
10
+ end
11
+ end
12
+
5
13
  resources :skills do
6
14
  member do
7
15
  post :approve
@@ -53,6 +53,11 @@ RailsConsoleAi.configure do |config|
53
53
  # Run RailsConsoleAi.setup! in the Rails console to create the table
54
54
  config.session_logging = true
55
55
 
56
+ # Public base URL of this app, so links the assistant hands out (artifact URLs)
57
+ # are absolute — a bare path is useless once pasted into Slack or opened on a
58
+ # phone. Falls back to the app's default_url_options, then to a relative path.
59
+ # config.base_url = 'https://secure.example.com'
60
+
56
61
  # Database connection for RailsConsoleAi tables (default: ActiveRecord::Base)
57
62
  # Set to a class that responds to .connection if tables live on a different DB
58
63
  # config.connection_class = Sharding::CentralizedModel
@@ -321,6 +321,8 @@ module RailsConsoleAi
321
321
  @engine.upgrade_to_thinking_model
322
322
  when '/unthink'
323
323
  @engine.downgrade_from_thinking_model
324
+ when '/cheap'
325
+ @engine.switch_to_cheap_model
324
326
  when /\A\/expand/
325
327
  expand_id = input.sub('/expand', '').strip.to_i
326
328
  full_output = expand_output(expand_id)
@@ -425,13 +427,14 @@ module RailsConsoleAi
425
427
  def display_model_info
426
428
  config = RailsConsoleAi.configuration
427
429
  model = @engine.effective_model
428
- thinking = config.resolved_thinking_model
429
430
  pricing = Configuration.pricing_for(model)
430
431
 
431
432
  @real_stdout.puts "\e[36m Model info:\e[0m"
432
433
  @real_stdout.puts "\e[2m Provider: #{config.provider}\e[0m"
433
- @real_stdout.puts "\e[2m Model: #{model}\e[0m"
434
- @real_stdout.puts "\e[2m Thinking model: #{thinking}\e[0m"
434
+ @real_stdout.puts "\e[2m Model: #{@engine.effective_model_label} [#{@engine.model_tier}]\e[0m"
435
+ @real_stdout.puts "\e[2m Default model: #{model_with_effort(config.resolved_model, config.effort)}\e[0m"
436
+ @real_stdout.puts "\e[2m Thinking model: #{model_with_effort(config.resolved_thinking_model, config.thinking_effort)}\e[0m"
437
+ @real_stdout.puts "\e[2m Cheap model: #{config.resolved_cheap_model}\e[0m"
435
438
  @real_stdout.puts "\e[2m Max tokens: #{config.resolved_max_tokens}\e[0m"
436
439
  if pricing
437
440
  @real_stdout.puts "\e[2m Pricing: $#{pricing[:input] * 1_000_000}/M in, $#{pricing[:output] * 1_000_000}/M out\e[0m"
@@ -444,6 +447,11 @@ module RailsConsoleAi
444
447
  @real_stdout.puts "\e[2m OpenRouter URL: #{config.openrouter_url}\e[0m" if config.openrouter_url
445
448
  end
446
449
 
450
+ def model_with_effort(model, effort)
451
+ effort = Configuration.effort_for(model, effort)
452
+ effort ? "#{model} (#{effort} effort)" : model
453
+ end
454
+
447
455
  def handle_name_command(input)
448
456
  name = input.sub('/name', '').strip.gsub(/\A(['"])(.*)\1\z/, '\2')
449
457
  if name.empty?
@@ -471,6 +479,7 @@ module RailsConsoleAi
471
479
  @real_stdout.puts "\e[2m /model Show provider, model, and pricing info\e[0m"
472
480
  @real_stdout.puts "\e[2m /think Switch to thinking model\e[0m"
473
481
  @real_stdout.puts "\e[2m /unthink Switch back to default model\e[0m"
482
+ @real_stdout.puts "\e[2m /cheap Switch to the cheaper, faster model\e[0m"
474
483
  @real_stdout.puts "\e[2m /compact Summarize conversation to reduce context\e[0m"
475
484
  @real_stdout.puts "\e[2m /usage Show session token totals\e[0m"
476
485
  @real_stdout.puts "\e[2m /cost Show cost estimate by model\e[0m"
@@ -71,7 +71,7 @@ module RailsConsoleAi
71
71
  stripped = strip_ansi(text).strip
72
72
  return if stripped.empty?
73
73
 
74
- if stripped =~ /\AThinking\.\.\.|\AAttempting to fix|\ACancelled|\A_session:/
74
+ if stripped =~ /\AThinking\.\.\.|\AAttempting to fix|\ACancelled|\A_Session\b/
75
75
  post(stripped)
76
76
  else
77
77
  @output_log.write("#{stripped}\n")
@@ -8,19 +8,23 @@ module RailsConsoleAi
8
8
  # (us.anthropic.claude-sonnet-5, global.anthropic.claude-opus-4-6-v1).
9
9
  #
10
10
  # input/output are $ per MTok (converted to per-token in .pricing_for).
11
- # Cache pricing is derived: read = 0.1x input, write = 1.25x input.
11
+ # Cache pricing is derived: read = 0.1x input, write = 1.25x input, unless a
12
+ # family sets cache_read ($ per MTok) explicitly (opus-5-5 reads at 0.05x).
12
13
  # temperature: false marks families that reject the `temperature` parameter
13
- # (removed on opus-4-7+, sonnet-5, and fable-5).
14
+ # (removed on opus-4-7+, sonnet-5+, and fable-5).
15
+ # effort: false marks families that reject `output_config.effort` (haiku-4-5).
14
16
  # max_tokens is the OUTPUT cap we request; context is the total window.
15
17
  MODEL_FAMILIES = {
16
18
  'claude-fable-5' => { input: 10.0, output: 50.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
19
+ 'claude-opus-5-5' => { input: 4.0, output: 20.0, cache_read: 0.20, max_tokens: 16_000, context: 1_000_000, temperature: false },
17
20
  'claude-opus-5' => { input: 5.0, output: 25.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
18
21
  'claude-opus-4-8' => { input: 5.0, output: 25.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
19
22
  'claude-opus-4-7' => { input: 5.0, output: 25.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
20
23
  'claude-opus-4-6' => { input: 5.0, output: 25.0, max_tokens: 16_000, context: 1_000_000, temperature: true },
24
+ 'claude-sonnet-5-5' => { input: 2.0, output: 10.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
21
25
  'claude-sonnet-5' => { input: 2.0, output: 10.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
22
26
  'claude-sonnet-4-6' => { input: 3.0, output: 15.0, max_tokens: 16_000, context: 1_000_000, temperature: true },
23
- 'claude-haiku-4-5' => { input: 1.0, output: 5.0, max_tokens: 16_000, context: 200_000, temperature: true },
27
+ 'claude-haiku-4-5' => { input: 1.0, output: 5.0, max_tokens: 16_000, context: 200_000, temperature: true, effort: false },
24
28
  }.freeze
25
29
 
26
30
  # Assumed context window for models with no family entry — local models and
@@ -28,9 +32,10 @@ module RailsConsoleAi
28
32
  # little early, over-guessing means no warning before the request is rejected.
29
33
  DEFAULT_CONTEXT_WINDOW = 200_000
30
34
 
35
+ EFFORT_LEVELS = %w[low medium high xhigh max].freeze
36
+
31
37
  # Family keys sorted longest-first so a more specific family always wins
32
- # if keys ever overlap (e.g. a future 'claude-sonnet-5-5' entry would match
33
- # before 'claude-sonnet-5').
38
+ # when keys overlap (e.g. 'claude-sonnet-5-5' matches before 'claude-sonnet-5').
34
39
  MODEL_FAMILY_KEYS = MODEL_FAMILIES.keys.sort_by { |k| -k.length }.freeze
35
40
 
36
41
  # Returns the family attributes for a model ID, or nil for unknown models.
@@ -41,6 +46,14 @@ module RailsConsoleAi
41
46
  key && MODEL_FAMILIES[key]
42
47
  end
43
48
 
49
+ # The effort a model would be sent, or nil when it takes none: unknown models
50
+ # (OpenAI, local) and families marked effort: false.
51
+ def self.effort_for(model_id, effort)
52
+ return nil if effort.nil? || effort.to_s.empty?
53
+ family = model_family(model_id)
54
+ family && family[:effort] != false ? effort.to_s : nil
55
+ end
56
+
44
57
  # Per-token pricing for a model ID, matched by family. Returns
45
58
  # { input:, output:, cache_read:, cache_write: } or nil for unknown models.
46
59
  # Cache reads bill at 0.1x the base input rate; cache writes at 1.25x for the
@@ -52,7 +65,7 @@ module RailsConsoleAi
52
65
  {
53
66
  input: input,
54
67
  output: family[:output] / 1_000_000,
55
- cache_read: input * 0.1,
68
+ cache_read: family[:cache_read] ? family[:cache_read] / 1_000_000 : input * 0.1,
56
69
  cache_write: input * (cache_ttl.to_s == '1h' ? 2.0 : 1.25),
57
70
  }
58
71
  end
@@ -98,7 +111,8 @@ module RailsConsoleAi
98
111
  }
99
112
  ].freeze
100
113
 
101
- attr_accessor :provider, :api_key, :model, :thinking_model, :max_tokens,
114
+ attr_accessor :provider, :api_key, :model, :thinking_model, :cheap_model, :max_tokens,
115
+ :effort, :thinking_effort,
102
116
  :auto_execute, :temperature,
103
117
  :timeout, :open_timeout, :max_retries, :debug, :max_tool_rounds,
104
118
  :error_hints,
@@ -118,13 +132,21 @@ module RailsConsoleAi
118
132
  :user_extra_info,
119
133
  :sub_agent_max_rounds,
120
134
  :sub_agent_model,
121
- :line_editor
135
+ :line_editor,
136
+ :base_url,
137
+ :artifact_date_prefix
122
138
 
123
139
  def initialize
124
140
  @provider = :anthropic
125
141
  @api_key = nil
126
142
  @model = nil
127
143
  @thinking_model = nil
144
+ @cheap_model = nil
145
+ # The default and thinking tiers run the same model by default and differ
146
+ # only in how hard it thinks. Sent as output_config.effort, and only to
147
+ # model families that accept it. The cheap tier sends no effort.
148
+ @effort = 'medium'
149
+ @thinking_effort = 'high'
128
150
  @max_tokens = nil
129
151
  @auto_execute = false
130
152
  @temperature = 0.2
@@ -179,6 +201,15 @@ module RailsConsoleAi
179
201
  # Interactive line editor: :auto prefers Reline (live "/" completion menu)
180
202
  # and falls back to Readline. Force one with :reline or :readline.
181
203
  @line_editor = :auto
204
+ # Public base URL of the host app, e.g. "https://secure.apptoto.com". Used
205
+ # to build links the gem hands out — an artifact URL is read in a console,
206
+ # pasted into Slack or opened on a phone, where a bare path is useless.
207
+ # Falls back to the app's default_url_options, then to a relative path.
208
+ @base_url = nil
209
+ # Stamp new artifact names with the day they were made ("20260911_Churn
210
+ # report"), so a list of them reads chronologically. Updates keep the name
211
+ # and date they were created with.
212
+ @artifact_date_prefix = true
182
213
  end
183
214
 
184
215
  def resolve_user_extra_info(username)
@@ -277,6 +308,25 @@ module RailsConsoleAi
277
308
  def resolved_model
278
309
  return @model if @model && !@model.empty?
279
310
 
311
+ case @provider
312
+ when :anthropic
313
+ 'claude-opus-5-5'
314
+ when :openai
315
+ 'gpt-5.3-codex'
316
+ when :openrouter
317
+ 'anthropic/claude-opus-5.5'
318
+ when :local
319
+ @local_model
320
+ when :bedrock
321
+ 'us.anthropic.claude-opus-5-5'
322
+ end
323
+ end
324
+
325
+ # The model /cheap switches to: faster and cheaper than the default, for
326
+ # routine lookups where the default's depth isn't needed.
327
+ def resolved_cheap_model
328
+ return @cheap_model if @cheap_model && !@cheap_model.empty?
329
+
280
330
  case @provider
281
331
  when :anthropic
282
332
  'claude-sonnet-5'
@@ -301,13 +351,18 @@ module RailsConsoleAi
301
351
  end
302
352
 
303
353
  # Returns nil for model families that reject the `temperature` parameter
304
- # (opus-4-7+, sonnet-5, fable-5) so providers omit the field from the request.
354
+ # (opus-4-7+, sonnet-5+, fable-5) so providers omit the field from the request.
305
355
  def resolved_temperature
306
356
  family = self.class.model_family(resolved_model)
307
357
  return nil if family && family[:temperature] == false
308
358
  @temperature
309
359
  end
310
360
 
361
+ # Returns the effort to send for the configured model, or nil to omit it.
362
+ def resolved_effort
363
+ self.class.effort_for(resolved_model, @effort)
364
+ end
365
+
311
366
  # Returns '1h' when the 1-hour prompt cache is requested, else nil (the
312
367
  # 5-minute default). Providers that offer only one cache duration ignore it.
313
368
  def resolved_cache_ttl
@@ -320,15 +375,15 @@ module RailsConsoleAi
320
375
 
321
376
  case @provider
322
377
  when :anthropic
323
- 'claude-opus-5'
378
+ 'claude-opus-5-5'
324
379
  when :openai
325
380
  'gpt-5.3-codex'
326
381
  when :openrouter
327
- 'anthropic/claude-opus-5'
382
+ 'anthropic/claude-opus-5.5'
328
383
  when :local
329
384
  @local_model
330
385
  when :bedrock
331
- 'us.anthropic.claude-opus-5'
386
+ 'us.anthropic.claude-opus-5-5'
332
387
  end
333
388
  end
334
389
 
@@ -347,6 +402,11 @@ module RailsConsoleAi
347
402
  raise ConfigurationError, "Unknown provider: #{@provider}. Valid: #{PROVIDERS.join(', ')}"
348
403
  end
349
404
 
405
+ { effort: @effort, thinking_effort: @thinking_effort }.each do |name, value|
406
+ next if value.nil? || EFFORT_LEVELS.include?(value.to_s)
407
+ raise ConfigurationError, "Invalid #{name}: #{value.inspect}. Valid: #{EFFORT_LEVELS.join(', ')}"
408
+ end
409
+
350
410
  if @provider == :local
351
411
  raise ConfigurationError, "No local_url configured for :local provider." unless @local_url && !@local_url.empty?
352
412
  elsif @provider == :bedrock
@@ -48,6 +48,26 @@ module RailsConsoleAi
48
48
  lines.join("\n")
49
49
  end
50
50
 
51
+ # The index of memory names the model can recall, with the instruction to
52
+ # look before answering. Public because SubAgent builds its own prompt and
53
+ # shares this section, so a sub-agent knows the same memories exist.
54
+ def memory_context
55
+ return nil unless @config.memories_enabled
56
+
57
+ require 'rails_console_ai/tools/memory_tools'
58
+ summaries = Tools::MemoryTools.new.memory_summaries
59
+ return nil if summaries.nil? || summaries.empty?
60
+
61
+ lines = ["## Memories"]
62
+ lines.concat(summaries)
63
+ lines << ""
64
+ lines << "Call recall_memory (by name) or recall_memories (by keyword) to get details before answering. Do NOT guess from the name alone."
65
+ lines.join("\n")
66
+ rescue => e
67
+ RailsConsoleAi.logger.debug("RailsConsoleAi: memory context failed: #{e.message}")
68
+ nil
69
+ end
70
+
51
71
  private
52
72
 
53
73
  def smart_system_instructions
@@ -102,6 +122,34 @@ module RailsConsoleAi
102
122
  - This keeps your conversation context small and efficient.
103
123
  - If a custom agent is available for the task (see Agents section), specify it by name.
104
124
 
125
+ You have artifact tools for output that is a document rather than an answer:
126
+ - When the user asks for a report, summary, export, table or page, build it with
127
+ save_artifact instead of printing it into the console. Reply with the URL it returns,
128
+ and tell them the handle so they can ask for it again later.
129
+ - New artifacts are named with today's date: "Q3 churn summary" is stored as
130
+ "20260911_Q3 churn summary" and addressed as 20260911-q3-churn-summary. Do not add the
131
+ date yourself — pass the plain title and it is stamped for you.
132
+ - Saving under a name that already exists replaces that artifact and bumps its revision.
133
+ To revise one from an earlier day, pass its exact handle as the name; passing the plain
134
+ title again makes a fresh artifact under today's date.
135
+ - When the user refers to an earlier artifact ("update the churn report", "add last month
136
+ to that table"), use list_artifacts to find its handle, read_artifact to get what is
137
+ already there, then save_artifact under the SAME name to replace it. Do not start a new
138
+ one unless they want a new one.
139
+ - delete_artifact removes one, only when asked.
140
+ - When the content already exists as a value in the console — a file you read from S3,
141
+ a string you built in code — do NOT retype it into save_artifact. Your response has an
142
+ output-token limit, and a large file will be cut off mid-argument and the call thrown
143
+ away. Save it from Ruby instead, inside execute_code:
144
+ html = S3.read_file("report.html")
145
+ RailsConsoleAi.save_artifact(name: "Mathnasium migration", content: html)
146
+ That is also the only way to store something verbatim: anything you retype is your
147
+ recollection of the bytes, not the bytes. Use save_artifact for content you are
148
+ composing yourself.
149
+ - HTML artifacts render in a sandboxed frame: inline CSS works, but scripts never run and
150
+ nothing loads over the network. Inline every style and embed the data directly.
151
+ - Use text/csv or application/json when the user wants data to take elsewhere.
152
+
105
153
  RULES:
106
154
  - Give ONE concise answer. Do not offer multiple alternatives or variations.
107
155
  - For multi-step tasks, use execute_plan to break the work into small, clear steps.
@@ -195,23 +243,5 @@ module RailsConsoleAi
195
243
  RailsConsoleAi.logger.debug("RailsConsoleAi: pinned memory context failed: #{e.message}")
196
244
  nil
197
245
  end
198
-
199
- def memory_context
200
- return nil unless @config.memories_enabled
201
-
202
- require 'rails_console_ai/tools/memory_tools'
203
- summaries = Tools::MemoryTools.new.memory_summaries
204
- return nil if summaries.nil? || summaries.empty?
205
-
206
- lines = ["## Memories"]
207
- lines.concat(summaries)
208
- lines << ""
209
- lines << "Call recall_memory (by name) or recall_memories (by keyword) to get details before answering. Do NOT guess from the name alone."
210
- lines.join("\n")
211
- rescue => e
212
- RailsConsoleAi.logger.debug("RailsConsoleAi: memory context failed: #{e.message}")
213
- nil
214
- end
215
-
216
246
  end
217
247
  end
@@ -13,6 +13,7 @@ module RailsConsoleAi
13
13
  REPEAT_ERROR_WARN_THRESHOLD = 3 # same error signature (any args) → inject warning
14
14
  REPEAT_ERROR_BREAK_THRESHOLD = 5 # same error signature (any args) → force wrap-up
15
15
  CONTEXT_WARN_FRACTION = 0.7 # share of the model's context window → suggest /compact
16
+ MAX_TRUNCATION_RETRIES = 1 # responses cut off at the output cap → nudge, then stop
16
17
 
17
18
  def initialize(binding_context:, channel:, slack_thread_ts: nil, slack_channel_name: nil)
18
19
  @binding_context = binding_context
@@ -131,6 +132,16 @@ module RailsConsoleAi
131
132
  end
132
133
  end
133
134
 
135
+ # Creates the session row before the first turn runs, so its id can be
136
+ # handed out (the Slack bot links to it) while that turn is still going.
137
+ # Returns nil when session logging is off.
138
+ def ensure_session_record(query)
139
+ init_interactive unless @interactive_start
140
+ @interactive_query ||= query
141
+ log_interactive_turn(status: 'running') unless @interactive_session_id
142
+ @interactive_session_id
143
+ end
144
+
134
145
  def init_guide
135
146
  storage = RailsConsoleAi.storage
136
147
  existing_guide = begin
@@ -596,38 +607,56 @@ module RailsConsoleAi
596
607
  upgrade_to_thinking_model
597
608
  end
598
609
 
599
- def upgrade_to_thinking_model
600
- config = RailsConsoleAi.configuration
601
- current = effective_model
602
- thinking = config.resolved_thinking_model
610
+ # Three tiers a session can run on. :default and :thinking usually share a
611
+ # model and differ in effort, so a switch is tracked by tier, not by model ID.
612
+ MODEL_TIERS = %i[default thinking cheap].freeze
603
613
 
604
- if current == thinking
605
- $stdout.puts "\e[36m Already using thinking model (#{current}).\e[0m"
606
- else
607
- @model_override = thinking
608
- @provider = nil
609
- $stdout.puts "\e[36m Switched to thinking model: #{thinking}\e[0m"
610
- end
611
- effective_model
614
+ def upgrade_to_thinking_model
615
+ switch_model_tier(:thinking)
612
616
  end
613
617
 
614
618
  def downgrade_from_thinking_model
615
- config = RailsConsoleAi.configuration
616
- default = config.resolved_model
617
- current = effective_model
619
+ switch_model_tier(:default)
620
+ end
621
+
622
+ def switch_to_cheap_model
623
+ switch_model_tier(:cheap)
624
+ end
625
+
626
+ def switch_model_tier(tier)
627
+ raise ArgumentError, "Unknown model tier: #{tier.inspect}" unless MODEL_TIERS.include?(tier)
618
628
 
619
- if current == default && @model_override.nil?
620
- $stdout.puts "\e[36m Already using default model (#{current}).\e[0m"
629
+ if tier == model_tier
630
+ $stdout.puts "\e[36m Already using #{tier} model (#{effective_model_label}).\e[0m"
621
631
  else
622
- @model_override = nil
632
+ @model_tier = tier
623
633
  @provider = nil
624
- $stdout.puts "\e[36m Switched back to default model: #{default}\e[0m"
634
+ $stdout.puts "\e[36m Switched to #{tier} model: #{effective_model_label}\e[0m"
625
635
  end
626
636
  effective_model
627
637
  end
628
638
 
639
+ def model_tier
640
+ @model_tier || :default
641
+ end
642
+
629
643
  def effective_model
630
- @model_override || RailsConsoleAi.configuration.resolved_model
644
+ config = RailsConsoleAi.configuration
645
+ case model_tier
646
+ when :thinking then config.resolved_thinking_model
647
+ when :cheap then config.resolved_cheap_model
648
+ else config.resolved_model
649
+ end
650
+ end
651
+
652
+ def effective_effort
653
+ tier_config.resolved_effort
654
+ end
655
+
656
+ # "claude-opus-5-5 (high effort)", or the bare model when no effort is sent.
657
+ def effective_model_label
658
+ effort = effective_effort
659
+ effort ? "#{effective_model} (#{effort} effort)" : effective_model
631
660
  end
632
661
 
633
662
  def compact_history
@@ -744,6 +773,9 @@ module RailsConsoleAi
744
773
  log_attrs[:user_name] = @channel.mode == 'slack' ? "slack:#{@channel.user_identity}" : @channel.user_identity
745
774
  end
746
775
  @interactive_session_id = SessionLogger.log(log_attrs)
776
+ # Tools that persist something (save_artifact) read this to point back at
777
+ # the conversation that produced it, without needing a handle on the engine.
778
+ RailsConsoleAi.current_session_id = @interactive_session_id
747
779
  end
748
780
  end
749
781
 
@@ -854,18 +886,24 @@ module RailsConsoleAi
854
886
 
855
887
  def provider
856
888
  @provider ||= begin
857
- p = if @model_override
858
- config = RailsConsoleAi.configuration.dup
859
- config.model = @model_override
860
- Providers.build(config)
861
- else
862
- Providers.build
863
- end
889
+ p = model_tier == :default ? Providers.build : Providers.build(tier_config)
864
890
  p.routing_session_id = @routing_session_id if p.respond_to?(:routing_session_id=)
865
891
  p
866
892
  end
867
893
  end
868
894
 
895
+ # The configuration for the current tier. Off the default tier it is a copy,
896
+ # so a switch never leaks into the global singleton or other sessions.
897
+ def tier_config
898
+ config = RailsConsoleAi.configuration
899
+ return config if model_tier == :default
900
+
901
+ config = config.dup
902
+ config.model = effective_model
903
+ config.effort = model_tier == :thinking ? config.thinking_effort : nil
904
+ config
905
+ end
906
+
869
907
  def context_builder
870
908
  @context_builder ||= ContextBuilder.new(channel_mode: @channel.mode, user_name: @channel.user_identity)
871
909
  end
@@ -986,6 +1024,7 @@ module RailsConsoleAi
986
1024
 
987
1025
  exhausted = false
988
1026
  wrap_up_reason = nil
1027
+ truncation_nudges = 0
989
1028
  tool_call_counts = Hash.new(0)
990
1029
  error_sig_counts = Hash.new(0)
991
1030
  warned_error_sigs = Set.new
@@ -1045,6 +1084,34 @@ module RailsConsoleAi
1045
1084
  debug_post_call(round, result, @total_input_tokens + total_input, @total_output_tokens + total_output)
1046
1085
  end
1047
1086
 
1087
+ # A response cut off at the output cap has nothing usable in it: the tool
1088
+ # call it was in the middle of writing arrives with half its arguments, so
1089
+ # it is discarded, and only the sentence the model wrote before starting
1090
+ # the call survives. Left unhandled that looks exactly like a finished
1091
+ # turn, and the model retries the same oversized call every time it is
1092
+ # asked again — the identical truncation, forever, at full output cost.
1093
+ # Say so, once, with the way out; the second one ends the turn.
1094
+ if result.truncated?
1095
+ cap = RailsConsoleAi.configuration.resolved_max_tokens
1096
+ @channel.display_warning(" Response hit the #{format_tokens(cap)} output-token cap mid-generation — the incomplete tool call was discarded.")
1097
+ if truncation_nudges >= MAX_TRUNCATION_RETRIES
1098
+ @channel.display_error(" Truncated again. Stopping — see the guidance above, or raise c.max_tokens.")
1099
+ break
1100
+ end
1101
+
1102
+ truncation_nudges += 1
1103
+ add_nudge.call(
1104
+ "Your last response was cut off at the #{cap}-token output limit, so the tool call you were " \
1105
+ "writing was discarded and never ran. Retrying it unchanged will be cut off at exactly the same " \
1106
+ "point. You must make the next response SMALLER. If you were passing a large block of content as " \
1107
+ "a tool argument — the content of an artifact, a file, a long report — do not inline it: run " \
1108
+ "execute_code and have the Ruby build the string and save it directly, e.g. " \
1109
+ "`RailsConsoleAi.save_artifact(name: \"...\", content: some_string)`, which never sends those " \
1110
+ "bytes through your response at all. Otherwise, split the work across several smaller tool calls."
1111
+ )
1112
+ next
1113
+ end
1114
+
1048
1115
  break unless result.tool_use?
1049
1116
 
1050
1117
  last_thinking = (result.text && !result.text.strip.empty?) ? result.text.strip : nil
@@ -1209,6 +1276,9 @@ module RailsConsoleAi
1209
1276
  total_output += result.output_tokens || 0
1210
1277
  total_cache_read += result.cache_read_input_tokens || 0
1211
1278
  total_cache_write += result.cache_write_input_tokens || 0
1279
+ if result.truncated?
1280
+ @channel.display_warning(" The final answer was cut off at the #{format_tokens(RailsConsoleAi.configuration.resolved_max_tokens)} output-token cap — what follows is incomplete.")
1281
+ end
1212
1282
  end
1213
1283
 
1214
1284
  last_llm_stats = result ? format_llm_stats(result) : nil
@@ -55,6 +55,8 @@ module RailsConsoleAi
55
55
  }
56
56
  temp = config.resolved_temperature
57
57
  body[:temperature] = temp unless temp.nil?
58
+ effort = config.resolved_effort
59
+ body[:output_config] = { effort: effort } if effort
58
60
  if system_prompt
59
61
  body[:system] = [
60
62
  { 'type' => 'text', 'text' => system_prompt, 'cache_control' => cache_control }
@@ -74,7 +76,7 @@ module RailsConsoleAi
74
76
  usage = data['usage'] || {}
75
77
 
76
78
  tool_calls = extract_tool_calls(data)
77
- stop = data['stop_reason'] == 'tool_use' ? :tool_use : :end_turn
79
+ stop = normalize_stop_reason(data['stop_reason'], %w[tool_use], %w[max_tokens])
78
80
 
79
81
  ChatResult.new(
80
82
  text: extract_text(data),
@@ -29,6 +29,20 @@ module RailsConsoleAi
29
29
 
30
30
  private
31
31
 
32
+ # Every provider reports "I stopped because I ran out of room" under its own
33
+ # name, and the obvious mapping — anything that isn't a tool call is a
34
+ # finished turn — is wrong in a way that costs the whole turn: a response cut
35
+ # off mid-tool-call arrives with its arguments half-written, the engine sees
36
+ # :end_turn, discards the partial call and prints whatever preamble the model
37
+ # managed first. That reads as the model deciding to answer in one sentence.
38
+ # Naming truncation lets the engine say so and let the model recover.
39
+ def normalize_stop_reason(raw, tool_values, truncation_values)
40
+ raw = raw.to_s
41
+ return :tool_use if tool_values.include?(raw)
42
+ return :max_tokens if truncation_values.include?(raw)
43
+ :end_turn
44
+ end
45
+
32
46
  # Read and connect timeouts are separate budgets. Establishing the TCP/TLS
33
47
  # connection either happens in a couple of seconds or is not going to, while
34
48
  # generation legitimately takes minutes with adaptive thinking and a large
@@ -159,6 +173,12 @@ module RailsConsoleAi
159
173
  def tool_use?
160
174
  stop_reason == :tool_use && tool_calls && !tool_calls.empty?
161
175
  end
176
+
177
+ # The response hit the output cap mid-generation. Whatever it was writing —
178
+ # text, or the arguments of a tool call — is incomplete and cannot be used.
179
+ def truncated?
180
+ stop_reason == :max_tokens
181
+ end
162
182
  end
163
183
 
164
184
  def self.build(config = RailsConsoleAi.configuration)
@@ -49,6 +49,9 @@ module RailsConsoleAi
49
49
  messages: mark_conversation_breakpoint(format_messages(messages)),
50
50
  inference_config: inference
51
51
  }
52
+ # Converse has no effort field; Anthropic request fields ride along here.
53
+ effort = config.resolved_effort
54
+ params[:additional_model_request_fields] = { output_config: { effort: effort } } if effort
52
55
  if system_prompt
53
56
  sys_blocks = [{ text: system_prompt }]
54
57
  sys_blocks << cache_point if cache_supported?
@@ -65,7 +68,7 @@ module RailsConsoleAi
65
68
  debug_bedrock_response(response)
66
69
 
67
70
  tool_calls = extract_tool_calls(response)
68
- stop = response.stop_reason == 'tool_use' ? :tool_use : :end_turn
71
+ stop = normalize_stop_reason(response.stop_reason, %w[tool_use], %w[max_tokens])
69
72
 
70
73
  usage = response.usage
71
74
  ChatResult.new(
@@ -78,7 +81,7 @@ module RailsConsoleAi
78
81
  stop_reason: stop
79
82
  )
80
83
  rescue aws_error_class => e
81
- raise ProviderError, "AWS Bedrock error: #{e.message}"
84
+ raise ProviderError, "AWS Bedrock error (#{e.class}): #{e.message}"
82
85
  end
83
86
 
84
87
  def client
@@ -41,7 +41,7 @@ module RailsConsoleAi
41
41
  end
42
42
  end
43
43
 
44
- stop = finish_reason == 'tool_calls' ? :tool_use : :end_turn
44
+ stop = normalize_stop_reason(finish_reason, %w[tool_calls], %w[length])
45
45
 
46
46
  ChatResult.new(
47
47
  text: message['content'] || '',
@@ -90,7 +90,7 @@ module RailsConsoleAi
90
90
  finish_reason = choice['finish_reason']
91
91
 
92
92
  tool_calls = extract_tool_calls(message)
93
- stop = finish_reason == 'tool_calls' ? :tool_use : :end_turn
93
+ stop = normalize_stop_reason(finish_reason, %w[tool_calls], %w[length])
94
94
 
95
95
  ChatResult.new(
96
96
  text: message['content'] || '',