rails_console_ai 0.20.0 → 0.22.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 +23 -0
- data/README.md +15 -6
- data/app/views/rails_console_ai/sessions/index.html.erb +2 -0
- data/app/views/rails_console_ai/sessions/show.html.erb +6 -0
- data/lib/rails_console_ai/channel/console.rb +54 -0
- data/lib/rails_console_ai/console_methods.rb +7 -2
- data/lib/rails_console_ai/context_builder.rb +0 -3
- data/lib/rails_console_ai/conversation_engine.rb +159 -25
- data/lib/rails_console_ai/executor.rb +9 -29
- data/lib/rails_console_ai/providers/bedrock.rb +7 -3
- data/lib/rails_console_ai/safety_guards.rb +16 -2
- data/lib/rails_console_ai/session_logger.rb +2 -1
- data/lib/rails_console_ai/slack_bot.rb +363 -26
- data/lib/rails_console_ai/tools/registry.rb +14 -1
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +11 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dba28b6d7543df66792877bddc51336f105c441a06b089de34ab0c7f89ca8f26
|
|
4
|
+
data.tar.gz: 4a54272fafd704abd003f06f2ab065d11f47b7c9a42b2f7c99c55b07b8712f8a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 27c8413cc63c84c94d0fad464c3f3d6cd0b376a744122a2358b5899e0382f82e696a3e188647db952235986ee2a973c85b73065939a898cd7229955a61c377cf
|
|
7
|
+
data.tar.gz: d7e3041e7e37dd2ed304537bdfb02693647d3d32a9f1b6888718d9e38fcafe0c1b92000606c970716cd5074e7424b81e37d894d099b93ac6155268703ad54738
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.22.0]
|
|
6
|
+
|
|
7
|
+
- Fix blank content block handling in Bedrock provider
|
|
8
|
+
- Fix `recall_output` to expand in place and restore preview after LLM responds, preventing context bloat
|
|
9
|
+
- Remove safety guard bypass from prompt
|
|
10
|
+
- Fix issue where LLM couldn't recall multiple outputs at once
|
|
11
|
+
|
|
12
|
+
## [0.21.0]
|
|
13
|
+
|
|
14
|
+
- Add Slack @mention support and channel name tracking
|
|
15
|
+
- Stop looping after user cancels execution
|
|
16
|
+
- Remove edit feature from executor
|
|
17
|
+
- Include more info in tool call log line
|
|
18
|
+
- Support class methods in `bypass_guards_for_methods`
|
|
19
|
+
- Rename setup tasks to `ai_db_setup` and `ai_db_migrate`
|
|
20
|
+
- Fix effective model resolution in multi-threaded Slack bot
|
|
21
|
+
- Fix cost tracking with prompt caching through Bedrock
|
|
22
|
+
- Add `/unthink` command
|
|
23
|
+
- Make `!think` / `/think` thread-safe for Slack
|
|
24
|
+
- Fix truncating console output
|
|
25
|
+
- Reduce cost by deferring large output until LLM requests it
|
|
26
|
+
- Add `!name`, `!model`, and `/model` commands to Slack and console
|
|
27
|
+
|
|
5
28
|
## [0.20.0]
|
|
6
29
|
|
|
7
30
|
- Add per-user system prompt seeding
|
data/README.md
CHANGED
|
@@ -12,7 +12,7 @@ irb> ai "find the 5 most recent orders over $100"
|
|
|
12
12
|
|
|
13
13
|
Order.where("total > ?", 100).order(created_at: :desc).limit(5)
|
|
14
14
|
|
|
15
|
-
Execute? [y/N/
|
|
15
|
+
Execute? [y/N/danger] y
|
|
16
16
|
=> [#<Order id: 4821, ...>, ...]
|
|
17
17
|
```
|
|
18
18
|
|
|
@@ -62,7 +62,8 @@ end
|
|
|
62
62
|
| `ai!` | Enter interactive mode (multi-turn conversation) |
|
|
63
63
|
| `ai? "query"` | Explain only, no execution |
|
|
64
64
|
| `ai_init` | Generate app guide for better AI context |
|
|
65
|
-
| `
|
|
65
|
+
| `ai_db_setup` | Install session logging table + run migrations |
|
|
66
|
+
| `ai_db_migrate` | Run pending session table migrations |
|
|
66
67
|
| `ai_sessions` | List recent sessions |
|
|
67
68
|
| `ai_resume` | Resume a session by name or ID |
|
|
68
69
|
| `ai_memories` | Show stored memories |
|
|
@@ -100,7 +101,7 @@ Say "think harder" in any query to auto-upgrade to the thinking model for that s
|
|
|
100
101
|
- **Skills** — predefined procedures with guard bypasses that the AI activates on demand
|
|
101
102
|
- **Memories** — AI saves what it learns about your app across sessions
|
|
102
103
|
- **App guide** — `ai_init` generates a guide injected into every system prompt
|
|
103
|
-
- **Sessions** — name, list, and resume interactive conversations (`
|
|
104
|
+
- **Sessions** — name, list, and resume interactive conversations (`ai_db_setup` to enable)
|
|
104
105
|
- **History compaction** — `/compact` summarizes long conversations to reduce cost and latency
|
|
105
106
|
- **Output trimming** — older execution outputs are automatically replaced with references; the LLM can recall them on demand via `recall_output`, and you can `/expand <id>` to see them
|
|
106
107
|
- **Debug mode** — `/debug` shows context breakdown, token counts, and per-call cost estimates before and after each LLM call
|
|
@@ -213,7 +214,7 @@ Skills and global `bypass_guards_for_methods` coexist — use config-level bypas
|
|
|
213
214
|
### Toggling Safe Mode
|
|
214
215
|
|
|
215
216
|
- **`/danger`** in interactive mode toggles all guards off/on for the session
|
|
216
|
-
- **`d`** at the `Execute? [y/N/
|
|
217
|
+
- **`d`** at the `Execute? [y/N/danger]` prompt disables guards for that single execution
|
|
217
218
|
- When a guard blocks an operation, the user is prompted: `Re-run with safe mode disabled? [y/N]`
|
|
218
219
|
|
|
219
220
|
## LLM Providers
|
|
@@ -296,7 +297,7 @@ Timeout is automatically raised to 300s minimum for local models to account for
|
|
|
296
297
|
RailsConsoleAi.configure do |config|
|
|
297
298
|
config.provider = :anthropic # :anthropic, :openai, :bedrock, :local
|
|
298
299
|
config.auto_execute = false # true to skip confirmations
|
|
299
|
-
config.session_logging = true # requires
|
|
300
|
+
config.session_logging = true # requires ai_db_setup
|
|
300
301
|
config.temperature = 0.2
|
|
301
302
|
config.timeout = 30 # HTTP timeout in seconds
|
|
302
303
|
config.max_tool_rounds = 200 # safety cap on tool-use loops
|
|
@@ -363,11 +364,14 @@ Run RailsConsoleAi as a Slack bot. Each Slack thread becomes an independent AI s
|
|
|
363
364
|
3. **Bot Token Scopes** — OAuth & Permissions → Bot Token Scopes, add:
|
|
364
365
|
- `chat:write`
|
|
365
366
|
- `channels:history` (public channels)
|
|
367
|
+
- `channels:read` (channel names in logs, optional)
|
|
366
368
|
- `groups:history` (private channels, optional)
|
|
369
|
+
- `groups:read` (private channel names in logs, optional)
|
|
367
370
|
- `im:history` (direct messages)
|
|
368
371
|
- `users:read`
|
|
369
372
|
|
|
370
373
|
4. **Event Subscriptions** — Event Subscriptions → toggle ON, then under "Subscribe to bot events" add:
|
|
374
|
+
- `app_mention` (respond when @mentioned in any channel)
|
|
371
375
|
- `message.channels` (public channels)
|
|
372
376
|
- `message.groups` (private channels, optional)
|
|
373
377
|
- `message.im` (direct messages)
|
|
@@ -399,7 +403,12 @@ end
|
|
|
399
403
|
bundle exec rake rails_console_ai:slack
|
|
400
404
|
```
|
|
401
405
|
|
|
402
|
-
This starts a long-running process (run it separately from your web server).
|
|
406
|
+
This starts a long-running process (run it separately from your web server). The bot auto-executes code with safety guards always enabled — there is no `/danger` equivalent in Slack.
|
|
407
|
+
|
|
408
|
+
**@mention behavior:**
|
|
409
|
+
- **DMs** — the bot responds to all messages, no @mention needed.
|
|
410
|
+
- **Channels** — the bot only responds when @mentioned. @mention it in any channel message or thread to start a session. The person who first @mentions the bot owns the session — only they can continue the conversation, and they must @mention the bot on each message. Exception: when the bot asks a question, the owner can reply without @mentioning.
|
|
411
|
+
- **Joining threads** — when @mentioned mid-thread, the bot reads the thread history for context so it understands what's already been discussed.
|
|
403
412
|
|
|
404
413
|
## Requirements
|
|
405
414
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
<tr>
|
|
14
14
|
<th>Time</th>
|
|
15
15
|
<th>User</th>
|
|
16
|
+
<th>Channel</th>
|
|
16
17
|
<th>Name</th>
|
|
17
18
|
<th style="max-width: 400px;">Query</th>
|
|
18
19
|
<th>Mode</th>
|
|
@@ -26,6 +27,7 @@
|
|
|
26
27
|
<tr>
|
|
27
28
|
<td class="mono"><%= session.created_at.strftime('%Y-%m-%d %H:%M') %></td>
|
|
28
29
|
<td><%= session.user_name %></td>
|
|
30
|
+
<td><%= session.try(:slack_channel_name).presence || '-' %></td>
|
|
29
31
|
<td><%= session.name.present? ? session.name : '-' %></td>
|
|
30
32
|
<td class="query-cell"><a href="<%= rails_console_ai.session_path(session) %>" title="<%= h session.query.truncate(200) %>"><%= truncate(session.query.gsub(/\s+/, ' ').strip, length: 80) %></a></td>
|
|
31
33
|
<td><span class="badge badge-<%= session.mode %>"><%= session.mode %></span></td>
|
|
@@ -30,6 +30,12 @@
|
|
|
30
30
|
<label>User</label>
|
|
31
31
|
<span><%= @session.user_name || '-' %></span>
|
|
32
32
|
</div>
|
|
33
|
+
<% if @session.try(:slack_channel_name).present? %>
|
|
34
|
+
<div class="meta-item">
|
|
35
|
+
<label>Channel</label>
|
|
36
|
+
<span><%= @session.slack_channel_name %></span>
|
|
37
|
+
</div>
|
|
38
|
+
<% end %>
|
|
33
39
|
<div class="meta-item">
|
|
34
40
|
<label>Provider / Model</label>
|
|
35
41
|
<span><%= @session.provider %> / <%= @session.model %></span>
|
|
@@ -37,6 +37,33 @@ module RailsConsoleAi
|
|
|
37
37
|
$stdout.puts
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
def display_result_output(output)
|
|
41
|
+
text = output.to_s
|
|
42
|
+
return if text.strip.empty?
|
|
43
|
+
|
|
44
|
+
lines = text.lines
|
|
45
|
+
total_lines = lines.length
|
|
46
|
+
total_chars = text.length
|
|
47
|
+
|
|
48
|
+
if total_lines <= MAX_DISPLAY_LINES && total_chars <= MAX_DISPLAY_CHARS
|
|
49
|
+
$stdout.print text
|
|
50
|
+
else
|
|
51
|
+
truncated = lines.first(MAX_DISPLAY_LINES).join
|
|
52
|
+
truncated = truncated[0, MAX_DISPLAY_CHARS] if truncated.length > MAX_DISPLAY_CHARS
|
|
53
|
+
$stdout.print truncated
|
|
54
|
+
|
|
55
|
+
omitted_lines = [total_lines - MAX_DISPLAY_LINES, 0].max
|
|
56
|
+
omitted_chars = [total_chars - truncated.length, 0].max
|
|
57
|
+
parts = []
|
|
58
|
+
parts << "#{omitted_lines} lines" if omitted_lines > 0
|
|
59
|
+
parts << "#{omitted_chars} chars" if omitted_chars > 0
|
|
60
|
+
|
|
61
|
+
@omitted_counter += 1
|
|
62
|
+
@omitted_outputs[@omitted_counter] = text
|
|
63
|
+
$stdout.puts colorize(" (output truncated, omitting #{parts.join(', ')}) /expand #{@omitted_counter} to see all", :yellow)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
40
67
|
def display_result(result)
|
|
41
68
|
full = "=> #{result.inspect}"
|
|
42
69
|
lines = full.lines
|
|
@@ -255,8 +282,12 @@ module RailsConsoleAi
|
|
|
255
282
|
@engine.display_conversation
|
|
256
283
|
when '/cost'
|
|
257
284
|
@engine.display_cost_summary
|
|
285
|
+
when '/model'
|
|
286
|
+
display_model_info
|
|
258
287
|
when '/think'
|
|
259
288
|
@engine.upgrade_to_thinking_model
|
|
289
|
+
when '/unthink'
|
|
290
|
+
@engine.downgrade_from_thinking_model
|
|
260
291
|
when /\A\/expand/
|
|
261
292
|
expand_id = input.sub('/expand', '').strip.to_i
|
|
262
293
|
full_output = expand_output(expand_id)
|
|
@@ -320,6 +351,27 @@ module RailsConsoleAi
|
|
|
320
351
|
end
|
|
321
352
|
end
|
|
322
353
|
|
|
354
|
+
def display_model_info
|
|
355
|
+
config = RailsConsoleAi.configuration
|
|
356
|
+
model = @engine.effective_model
|
|
357
|
+
thinking = config.resolved_thinking_model
|
|
358
|
+
pricing = Configuration::PRICING[model]
|
|
359
|
+
|
|
360
|
+
@real_stdout.puts "\e[36m Model info:\e[0m"
|
|
361
|
+
@real_stdout.puts "\e[2m Provider: #{config.provider}\e[0m"
|
|
362
|
+
@real_stdout.puts "\e[2m Model: #{model}\e[0m"
|
|
363
|
+
@real_stdout.puts "\e[2m Thinking model: #{thinking}\e[0m"
|
|
364
|
+
@real_stdout.puts "\e[2m Max tokens: #{config.resolved_max_tokens}\e[0m"
|
|
365
|
+
if pricing
|
|
366
|
+
@real_stdout.puts "\e[2m Pricing: $#{pricing[:input] * 1_000_000}/M in, $#{pricing[:output] * 1_000_000}/M out\e[0m"
|
|
367
|
+
if pricing[:cache_read]
|
|
368
|
+
@real_stdout.puts "\e[2m Cache pricing: $#{pricing[:cache_read] * 1_000_000}/M read, $#{pricing[:cache_write] * 1_000_000}/M write\e[0m"
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
@real_stdout.puts "\e[2m Bedrock region: #{config.bedrock_region}\e[0m" if config.provider == :bedrock
|
|
372
|
+
@real_stdout.puts "\e[2m Local URL: #{config.local_url}\e[0m" if config.provider == :local
|
|
373
|
+
end
|
|
374
|
+
|
|
323
375
|
def handle_name_command(input)
|
|
324
376
|
name = input.sub('/name', '').strip.gsub(/\A(['"])(.*)\1\z/, '\2')
|
|
325
377
|
if name.empty?
|
|
@@ -344,7 +396,9 @@ module RailsConsoleAi
|
|
|
344
396
|
@real_stdout.puts "\e[2m /danger Toggle safe mode (currently #{safe_status})\e[0m"
|
|
345
397
|
@real_stdout.puts "\e[2m /safe Show safety guard status\e[0m"
|
|
346
398
|
end
|
|
399
|
+
@real_stdout.puts "\e[2m /model Show provider, model, and pricing info\e[0m"
|
|
347
400
|
@real_stdout.puts "\e[2m /think Switch to thinking model\e[0m"
|
|
401
|
+
@real_stdout.puts "\e[2m /unthink Switch back to default model\e[0m"
|
|
348
402
|
@real_stdout.puts "\e[2m /compact Summarize conversation to reduce context\e[0m"
|
|
349
403
|
@real_stdout.puts "\e[2m /usage Show session token totals\e[0m"
|
|
350
404
|
@real_stdout.puts "\e[2m /cost Show cost estimate by model\e[0m"
|
|
@@ -130,10 +130,14 @@ module RailsConsoleAi
|
|
|
130
130
|
nil
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
-
def
|
|
133
|
+
def ai_db_setup
|
|
134
134
|
RailsConsoleAi.setup!
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
+
def ai_db_migrate
|
|
138
|
+
RailsConsoleAi.migrate!
|
|
139
|
+
end
|
|
140
|
+
|
|
137
141
|
def ai_init
|
|
138
142
|
require 'rails_console_ai/context_builder'
|
|
139
143
|
require 'rails_console_ai/providers/base'
|
|
@@ -157,7 +161,8 @@ module RailsConsoleAi
|
|
|
157
161
|
$stderr.puts "\e[33m ai_sessions - list recent sessions\e[0m"
|
|
158
162
|
$stderr.puts "\e[33m ai_resume - resume a session by name or id\e[0m"
|
|
159
163
|
$stderr.puts "\e[33m ai_name - name a session: ai_name 42, \"my_label\"\e[0m"
|
|
160
|
-
$stderr.puts "\e[33m
|
|
164
|
+
$stderr.puts "\e[33m ai_db_setup - install session logging table + run migrations\e[0m"
|
|
165
|
+
$stderr.puts "\e[33m ai_db_migrate- run pending session table migrations\e[0m"
|
|
161
166
|
$stderr.puts "\e[33m ai_status - show current configuration\e[0m"
|
|
162
167
|
$stderr.puts "\e[33m ai_memories - show recent memories (ai_memories(n) for last n)\e[0m"
|
|
163
168
|
return nil
|
|
@@ -82,9 +82,6 @@ module RailsConsoleAi
|
|
|
82
82
|
(e.g. `api = SalesforceApi.new(step1)`).
|
|
83
83
|
- If the user asks you to provide code for them to run later (not execute now), put it
|
|
84
84
|
in a ```ruby code block in your text response.
|
|
85
|
-
- Use `RailsConsoleAi.configuration.safety_guards.without_guards { }` to wrap any
|
|
86
|
-
operation that should bypass safety guards (e.g. calling a known-safe admin method).
|
|
87
|
-
|
|
88
85
|
You have skills — predefined procedures for specific operations. When a user's request
|
|
89
86
|
matches a skill, call activate_skill first to load the recipe and enable its guard
|
|
90
87
|
bypasses, then follow the recipe.
|
|
@@ -4,11 +4,14 @@ module RailsConsoleAi
|
|
|
4
4
|
:interactive_session_id, :session_name
|
|
5
5
|
|
|
6
6
|
RECENT_OUTPUTS_TO_KEEP = 2
|
|
7
|
+
LARGE_OUTPUT_THRESHOLD = 10_000 # chars — truncate tool results larger than this immediately
|
|
8
|
+
LARGE_OUTPUT_PREVIEW_CHARS = 8_000 # chars — how much of the output the LLM sees upfront
|
|
7
9
|
|
|
8
|
-
def initialize(binding_context:, channel:, slack_thread_ts: nil)
|
|
10
|
+
def initialize(binding_context:, channel:, slack_thread_ts: nil, slack_channel_name: nil)
|
|
9
11
|
@binding_context = binding_context
|
|
10
12
|
@channel = channel
|
|
11
13
|
@slack_thread_ts = slack_thread_ts
|
|
14
|
+
@slack_channel_name = slack_channel_name
|
|
12
15
|
@executor = Executor.new(binding_context, channel: channel)
|
|
13
16
|
@provider = nil
|
|
14
17
|
@context_builder = nil
|
|
@@ -239,11 +242,16 @@ module RailsConsoleAi
|
|
|
239
242
|
output_parts << "Return value: #{exec_result.inspect}" if exec_result
|
|
240
243
|
|
|
241
244
|
result_str = output_parts.join("\n\n")
|
|
242
|
-
result_str = result_str[0..1000] + '...' if result_str.length > 1000
|
|
243
245
|
|
|
244
246
|
context_msg = "User directly executed code: `#{raw_code}`"
|
|
245
|
-
|
|
246
|
-
|
|
247
|
+
if result_str.length > LARGE_OUTPUT_THRESHOLD
|
|
248
|
+
output_id = @executor.store_output(result_str)
|
|
249
|
+
preview = result_str[0, LARGE_OUTPUT_PREVIEW_CHARS]
|
|
250
|
+
context_msg += "\n#{preview}\n\n[Output truncated at #{LARGE_OUTPUT_PREVIEW_CHARS} of #{result_str.length} chars — use recall_output tool with id #{output_id} to retrieve the full output]"
|
|
251
|
+
elsif !output_parts.empty?
|
|
252
|
+
output_id = @executor.store_output(result_str)
|
|
253
|
+
context_msg += "\n#{result_str}"
|
|
254
|
+
end
|
|
247
255
|
@history << { role: :user, content: context_msg, output_id: output_id }
|
|
248
256
|
|
|
249
257
|
@interactive_query ||= "> #{raw_code}"
|
|
@@ -295,7 +303,6 @@ module RailsConsoleAi
|
|
|
295
303
|
end
|
|
296
304
|
|
|
297
305
|
if @executor.last_cancelled?
|
|
298
|
-
@history << { role: :user, content: "User declined to execute the code." }
|
|
299
306
|
:cancelled
|
|
300
307
|
elsif @executor.last_safety_error
|
|
301
308
|
exec_result = @executor.offer_danger_retry(code)
|
|
@@ -312,13 +319,18 @@ module RailsConsoleAi
|
|
|
312
319
|
output_parts << "Return value: #{exec_result.inspect}" if exec_result
|
|
313
320
|
unless output_parts.empty?
|
|
314
321
|
result_str = output_parts.join("\n\n")
|
|
315
|
-
result_str = result_str[0..1000] + '...' if result_str.length > 1000
|
|
316
322
|
output_id = @executor.store_output(result_str)
|
|
317
|
-
|
|
323
|
+
context_msg = "Code was executed (safety override). "
|
|
324
|
+
if result_str.length > LARGE_OUTPUT_THRESHOLD
|
|
325
|
+
context_msg += result_str[0, LARGE_OUTPUT_PREVIEW_CHARS]
|
|
326
|
+
context_msg += "\n\n[Output truncated at #{LARGE_OUTPUT_PREVIEW_CHARS} of #{result_str.length} chars — use recall_output tool with id #{output_id} to retrieve the full output]"
|
|
327
|
+
else
|
|
328
|
+
context_msg += result_str
|
|
329
|
+
end
|
|
330
|
+
@history << { role: :user, content: context_msg, output_id: output_id }
|
|
318
331
|
end
|
|
319
332
|
:success
|
|
320
333
|
else
|
|
321
|
-
@history << { role: :user, content: "User declined to execute with safe mode disabled." }
|
|
322
334
|
:cancelled
|
|
323
335
|
end
|
|
324
336
|
elsif @executor.last_error
|
|
@@ -335,9 +347,15 @@ module RailsConsoleAi
|
|
|
335
347
|
|
|
336
348
|
unless output_parts.empty?
|
|
337
349
|
result_str = output_parts.join("\n\n")
|
|
338
|
-
result_str = result_str[0..1000] + '...' if result_str.length > 1000
|
|
339
350
|
output_id = @executor.store_output(result_str)
|
|
340
|
-
|
|
351
|
+
context_msg = "Code was executed. "
|
|
352
|
+
if result_str.length > LARGE_OUTPUT_THRESHOLD
|
|
353
|
+
context_msg += result_str[0, LARGE_OUTPUT_PREVIEW_CHARS]
|
|
354
|
+
context_msg += "\n\n[Output truncated at #{LARGE_OUTPUT_PREVIEW_CHARS} of #{result_str.length} chars — use recall_output tool with id #{output_id} to retrieve the full output]"
|
|
355
|
+
else
|
|
356
|
+
context_msg += result_str
|
|
357
|
+
end
|
|
358
|
+
@history << { role: :user, content: context_msg, output_id: output_id }
|
|
341
359
|
end
|
|
342
360
|
|
|
343
361
|
:success
|
|
@@ -417,16 +435,36 @@ module RailsConsoleAi
|
|
|
417
435
|
|
|
418
436
|
def upgrade_to_thinking_model
|
|
419
437
|
config = RailsConsoleAi.configuration
|
|
420
|
-
current =
|
|
438
|
+
current = effective_model
|
|
421
439
|
thinking = config.resolved_thinking_model
|
|
422
440
|
|
|
423
441
|
if current == thinking
|
|
424
442
|
$stdout.puts "\e[36m Already using thinking model (#{current}).\e[0m"
|
|
425
443
|
else
|
|
426
|
-
|
|
444
|
+
@model_override = thinking
|
|
427
445
|
@provider = nil
|
|
428
446
|
$stdout.puts "\e[36m Switched to thinking model: #{thinking}\e[0m"
|
|
429
447
|
end
|
|
448
|
+
effective_model
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def downgrade_from_thinking_model
|
|
452
|
+
config = RailsConsoleAi.configuration
|
|
453
|
+
default = config.resolved_model
|
|
454
|
+
current = effective_model
|
|
455
|
+
|
|
456
|
+
if current == default && @model_override.nil?
|
|
457
|
+
$stdout.puts "\e[36m Already using default model (#{current}).\e[0m"
|
|
458
|
+
else
|
|
459
|
+
@model_override = nil
|
|
460
|
+
@provider = nil
|
|
461
|
+
$stdout.puts "\e[36m Switched back to default model: #{default}\e[0m"
|
|
462
|
+
end
|
|
463
|
+
effective_model
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def effective_model
|
|
467
|
+
@model_override || RailsConsoleAi.configuration.resolved_model
|
|
430
468
|
end
|
|
431
469
|
|
|
432
470
|
def compact_history
|
|
@@ -522,6 +560,8 @@ module RailsConsoleAi
|
|
|
522
560
|
name: @session_name
|
|
523
561
|
)
|
|
524
562
|
log_attrs[:slack_thread_ts] = @slack_thread_ts if @slack_thread_ts
|
|
563
|
+
log_attrs[:slack_channel_name] = @slack_channel_name if @slack_channel_name
|
|
564
|
+
log_attrs[:model] = effective_model
|
|
525
565
|
if @channel.user_identity
|
|
526
566
|
log_attrs[:user_name] = @channel.mode == 'slack' ? "slack:#{@channel.user_identity}" : @channel.user_identity
|
|
527
567
|
end
|
|
@@ -558,6 +598,7 @@ module RailsConsoleAi
|
|
|
558
598
|
start_time: @interactive_start
|
|
559
599
|
}
|
|
560
600
|
log_attrs[:slack_thread_ts] = @slack_thread_ts if @slack_thread_ts
|
|
601
|
+
log_attrs[:slack_channel_name] = @slack_channel_name if @slack_channel_name
|
|
561
602
|
if @channel.user_identity
|
|
562
603
|
log_attrs[:user_name] = @channel.mode == 'slack' ? "slack:#{@channel.user_identity}" : @channel.user_identity
|
|
563
604
|
end
|
|
@@ -591,6 +632,8 @@ module RailsConsoleAi
|
|
|
591
632
|
|
|
592
633
|
This session has safety guards that block side effects (database writes, HTTP mutations, etc.).
|
|
593
634
|
If an operation is blocked, the user will be prompted to allow it or disable guards.
|
|
635
|
+
Do NOT attempt to bypass or work around safety guards in your code — just write the
|
|
636
|
+
operation normally and let the safety system handle it.
|
|
594
637
|
PROMPT
|
|
595
638
|
end
|
|
596
639
|
end
|
|
@@ -619,7 +662,15 @@ module RailsConsoleAi
|
|
|
619
662
|
end
|
|
620
663
|
|
|
621
664
|
def provider
|
|
622
|
-
@provider ||=
|
|
665
|
+
@provider ||= begin
|
|
666
|
+
if @model_override
|
|
667
|
+
config = RailsConsoleAi.configuration.dup
|
|
668
|
+
config.model = @model_override
|
|
669
|
+
Providers.build(config)
|
|
670
|
+
else
|
|
671
|
+
Providers.build
|
|
672
|
+
end
|
|
673
|
+
end
|
|
623
674
|
end
|
|
624
675
|
|
|
625
676
|
def context_builder
|
|
@@ -736,6 +787,10 @@ module RailsConsoleAi
|
|
|
736
787
|
@channel.display_dim(" #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}")
|
|
737
788
|
end
|
|
738
789
|
|
|
790
|
+
# Trim old tool outputs between rounds to prevent context explosion.
|
|
791
|
+
# The LLM can still retrieve omitted outputs via recall_output.
|
|
792
|
+
messages = trim_old_outputs(messages) if round > 0
|
|
793
|
+
|
|
739
794
|
if RailsConsoleAi.configuration.debug
|
|
740
795
|
debug_pre_call(round, messages, active_system_prompt, tools, total_input, total_output)
|
|
741
796
|
end
|
|
@@ -767,6 +822,28 @@ module RailsConsoleAi
|
|
|
767
822
|
last_tool_names = result.tool_calls.map { |tc| tc[:name] }
|
|
768
823
|
result.tool_calls.each do |tc|
|
|
769
824
|
break if @channel.cancelled?
|
|
825
|
+
|
|
826
|
+
# Intercept recall_output/recall_outputs: expand in place instead of adding large messages
|
|
827
|
+
if tc[:name] == 'recall_output' || tc[:name] == 'recall_outputs'
|
|
828
|
+
ids = if tc[:name] == 'recall_outputs'
|
|
829
|
+
Array(tc[:arguments]['ids']).map(&:to_i)
|
|
830
|
+
else
|
|
831
|
+
[tc[:arguments]['id'].to_i]
|
|
832
|
+
end
|
|
833
|
+
@channel.display_tool_call("#{tc[:name]}(#{ids.join(', ')})")
|
|
834
|
+
expanded = expand_outputs_in_place(messages, ids)
|
|
835
|
+
tool_result = if expanded.any?
|
|
836
|
+
"Expanded #{expanded.length} output(s) in conversation. The full content is now visible in the original message(s) above."
|
|
837
|
+
else
|
|
838
|
+
"No matching outputs found with id(s) #{ids.join(', ')}."
|
|
839
|
+
end
|
|
840
|
+
@channel.display_dim(" #{tool_result}")
|
|
841
|
+
tool_msg = provider.format_tool_result(tc[:id], tool_result)
|
|
842
|
+
messages << tool_msg
|
|
843
|
+
new_messages << tool_msg
|
|
844
|
+
next
|
|
845
|
+
end
|
|
846
|
+
|
|
770
847
|
if tc[:name] == 'ask_user' || tc[:name] == 'execute_plan'
|
|
771
848
|
# Display any pending LLM text before prompting the user
|
|
772
849
|
if last_thinking
|
|
@@ -790,16 +867,32 @@ module RailsConsoleAi
|
|
|
790
867
|
end
|
|
791
868
|
|
|
792
869
|
tool_msg = provider.format_tool_result(tc[:id], tool_result)
|
|
793
|
-
|
|
794
|
-
|
|
870
|
+
full_text = tool_result.to_s
|
|
871
|
+
if full_text.length > LARGE_OUTPUT_THRESHOLD
|
|
872
|
+
output_id = @executor.store_output(full_text)
|
|
873
|
+
tool_msg[:output_id] = output_id
|
|
874
|
+
truncated = full_text[0, LARGE_OUTPUT_PREVIEW_CHARS]
|
|
875
|
+
truncated += "\n\n[Output truncated at #{LARGE_OUTPUT_PREVIEW_CHARS} of #{full_text.length} chars — use recall_output tool with id #{output_id} to retrieve the full output]"
|
|
876
|
+
tool_msg = provider.format_tool_result(tc[:id], truncated)
|
|
877
|
+
tool_msg[:output_id] = output_id
|
|
878
|
+
elsif full_text.length > 200
|
|
879
|
+
tool_msg[:output_id] = @executor.store_output(full_text)
|
|
795
880
|
end
|
|
796
881
|
messages << tool_msg
|
|
797
882
|
new_messages << tool_msg
|
|
798
883
|
end
|
|
799
884
|
|
|
885
|
+
# If the user declined execution, don't call the LLM again —
|
|
886
|
+
# just return to the prompt so they can correct their request.
|
|
887
|
+
break if @executor.last_cancelled?
|
|
888
|
+
|
|
800
889
|
exhausted = true if round == max_rounds - 1
|
|
801
890
|
end
|
|
802
891
|
|
|
892
|
+
# Re-truncate any outputs that were expanded for the LLM — the LLM has
|
|
893
|
+
# seen them and responded, so collapse back to save context on future calls.
|
|
894
|
+
re_truncate_expanded(messages)
|
|
895
|
+
|
|
803
896
|
if exhausted
|
|
804
897
|
$stdout.puts "\e[33m Hit tool round limit (#{max_rounds}). Forcing final answer. Increase with: RailsConsoleAi.configure { |c| c.max_tool_rounds = 200 }\e[0m"
|
|
805
898
|
messages << { role: :user, content: "You've used all available tool rounds. Please provide your best answer now based on what you've learned so far." }
|
|
@@ -821,7 +914,7 @@ module RailsConsoleAi
|
|
|
821
914
|
@total_input_tokens += result.input_tokens || 0
|
|
822
915
|
@total_output_tokens += result.output_tokens || 0
|
|
823
916
|
|
|
824
|
-
model =
|
|
917
|
+
model = effective_model
|
|
825
918
|
@token_usage[model][:input] += result.input_tokens || 0
|
|
826
919
|
@token_usage[model][:output] += result.output_tokens || 0
|
|
827
920
|
@token_usage[model][:cache_read] = (@token_usage[model][:cache_read] || 0) + (result.cache_read_input_tokens || 0)
|
|
@@ -865,7 +958,8 @@ module RailsConsoleAi
|
|
|
865
958
|
attrs.merge(
|
|
866
959
|
input_tokens: @total_input_tokens,
|
|
867
960
|
output_tokens: @total_output_tokens,
|
|
868
|
-
duration_ms: duration_ms
|
|
961
|
+
duration_ms: duration_ms,
|
|
962
|
+
model: effective_model
|
|
869
963
|
)
|
|
870
964
|
)
|
|
871
965
|
end
|
|
@@ -896,6 +990,8 @@ module RailsConsoleAi
|
|
|
896
990
|
when 'save_memory' then "(\"#{args['name']}\")"
|
|
897
991
|
when 'delete_memory' then "(\"#{args['name']}\")"
|
|
898
992
|
when 'recall_memories' then args['query'] ? "(\"#{args['query']}\")" : ''
|
|
993
|
+
when 'activate_skill' then "(\"#{args['name']}\")"
|
|
994
|
+
when 'recall_output' then "(#{args['id']})"
|
|
899
995
|
when 'execute_plan'
|
|
900
996
|
steps = args['steps']
|
|
901
997
|
steps ? "(#{steps.length} steps)" : ''
|
|
@@ -1021,7 +1117,7 @@ module RailsConsoleAi
|
|
|
1021
1117
|
|
|
1022
1118
|
input_t = result.input_tokens || 0
|
|
1023
1119
|
output_t = result.output_tokens || 0
|
|
1024
|
-
model =
|
|
1120
|
+
model = effective_model
|
|
1025
1121
|
pricing = Configuration::PRICING[model]
|
|
1026
1122
|
pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
|
|
1027
1123
|
|
|
@@ -1059,16 +1155,14 @@ module RailsConsoleAi
|
|
|
1059
1155
|
.select { |m, _| m[:output_id] }
|
|
1060
1156
|
.map { |_, i| i }
|
|
1061
1157
|
|
|
1062
|
-
if output_indices.length <= RECENT_OUTPUTS_TO_KEEP
|
|
1063
|
-
return messages.map { |m| m.except(:output_id) }
|
|
1064
|
-
end
|
|
1158
|
+
return messages if output_indices.length <= RECENT_OUTPUTS_TO_KEEP
|
|
1065
1159
|
|
|
1066
1160
|
trim_indices = output_indices[0..-(RECENT_OUTPUTS_TO_KEEP + 1)]
|
|
1067
1161
|
messages.each_with_index.map do |msg, i|
|
|
1068
1162
|
if trim_indices.include?(i)
|
|
1069
1163
|
trim_message(msg)
|
|
1070
1164
|
else
|
|
1071
|
-
msg
|
|
1165
|
+
msg
|
|
1072
1166
|
end
|
|
1073
1167
|
end
|
|
1074
1168
|
end
|
|
@@ -1084,12 +1178,52 @@ module RailsConsoleAi
|
|
|
1084
1178
|
block
|
|
1085
1179
|
end
|
|
1086
1180
|
end
|
|
1087
|
-
|
|
1181
|
+
msg.merge(content: trimmed_content)
|
|
1088
1182
|
elsif msg[:role].to_s == 'tool'
|
|
1089
|
-
msg.
|
|
1183
|
+
msg.merge(content: ref)
|
|
1090
1184
|
else
|
|
1091
1185
|
first_line = msg[:content].to_s.lines.first&.strip || msg[:content]
|
|
1092
|
-
|
|
1186
|
+
msg.merge(content: "#{first_line}\n#{ref}")
|
|
1187
|
+
end
|
|
1188
|
+
end
|
|
1189
|
+
|
|
1190
|
+
def expand_outputs_in_place(messages, ids)
|
|
1191
|
+
expanded = []
|
|
1192
|
+
messages.each do |msg|
|
|
1193
|
+
next unless msg[:output_id] && ids.include?(msg[:output_id])
|
|
1194
|
+
full_output = @executor.recall_output(msg[:output_id])
|
|
1195
|
+
next unless full_output
|
|
1196
|
+
# Save original content so re_truncate_expanded can restore it
|
|
1197
|
+
msg[:pre_expand_content] = msg[:content]
|
|
1198
|
+
# Replace content with full output (handle Anthropic, OpenAI, and user message formats)
|
|
1199
|
+
if msg[:content].is_a?(Array)
|
|
1200
|
+
msg[:content] = msg[:content].map do |block|
|
|
1201
|
+
if block.is_a?(Hash) && block['type'] == 'tool_result'
|
|
1202
|
+
block.merge('content' => full_output)
|
|
1203
|
+
else
|
|
1204
|
+
block
|
|
1205
|
+
end
|
|
1206
|
+
end
|
|
1207
|
+
elsif msg[:role].to_s == 'tool'
|
|
1208
|
+
msg[:content] = full_output
|
|
1209
|
+
else
|
|
1210
|
+
# User messages (e.g., direct execution) — preserve first line, replace rest
|
|
1211
|
+
first_line = msg[:content].to_s.lines.first&.chomp || ''
|
|
1212
|
+
msg[:content] = "#{first_line}\n#{full_output}"
|
|
1213
|
+
end
|
|
1214
|
+
msg[:expanded] = true
|
|
1215
|
+
expanded << msg[:output_id]
|
|
1216
|
+
end
|
|
1217
|
+
expanded
|
|
1218
|
+
end
|
|
1219
|
+
|
|
1220
|
+
# Restore messages that were temporarily expanded back to their original
|
|
1221
|
+
# (preview/truncated) content. Called after the LLM has seen the expanded
|
|
1222
|
+
# content and responded.
|
|
1223
|
+
def re_truncate_expanded(messages)
|
|
1224
|
+
messages.each do |msg|
|
|
1225
|
+
next unless msg.delete(:expanded)
|
|
1226
|
+
msg[:content] = msg.delete(:pre_expand_content)
|
|
1093
1227
|
end
|
|
1094
1228
|
end
|
|
1095
1229
|
|