console_agent 0.7.0 → 0.9.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 +58 -0
- data/README.md +40 -0
- data/app/controllers/console_agent/application_controller.rb +15 -8
- data/lib/console_agent/configuration.rb +36 -4
- data/lib/console_agent/executor.rb +55 -21
- data/lib/console_agent/providers/anthropic.rb +1 -1
- data/lib/console_agent/providers/openai.rb +1 -1
- data/lib/console_agent/repl.rb +216 -8
- data/lib/console_agent/version.rb +1 -1
- data/lib/generators/console_agent/templates/initializer.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5f82de0e3bdd6d1e3189c3b1bd0c88c386a6b31b4dff317656365352490f06c9
|
|
4
|
+
data.tar.gz: 1347a93e946c254809cdf6bfa90e3dea0378b7aef9a5e04950bdf0151569cd79
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cec5e56aaddea19c5d1b9046fcbd7fd4707d16efa3fab65f9977703e24d28d177c12331c9cba7590048b1ad2f412d67bcb9ffe78c5cef288d6da40916cb53a86
|
|
7
|
+
data.tar.gz: 9e67f0c30bbd38561c400523ca19240f931ff5c08b353fa545037472b6b2195f931b5987b21d3c643a35e2cb3377647447cc676aa7544ba5a2ccec2a505f7e16
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.8.0]
|
|
6
|
+
|
|
7
|
+
- Add authentication function support so host apps can avoid using basic auth
|
|
8
|
+
- Add `/think` and `/cost` commands with Sonnet vs Opus support
|
|
9
|
+
- Gracefully handle token limit exceeded errors
|
|
10
|
+
|
|
11
|
+
## [0.7.0]
|
|
12
|
+
|
|
13
|
+
- Include binding variables and their classes in the Rails console context
|
|
14
|
+
- Add `ai_setup` command
|
|
15
|
+
- Add `/compact` mechanism for conversation management
|
|
16
|
+
- Catch errors and attempt to auto-fix them
|
|
17
|
+
|
|
18
|
+
## [0.6.0]
|
|
19
|
+
|
|
20
|
+
- Add core memory (`console_agent.md`) that persists across sessions in the system prompt
|
|
21
|
+
- Add `ai_init` command to seed core memory
|
|
22
|
+
- Allow reading partial files
|
|
23
|
+
- Fix rspec hanging issues
|
|
24
|
+
|
|
25
|
+
## [0.5.0]
|
|
26
|
+
|
|
27
|
+
- Auto-accept single-step plans
|
|
28
|
+
- Support `>` shorthand to run code directly
|
|
29
|
+
- Add `script/release` for releases
|
|
30
|
+
|
|
31
|
+
## [0.4.0]
|
|
32
|
+
|
|
33
|
+
- Fix resuming sessions repeatedly
|
|
34
|
+
- Fix terminal flashing/loading in production (kubectl)
|
|
35
|
+
- Better escaping during thinking output
|
|
36
|
+
|
|
37
|
+
## [0.3.0]
|
|
38
|
+
|
|
39
|
+
- Add plan mechanism with "auto" execution mode
|
|
40
|
+
- Add session logging to DB with `/console_agent` admin UI
|
|
41
|
+
- List and resume past sessions with pagination
|
|
42
|
+
- Add shift-tab for auto-execute mode
|
|
43
|
+
- Add usage display and debug toggle
|
|
44
|
+
- Store sessions incrementally; improved code segment display
|
|
45
|
+
|
|
46
|
+
## [0.2.0]
|
|
47
|
+
|
|
48
|
+
- Add memory system with individual file storage
|
|
49
|
+
- Add `ask_user` tool
|
|
50
|
+
- Add registry cache
|
|
51
|
+
- Fix REPL up-key and ctrl-a navigation
|
|
52
|
+
- Show tool usage and model processing info
|
|
53
|
+
- Add token count information and debug ability
|
|
54
|
+
- Use tools-based approach instead of sending everything at once
|
|
55
|
+
|
|
56
|
+
## [0.1.0]
|
|
57
|
+
|
|
58
|
+
- Initial implementation
|
data/README.md
CHANGED
|
@@ -77,15 +77,21 @@ end
|
|
|
77
77
|
| `/auto` | Toggle auto-execute (skip confirmations) |
|
|
78
78
|
| `/compact` | Compress history into a summary (saves tokens) |
|
|
79
79
|
| `/usage` | Show token stats |
|
|
80
|
+
| `/cost` | Show per-model cost breakdown |
|
|
81
|
+
| `/think` | Upgrade to thinking model (Opus) for the rest of the session |
|
|
80
82
|
| `/debug` | Toggle raw API output |
|
|
81
83
|
| `/name <label>` | Name the session for easy resume |
|
|
82
84
|
|
|
83
85
|
Prefix input with `>` to run Ruby directly (no LLM round-trip). The result is added to conversation context.
|
|
84
86
|
|
|
87
|
+
Say "think harder" in any query to auto-upgrade to the thinking model for that session. After 5+ tool rounds, you'll also be prompted to switch.
|
|
88
|
+
|
|
85
89
|
## Features
|
|
86
90
|
|
|
87
91
|
- **Tool use** — AI introspects your schema, models, files, and code to write accurate queries
|
|
88
92
|
- **Multi-step plans** — complex tasks are broken into steps, executed sequentially with `step1`/`step2` references
|
|
93
|
+
- **Two-tier models** — defaults to Sonnet for speed/cost; `/think` upgrades to Opus when you need it
|
|
94
|
+
- **Cost tracking** — `/cost` shows per-model token usage and estimated spend
|
|
89
95
|
- **Memories** — AI saves what it learns about your app across sessions
|
|
90
96
|
- **App guide** — `ai_init` generates a guide injected into every system prompt
|
|
91
97
|
- **Sessions** — name, list, and resume interactive conversations (`ai_setup` to enable)
|
|
@@ -98,9 +104,43 @@ ConsoleAgent.configure do |config|
|
|
|
98
104
|
config.provider = :anthropic # or :openai
|
|
99
105
|
config.auto_execute = false # true to skip confirmations
|
|
100
106
|
config.session_logging = true # requires ai_setup
|
|
107
|
+
config.model = 'claude-sonnet-4-6' # model used by /think (default)
|
|
108
|
+
config.thinking_model = 'claude-opus-4-6' # model used by /think (default)
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The default model is `claude-sonnet-4-6` (Anthropic) or `gpt-5.3-codex` (OpenAI). The thinking model defaults to `claude-opus-4-6` and is activated via `/think` or by saying "think harder".
|
|
113
|
+
|
|
114
|
+
## Web UI Authentication
|
|
115
|
+
|
|
116
|
+
The engine mounts a session viewer at `/console_agent`. By default it's open — you can protect it with basic auth or a custom authentication function.
|
|
117
|
+
|
|
118
|
+
### Basic Auth
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
ConsoleAgent.configure do |config|
|
|
122
|
+
config.admin_username = 'admin'
|
|
123
|
+
config.admin_password = ENV['CONSOLE_AGENT_PASSWORD']
|
|
101
124
|
end
|
|
102
125
|
```
|
|
103
126
|
|
|
127
|
+
### Custom Authentication
|
|
128
|
+
|
|
129
|
+
For apps with their own auth system, pass a proc to `authenticate`. It runs in the controller context, so you have access to `session`, `request`, `redirect_to`, etc.
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
ConsoleAgent.configure do |config|
|
|
133
|
+
config.authenticate = proc {
|
|
134
|
+
user = User.find_by(id: session[:user_id])
|
|
135
|
+
unless user&.admin?
|
|
136
|
+
redirect_to '/login'
|
|
137
|
+
end
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
When `authenticate` is set, `admin_username` / `admin_password` are ignored.
|
|
143
|
+
|
|
104
144
|
## Requirements
|
|
105
145
|
|
|
106
146
|
Ruby >= 2.5, Rails >= 5.0, Faraday >= 1.0
|
|
@@ -2,19 +2,26 @@ module ConsoleAgent
|
|
|
2
2
|
class ApplicationController < ActionController::Base
|
|
3
3
|
protect_from_forgery with: :exception
|
|
4
4
|
|
|
5
|
-
before_action :
|
|
5
|
+
before_action :console_agent_authenticate!
|
|
6
6
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
|
-
def
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
def console_agent_authenticate!
|
|
10
|
+
if (auth = ConsoleAgent.configuration.authenticate)
|
|
11
|
+
instance_exec(&auth)
|
|
12
|
+
else
|
|
13
|
+
username = ConsoleAgent.configuration.admin_username
|
|
14
|
+
password = ConsoleAgent.configuration.admin_password
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
unless username && password
|
|
17
|
+
head :unauthorized
|
|
18
|
+
return
|
|
19
|
+
end
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
authenticate_or_request_with_http_basic('ConsoleAgent Admin') do |u, p|
|
|
22
|
+
ActiveSupport::SecurityUtils.secure_compare(u, username) &
|
|
23
|
+
ActiveSupport::SecurityUtils.secure_compare(p, password)
|
|
24
|
+
end
|
|
18
25
|
end
|
|
19
26
|
end
|
|
20
27
|
end
|
|
@@ -2,29 +2,44 @@ module ConsoleAgent
|
|
|
2
2
|
class Configuration
|
|
3
3
|
PROVIDERS = %i[anthropic openai].freeze
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
PRICING = {
|
|
6
|
+
'claude-sonnet-4-6' => { input: 3.0 / 1_000_000, output: 15.0 / 1_000_000 },
|
|
7
|
+
'claude-opus-4-6' => { input: 15.0 / 1_000_000, output: 75.0 / 1_000_000 },
|
|
8
|
+
'claude-haiku-4-5-20251001' => { input: 0.80 / 1_000_000, output: 4.0 / 1_000_000 },
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
DEFAULT_MAX_TOKENS = {
|
|
12
|
+
'claude-sonnet-4-6' => 16_000,
|
|
13
|
+
'claude-haiku-4-5-20251001' => 16_000,
|
|
14
|
+
'claude-opus-4-6' => 4_096,
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
attr_accessor :provider, :api_key, :model, :thinking_model, :max_tokens,
|
|
6
18
|
:auto_execute, :temperature,
|
|
7
19
|
:timeout, :debug, :max_tool_rounds,
|
|
8
20
|
:storage_adapter, :memories_enabled,
|
|
9
21
|
:session_logging, :connection_class,
|
|
10
|
-
:admin_username, :admin_password
|
|
22
|
+
:admin_username, :admin_password,
|
|
23
|
+
:authenticate
|
|
11
24
|
|
|
12
25
|
def initialize
|
|
13
26
|
@provider = :anthropic
|
|
14
27
|
@api_key = nil
|
|
15
28
|
@model = nil
|
|
16
|
-
@
|
|
29
|
+
@thinking_model = nil
|
|
30
|
+
@max_tokens = nil
|
|
17
31
|
@auto_execute = false
|
|
18
32
|
@temperature = 0.2
|
|
19
33
|
@timeout = 30
|
|
20
34
|
@debug = false
|
|
21
|
-
@max_tool_rounds =
|
|
35
|
+
@max_tool_rounds = 200
|
|
22
36
|
@storage_adapter = nil
|
|
23
37
|
@memories_enabled = true
|
|
24
38
|
@session_logging = true
|
|
25
39
|
@connection_class = nil
|
|
26
40
|
@admin_username = nil
|
|
27
41
|
@admin_password = nil
|
|
42
|
+
@authenticate = nil
|
|
28
43
|
end
|
|
29
44
|
|
|
30
45
|
def resolved_api_key
|
|
@@ -41,6 +56,23 @@ module ConsoleAgent
|
|
|
41
56
|
def resolved_model
|
|
42
57
|
return @model if @model && !@model.empty?
|
|
43
58
|
|
|
59
|
+
case @provider
|
|
60
|
+
when :anthropic
|
|
61
|
+
'claude-sonnet-4-6'
|
|
62
|
+
when :openai
|
|
63
|
+
'gpt-5.3-codex'
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def resolved_max_tokens
|
|
68
|
+
return @max_tokens if @max_tokens
|
|
69
|
+
|
|
70
|
+
DEFAULT_MAX_TOKENS.fetch(resolved_model, 4096)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def resolved_thinking_model
|
|
74
|
+
return @thinking_model if @thinking_model && !@thinking_model.empty?
|
|
75
|
+
|
|
44
76
|
case @provider
|
|
45
77
|
when :anthropic
|
|
46
78
|
'claude-opus-4-6'
|
|
@@ -84,7 +84,7 @@ module ConsoleAgent
|
|
|
84
84
|
result = binding_context.eval(code, "(console_agent)", 1)
|
|
85
85
|
|
|
86
86
|
$stdout = old_stdout
|
|
87
|
-
|
|
87
|
+
display_result(result)
|
|
88
88
|
|
|
89
89
|
@last_output = captured_output.string
|
|
90
90
|
result
|
|
@@ -126,35 +126,69 @@ module ConsoleAgent
|
|
|
126
126
|
@last_answer = answer
|
|
127
127
|
echo_stdin(answer)
|
|
128
128
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
129
|
+
loop do
|
|
130
|
+
case answer
|
|
131
|
+
when 'y', 'yes', 'a'
|
|
132
|
+
return execute(code)
|
|
133
|
+
when 'e', 'edit'
|
|
134
|
+
edited = open_in_editor(code)
|
|
135
|
+
if edited && edited != code
|
|
136
|
+
$stdout.puts colorize("# Edited code:", :yellow)
|
|
137
|
+
$stdout.puts highlight_code(edited)
|
|
138
|
+
$stdout.print colorize("Execute edited code? [y/N] ", :yellow)
|
|
139
|
+
edit_answer = $stdin.gets.to_s.strip.downcase
|
|
140
|
+
echo_stdin(edit_answer)
|
|
141
|
+
if edit_answer == 'y'
|
|
142
|
+
return execute(edited)
|
|
143
|
+
else
|
|
144
|
+
$stdout.puts colorize("Cancelled.", :yellow)
|
|
145
|
+
return nil
|
|
146
|
+
end
|
|
142
147
|
else
|
|
143
|
-
|
|
144
|
-
nil
|
|
148
|
+
return execute(code)
|
|
145
149
|
end
|
|
150
|
+
when 'n', 'no', ''
|
|
151
|
+
$stdout.puts colorize("Cancelled.", :yellow)
|
|
152
|
+
@last_cancelled = true
|
|
153
|
+
return nil
|
|
146
154
|
else
|
|
147
|
-
|
|
155
|
+
$stdout.print colorize("Execute? [y/N/edit] ", :yellow)
|
|
156
|
+
@on_prompt&.call
|
|
157
|
+
answer = $stdin.gets.to_s.strip.downcase
|
|
158
|
+
@last_answer = answer
|
|
159
|
+
echo_stdin(answer)
|
|
148
160
|
end
|
|
149
|
-
else
|
|
150
|
-
$stdout.puts colorize("Cancelled.", :yellow)
|
|
151
|
-
@last_cancelled = true
|
|
152
|
-
nil
|
|
153
161
|
end
|
|
154
162
|
end
|
|
155
163
|
|
|
156
164
|
private
|
|
157
165
|
|
|
166
|
+
MAX_DISPLAY_LINES = 10
|
|
167
|
+
MAX_DISPLAY_CHARS = 2000
|
|
168
|
+
|
|
169
|
+
def display_result(result)
|
|
170
|
+
full = "=> #{result.inspect}"
|
|
171
|
+
lines = full.lines
|
|
172
|
+
total_lines = lines.length
|
|
173
|
+
total_chars = full.length
|
|
174
|
+
|
|
175
|
+
if total_lines <= MAX_DISPLAY_LINES && total_chars <= MAX_DISPLAY_CHARS
|
|
176
|
+
$stdout.puts colorize(full, :green)
|
|
177
|
+
else
|
|
178
|
+
# Truncate by lines first, then by chars
|
|
179
|
+
truncated = lines.first(MAX_DISPLAY_LINES).join
|
|
180
|
+
truncated = truncated[0, MAX_DISPLAY_CHARS] if truncated.length > MAX_DISPLAY_CHARS
|
|
181
|
+
$stdout.puts colorize(truncated, :green)
|
|
182
|
+
|
|
183
|
+
omitted_lines = [total_lines - MAX_DISPLAY_LINES, 0].max
|
|
184
|
+
omitted_chars = [total_chars - truncated.length, 0].max
|
|
185
|
+
parts = []
|
|
186
|
+
parts << "#{omitted_lines} lines" if omitted_lines > 0
|
|
187
|
+
parts << "#{omitted_chars} chars" if omitted_chars > 0
|
|
188
|
+
$stdout.puts colorize(" (omitting #{parts.join(', ')})", :yellow)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
158
192
|
# Write stdin input to the capture IO only (avoids double-echo on terminal)
|
|
159
193
|
def echo_stdin(text)
|
|
160
194
|
$stdout.secondary.write("#{text}\n") if $stdout.respond_to?(:secondary)
|
data/lib/console_agent/repl.rb
CHANGED
|
@@ -11,6 +11,7 @@ module ConsoleAgent
|
|
|
11
11
|
@history = []
|
|
12
12
|
@total_input_tokens = 0
|
|
13
13
|
@total_output_tokens = 0
|
|
14
|
+
@token_usage = Hash.new { |h, k| h[k] = { input: 0, output: 0 } }
|
|
14
15
|
@input_history = []
|
|
15
16
|
end
|
|
16
17
|
|
|
@@ -209,6 +210,7 @@ module ConsoleAgent
|
|
|
209
210
|
@history = []
|
|
210
211
|
@total_input_tokens = 0
|
|
211
212
|
@total_output_tokens = 0
|
|
213
|
+
@token_usage = Hash.new { |h, k| h[k] = { input: 0, output: 0 } }
|
|
212
214
|
@interactive_query = nil
|
|
213
215
|
@interactive_session_id = nil
|
|
214
216
|
@interactive_session_name = nil
|
|
@@ -224,7 +226,7 @@ module ConsoleAgent
|
|
|
224
226
|
name_display = @interactive_session_name ? " (#{@interactive_session_name})" : ""
|
|
225
227
|
# Write banner to real stdout (bypass TeeIO) so it doesn't accumulate on resume
|
|
226
228
|
@interactive_old_stdout.puts "\e[36mConsoleAgent interactive mode#{name_display}. Type 'exit' or 'quit' to leave.\e[0m"
|
|
227
|
-
@interactive_old_stdout.puts "\e[2m Auto-execute: #{auto ? 'ON' : 'OFF'} (Shift-Tab or /auto to toggle) | > code
|
|
229
|
+
@interactive_old_stdout.puts "\e[2m Auto-execute: #{auto ? 'ON' : 'OFF'} (Shift-Tab or /auto to toggle) | > code | /usage | /cost | /compact | /think | /name <label>\e[0m"
|
|
228
230
|
|
|
229
231
|
# Bind Shift-Tab to insert /auto command and submit
|
|
230
232
|
if Readline.respond_to?(:parse_and_bind)
|
|
@@ -239,6 +241,11 @@ module ConsoleAgent
|
|
|
239
241
|
break if input.downcase == 'exit' || input.downcase == 'quit'
|
|
240
242
|
next if input.empty?
|
|
241
243
|
|
|
244
|
+
if input == '?' || input == '/'
|
|
245
|
+
display_help
|
|
246
|
+
next
|
|
247
|
+
end
|
|
248
|
+
|
|
242
249
|
if input == '/auto'
|
|
243
250
|
ConsoleAgent.configuration.auto_execute = !ConsoleAgent.configuration.auto_execute
|
|
244
251
|
mode = ConsoleAgent.configuration.auto_execute ? 'ON' : 'OFF'
|
|
@@ -263,6 +270,26 @@ module ConsoleAgent
|
|
|
263
270
|
next
|
|
264
271
|
end
|
|
265
272
|
|
|
273
|
+
if input == '/system'
|
|
274
|
+
@interactive_old_stdout.puts "\e[2m#{context}\e[0m"
|
|
275
|
+
next
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
if input == '/context'
|
|
279
|
+
display_conversation
|
|
280
|
+
next
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
if input == '/cost'
|
|
284
|
+
display_cost_summary
|
|
285
|
+
next
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
if input == '/think'
|
|
289
|
+
upgrade_to_thinking_model
|
|
290
|
+
next
|
|
291
|
+
end
|
|
292
|
+
|
|
266
293
|
if input.start_with?('/name')
|
|
267
294
|
name = input.sub('/name', '').strip
|
|
268
295
|
if name.empty?
|
|
@@ -314,6 +341,11 @@ module ConsoleAgent
|
|
|
314
341
|
# Add to Readline history (avoid consecutive duplicates)
|
|
315
342
|
Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
|
|
316
343
|
|
|
344
|
+
# Auto-upgrade to thinking model on "think harder" phrases
|
|
345
|
+
if input =~ /think\s*harder/i
|
|
346
|
+
upgrade_to_thinking_model
|
|
347
|
+
end
|
|
348
|
+
|
|
317
349
|
@interactive_query ||= input
|
|
318
350
|
@history << { role: :user, content: input }
|
|
319
351
|
|
|
@@ -365,6 +397,13 @@ module ConsoleAgent
|
|
|
365
397
|
def send_and_execute
|
|
366
398
|
begin
|
|
367
399
|
result, tool_messages = send_query(nil, conversation: @history)
|
|
400
|
+
rescue Providers::ProviderError => e
|
|
401
|
+
if e.message.include?("prompt is too long") && @history.length >= 6
|
|
402
|
+
$stdout.puts "\e[33m Context limit reached. Run /compact to reduce context size, then try again.\e[0m"
|
|
403
|
+
else
|
|
404
|
+
$stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
|
|
405
|
+
end
|
|
406
|
+
return :error
|
|
368
407
|
rescue Interrupt
|
|
369
408
|
$stdout.puts "\n\e[33m Aborted.\e[0m"
|
|
370
409
|
return :interrupted
|
|
@@ -547,8 +586,12 @@ module ConsoleAgent
|
|
|
547
586
|
$stdout.puts "\e[2m #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}\e[0m"
|
|
548
587
|
end
|
|
549
588
|
|
|
550
|
-
|
|
551
|
-
|
|
589
|
+
begin
|
|
590
|
+
result = with_escape_monitoring do
|
|
591
|
+
provider.chat_with_tools(messages, tools: tools, system_prompt: active_system_prompt)
|
|
592
|
+
end
|
|
593
|
+
rescue Providers::ProviderError => e
|
|
594
|
+
raise
|
|
552
595
|
end
|
|
553
596
|
total_input += result.input_tokens || 0
|
|
554
597
|
total_output += result.output_tokens || 0
|
|
@@ -776,6 +819,10 @@ module ConsoleAgent
|
|
|
776
819
|
def track_usage(result)
|
|
777
820
|
@total_input_tokens += result.input_tokens || 0
|
|
778
821
|
@total_output_tokens += result.output_tokens || 0
|
|
822
|
+
|
|
823
|
+
model = ConsoleAgent.configuration.resolved_model
|
|
824
|
+
@token_usage[model][:input] += result.input_tokens || 0
|
|
825
|
+
@token_usage[model][:output] += result.output_tokens || 0
|
|
779
826
|
end
|
|
780
827
|
|
|
781
828
|
def display_usage(result, show_session: false)
|
|
@@ -883,12 +930,58 @@ module ConsoleAgent
|
|
|
883
930
|
$stdout.puts "\e[2m[session totals — in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
|
|
884
931
|
end
|
|
885
932
|
|
|
933
|
+
def display_cost_summary
|
|
934
|
+
if @token_usage.empty?
|
|
935
|
+
$stdout.puts "\e[2m No usage yet.\e[0m"
|
|
936
|
+
return
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
total_cost = 0.0
|
|
940
|
+
$stdout.puts "\e[36m Cost estimate:\e[0m"
|
|
941
|
+
|
|
942
|
+
@token_usage.each do |model, usage|
|
|
943
|
+
pricing = Configuration::PRICING[model]
|
|
944
|
+
input_str = "in: #{format_tokens(usage[:input])}"
|
|
945
|
+
output_str = "out: #{format_tokens(usage[:output])}"
|
|
946
|
+
|
|
947
|
+
if pricing
|
|
948
|
+
cost = (usage[:input] * pricing[:input]) + (usage[:output] * pricing[:output])
|
|
949
|
+
total_cost += cost
|
|
950
|
+
$stdout.puts "\e[2m #{model}: #{input_str} #{output_str} ~$#{'%.2f' % cost}\e[0m"
|
|
951
|
+
else
|
|
952
|
+
$stdout.puts "\e[2m #{model}: #{input_str} #{output_str} (pricing unknown)\e[0m"
|
|
953
|
+
end
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
$stdout.puts "\e[36m Total: ~$#{'%.2f' % total_cost}\e[0m"
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
def upgrade_to_thinking_model
|
|
960
|
+
config = ConsoleAgent.configuration
|
|
961
|
+
current = config.resolved_model
|
|
962
|
+
thinking = config.resolved_thinking_model
|
|
963
|
+
|
|
964
|
+
if current == thinking
|
|
965
|
+
$stdout.puts "\e[36m Already using thinking model (#{current}).\e[0m"
|
|
966
|
+
else
|
|
967
|
+
config.model = thinking
|
|
968
|
+
@provider = nil
|
|
969
|
+
$stdout.puts "\e[36m Switched to thinking model: #{thinking}\e[0m"
|
|
970
|
+
end
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
def on_thinking_model?
|
|
974
|
+
config = ConsoleAgent.configuration
|
|
975
|
+
config.resolved_model == config.resolved_thinking_model
|
|
976
|
+
end
|
|
977
|
+
|
|
886
978
|
def warn_if_history_large
|
|
887
979
|
chars = @history.sum { |m| m[:content].to_s.length }
|
|
888
|
-
return if chars < 50_000 || @compact_warned
|
|
889
980
|
|
|
890
|
-
|
|
891
|
-
|
|
981
|
+
if chars > 50_000 && !@compact_warned
|
|
982
|
+
@compact_warned = true
|
|
983
|
+
$stdout.puts "\e[33m Conversation is getting large (~#{format_tokens(chars)} chars). Consider running /compact to reduce context size.\e[0m"
|
|
984
|
+
end
|
|
892
985
|
end
|
|
893
986
|
|
|
894
987
|
def compact_history
|
|
@@ -900,6 +993,9 @@ module ConsoleAgent
|
|
|
900
993
|
before_chars = @history.sum { |m| m[:content].to_s.length }
|
|
901
994
|
before_count = @history.length
|
|
902
995
|
|
|
996
|
+
# Extract successfully executed code before summarizing
|
|
997
|
+
executed_code = extract_executed_code(@history)
|
|
998
|
+
|
|
903
999
|
$stdout.puts "\e[2m Compacting #{before_count} messages (~#{format_tokens(before_chars)} chars)...\e[0m"
|
|
904
1000
|
|
|
905
1001
|
system_prompt = <<~PROMPT
|
|
@@ -910,8 +1006,8 @@ module ConsoleAgent
|
|
|
910
1006
|
- Key findings and data discovered (include specific values, IDs, record counts)
|
|
911
1007
|
- Current state: what worked, what failed, where things stand
|
|
912
1008
|
- Important variable names, model names, or table names referenced
|
|
913
|
-
- Any code that was executed and its results
|
|
914
1009
|
|
|
1010
|
+
Do NOT include code that was executed — that will be preserved separately.
|
|
915
1011
|
Be concise but preserve all information that would be needed to continue the conversation naturally.
|
|
916
1012
|
Do NOT include any preamble — just output the summary directly.
|
|
917
1013
|
PROMPT
|
|
@@ -929,18 +1025,130 @@ module ConsoleAgent
|
|
|
929
1025
|
return
|
|
930
1026
|
end
|
|
931
1027
|
|
|
932
|
-
|
|
1028
|
+
content = "CONVERSATION SUMMARY (compacted):\n#{summary}"
|
|
1029
|
+
unless executed_code.empty?
|
|
1030
|
+
content += "\n\nCODE EXECUTED THIS SESSION (preserved for continuation):\n#{executed_code}"
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
@history = [{ role: :user, content: content }]
|
|
933
1034
|
@compact_warned = false
|
|
934
1035
|
|
|
935
1036
|
after_chars = @history.first[:content].length
|
|
936
1037
|
$stdout.puts "\e[36m Compacted: #{before_count} messages -> 1 summary (~#{format_tokens(before_chars)} -> ~#{format_tokens(after_chars)} chars)\e[0m"
|
|
937
1038
|
summary.each_line { |line| $stdout.puts "\e[2m #{line.rstrip}\e[0m" }
|
|
1039
|
+
if !executed_code.empty?
|
|
1040
|
+
$stdout.puts "\e[2m (preserved #{executed_code.scan(/```ruby/).length} executed code block(s))\e[0m"
|
|
1041
|
+
end
|
|
938
1042
|
display_usage(result)
|
|
939
1043
|
rescue => e
|
|
940
1044
|
$stdout.puts "\e[31m Compaction failed: #{e.message}\e[0m"
|
|
941
1045
|
end
|
|
942
1046
|
end
|
|
943
1047
|
|
|
1048
|
+
# Extracts code blocks that were successfully executed from conversation history.
|
|
1049
|
+
# Looks for:
|
|
1050
|
+
# 1. Assistant messages with ```ruby blocks followed by "Code was executed." user messages
|
|
1051
|
+
# 2. execute_plan tool calls followed by results without ERROR
|
|
1052
|
+
# Skips code that failed or was declined.
|
|
1053
|
+
def extract_executed_code(history)
|
|
1054
|
+
code_blocks = []
|
|
1055
|
+
history.each_cons(2) do |msg, next_msg|
|
|
1056
|
+
# Pattern 1: Assistant ```ruby blocks with successful execution
|
|
1057
|
+
if msg[:role].to_s == 'assistant' && next_msg[:role].to_s == 'user'
|
|
1058
|
+
content = msg[:content].to_s
|
|
1059
|
+
next_content = next_msg[:content].to_s
|
|
1060
|
+
|
|
1061
|
+
if next_content.start_with?('Code was executed.')
|
|
1062
|
+
content.scan(/```ruby\s*\n(.*?)```/m).each do |match|
|
|
1063
|
+
code = match[0].strip
|
|
1064
|
+
next if code.empty?
|
|
1065
|
+
result_summary = next_content[0..200].gsub("\n", "\n# ")
|
|
1066
|
+
code_blocks << "```ruby\n#{code}\n```\n# #{result_summary}"
|
|
1067
|
+
end
|
|
1068
|
+
end
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
# Pattern 2: execute_plan tool calls in provider-formatted messages
|
|
1072
|
+
if msg[:role].to_s == 'assistant' && msg[:content].is_a?(Array)
|
|
1073
|
+
msg[:content].each do |block|
|
|
1074
|
+
next unless block.is_a?(Hash) && block['type'] == 'tool_use' && block['name'] == 'execute_plan'
|
|
1075
|
+
input = block['input'] || {}
|
|
1076
|
+
steps = input['steps'] || []
|
|
1077
|
+
|
|
1078
|
+
# Find the matching tool_result in subsequent messages
|
|
1079
|
+
tool_id = block['id']
|
|
1080
|
+
result_msg = find_tool_result(history, tool_id)
|
|
1081
|
+
next unless result_msg
|
|
1082
|
+
|
|
1083
|
+
result_text = result_msg.to_s
|
|
1084
|
+
# Extract only steps that succeeded (no ERROR in their result)
|
|
1085
|
+
steps.each_with_index do |step, i|
|
|
1086
|
+
step_num = i + 1
|
|
1087
|
+
# Check if this specific step had an error
|
|
1088
|
+
step_section = result_text[/Step #{step_num}\b.*?(?=Step #{step_num + 1}\b|\z)/m] || ''
|
|
1089
|
+
next if step_section.include?('ERROR:')
|
|
1090
|
+
next if step_section.include?('User declined')
|
|
1091
|
+
|
|
1092
|
+
code = step['code'].to_s.strip
|
|
1093
|
+
next if code.empty?
|
|
1094
|
+
desc = step['description'] || "Step #{step_num}"
|
|
1095
|
+
code_blocks << "```ruby\n# #{desc}\n#{code}\n```"
|
|
1096
|
+
end
|
|
1097
|
+
end
|
|
1098
|
+
end
|
|
1099
|
+
end
|
|
1100
|
+
code_blocks.join("\n\n")
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
def find_tool_result(history, tool_id)
|
|
1104
|
+
history.each do |msg|
|
|
1105
|
+
next unless msg[:content].is_a?(Array)
|
|
1106
|
+
msg[:content].each do |block|
|
|
1107
|
+
next unless block.is_a?(Hash)
|
|
1108
|
+
if block['type'] == 'tool_result' && block['tool_use_id'] == tool_id
|
|
1109
|
+
return block['content']
|
|
1110
|
+
end
|
|
1111
|
+
# OpenAI format
|
|
1112
|
+
if msg[:role].to_s == 'tool' && msg[:tool_call_id] == tool_id
|
|
1113
|
+
return msg[:content]
|
|
1114
|
+
end
|
|
1115
|
+
end
|
|
1116
|
+
end
|
|
1117
|
+
nil
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
def display_conversation
|
|
1121
|
+
if @history.empty?
|
|
1122
|
+
@interactive_old_stdout.puts "\e[2m (no conversation history yet)\e[0m"
|
|
1123
|
+
return
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1126
|
+
@interactive_old_stdout.puts "\e[36m Conversation (#{@history.length} messages):\e[0m"
|
|
1127
|
+
@history.each_with_index do |msg, i|
|
|
1128
|
+
role = msg[:role].to_s
|
|
1129
|
+
content = msg[:content].to_s
|
|
1130
|
+
label = role == 'user' ? "\e[33m[user]\e[0m" : "\e[36m[assistant]\e[0m"
|
|
1131
|
+
@interactive_old_stdout.puts "#{label} #{content}"
|
|
1132
|
+
@interactive_old_stdout.puts if i < @history.length - 1
|
|
1133
|
+
end
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
def display_help
|
|
1137
|
+
auto = ConsoleAgent.configuration.auto_execute ? 'ON' : 'OFF'
|
|
1138
|
+
@interactive_old_stdout.puts "\e[36m Commands:\e[0m"
|
|
1139
|
+
@interactive_old_stdout.puts "\e[2m /auto Toggle auto-execute (currently #{auto}) (Shift-Tab)\e[0m"
|
|
1140
|
+
@interactive_old_stdout.puts "\e[2m /think Switch to thinking model\e[0m"
|
|
1141
|
+
@interactive_old_stdout.puts "\e[2m /compact Summarize conversation to reduce context\e[0m"
|
|
1142
|
+
@interactive_old_stdout.puts "\e[2m /usage Show session token totals\e[0m"
|
|
1143
|
+
@interactive_old_stdout.puts "\e[2m /cost Show cost estimate by model\e[0m"
|
|
1144
|
+
@interactive_old_stdout.puts "\e[2m /name <lbl> Name this session for easy resume\e[0m"
|
|
1145
|
+
@interactive_old_stdout.puts "\e[2m /context Show conversation history sent to the LLM\e[0m"
|
|
1146
|
+
@interactive_old_stdout.puts "\e[2m /system Show the system prompt\e[0m"
|
|
1147
|
+
@interactive_old_stdout.puts "\e[2m /debug Toggle debug mode\e[0m"
|
|
1148
|
+
@interactive_old_stdout.puts "\e[2m > code Execute Ruby directly (skip LLM)\e[0m"
|
|
1149
|
+
@interactive_old_stdout.puts "\e[2m exit/quit Leave interactive mode\e[0m"
|
|
1150
|
+
end
|
|
1151
|
+
|
|
944
1152
|
def display_exit_info
|
|
945
1153
|
display_session_summary
|
|
946
1154
|
if @interactive_session_id
|
|
@@ -35,7 +35,7 @@ ConsoleAgent.configure do |config|
|
|
|
35
35
|
# config.connection_class = Sharding::CentralizedModel
|
|
36
36
|
|
|
37
37
|
# Admin UI credentials (mount ConsoleAgent::Engine => '/console_agent' in routes.rb)
|
|
38
|
-
# When nil,
|
|
38
|
+
# When nil, all requests are denied. Set credentials or use config.authenticate.
|
|
39
39
|
# config.admin_username = 'admin'
|
|
40
40
|
# config.admin_password = 'changeme'
|
|
41
41
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: console_agent
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cortfr
|
|
@@ -86,6 +86,7 @@ executables: []
|
|
|
86
86
|
extensions: []
|
|
87
87
|
extra_rdoc_files: []
|
|
88
88
|
files:
|
|
89
|
+
- CHANGELOG.md
|
|
89
90
|
- LICENSE
|
|
90
91
|
- README.md
|
|
91
92
|
- app/controllers/console_agent/application_controller.rb
|