console_agent 0.2.0 → 0.3.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.
@@ -15,17 +15,44 @@ module ConsoleAgent
15
15
  end
16
16
 
17
17
  def one_shot(query)
18
- result = send_query(query)
19
- track_usage(result)
20
- code = @executor.display_response(result.text)
21
- display_usage(result)
22
- return nil if code.nil? || code.strip.empty?
23
-
24
- if ConsoleAgent.configuration.auto_execute
25
- @executor.execute(code)
26
- else
27
- @executor.confirm_and_execute(code)
18
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
19
+ console_capture = StringIO.new
20
+ exec_result = with_console_capture(console_capture) do
21
+ result, _ = send_query(query)
22
+ track_usage(result)
23
+ code = @executor.display_response(result.text)
24
+ display_usage(result)
25
+
26
+ exec_result = nil
27
+ executed = false
28
+ has_code = code && !code.strip.empty?
29
+
30
+ if has_code
31
+ exec_result = if ConsoleAgent.configuration.auto_execute
32
+ @executor.execute(code)
33
+ else
34
+ @executor.confirm_and_execute(code)
35
+ end
36
+ executed = !@executor.last_cancelled?
37
+ end
38
+
39
+ @_last_log_attrs = {
40
+ query: query,
41
+ conversation: [{ role: :user, content: query }, { role: :assistant, content: result.text }],
42
+ mode: 'one_shot',
43
+ code_executed: has_code ? code : nil,
44
+ code_output: executed ? @executor.last_output : nil,
45
+ code_result: executed && exec_result ? exec_result.inspect : nil,
46
+ executed: executed,
47
+ start_time: start_time
48
+ }
49
+
50
+ exec_result
28
51
  end
52
+
53
+ log_session(@_last_log_attrs.merge(console_output: console_capture.string))
54
+
55
+ exec_result
29
56
  rescue Providers::ProviderError => e
30
57
  $stderr.puts "\e[31mConsoleAgent Error: #{e.message}\e[0m"
31
58
  nil
@@ -35,10 +62,25 @@ module ConsoleAgent
35
62
  end
36
63
 
37
64
  def explain(query)
38
- result = send_query(query)
39
- track_usage(result)
40
- @executor.display_response(result.text)
41
- display_usage(result)
65
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
66
+ console_capture = StringIO.new
67
+ with_console_capture(console_capture) do
68
+ result, _ = send_query(query)
69
+ track_usage(result)
70
+ @executor.display_response(result.text)
71
+ display_usage(result)
72
+
73
+ @_last_log_attrs = {
74
+ query: query,
75
+ conversation: [{ role: :user, content: query }, { role: :assistant, content: result.text }],
76
+ mode: 'explain',
77
+ executed: false,
78
+ start_time: start_time
79
+ }
80
+ end
81
+
82
+ log_session(@_last_log_attrs.merge(console_output: console_capture.string))
83
+
42
84
  nil
43
85
  rescue Providers::ProviderError => e
44
86
  $stderr.puts "\e[31mConsoleAgent Error: #{e.message}\e[0m"
@@ -49,10 +91,66 @@ module ConsoleAgent
49
91
  end
50
92
 
51
93
  def interactive
52
- $stdout.puts "\e[36mConsoleAgent interactive mode. Type 'exit' or 'quit' to leave.\e[0m"
94
+ init_interactive_state
95
+ interactive_loop
96
+ end
97
+
98
+ def resume(session)
99
+ init_interactive_state
100
+
101
+ # Restore state from the previous session
102
+ @history = JSON.parse(session.conversation, symbolize_names: true)
103
+ @interactive_session_id = session.id
104
+ @interactive_query = session.query
105
+ @interactive_session_name = session.name
106
+ @total_input_tokens = session.input_tokens || 0
107
+ @total_output_tokens = session.output_tokens || 0
108
+
109
+ # Replay stored console output so the user sees previous context
110
+ if session.console_output && !session.console_output.strip.empty?
111
+ $stdout.puts "\e[2m--- Replaying previous session output ---\e[0m"
112
+ $stdout.puts session.console_output
113
+ $stdout.puts "\e[2m--- End of previous output ---\e[0m"
114
+ $stdout.puts
115
+ end
116
+
117
+ # Copy replayed output into the capture buffer so it's preserved on save
118
+ @interactive_console_capture.write(session.console_output.to_s)
119
+
120
+ interactive_loop
121
+ end
122
+
123
+ private
124
+
125
+ def init_interactive_state
126
+ @interactive_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
127
+ @interactive_console_capture = StringIO.new
128
+ @interactive_old_stdout = $stdout
129
+ $stdout = TeeIO.new(@interactive_old_stdout, @interactive_console_capture)
130
+ @executor.on_prompt = -> { log_interactive_turn }
131
+
53
132
  @history = []
54
133
  @total_input_tokens = 0
55
134
  @total_output_tokens = 0
135
+ @interactive_query = nil
136
+ @interactive_session_id = nil
137
+ @interactive_session_name = nil
138
+ @last_interactive_code = nil
139
+ @last_interactive_output = nil
140
+ @last_interactive_result = nil
141
+ @last_interactive_executed = false
142
+ end
143
+
144
+ def interactive_loop
145
+ auto = ConsoleAgent.configuration.auto_execute
146
+ name_display = @interactive_session_name ? " (#{@interactive_session_name})" : ""
147
+ $stdout.puts "\e[36mConsoleAgent interactive mode#{name_display}. Type 'exit' or 'quit' to leave.\e[0m"
148
+ $stdout.puts "\e[2m Auto-execute: #{auto ? 'ON' : 'OFF'} (Shift-Tab or /auto to toggle) | /usage | /name <label>\e[0m"
149
+
150
+ # Bind Shift-Tab to insert /auto command and submit
151
+ if Readline.respond_to?(:parse_and_bind)
152
+ Readline.parse_and_bind('"\e[Z": "\C-a\C-k/auto\C-m"')
153
+ end
56
154
 
57
155
  loop do
58
156
  input = Readline.readline("\e[33mai> \e[0m", false)
@@ -62,16 +160,62 @@ module ConsoleAgent
62
160
  break if input.downcase == 'exit' || input.downcase == 'quit'
63
161
  next if input.empty?
64
162
 
163
+ if input == '/auto'
164
+ ConsoleAgent.configuration.auto_execute = !ConsoleAgent.configuration.auto_execute
165
+ mode = ConsoleAgent.configuration.auto_execute ? 'ON' : 'OFF'
166
+ $stdout.puts "\e[36m Auto-execute: #{mode}\e[0m"
167
+ next
168
+ end
169
+
170
+ if input == '/usage'
171
+ display_session_summary
172
+ next
173
+ end
174
+
175
+ if input == '/debug'
176
+ ConsoleAgent.configuration.debug = !ConsoleAgent.configuration.debug
177
+ mode = ConsoleAgent.configuration.debug ? 'ON' : 'OFF'
178
+ $stdout.puts "\e[36m Debug: #{mode}\e[0m"
179
+ next
180
+ end
181
+
182
+ if input.start_with?('/name')
183
+ name = input.sub('/name', '').strip
184
+ if name.empty?
185
+ if @interactive_session_name
186
+ $stdout.puts "\e[36m Session name: #{@interactive_session_name}\e[0m"
187
+ else
188
+ $stdout.puts "\e[33m Usage: /name <label> (e.g. /name salesforce_user_123)\e[0m"
189
+ end
190
+ else
191
+ @interactive_session_name = name
192
+ if @interactive_session_id
193
+ require 'console_agent/session_logger'
194
+ SessionLogger.update(@interactive_session_id, name: name)
195
+ end
196
+ $stdout.puts "\e[36m Session named: #{name}\e[0m"
197
+ end
198
+ next
199
+ end
200
+
65
201
  # Add to Readline history (avoid consecutive duplicates)
66
202
  Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
67
203
 
204
+ @interactive_query ||= input
68
205
  @history << { role: :user, content: input }
69
206
 
207
+ # Log the user's prompt line to the console capture (Readline doesn't go through $stdout)
208
+ @interactive_console_capture.write("ai> #{input}\n")
209
+
210
+ # Save immediately so the session is visible in the admin UI while the AI thinks
211
+ log_interactive_turn
212
+
70
213
  begin
71
- result = send_query(input, conversation: @history)
214
+ result, tool_messages = send_query(input, conversation: @history)
72
215
  rescue Interrupt
73
216
  $stdout.puts "\n\e[33m Aborted.\e[0m"
74
217
  @history.pop # Remove the user message that never got a response
218
+ log_interactive_turn
75
219
  next
76
220
  end
77
221
 
@@ -79,6 +223,11 @@ module ConsoleAgent
79
223
  code = @executor.display_response(result.text)
80
224
  display_usage(result, show_session: true)
81
225
 
226
+ # Save after response is displayed so viewer shows progress before Execute prompt
227
+ log_interactive_turn
228
+
229
+ # Add tool call/result messages so the LLM remembers what it learned
230
+ @history.concat(tool_messages) if tool_messages && !tool_messages.empty?
82
231
  @history << { role: :assistant, content: result.text }
83
232
 
84
233
  if code && !code.strip.empty?
@@ -88,6 +237,13 @@ module ConsoleAgent
88
237
  exec_result = @executor.confirm_and_execute(code)
89
238
  end
90
239
 
240
+ unless @executor.last_cancelled?
241
+ @last_interactive_code = code
242
+ @last_interactive_output = @executor.last_output
243
+ @last_interactive_result = exec_result ? exec_result.inspect : nil
244
+ @last_interactive_executed = true
245
+ end
246
+
91
247
  if @executor.last_cancelled?
92
248
  @history << { role: :user, content: "User declined to execute the code." }
93
249
  else
@@ -110,21 +266,28 @@ module ConsoleAgent
110
266
  end
111
267
  end
112
268
  end
269
+
270
+ # Update with the AI response, tokens, and any execution results
271
+ log_interactive_turn
113
272
  end
114
273
 
115
- display_session_summary
116
- $stdout.puts "\e[36mLeft ConsoleAgent interactive mode.\e[0m"
274
+ $stdout = @interactive_old_stdout
275
+ @executor.on_prompt = nil
276
+ finish_interactive_session
277
+ display_exit_info
117
278
  rescue Interrupt
118
279
  # Ctrl-C during Readline input — exit cleanly
280
+ $stdout = @interactive_old_stdout if @interactive_old_stdout
281
+ @executor.on_prompt = nil
119
282
  $stdout.puts
120
- display_session_summary
121
- $stdout.puts "\e[36mLeft ConsoleAgent interactive mode.\e[0m"
283
+ finish_interactive_session
284
+ display_exit_info
122
285
  rescue => e
286
+ $stdout = @interactive_old_stdout if @interactive_old_stdout
287
+ @executor.on_prompt = nil
123
288
  $stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
124
289
  end
125
290
 
126
- private
127
-
128
291
  def provider
129
292
  @provider ||= Providers.build
130
293
  end
@@ -141,25 +304,22 @@ module ConsoleAgent
141
304
  ConsoleAgent.configuration.validate!
142
305
 
143
306
  messages = if conversation
144
- conversation.map { |m| { role: m[:role], content: m[:content] } }
307
+ conversation.dup
145
308
  else
146
309
  [{ role: :user, content: query }]
147
310
  end
148
311
 
149
- if ConsoleAgent.configuration.context_mode == :smart
150
- send_query_with_tools(messages)
151
- else
152
- provider.chat(messages, system_prompt: context)
153
- end
312
+ send_query_with_tools(messages)
154
313
  end
155
314
 
156
315
  def send_query_with_tools(messages)
157
316
  require 'console_agent/tools/registry'
158
- tools = Tools::Registry.new
317
+ tools = Tools::Registry.new(executor: @executor)
159
318
  max_rounds = ConsoleAgent.configuration.max_tool_rounds
160
319
  total_input = 0
161
320
  total_output = 0
162
321
  result = nil
322
+ new_messages = [] # Track messages added during tool use
163
323
 
164
324
  exhausted = false
165
325
 
@@ -180,12 +340,14 @@ module ConsoleAgent
180
340
  end
181
341
 
182
342
  # Add assistant message with tool calls to conversation
183
- messages << provider.format_assistant_message(result)
343
+ assistant_msg = provider.format_assistant_message(result)
344
+ messages << assistant_msg
345
+ new_messages << assistant_msg
184
346
 
185
347
  # Execute each tool and show progress
186
348
  result.tool_calls.each do |tc|
187
- # ask_user handles its own display (prompt + input)
188
- if tc[:name] == 'ask_user'
349
+ # ask_user and execute_plan handle their own display
350
+ if tc[:name] == 'ask_user' || tc[:name] == 'execute_plan'
189
351
  tool_result = tools.execute(tc[:name], tc[:arguments])
190
352
  else
191
353
  args_display = format_tool_args(tc[:name], tc[:arguments])
@@ -202,7 +364,9 @@ module ConsoleAgent
202
364
  $stderr.puts "\e[35m[debug tool result] #{tool_result}\e[0m"
203
365
  end
204
366
 
205
- messages << provider.format_tool_result(tc[:id], tool_result)
367
+ tool_msg = provider.format_tool_result(tc[:id], tool_result)
368
+ messages << tool_msg
369
+ new_messages << tool_msg
206
370
  end
207
371
 
208
372
  exhausted = true if round == max_rounds - 1
@@ -217,12 +381,13 @@ module ConsoleAgent
217
381
  total_output += result.output_tokens || 0
218
382
  end
219
383
 
220
- Providers::ChatResult.new(
384
+ final_result = Providers::ChatResult.new(
221
385
  text: result ? result.text : '',
222
386
  input_tokens: total_input,
223
387
  output_tokens: total_output,
224
388
  stop_reason: result ? result.stop_reason : :end_turn
225
389
  )
390
+ [final_result, new_messages]
226
391
  end
227
392
 
228
393
  def format_tool_args(name, args)
@@ -246,6 +411,9 @@ module ConsoleAgent
246
411
  "(\"#{args['name']}\")"
247
412
  when 'recall_memories'
248
413
  args['query'] ? "(\"#{args['query']}\")" : ''
414
+ when 'execute_plan'
415
+ steps = args['steps']
416
+ steps ? "(#{steps.length} steps)" : ''
249
417
  else
250
418
  ''
251
419
  end
@@ -300,6 +468,9 @@ module ConsoleAgent
300
468
  when 'recall_memories'
301
469
  chunks = result.split("\n\n")
302
470
  chunks.length > 1 ? "#{chunks.length} memories found" : truncate(result, 80)
471
+ when 'execute_plan'
472
+ steps_done = result.scan(/^Step \d+/).length
473
+ steps_done > 0 ? "#{steps_done} steps executed" : truncate(result, 80)
303
474
  else
304
475
  truncate(result, 80)
305
476
  end
@@ -333,10 +504,104 @@ module ConsoleAgent
333
504
  $stdout.puts line
334
505
  end
335
506
 
507
+ def with_console_capture(capture_io)
508
+ old_stdout = $stdout
509
+ $stdout = TeeIO.new(old_stdout, capture_io)
510
+ yield
511
+ ensure
512
+ $stdout = old_stdout
513
+ end
514
+
515
+ def log_interactive_turn
516
+ require 'console_agent/session_logger'
517
+ session_attrs = {
518
+ conversation: @history,
519
+ input_tokens: @total_input_tokens,
520
+ output_tokens: @total_output_tokens,
521
+ code_executed: @last_interactive_code,
522
+ code_output: @last_interactive_output,
523
+ code_result: @last_interactive_result,
524
+ executed: @last_interactive_executed,
525
+ console_output: @interactive_console_capture&.string
526
+ }
527
+
528
+ if @interactive_session_id
529
+ SessionLogger.update(@interactive_session_id, session_attrs)
530
+ else
531
+ @interactive_session_id = SessionLogger.log(
532
+ session_attrs.merge(
533
+ query: @interactive_query || '(interactive session)',
534
+ mode: 'interactive',
535
+ name: @interactive_session_name
536
+ )
537
+ )
538
+ end
539
+ end
540
+
541
+ def finish_interactive_session
542
+ require 'console_agent/session_logger'
543
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @interactive_start) * 1000).round
544
+ if @interactive_session_id
545
+ SessionLogger.update(@interactive_session_id,
546
+ conversation: @history,
547
+ input_tokens: @total_input_tokens,
548
+ output_tokens: @total_output_tokens,
549
+ code_executed: @last_interactive_code,
550
+ code_output: @last_interactive_output,
551
+ code_result: @last_interactive_result,
552
+ executed: @last_interactive_executed,
553
+ console_output: @interactive_console_capture&.string,
554
+ duration_ms: duration_ms
555
+ )
556
+ elsif @interactive_query
557
+ # Session was never created (e.g., only one turn that failed to log)
558
+ log_session(
559
+ query: @interactive_query,
560
+ conversation: @history,
561
+ mode: 'interactive',
562
+ code_executed: @last_interactive_code,
563
+ code_output: @last_interactive_output,
564
+ code_result: @last_interactive_result,
565
+ executed: @last_interactive_executed,
566
+ console_output: @interactive_console_capture&.string,
567
+ start_time: @interactive_start
568
+ )
569
+ end
570
+ end
571
+
572
+ def log_session(attrs)
573
+ require 'console_agent/session_logger'
574
+ start_time = attrs.delete(:start_time)
575
+ duration_ms = if start_time
576
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
577
+ end
578
+ SessionLogger.log(
579
+ attrs.merge(
580
+ input_tokens: @total_input_tokens,
581
+ output_tokens: @total_output_tokens,
582
+ duration_ms: duration_ms
583
+ )
584
+ )
585
+ end
586
+
336
587
  def display_session_summary
337
588
  return if @total_input_tokens == 0 && @total_output_tokens == 0
338
589
 
339
590
  $stdout.puts "\e[2m[session totals — in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
340
591
  end
592
+
593
+ def display_exit_info
594
+ display_session_summary
595
+ if @interactive_session_id
596
+ $stdout.puts "\e[36mSession ##{@interactive_session_id} saved.\e[0m"
597
+ if @interactive_session_name
598
+ $stdout.puts "\e[2m Resume with: ai_resume \"#{@interactive_session_name}\"\e[0m"
599
+ else
600
+ $stdout.puts "\e[2m Name it: ai_name #{@interactive_session_id}, \"descriptive_name\"\e[0m"
601
+ $stdout.puts "\e[2m Resume it: ai_resume #{@interactive_session_id}\e[0m"
602
+ end
603
+ end
604
+ $stdout.puts "\e[36mLeft ConsoleAgent interactive mode.\e[0m"
605
+ end
341
606
  end
342
607
  end
@@ -0,0 +1,79 @@
1
+ module ConsoleAgent
2
+ module SessionLogger
3
+ class << self
4
+ def log(attrs)
5
+ return unless ConsoleAgent.configuration.session_logging
6
+ return unless table_exists?
7
+
8
+ record = session_class.create!(
9
+ query: attrs[:query],
10
+ conversation: Array(attrs[:conversation]).to_json,
11
+ input_tokens: attrs[:input_tokens] || 0,
12
+ output_tokens: attrs[:output_tokens] || 0,
13
+ user_name: current_user_name,
14
+ mode: attrs[:mode].to_s,
15
+ name: attrs[:name],
16
+ code_executed: attrs[:code_executed],
17
+ code_output: attrs[:code_output],
18
+ code_result: attrs[:code_result],
19
+ console_output: attrs[:console_output],
20
+ executed: attrs[:executed] || false,
21
+ provider: ConsoleAgent.configuration.provider.to_s,
22
+ model: ConsoleAgent.configuration.resolved_model,
23
+ duration_ms: attrs[:duration_ms],
24
+ created_at: Time.respond_to?(:current) ? Time.current : Time.now
25
+ )
26
+ record.id
27
+ rescue => e
28
+ msg = "ConsoleAgent: session logging failed: #{e.class}: #{e.message}"
29
+ $stderr.puts "\e[33m#{msg}\e[0m" if $stderr.respond_to?(:puts)
30
+ ConsoleAgent.logger.warn(msg)
31
+ nil
32
+ end
33
+
34
+ def update(id, attrs)
35
+ return unless id
36
+ return unless ConsoleAgent.configuration.session_logging
37
+ return unless table_exists?
38
+
39
+ updates = {}
40
+ updates[:conversation] = Array(attrs[:conversation]).to_json if attrs.key?(:conversation)
41
+ updates[:input_tokens] = attrs[:input_tokens] if attrs.key?(:input_tokens)
42
+ updates[:output_tokens] = attrs[:output_tokens] if attrs.key?(:output_tokens)
43
+ updates[:code_executed] = attrs[:code_executed] if attrs.key?(:code_executed)
44
+ updates[:code_output] = attrs[:code_output] if attrs.key?(:code_output)
45
+ updates[:code_result] = attrs[:code_result] if attrs.key?(:code_result)
46
+ updates[:console_output] = attrs[:console_output] if attrs.key?(:console_output)
47
+ updates[:executed] = attrs[:executed] if attrs.key?(:executed)
48
+ updates[:duration_ms] = attrs[:duration_ms] if attrs.key?(:duration_ms)
49
+ updates[:name] = attrs[:name] if attrs.key?(:name)
50
+
51
+ session_class.where(id: id).update_all(updates) unless updates.empty?
52
+ rescue => e
53
+ msg = "ConsoleAgent: session update failed: #{e.class}: #{e.message}"
54
+ $stderr.puts "\e[33m#{msg}\e[0m" if $stderr.respond_to?(:puts)
55
+ ConsoleAgent.logger.warn(msg)
56
+ nil
57
+ end
58
+
59
+ private
60
+
61
+ def table_exists?
62
+ # Only cache positive results — retry on failure so transient
63
+ # errors (boot timing, connection not ready) don't stick forever
64
+ return true if @table_exists
65
+ @table_exists = session_class.connection.table_exists?('console_agent_sessions')
66
+ rescue
67
+ false
68
+ end
69
+
70
+ def session_class
71
+ Object.const_get('ConsoleAgent::Session')
72
+ end
73
+
74
+ def current_user_name
75
+ ConsoleAgent.current_user || ENV['USER']
76
+ end
77
+ end
78
+ end
79
+ end