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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +49 -11
- data/app/controllers/rails_console_ai/artifacts_controller.rb +67 -0
- data/app/models/rails_console_ai/artifact.rb +231 -0
- data/app/views/layouts/rails_console_ai/application.html.erb +1 -0
- data/app/views/rails_console_ai/artifacts/index.html.erb +60 -0
- data/app/views/rails_console_ai/artifacts/show.html.erb +119 -0
- data/app/views/rails_console_ai/sessions/show.html.erb +6 -0
- data/config/routes.rb +8 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +5 -0
- data/lib/rails_console_ai/channel/console.rb +12 -3
- data/lib/rails_console_ai/channel/slack.rb +1 -1
- data/lib/rails_console_ai/configuration.rb +72 -12
- data/lib/rails_console_ai/context_builder.rb +48 -18
- data/lib/rails_console_ai/conversation_engine.rb +97 -27
- data/lib/rails_console_ai/providers/anthropic.rb +3 -1
- data/lib/rails_console_ai/providers/base.rb +20 -0
- data/lib/rails_console_ai/providers/bedrock.rb +5 -2
- data/lib/rails_console_ai/providers/local.rb +1 -1
- data/lib/rails_console_ai/providers/openai.rb +1 -1
- data/lib/rails_console_ai/providers/openrouter.rb +9 -1
- data/lib/rails_console_ai/slack_bot.rb +40 -12
- data/lib/rails_console_ai/slash_commands.rb +1 -0
- data/lib/rails_console_ai/sub_agent.rb +28 -0
- data/lib/rails_console_ai/tools/registry.rb +231 -1
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +222 -0
- metadata +5 -1
|
@@ -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: #{
|
|
434
|
-
@real_stdout.puts "\e[2m
|
|
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|\
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
605
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
|
620
|
-
$stdout.puts "\e[36m Already using
|
|
629
|
+
if tier == model_tier
|
|
630
|
+
$stdout.puts "\e[36m Already using #{tier} model (#{effective_model_label}).\e[0m"
|
|
621
631
|
else
|
|
622
|
-
@
|
|
632
|
+
@model_tier = tier
|
|
623
633
|
@provider = nil
|
|
624
|
-
$stdout.puts "\e[36m Switched
|
|
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
|
-
|
|
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 =
|
|
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']
|
|
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
|
|
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
|
|
@@ -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
|
|
93
|
+
stop = normalize_stop_reason(finish_reason, %w[tool_calls], %w[length])
|
|
94
94
|
|
|
95
95
|
ChatResult.new(
|
|
96
96
|
text: message['content'] || '',
|