rails_console_ai 0.35.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 +25 -0
- data/README.md +102 -10
- data/app/controllers/rails_console_ai/artifacts_controller.rb +67 -0
- data/app/helpers/rails_console_ai/sessions_helper.rb +11 -11
- 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 +12 -1
- data/lib/rails_console_ai/channel/console.rb +132 -18
- data/lib/rails_console_ai/channel/slack.rb +1 -1
- data/lib/rails_console_ai/configuration.rb +114 -15
- data/lib/rails_console_ai/context_builder.rb +48 -18
- data/lib/rails_console_ai/conversation_engine.rb +231 -61
- data/lib/rails_console_ai/line_editor.rb +142 -0
- data/lib/rails_console_ai/providers/anthropic.rb +3 -1
- data/lib/rails_console_ai/providers/base.rb +26 -1
- data/lib/rails_console_ai/providers/bedrock.rb +5 -2
- data/lib/rails_console_ai/providers/local.rb +17 -37
- data/lib/rails_console_ai/providers/openai.rb +31 -10
- data/lib/rails_console_ai/providers/openrouter.rb +108 -0
- data/lib/rails_console_ai/slack_bot.rb +58 -23
- data/lib/rails_console_ai/slash_commands.rb +150 -0
- data/lib/rails_console_ai/sub_agent.rb +35 -2
- data/lib/rails_console_ai/tools/registry.rb +233 -3
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +223 -0
- metadata +8 -1
|
@@ -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
|
|
@@ -3,38 +3,19 @@ module RailsConsoleAi
|
|
|
3
3
|
class Local < OpenAI
|
|
4
4
|
private
|
|
5
5
|
|
|
6
|
-
def
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
headers = { 'Content-Type' => 'application/json' }
|
|
10
|
-
api_key = config.local_api_key
|
|
11
|
-
if api_key && api_key != 'no-key' && !api_key.empty?
|
|
12
|
-
headers['Authorization'] = "Bearer #{api_key}"
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
conn = build_connection(base_url, headers)
|
|
16
|
-
|
|
17
|
-
formatted = []
|
|
18
|
-
formatted << { role: 'system', content: system_prompt } if system_prompt
|
|
19
|
-
formatted.concat(format_messages(messages))
|
|
20
|
-
|
|
21
|
-
body = {
|
|
22
|
-
model: config.resolved_model,
|
|
23
|
-
max_tokens: config.resolved_max_tokens,
|
|
24
|
-
messages: formatted
|
|
25
|
-
}
|
|
26
|
-
temp = config.resolved_temperature
|
|
27
|
-
body[:temperature] = temp unless temp.nil?
|
|
28
|
-
body[:tools] = tools.to_openai_format if tools
|
|
6
|
+
def api_base
|
|
7
|
+
config.local_url
|
|
8
|
+
end
|
|
29
9
|
|
|
30
|
-
|
|
10
|
+
def request_headers
|
|
11
|
+
key = config.local_api_key
|
|
12
|
+
return {} if key.nil? || key.empty? || key == 'no-key'
|
|
13
|
+
{ 'Authorization' => "Bearer #{key}" }
|
|
14
|
+
end
|
|
31
15
|
|
|
32
|
-
|
|
33
|
-
debug_request("#{base_url}/v1/chat/completions", body)
|
|
34
|
-
response = conn.post('/v1/chat/completions', json_body)
|
|
35
|
-
debug_response(response.body)
|
|
36
|
-
data = parse_response(response)
|
|
16
|
+
def build_result(data, body:, tools: nil)
|
|
37
17
|
usage = data['usage'] || {}
|
|
18
|
+
estimated_input_tokens = estimate_tokens(body)
|
|
38
19
|
|
|
39
20
|
prompt_tokens = usage['prompt_tokens']
|
|
40
21
|
if prompt_tokens && estimated_input_tokens > 0 && prompt_tokens < estimated_input_tokens * 0.5
|
|
@@ -50,9 +31,6 @@ module RailsConsoleAi
|
|
|
50
31
|
|
|
51
32
|
tool_calls = extract_tool_calls(message)
|
|
52
33
|
|
|
53
|
-
# Fallback: some local models (e.g. Ollama) emit tool calls as JSON
|
|
54
|
-
# in the content field instead of using the structured tool_calls format.
|
|
55
|
-
# Only match when the JSON "name" is a known tool name to avoid false positives.
|
|
56
34
|
if tool_calls.empty? && tools
|
|
57
35
|
tool_names = tools.to_openai_format.map { |t| t.dig('function', 'name') }.compact
|
|
58
36
|
text_calls = extract_tool_calls_from_text(message['content'], tool_names)
|
|
@@ -63,7 +41,7 @@ module RailsConsoleAi
|
|
|
63
41
|
end
|
|
64
42
|
end
|
|
65
43
|
|
|
66
|
-
stop = finish_reason
|
|
44
|
+
stop = normalize_stop_reason(finish_reason, %w[tool_calls], %w[length])
|
|
67
45
|
|
|
68
46
|
ChatResult.new(
|
|
69
47
|
text: message['content'] || '',
|
|
@@ -74,10 +52,12 @@ module RailsConsoleAi
|
|
|
74
52
|
)
|
|
75
53
|
end
|
|
76
54
|
|
|
77
|
-
def estimate_tokens(
|
|
78
|
-
chars =
|
|
79
|
-
messages
|
|
80
|
-
|
|
55
|
+
def estimate_tokens(body)
|
|
56
|
+
chars = 0
|
|
57
|
+
(body[:messages] || []).each do |m|
|
|
58
|
+
chars += m[:content].to_s.length + (m[:tool_calls].to_s.length)
|
|
59
|
+
end
|
|
60
|
+
chars += body[:tools].to_s.length if body[:tools]
|
|
81
61
|
chars / 4
|
|
82
62
|
end
|
|
83
63
|
|
|
@@ -40,12 +40,35 @@ module RailsConsoleAi
|
|
|
40
40
|
private
|
|
41
41
|
|
|
42
42
|
def call_api(messages, system_prompt: nil, tools: nil)
|
|
43
|
-
conn = build_connection(
|
|
44
|
-
|
|
45
|
-
})
|
|
43
|
+
conn = build_connection(api_base, request_headers)
|
|
44
|
+
body = build_body(messages, system_prompt: system_prompt, tools: tools)
|
|
45
|
+
debug_request("#{api_base}#{endpoint_path}", body)
|
|
46
|
+
response = with_retries { conn.post(endpoint_path, JSON.generate(body)) }
|
|
47
|
+
debug_response(response.body)
|
|
48
|
+
build_result(parse_response(response), body: body, tools: tools)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def api_base
|
|
52
|
+
API_URL
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def endpoint_path
|
|
56
|
+
'/v1/chat/completions'
|
|
57
|
+
end
|
|
46
58
|
|
|
59
|
+
def request_headers
|
|
60
|
+
{ 'Authorization' => "Bearer #{config.resolved_api_key}" }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Overridable: providers that support explicit cache breakpoints emit
|
|
64
|
+
# multipart content here instead of a bare string.
|
|
65
|
+
def system_message(system_prompt)
|
|
66
|
+
{ role: 'system', content: system_prompt }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build_body(messages, system_prompt:, tools:)
|
|
47
70
|
formatted = []
|
|
48
|
-
formatted <<
|
|
71
|
+
formatted << system_message(system_prompt) if system_prompt
|
|
49
72
|
formatted.concat(format_messages(messages))
|
|
50
73
|
|
|
51
74
|
body = {
|
|
@@ -56,12 +79,10 @@ module RailsConsoleAi
|
|
|
56
79
|
temp = config.resolved_temperature
|
|
57
80
|
body[:temperature] = temp unless temp.nil?
|
|
58
81
|
body[:tools] = tools.to_openai_format if tools
|
|
82
|
+
body
|
|
83
|
+
end
|
|
59
84
|
|
|
60
|
-
|
|
61
|
-
debug_request("#{API_URL}/v1/chat/completions", body)
|
|
62
|
-
response = with_retries { conn.post('/v1/chat/completions', json_body) }
|
|
63
|
-
debug_response(response.body)
|
|
64
|
-
data = parse_response(response)
|
|
85
|
+
def build_result(data, body:, tools: nil)
|
|
65
86
|
usage = data['usage'] || {}
|
|
66
87
|
|
|
67
88
|
choice = (data['choices'] || []).first || {}
|
|
@@ -69,7 +90,7 @@ module RailsConsoleAi
|
|
|
69
90
|
finish_reason = choice['finish_reason']
|
|
70
91
|
|
|
71
92
|
tool_calls = extract_tool_calls(message)
|
|
72
|
-
stop = finish_reason
|
|
93
|
+
stop = normalize_stop_reason(finish_reason, %w[tool_calls], %w[length])
|
|
73
94
|
|
|
74
95
|
ChatResult.new(
|
|
75
96
|
text: message['content'] || '',
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
module RailsConsoleAi
|
|
2
|
+
module Providers
|
|
3
|
+
class OpenRouter < OpenAI
|
|
4
|
+
DEFAULT_URL = 'https://openrouter.ai'.freeze
|
|
5
|
+
ANTHROPIC_MODEL = /anthropic\/|claude/i
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def api_base
|
|
10
|
+
config.openrouter_url || DEFAULT_URL
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def endpoint_path
|
|
14
|
+
'/api/v1/chat/completions'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def request_headers
|
|
18
|
+
h = { 'Authorization' => "Bearer #{config.resolved_api_key}" }
|
|
19
|
+
h['HTTP-Referer'] = config.openrouter_site_url if config.openrouter_site_url
|
|
20
|
+
h['X-Title'] = config.openrouter_app_name if config.openrouter_app_name
|
|
21
|
+
h
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build_body(messages, system_prompt:, tools:)
|
|
25
|
+
body = super
|
|
26
|
+
# Root-level cache_control is OpenRouter's automatic mode: it places a
|
|
27
|
+
# breakpoint on the last cacheable block and moves it forward as the
|
|
28
|
+
# conversation grows, which is what a tool loop needs — otherwise every
|
|
29
|
+
# round re-bills the whole accumulated history at full input price.
|
|
30
|
+
body[:cache_control] = cache_control if cache_supported?
|
|
31
|
+
body[:session_id] = routing_session_id if routing_session_id
|
|
32
|
+
# OpenRouter only returns usage.cost — the real dollars every cost
|
|
33
|
+
# readout prefers over an estimate — when the request asks for it.
|
|
34
|
+
body[:usage] = { include: true }
|
|
35
|
+
# OpenRouter maps `verbosity` to Anthropic's output_config.effort; its
|
|
36
|
+
# `reasoning.effort` sets a thinking-token budget instead.
|
|
37
|
+
effort = config.resolved_effort
|
|
38
|
+
body[:verbosity] = effort if effort
|
|
39
|
+
body
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The static system prefix gets its own explicit breakpoint so it has a
|
|
43
|
+
# guaranteed read point no matter what happens later in `messages`; the
|
|
44
|
+
# automatic breakpoint above then covers the growing tail. OpenRouter
|
|
45
|
+
# expresses Anthropic breakpoints as OpenAI-style multipart content.
|
|
46
|
+
def system_message(system_prompt)
|
|
47
|
+
return super unless cache_supported?
|
|
48
|
+
|
|
49
|
+
{ role: 'system',
|
|
50
|
+
content: [{ type: 'text', text: system_prompt, cache_control: cache_control }] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Same TTL on every breakpoint: entries with the longer TTL must precede
|
|
54
|
+
# shorter ones, and an explicit marker whose TTL differs from the root-level
|
|
55
|
+
# field's is rejected outright.
|
|
56
|
+
def cache_control
|
|
57
|
+
ttl = config.respond_to?(:resolved_cache_ttl) ? config.resolved_cache_ttl : nil
|
|
58
|
+
ttl ? { type: 'ephemeral', ttl: ttl } : { type: 'ephemeral' }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_result(data, body:, tools: nil)
|
|
62
|
+
raise_inline_error!(data)
|
|
63
|
+
result = super
|
|
64
|
+
usage = data['usage'] || {}
|
|
65
|
+
details = usage['prompt_tokens_details'] || {}
|
|
66
|
+
|
|
67
|
+
cache_read = details['cached_tokens'].to_i
|
|
68
|
+
cache_write = details['cache_write_tokens'].to_i
|
|
69
|
+
result.cache_read_input_tokens = cache_read
|
|
70
|
+
result.cache_write_input_tokens = cache_write
|
|
71
|
+
result.cost = usage['cost']
|
|
72
|
+
|
|
73
|
+
# OpenAI-shaped `prompt_tokens` is the WHOLE prompt, cached tokens
|
|
74
|
+
# included; Anthropic's `input_tokens` is the uncached remainder, and the
|
|
75
|
+
# engine is built on the Anthropic contract — it reconstructs total prompt
|
|
76
|
+
# volume as input + cache_read + cache_write for the runaway-loop budget
|
|
77
|
+
# breakers. Left as sent, a cached round counts twice and those breakers
|
|
78
|
+
# fire at half the volume they are set to.
|
|
79
|
+
if result.input_tokens
|
|
80
|
+
result.input_tokens = [result.input_tokens - cache_read - cache_write, 0].max
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Some OpenRouter upstreams return tool calls with a finish_reason of
|
|
84
|
+
# "stop"; trust the calls over the label. Not when the response was cut
|
|
85
|
+
# off at the output cap, though — those arguments are half-written, and
|
|
86
|
+
# promoting them to :tool_use hands the engine a truncated call to run.
|
|
87
|
+
if result.tool_calls&.any? && !result.truncated?
|
|
88
|
+
result.stop_reason = :tool_use
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
result
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cache_supported?
|
|
95
|
+
config.resolved_model.to_s.match?(ANTHROPIC_MODEL)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def raise_inline_error!(data)
|
|
99
|
+
err = data['error'] || (data['choices'] || []).first&.dig('error')
|
|
100
|
+
return unless err
|
|
101
|
+
|
|
102
|
+
msg = err.is_a?(Hash) ? (err['message'] || err.to_s) : err.to_s
|
|
103
|
+
code = err.is_a?(Hash) ? err['code'] : 'unknown'
|
|
104
|
+
raise ProviderError, "OpenRouter error (#{code}): #{msg}"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -453,7 +453,7 @@ module RailsConsoleAi
|
|
|
453
453
|
Thread.current.report_on_exception = false
|
|
454
454
|
Thread.current[:log_prefix] = "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} @#{user_name}"
|
|
455
455
|
begin
|
|
456
|
-
channel.display_status("
|
|
456
|
+
channel.display_status("_#{session_reference(engine, text, channel_id, thread_ts)}_")
|
|
457
457
|
if restored
|
|
458
458
|
puts "Restored session for thread #{thread_ts} (#{engine.history.length} messages)"
|
|
459
459
|
channel.display_status("_(session restored — continuing from previous conversation)_")
|
|
@@ -468,6 +468,32 @@ module RailsConsoleAi
|
|
|
468
468
|
end
|
|
469
469
|
end
|
|
470
470
|
|
|
471
|
+
# A link to the session's page, so whoever is in the thread can find the
|
|
472
|
+
# transcript later. Falls back to the channel/thread pair the logs are
|
|
473
|
+
# tagged with when there is no row to link to, or no host to link with.
|
|
474
|
+
def session_reference(engine, text, channel_id, thread_ts)
|
|
475
|
+
session_id = engine.ensure_session_record(text)
|
|
476
|
+
unless session_id
|
|
477
|
+
return session_reference_fallback(channel_id, thread_ts, "no session row (session_logging off, or the sessions table not found)")
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
url = RailsConsoleAi.session_url(session_id)
|
|
481
|
+
unless url.start_with?('http')
|
|
482
|
+
return session_reference_fallback(channel_id, thread_ts, "no host for #{url} (set config.base_url)")
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
"Session ##{session_id}: <#{url}|view transcript>"
|
|
486
|
+
rescue => e
|
|
487
|
+
session_reference_fallback(channel_id, thread_ts, "#{e.class}: #{e.message}")
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Said on stdout, where the bot's own log goes, so a missing link is explained.
|
|
491
|
+
# The session thread already prefixes the line with the channel and thread.
|
|
492
|
+
def session_reference_fallback(channel_id, thread_ts, reason)
|
|
493
|
+
puts "no session link: #{reason}"
|
|
494
|
+
"Session: #{channel_id}/#{thread_ts}"
|
|
495
|
+
end
|
|
496
|
+
|
|
471
497
|
def restore_from_db(engine, thread_ts)
|
|
472
498
|
require 'rails_console_ai/session_logger'
|
|
473
499
|
saved = SessionLogger.find_by_slack_thread(thread_ts)
|
|
@@ -652,14 +678,11 @@ module RailsConsoleAi
|
|
|
652
678
|
when 'model'
|
|
653
679
|
summary = bang_model(engine)
|
|
654
680
|
post_message(channel: channel_id, thread_ts: thread_ts, text: summary)
|
|
655
|
-
when 'think'
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
text: "Switched to thinking model: `#{model}`")
|
|
659
|
-
when 'unthink'
|
|
660
|
-
model = engine.downgrade_from_thinking_model
|
|
681
|
+
when 'think', 'unthink', 'cheap'
|
|
682
|
+
tier = { 'think' => :thinking, 'unthink' => :default, 'cheap' => :cheap }[cmd]
|
|
683
|
+
engine.switch_model_tier(tier)
|
|
661
684
|
post_message(channel: channel_id, thread_ts: thread_ts,
|
|
662
|
-
text: "Switched to
|
|
685
|
+
text: "Switched to #{tier} model: `#{engine.effective_model_label}`")
|
|
663
686
|
when 'context'
|
|
664
687
|
summary = bang_context(engine)
|
|
665
688
|
post_message(channel: channel_id, thread_ts: thread_ts, text: summary)
|
|
@@ -678,7 +701,7 @@ module RailsConsoleAi
|
|
|
678
701
|
end
|
|
679
702
|
end
|
|
680
703
|
else
|
|
681
|
-
commands = %w[name usage cost model compact think unthink context retry].map { |c| "`!#{c}`" }
|
|
704
|
+
commands = %w[name usage cost model compact think unthink cheap context retry].map { |c| "`!#{c}`" }
|
|
682
705
|
post_message(channel: channel_id, thread_ts: thread_ts,
|
|
683
706
|
text: "Unknown command `!#{cmd}`. Available: #{commands.join(', ')}")
|
|
684
707
|
end
|
|
@@ -705,13 +728,18 @@ module RailsConsoleAi
|
|
|
705
728
|
def bang_model(engine = nil)
|
|
706
729
|
config = RailsConsoleAi.configuration
|
|
707
730
|
model = engine ? engine.effective_model : config.resolved_model
|
|
708
|
-
thinking = config.resolved_thinking_model
|
|
709
731
|
pricing = Configuration.pricing_for(model)
|
|
732
|
+
with_effort = lambda do |m, e|
|
|
733
|
+
e = Configuration.effort_for(m, e)
|
|
734
|
+
e ? "`#{m}` (#{e} effort)" : "`#{m}`"
|
|
735
|
+
end
|
|
710
736
|
|
|
711
737
|
lines = ["*Model info:*"]
|
|
712
738
|
lines << " Provider: `#{config.provider}`"
|
|
713
|
-
lines << " Model: `#{model}`"
|
|
714
|
-
lines << "
|
|
739
|
+
lines << (engine ? " Model: `#{engine.effective_model_label}` [#{engine.model_tier}]" : " Model: `#{model}`")
|
|
740
|
+
lines << " Default model: #{with_effort.(config.resolved_model, config.effort)}"
|
|
741
|
+
lines << " Thinking model: #{with_effort.(config.resolved_thinking_model, config.thinking_effort)}"
|
|
742
|
+
lines << " Cheap model: `#{config.resolved_cheap_model}`"
|
|
715
743
|
lines << " Max tokens: #{config.resolved_max_tokens}"
|
|
716
744
|
if pricing
|
|
717
745
|
lines << " Pricing: $#{pricing[:input] * 1_000_000}/M in, $#{pricing[:output] * 1_000_000}/M out"
|
|
@@ -737,6 +765,7 @@ module RailsConsoleAi
|
|
|
737
765
|
|
|
738
766
|
lines = ["*Cost estimate:*"]
|
|
739
767
|
total_cost = 0.0
|
|
768
|
+
has_reported_cost = false
|
|
740
769
|
|
|
741
770
|
token_usage.each do |model, usage|
|
|
742
771
|
pricing = Configuration.pricing_for(model, cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
|
|
@@ -744,18 +773,23 @@ module RailsConsoleAi
|
|
|
744
773
|
input_str = "in: #{usage[:input]}"
|
|
745
774
|
output_str = "out: #{usage[:output]}"
|
|
746
775
|
|
|
747
|
-
|
|
748
|
-
|
|
776
|
+
reported_cost = usage[:cost]
|
|
777
|
+
if reported_cost && reported_cost > 0
|
|
778
|
+
has_reported_cost = true
|
|
779
|
+
total_cost += reported_cost
|
|
780
|
+
cache_read = usage[:cache_read] || 0
|
|
781
|
+
cache_write = usage[:cache_write] || 0
|
|
782
|
+
cache_str = ""
|
|
783
|
+
cache_str = " cache r: #{cache_read} w: #{cache_write}" if cache_read > 0 || cache_write > 0
|
|
784
|
+
lines << " `#{model}`: #{input_str} #{output_str}#{cache_str} $#{'%.4f' % reported_cost}"
|
|
785
|
+
elsif pricing
|
|
786
|
+
cost = Configuration.estimate_cost(model,
|
|
787
|
+
input: usage[:input], output: usage[:output],
|
|
788
|
+
cache_read: usage[:cache_read] || 0, cache_write: usage[:cache_write] || 0,
|
|
789
|
+
cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
|
|
790
|
+
total_cost += cost if cost
|
|
749
791
|
cache_read = usage[:cache_read] || 0
|
|
750
792
|
cache_write = usage[:cache_write] || 0
|
|
751
|
-
if (cache_read > 0 || cache_write > 0) && pricing[:cache_read]
|
|
752
|
-
# input_tokens excludes cached tokens — bill each bucket at its own
|
|
753
|
-
# rate rather than discounting cache_read out of input (see
|
|
754
|
-
# ConversationEngine#display_cost_summary).
|
|
755
|
-
cost += cache_read * pricing[:cache_read]
|
|
756
|
-
cost += cache_write * pricing[:cache_write]
|
|
757
|
-
end
|
|
758
|
-
total_cost += cost
|
|
759
793
|
cache_str = ""
|
|
760
794
|
cache_str = " cache r: #{cache_read} w: #{cache_write}" if cache_read > 0 || cache_write > 0
|
|
761
795
|
lines << " `#{model}`: #{input_str} #{output_str}#{cache_str} ~$#{'%.2f' % cost}"
|
|
@@ -764,7 +798,8 @@ module RailsConsoleAi
|
|
|
764
798
|
end
|
|
765
799
|
end
|
|
766
800
|
|
|
767
|
-
|
|
801
|
+
label = has_reported_cost ? "Total:" : "Total: ~"
|
|
802
|
+
lines << "*#{label}$#{'%.2f' % total_cost}*"
|
|
768
803
|
lines.join("\n")
|
|
769
804
|
end
|
|
770
805
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
require 'rails_console_ai/skill_loader'
|
|
2
|
+
require 'rails_console_ai/agent_loader'
|
|
3
|
+
|
|
4
|
+
module RailsConsoleAi
|
|
5
|
+
# The union of everything a user can invoke by typing "/" in interactive mode:
|
|
6
|
+
# the fixed REPL commands, plus every activatable skill and agent, each exposed
|
|
7
|
+
# under a slug derived from its display name ("Restart user trial" -> /restart-user-trial).
|
|
8
|
+
#
|
|
9
|
+
# Backs three surfaces that must agree with each other: the "/" listing, the
|
|
10
|
+
# completion candidates offered by the line editor, and the dispatcher that
|
|
11
|
+
# decides what a typed slash command actually does.
|
|
12
|
+
class SlashCommands
|
|
13
|
+
Command = Struct.new(:slug, :name, :description, :kind, :record, keyword_init: true) do
|
|
14
|
+
def builtin?; kind == :builtin; end
|
|
15
|
+
def skill?; kind == :skill; end
|
|
16
|
+
def agent?; kind == :agent; end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
KIND_LABELS = { builtin: 'command', skill: 'skill', agent: 'agent' }.freeze
|
|
20
|
+
|
|
21
|
+
# Slug => one-line help. Order is the order they render in the listing.
|
|
22
|
+
# Descriptions that depend on live state (auto-execute on/off, safe mode)
|
|
23
|
+
# are filled in by the channel at render time; these are the static fallbacks.
|
|
24
|
+
BUILTINS = {
|
|
25
|
+
'auto' => 'Toggle auto-execute',
|
|
26
|
+
'danger' => 'Toggle safe mode',
|
|
27
|
+
'safe' => 'Show safety guard status',
|
|
28
|
+
'model' => 'Show provider, model, and pricing info',
|
|
29
|
+
'think' => 'Switch to thinking model',
|
|
30
|
+
'unthink' => 'Switch back to default model',
|
|
31
|
+
'cheap' => 'Switch to the cheaper, faster model',
|
|
32
|
+
'compact' => 'Summarize conversation to reduce context',
|
|
33
|
+
'usage' => 'Show session token totals',
|
|
34
|
+
'cost' => 'Show cost estimate by model',
|
|
35
|
+
'name' => 'Name this session for easy resume',
|
|
36
|
+
'context' => 'Show conversation history sent to the LLM',
|
|
37
|
+
'system' => 'Show the system prompt',
|
|
38
|
+
'expand' => 'Show full omitted output',
|
|
39
|
+
'debug' => 'Toggle debug summaries',
|
|
40
|
+
'retry' => 'Re-execute the last code block'
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
# "Restart user trial" -> restart-user-trial.
|
|
44
|
+
#
|
|
45
|
+
# Close to SkillLoader#skill_key / AgentLoader#agent_key, but kinder to names
|
|
46
|
+
# that aren't plain prose: those two drop "/" and case boundaries outright,
|
|
47
|
+
# which turns "Approve/Reject ChangeApprovals" into the unreadable
|
|
48
|
+
# "approvereject-changeapprovals". Here every boundary a human would read as
|
|
49
|
+
# a word break becomes a dash. The slug is only ever a handle for typing and
|
|
50
|
+
# dispatch — file lookup still goes through the loaders — so the two are free
|
|
51
|
+
# to differ.
|
|
52
|
+
def self.slugify(name)
|
|
53
|
+
name.to_s.strip
|
|
54
|
+
.gsub(%r{[/_\s]+}, '-') # separators
|
|
55
|
+
.gsub(/([a-z0-9])([A-Z])/, '\1-\2') # fooBar -> foo-Bar
|
|
56
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2') # HTTPToken -> HTTP-Token
|
|
57
|
+
.downcase
|
|
58
|
+
.gsub(/[^a-z0-9-]/, '')
|
|
59
|
+
.gsub(/-+/, '-')
|
|
60
|
+
.sub(/\A-/, '').sub(/-\z/, '')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def initialize(skill_loader: nil, agent_loader: nil)
|
|
64
|
+
@skill_loader = skill_loader
|
|
65
|
+
@agent_loader = agent_loader
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Loading skills and agents touches the DB and the filesystem, and completion
|
|
69
|
+
# runs on every keystroke — so the list is built once and reused until something
|
|
70
|
+
# that could have changed it (a save_skill/save_agent tool call) finishes.
|
|
71
|
+
def refresh!
|
|
72
|
+
@commands = nil
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def commands
|
|
77
|
+
@commands ||= build
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def skills
|
|
81
|
+
commands.select(&:skill?)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def agents
|
|
85
|
+
commands.select(&:agent?)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Completion candidates, already "/"-prefixed.
|
|
89
|
+
def candidates
|
|
90
|
+
commands.map { |c| "/#{c.slug}" }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# [slug, kind label] pairs for the line editor. The label is what lets a Tab
|
|
94
|
+
# list say which entries are built-in commands and which are skills or agents.
|
|
95
|
+
def completion_entries
|
|
96
|
+
commands.map { |c| ["/#{c.slug}", KIND_LABELS[c.kind]] }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def find(slug)
|
|
100
|
+
slug = slug.to_s.sub(%r{\A/}, '').downcase
|
|
101
|
+
commands.find { |c| c.slug == slug }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def build
|
|
107
|
+
builtins = BUILTINS.map do |slug, desc|
|
|
108
|
+
Command.new(slug: slug, name: slug, description: desc, kind: :builtin, record: nil)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
taken = builtins.map(&:slug)
|
|
112
|
+
|
|
113
|
+
skills = safe(:skills) do
|
|
114
|
+
skill_loader.load_activatable_skills.filter_map do |s|
|
|
115
|
+
slug = self.class.slugify(s['name'])
|
|
116
|
+
next if slug.empty? || taken.include?(slug)
|
|
117
|
+
taken << slug
|
|
118
|
+
Command.new(slug: slug, name: s['name'], description: s['description'], kind: :skill, record: s)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
agents = safe(:agents) do
|
|
123
|
+
agent_loader.load_activatable_agents.filter_map do |a|
|
|
124
|
+
slug = self.class.slugify(a['name'])
|
|
125
|
+
next if slug.empty? || taken.include?(slug)
|
|
126
|
+
taken << slug
|
|
127
|
+
Command.new(slug: slug, name: a['name'], description: a['description'], kind: :agent, record: a)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
builtins + skills.sort_by(&:slug) + agents.sort_by(&:slug)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# A broken skill file or an unmigrated database must not take down the prompt.
|
|
135
|
+
def safe(what)
|
|
136
|
+
yield
|
|
137
|
+
rescue => e
|
|
138
|
+
RailsConsoleAi.logger.warn("RailsConsoleAi: failed to load #{what} for slash commands: #{e.message}")
|
|
139
|
+
[]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def skill_loader
|
|
143
|
+
@skill_loader ||= SkillLoader.new
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def agent_loader
|
|
147
|
+
@agent_loader ||= AgentLoader.new
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -10,7 +10,7 @@ module RailsConsoleAi
|
|
|
10
10
|
LARGE_OUTPUT_THRESHOLD = 10_000
|
|
11
11
|
LARGE_OUTPUT_PREVIEW_CHARS = 8_000
|
|
12
12
|
|
|
13
|
-
attr_reader :input_tokens, :output_tokens, :model_used
|
|
13
|
+
attr_reader :input_tokens, :output_tokens, :model_used, :cost
|
|
14
14
|
|
|
15
15
|
def initialize(task:, agent_config:, binding_context:, parent_channel:, executor:,
|
|
16
16
|
output_payload: nil, output_local_name: :output)
|
|
@@ -23,6 +23,7 @@ module RailsConsoleAi
|
|
|
23
23
|
@output_local_name = output_local_name
|
|
24
24
|
@input_tokens = 0
|
|
25
25
|
@output_tokens = 0
|
|
26
|
+
@cost = 0
|
|
26
27
|
@model_used = nil
|
|
27
28
|
end
|
|
28
29
|
|
|
@@ -83,8 +84,18 @@ module RailsConsoleAi
|
|
|
83
84
|
end
|
|
84
85
|
@input_tokens += result.input_tokens || 0
|
|
85
86
|
@output_tokens += result.output_tokens || 0
|
|
87
|
+
@cost += result.cost || 0
|
|
86
88
|
|
|
87
89
|
break if channel.cancelled?
|
|
90
|
+
|
|
91
|
+
# Cut off at the output cap: the tool call it was writing is incomplete
|
|
92
|
+
# and gets discarded, so the sub-agent would otherwise return whatever
|
|
93
|
+
# half-sentence preceded it as if that were its finding.
|
|
94
|
+
if result.truncated?
|
|
95
|
+
channel.display_warning(" Sub-agent response hit the output-token cap — its result is incomplete.")
|
|
96
|
+
break
|
|
97
|
+
end
|
|
98
|
+
|
|
88
99
|
break unless result.tool_use?
|
|
89
100
|
|
|
90
101
|
# Display the LLM's reasoning text before executing its tool calls
|
|
@@ -152,6 +163,7 @@ module RailsConsoleAi
|
|
|
152
163
|
result = provider.chat_with_tools(messages, tools: tools, system_prompt: system_prompt)
|
|
153
164
|
@input_tokens += result.input_tokens || 0
|
|
154
165
|
@output_tokens += result.output_tokens || 0
|
|
166
|
+
@cost += result.cost || 0
|
|
155
167
|
end
|
|
156
168
|
|
|
157
169
|
text = result&.text.to_s
|
|
@@ -181,7 +193,7 @@ module RailsConsoleAi
|
|
|
181
193
|
config = RailsConsoleAi.configuration
|
|
182
194
|
model_override = @agent_config['model'] || config.sub_agent_model
|
|
183
195
|
|
|
184
|
-
if model_override
|
|
196
|
+
p = if model_override
|
|
185
197
|
config_dup = config.dup
|
|
186
198
|
config_dup.model = model_override
|
|
187
199
|
@model_used = model_override
|
|
@@ -190,6 +202,8 @@ module RailsConsoleAi
|
|
|
190
202
|
@model_used = config.resolved_model
|
|
191
203
|
Providers.build(config)
|
|
192
204
|
end
|
|
205
|
+
p.routing_session_id = SecureRandom.hex(8) if p.respond_to?(:routing_session_id=)
|
|
206
|
+
p
|
|
193
207
|
end
|
|
194
208
|
|
|
195
209
|
def build_system_prompt
|
|
@@ -197,6 +211,7 @@ module RailsConsoleAi
|
|
|
197
211
|
parts << base_instructions unless @agent_config['skip_base_instructions']
|
|
198
212
|
parts << guide_context
|
|
199
213
|
parts << pinned_memory_context
|
|
214
|
+
parts << memory_context if memory_recall_available?
|
|
200
215
|
parts << @agent_config['body'] if @agent_config['body'] && !@agent_config['body'].strip.empty?
|
|
201
216
|
parts.compact.join("\n\n")
|
|
202
217
|
end
|
|
@@ -212,6 +227,9 @@ module RailsConsoleAi
|
|
|
212
227
|
- Use tools to look up schema/model details rather than guessing.
|
|
213
228
|
- Prefer ActiveRecord query interface over raw SQL.
|
|
214
229
|
- Use describe_model to understand models before querying them.
|
|
230
|
+
- Check the Memories section below BEFORE investigating. If a memory looks relevant,
|
|
231
|
+
call recall_memory (by name) or recall_memories (by keyword) to read it, and apply it.
|
|
232
|
+
Memories record how this app actually works; do not rediscover what one already says.
|
|
215
233
|
- NEVER fabricate URLs, IDs, or data. Always look things up using model methods.
|
|
216
234
|
- When you find methods on a model (e.g. via .methods.grep), USE them rather than
|
|
217
235
|
constructing values manually.
|
|
@@ -230,6 +248,21 @@ module RailsConsoleAi
|
|
|
230
248
|
nil
|
|
231
249
|
end
|
|
232
250
|
|
|
251
|
+
# Same memory index the main agent sees, so the sub-agent knows what it can
|
|
252
|
+
# recall instead of finding out only if it happens to call recall_memories.
|
|
253
|
+
def memory_context
|
|
254
|
+
require 'rails_console_ai/context_builder'
|
|
255
|
+
ContextBuilder.new(RailsConsoleAi.configuration).memory_context
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# An agent definition can restrict tools; the output-explorer does, to
|
|
259
|
+
# execute_code alone. Listing memories it cannot recall would only invite
|
|
260
|
+
# calls to a tool it does not have.
|
|
261
|
+
def memory_recall_available?
|
|
262
|
+
allowed = @agent_config['tools']
|
|
263
|
+
allowed.nil? || Array(allowed).include?('recall_memories')
|
|
264
|
+
end
|
|
265
|
+
|
|
233
266
|
def pinned_memory_context
|
|
234
267
|
channel_mode = @parent_channel.respond_to?(:mode) ? @parent_channel.mode : nil
|
|
235
268
|
# For sub-agents spawned from Slack, use the Slack channel's pinned memories.
|